优化
This commit is contained in:
@@ -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<String> 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位大写字母");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-35
@@ -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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-10
@@ -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<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
|
||||
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
|
||||
|
||||
+3
-1
@@ -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<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
|
||||
private static final Set<String> 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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+3
-1
@@ -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<String> 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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<SailingEntity> 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);
|
||||
}
|
||||
|
||||
+23
-47
@@ -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<String> 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<String> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +91,13 @@ public class SmartLoadingService {
|
||||
}
|
||||
return repository.candidates(warehouseId);
|
||||
}
|
||||
/** 按柜次渠道查询候选,关联订舱客户快照不限制其他客户的货物。 */
|
||||
public List<Candidate> 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<Candidate> 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<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking,
|
||||
Long customerId, String inboundCode) {
|
||||
List<Candidate> 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<Candidate> 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<LoadingPlanItemEntity> 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<Candidate> available = manualCandidates(booking);
|
||||
List<Candidate> available = manualCandidates();
|
||||
Map<Long, List<Candidate>> 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<Candidate> 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<Candidate> manualCandidates(ContainerBookingEntity booking) {
|
||||
return repository.candidates(null).stream()
|
||||
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
|
||||
/** 平台人工分配可拼装多个客户,明细始终保留各自的真实客户归属。 */
|
||||
private List<Candidate> manualCandidates() {
|
||||
return repository.candidates(null);
|
||||
}
|
||||
|
||||
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> 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);}
|
||||
}
|
||||
|
||||
@@ -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<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> 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"));
|
||||
|
||||
+3
-2
@@ -28,8 +28,9 @@ public class SmartDispatchController {
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
||||
@RequestParam(required=false)List<Long> 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")
|
||||
|
||||
+59
@@ -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<DispatchPalletCandidate> 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<String> PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource");
|
||||
private static final Set<String> 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<String> 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="<bind name='customerId' value='null'/><bind name='relabelNo' value='null'/>"
|
||||
+"<bind name='planIds' value='query.planIds'/><bind name='orderNo' value='query.orderNo'/>"
|
||||
+"<bind name='fbaWarehouseCode' value='query.destinationWarehouseCode'/><bind name='fba' value='query.destinationWarehouseCode'/>"
|
||||
+"<bind name='sourceStage' value='query.sourceStage'/><bind name='draftToken' value='query.draftToken'/>";
|
||||
/** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */
|
||||
private static String project(String body,Set<String> 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 "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields)
|
||||
+"</when><otherwise>SELECT "+Arrays.stream(FIELDS.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
|
||||
+"<if test='planIds == null or planIds.isEmpty()'>"
|
||||
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS)+"</if>"
|
||||
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
|
||||
+project(DispatchCandidateMapper.RELABEL_BODY,planFields)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS)+"</if></if>";
|
||||
}
|
||||
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||
private static String filtered() {
|
||||
return " FROM ("+rows()+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
|
||||
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
|
||||
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
|
||||
}
|
||||
/** 同一事务内统计分页全集。 */
|
||||
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered()+"</script>";}
|
||||
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
|
||||
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered()
|
||||
+" ORDER BY customerId,orderNo,palletNo,sourceStage,planPalletId,returnReceiptId,orderRefs,returnCartonSource"
|
||||
+" LIMIT #{query.size} OFFSET #{offset}</script>";}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -12,7 +12,8 @@ public final class DispatchPlanningModels {
|
||||
private DispatchPlanningModels() { }
|
||||
|
||||
public record CandidateQuery(String warehouseId, List<Long> 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<String> sourceKeys,
|
||||
long packageCount) { }
|
||||
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
|
||||
/** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete,
|
||||
long total, int page, int size, long pages) { }
|
||||
|
||||
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
|
||||
List<String> sourceKeys) { }
|
||||
|
||||
+7
@@ -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<DispatchPalletCandidate> 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;
|
||||
|
||||
+7
-3
@@ -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推断删除身份。 */
|
||||
|
||||
+36
@@ -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<DispatchViews.Position>();
|
||||
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<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> 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作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
|
||||
+11
-21
@@ -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<DispatchPalletCandidate> wholeCandidates(String operator,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) {
|
||||
/** 总数与当前页同事务读取;页外候选不参与逐组装配。 */
|
||||
public record CandidatePage(List<DispatchPalletCandidate> 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<DispatchPalletCandidate>();
|
||||
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<WholeSource> lockWholeSources(String warehouseId,
|
||||
|
||||
+3
-5
@@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock {
|
||||
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
|
||||
}
|
||||
|
||||
/** 整车候选只解析已经按仓限定且有界的原生退货记录。 */
|
||||
public List<DispatchPalletCandidate> 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<DispatchPalletCandidate> rows) {
|
||||
if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查");
|
||||
}
|
||||
|
||||
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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(){
|
||||
|
||||
+3
-1
@@ -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));
|
||||
|
||||
+91
@@ -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"));}
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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<DomesticContainerDtos.OrderView> mapOrders(PageResult<DomesticContainerOrderEntity> 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()); }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user