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] =?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); + } +}