feat(numbering): unify daily business document numbers (backend)
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
|
||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 |
|
||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||
@@ -81,7 +81,7 @@
|
||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||
|
||||
-1
@@ -56,7 +56,6 @@ public final class ContainerCustomsDtos {
|
||||
String remark, List<ClearanceResultView> results) {}
|
||||
|
||||
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
||||
@Size(max = 64) String batchNo,
|
||||
@Size(max = 64) String customsEntryNo,
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
|
||||
+11
-15
@@ -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";
|
||||
@@ -179,6 +181,7 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */
|
||||
@Transactional
|
||||
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
|
||||
ActionRequest request) {
|
||||
@@ -197,7 +200,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("首次发送报关行批次创建失败");
|
||||
@@ -356,11 +359,12 @@ public class ContainerCustomsService {
|
||||
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
||||
}
|
||||
|
||||
/** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */
|
||||
@Transactional
|
||||
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
||||
String key, DeclarationBatchRequest request) {
|
||||
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
||||
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark());
|
||||
String requestHash = hash(action, request.expectedVersion(), request.remark());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
@@ -371,8 +375,8 @@ public class ContainerCustomsService {
|
||||
}
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
long batchId = IdWorker.getId();
|
||||
String batchNo = trim(request.batchNo());
|
||||
if (batchNo == null) batchNo = "DECL-" + batchId;
|
||||
// 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。
|
||||
String batchNo = documentNumbers.next("DECL");
|
||||
String snapshot = declarationSnapshot(container.id());
|
||||
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
||||
@@ -381,20 +385,20 @@ public class ContainerCustomsService {
|
||||
return declarationBatchView(mapper.declarationBatch(batchId));
|
||||
}
|
||||
|
||||
/** 提交只回填外部报关单号,内部批次号保持创建时的值。 */
|
||||
@Transactional
|
||||
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
||||
String key, DeclarationBatchRequest request) {
|
||||
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
||||
String entryNo = trim(request.customsEntryNo());
|
||||
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
||||
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark());
|
||||
String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
||||
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
||||
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
||||
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||
String batchNo = trim(request.batchNo());
|
||||
if (batchNo == null) batchNo = batch.batchNo();
|
||||
String batchNo = batch.batchNo();
|
||||
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
||||
trim(request.remark())) != 1) throw concurrent();
|
||||
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||
@@ -501,14 +505,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);}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ CALL phase3_add_allocation_picked_count();
|
||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||
) ENGINE=InnoDB COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
|
||||
@@ -257,7 +257,7 @@ components:
|
||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
||||
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
||||
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
||||
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||
|
||||
@@ -700,7 +700,8 @@ paths:
|
||||
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
|
||||
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
|
||||
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
|
||||
仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。
|
||||
仍由服务端重新校验国内仓、货物原客户归属、占用、同柜组、箱数和容量资格。
|
||||
平台柜次支持跨客户拼柜,关联订舱的客户快照不限制柜内成员客户。
|
||||
parameters:
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
@@ -835,6 +836,7 @@ paths:
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围;
|
||||
传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。
|
||||
关联订舱的客户快照不裁剪候选,未指定客户筛选时可推荐多个客户的货物。
|
||||
一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -848,7 +850,8 @@ paths:
|
||||
summary: 向预装计划新增入库单明细
|
||||
description: >-
|
||||
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
||||
校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同,
|
||||
校验货物原客户归属、可用箱数、容积和限重后原子新增/累加明细;平台允许跨客户拼柜,
|
||||
不以关联订舱客户快照限定成员客户。人工加入允许订单国内仓及渠道与柜次不同,
|
||||
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
|
||||
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
||||
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
||||
@@ -870,6 +873,7 @@ paths:
|
||||
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
||||
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
||||
占用、同柜组和容量门禁不变。
|
||||
支持将不同客户的入仓码加入同一柜次,明细及费用仍保留各自原客户归属。
|
||||
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
||||
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
||||
parameters:
|
||||
@@ -886,6 +890,7 @@ paths:
|
||||
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
||||
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
||||
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
|
||||
不同客户可加入同一柜次,关联订舱客户快照不作为客户准入限制。
|
||||
已被其他有效柜次占用、国内仓不一致、
|
||||
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
||||
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
||||
@@ -2376,7 +2381,7 @@ components:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||
planNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||
@@ -2543,7 +2548,7 @@ components:
|
||||
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
|
||||
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
|
||||
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
|
||||
loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示}
|
||||
loadingPlanNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', description: 装柜计划业务号,UI主展示}
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
|
||||
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
|
||||
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
|
||||
@@ -2676,7 +2681,7 @@ components:
|
||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
|
||||
properties:
|
||||
id: {type: string}
|
||||
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
|
||||
planNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号}
|
||||
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||
bookingId: {type: string}
|
||||
containerNo: {type: string, nullable: true}
|
||||
|
||||
@@ -299,7 +299,7 @@ components:
|
||||
properties:
|
||||
planId: {$ref: '#/components/schemas/LongId'}
|
||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||
preloadNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||
@@ -356,7 +356,7 @@ components:
|
||||
properties:
|
||||
planId: {$ref: '#/components/schemas/LongId'}
|
||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||
loadingListNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
|
||||
@@ -313,7 +313,7 @@ components:
|
||||
oceanBooking:
|
||||
type: object
|
||||
properties:
|
||||
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||
parties: {type: array}
|
||||
containerRequirements: {type: array}
|
||||
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
||||
|
||||
@@ -235,9 +235,9 @@ components:
|
||||
required: [appointmentNo, carrierId, carrierName, attachmentCount]
|
||||
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
|
||||
properties:
|
||||
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
|
||||
appointmentNo: {type: string, pattern: '^TA[0-9]{12}$', example: TA202608180001, description: 派车单号;按北京时间创建日逐日编号}
|
||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
|
||||
oceanBookingNo: {type: string, nullable: true, pattern: '^OB[0-9]{12}$', example: OB202608180001}
|
||||
warehouseId: {type: integer, format: int64, nullable: true}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||
|
||||
+6
-3
@@ -32,7 +32,7 @@ class InboundMeasurementReviewContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID")
|
||||
@DisplayName("测量审核不进入待客户确认,并声明幂等和字符串长ID")
|
||||
void shouldDocumentReviewContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -46,8 +46,11 @@ class InboundMeasurementReviewContractTest {
|
||||
.contains("name: inboundId")
|
||||
.contains("schema: {type: string, pattern:")
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
|
||||
.doesNotContain("待客户确认");
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:");
|
||||
// 约束仅针对测量审核;客户费用确认属于另一业务,允许独立存在。
|
||||
int start = openapi.indexOf(" /api/platform/v1/inbounds/{inboundId}/measurements/approve:");
|
||||
int end = openapi.indexOf("\n /", start + 1);
|
||||
assertThat(openapi.substring(start, end < 0 ? openapi.length() : end)).doesNotContain("待客户确认");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+59
-5
@@ -28,6 +28,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContainerCustomsServiceTest {
|
||||
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
|
||||
@Mock ContainerCustomsMapper mapper;
|
||||
@Mock CustomsFileClient files;
|
||||
@Mock ObjectMapper objectMapper;
|
||||
@@ -35,6 +37,58 @@ class ContainerCustomsServiceTest {
|
||||
@InjectMocks ContainerCustomsService service;
|
||||
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);}
|
||||
|
||||
/** 服务端分配批次号后,命令重放必须返回原号且不再消耗流水。 */
|
||||
@Test void declarationCreationReplayKeepsAllocatedNumber() throws Exception {
|
||||
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_9";
|
||||
var command = new AtomicReference<ContainerCustomsMapper.CommandRow>();
|
||||
var batch = new AtomicReference<ContainerCustomsMapper.DeclarationBatchRow>();
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq(action), eq("batch-key"), anyString())).thenAnswer(call -> {
|
||||
if (command.get() != null) return 0;
|
||||
command.set(new ContainerCustomsMapper.CommandRow(1L, 5L, action, "batch-key", call.getArgument(4), "PROCESSING", null, null, null));
|
||||
return 1;
|
||||
});
|
||||
when(mapper.command(5L, action, "batch-key")).thenAnswer(call -> command.get());
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
|
||||
when(mapper.taxRefundCount(9L)).thenReturn(1);
|
||||
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.of(2026, 10, 20, 9, 0));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||
when(mapper.insertDeclarationBatch(anyLong(), eq(2L), eq(9L), anyString(), eq(3), eq("[]"), isNull())).thenAnswer(call -> {
|
||||
batch.set(new ContainerCustomsMapper.DeclarationBatchRow(call.getArgument(0), 2L, 9L, call.getArgument(3),
|
||||
"READY", 3, null, null, null, null, null, null, 0, null, null));
|
||||
return 1;
|
||||
});
|
||||
when(mapper.declarationBatch(anyLong())).thenAnswer(call -> batch.get());
|
||||
when(mapper.insertHistory(anyLong(), eq(2L), eq("CREATE_CUSTOMS_DECLARATION_BATCH"), anyString(), anyString(), anyString(), eq(5L), eq("admin"))).thenReturn(1);
|
||||
when(mapper.completeCommand(eq(5L), eq(action), eq("batch-key"), anyLong())).thenAnswer(call -> {
|
||||
var old = command.get();
|
||||
command.set(new ContainerCustomsMapper.CommandRow(old.id(), 5L, action, "batch-key", old.requestHash(), "SUCCESS", call.getArgument(3), null, null));
|
||||
return 1;
|
||||
});
|
||||
var request = new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(3, null, null);
|
||||
var created = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request);
|
||||
var replay = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request);
|
||||
assertThat(created.batchNo()).isEqualTo("DECL202610200001");
|
||||
assertThat(replay.id()).isEqualTo(created.id());
|
||||
assertThat(replay.batchNo()).isEqualTo(created.batchNo());
|
||||
verify(documentNumbers, times(1)).next("DECL");
|
||||
}
|
||||
|
||||
/** 外部报关单号与内部批次号独立,正式提交不能修改内部号。 */
|
||||
@Test void declarationSubmissionPreservesBatchNumber() {
|
||||
var batch = new ContainerCustomsMapper.DeclarationBatchRow(41L, 2L, 9L, "DECL202610200001",
|
||||
"READY", 3, null, null, null, null, null, null, 0, null, null);
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_DECLARATION_BATCH_41"), eq("submit-key"), anyString())).thenReturn(1);
|
||||
when(mapper.lockDeclarationBatch(41L)).thenReturn(batch);
|
||||
when(mapper.submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null)).thenReturn(1);
|
||||
when(mapper.completeCommand(5L, "SUBMIT_CUSTOMS_DECLARATION_BATCH_41", "submit-key", 41L)).thenReturn(1);
|
||||
when(mapper.declarationBatch(41L)).thenReturn(batch);
|
||||
var result = service.submitDeclarationBatch(41L, 5L, "admin", "submit-key",
|
||||
new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(0, "CUSTOMS-EXTERNAL-1", null));
|
||||
assertThat(result.batchNo()).isEqualTo(batch.batchNo());
|
||||
verify(mapper).submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null);
|
||||
verifyNoInteractions(documentNumbers);
|
||||
}
|
||||
|
||||
@Test void merchantOwnBuyOrderIsNotFound(){
|
||||
assertMerchantOrderNotFound(7L,31L);
|
||||
}
|
||||
@@ -211,7 +265,7 @@ class ContainerCustomsServiceTest {
|
||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||
var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64),
|
||||
"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);
|
||||
var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",
|
||||
var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",
|
||||
java.time.LocalDateTime.now(),1);
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
@@ -220,7 +274,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.documents(2L,null)).thenReturn(List.of(source));
|
||||
when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I")));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||
when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1);
|
||||
when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1);
|
||||
@@ -235,9 +289,9 @@ class ContainerCustomsServiceTest {
|
||||
var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null));
|
||||
|
||||
assertThat(result.brokerSubmission()).isNotNull();
|
||||
assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01");
|
||||
assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR202610200001");
|
||||
assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3);
|
||||
verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"));
|
||||
verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"));
|
||||
}
|
||||
|
||||
@Test void initialBrokerSendRejectsStaleAggregateVersion(){
|
||||
@@ -535,7 +589,7 @@ class ContainerCustomsServiceTest {
|
||||
}
|
||||
|
||||
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
|
||||
private void assertMerchantOrderNotFound(long tenantId,long orderId){
|
||||
assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId))
|
||||
|
||||
+10
-1
@@ -37,8 +37,17 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(ColoadGroupService.class)
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
|
||||
class ColoadGroupServiceTest {
|
||||
@org.springframework.context.annotation.Configuration
|
||||
static class Numbers {
|
||||
/** 同柜组业务测试使用真实格式,序列由隔离测试替身提供。 */
|
||||
@org.springframework.context.annotation.Bean
|
||||
com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers() {
|
||||
return com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
}
|
||||
}
|
||||
|
||||
@MockBean
|
||||
private ColoadGroupMapper mapper;
|
||||
@MockBean private PendingReviewOrderClient pendingReviewOrders;
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ class ArrivalCalendarTest {
|
||||
void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() {
|
||||
var repository = mock(PortScheduleRepository.class);
|
||||
var service = new PortScheduleService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.firstleg.service.TestDocumentNumbers.documents());
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
var from = LocalDate.of(2026, 9, 28);
|
||||
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from));
|
||||
|
||||
+2
@@ -35,6 +35,8 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PortScheduleServiceTest {
|
||||
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
|
||||
@Test
|
||||
void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DocumentSequenceMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
||||
class DocumentSequenceRepositoryTest {
|
||||
private static class Fixture {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DocumentSequenceMapper mapper;
|
||||
final DocumentSequenceRepository repository;
|
||||
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||
jdbc.execute("CREATE TABLE fl_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
||||
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
||||
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||
repository=(DocumentSequenceRepository)proxy.getProxy();
|
||||
}
|
||||
}
|
||||
/** 日期和前缀各自独立,重复分配持续递增。 */
|
||||
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
||||
}
|
||||
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
||||
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
||||
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var work=new ArrayList<Callable<Long>>();
|
||||
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
||||
var values=new TreeSet<Long>();
|
||||
for(var future:pool.invokeAll(work))values.add(future.get());
|
||||
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
||||
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
throw new IllegalStateException("业务回滚");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
}
|
||||
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
||||
@Test void connectionResultIsNeverCached() throws Exception {
|
||||
var f=new Fixture();
|
||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
@Test
|
||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||
String sql = sql();
|
||||
int expectedTables = 228;
|
||||
int expectedTables = 230;
|
||||
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
||||
.matcher(sql).results().count();
|
||||
assertThat(actualTables).isEqualTo(expectedTables);
|
||||
|
||||
+2
@@ -24,6 +24,8 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SailingCalendarServiceTest {
|
||||
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
|
||||
@Mock private SailingCalendarRepository repository;
|
||||
@InjectMocks private SailingCalendarService service;
|
||||
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
|
||||
|
||||
+37
-58
@@ -1,73 +1,52 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.time.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FirstlegDocumentNumberGeneratorTest {
|
||||
private static final Clock CLOCK = Clock.fixed(
|
||||
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
@Test
|
||||
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
||||
context.register(FirstlegDocumentNumberGenerator.class);
|
||||
context.refresh();
|
||||
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
||||
|
||||
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
||||
@Test void springCreatesGeneratorWithRepository() {
|
||||
try(var context=new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
||||
context.register(FirstlegDocumentNumberGenerator.class);context.refresh();
|
||||
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatsAllOperationalDocumentPrefixes() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
|
||||
.thenReturn(1L, 1L, 1L, 1L, 1L);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
|
||||
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
|
||||
assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
|
||||
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||
eq("OB:20260818"));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||
eq("CABINET"));
|
||||
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
||||
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
when(sequences.next("LP:20261020")).thenReturn(1L,2L,9999L);
|
||||
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThat(numbers.next("LP")).isEqualTo("LP202610200001");
|
||||
assertThat(numbers.next("LP")).isEqualTo("LP202610200002");
|
||||
assertThat(numbers.next("LP")).isEqualTo("LP202610209999");
|
||||
verify(sequences,times(3)).next("LP:20261020");
|
||||
}
|
||||
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
||||
@Test void rejectsUnknownTypeAndOverflow() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
||||
verifyNoInteractions(sequences);
|
||||
when(sequences.next("LP:20261020")).thenReturn(10000L);
|
||||
assertThatThrownBy(()->numbers.next("LP")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cabinetSequenceDoesNotResetOrStopAtFourDigits() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D9999");
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D10000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedTypeAndDailyOverflow() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||
assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class);
|
||||
/** 柜次和实物标签明确不改格式,柜次允许超过四位。 */
|
||||
@Test void preservesCabinetAndPalletRules() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
when(sequences.next("CABINET")).thenReturn(9999L,10000L);
|
||||
when(sequences.next("DP:20261020")).thenReturn(1L);
|
||||
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThat(numbers.nextCabinetNo()).isEqualTo("D9999");
|
||||
assertThat(numbers.nextCabinetNo()).isEqualTo("D10000");
|
||||
assertThat(numbers.next("DP")).isEqualTo("DP-261020-0001");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||
import java.time.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */
|
||||
public final class TestDocumentNumbers {
|
||||
/** 测试工具不实例化。 */
|
||||
private TestDocumentNumbers() {}
|
||||
/** 每个测试生成器独立计数,类型前缀之间互不占号。 */
|
||||
public static FirstlegDocumentNumberGenerator documents() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
var counters=new ConcurrentHashMap<String,AtomicLong>();
|
||||
lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet());
|
||||
return new FirstlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user