This commit is contained in:
2026-08-11 18:06:28 +08:00
26 changed files with 321 additions and 51 deletions
@@ -5115,6 +5115,14 @@ BEGIN
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
OR (OLD.plan_status = 'LOADING'
AND NEW.plan_status IN ('LOADING', 'COMPLETED'))
OR (OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_task.loading_plan_id = OLD.id
AND pick_item.picked_package_count > 0
))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
END IF;
@@ -13770,6 +13778,20 @@ DROP PROCEDURE IF EXISTS assert_unified_final_schema;
CALL assert_loading_pick_task_shapes(1);
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes;
-- 修复旧版本“推送预装即拣货中”的状态污染;从未实际拣货的计划回收到预装柜中。
UPDATE fl_loading_plan plan
SET plan_status = 'EDITING',
updated_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE plan.plan_status = 'LOADING'
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_task.loading_plan_id = plan.id
AND pick_item.picked_package_count > 0
);
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
+5 -4
View File
@@ -134,9 +134,10 @@
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 装柜计划 | `EDITING` | 编辑中,可维护货柜信息,并可增删未拣货明细。 |
| 装柜计划 | `LOADING` | 装柜中,已生成 PDA 拣货任务;未拣货明细仍可追加、修改或删除。 |
| 装柜计划 | `COMPLETED` | PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 |
| 货柜聚合 | `WAIT_LOADING` | 待装柜。货柜已创建但尚未创建装柜计划的派生状态,只在查询结果中返回,不写入装柜计划表。 |
| 装柜计划 | `EDITING` | 预装柜中,可维护货柜信息,并可增删未拣货明细。 |
| 装柜计划 | `LOADING` | 拣货中,已生成拣货任务;未拣货明细仍可追加、修改或删除。 |
| 装柜计划 | `COMPLETED` | 装柜完成。拣货完成后自动完成,计划、明细和货柜信息只读。 |
| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 |
| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 |
| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 |
@@ -153,7 +154,7 @@
| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 |
| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 |
装柜计划只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。
货柜列表状态为:`WAIT_LOADING`(待装柜)-> `EDITING`(预装柜中)-> `LOADING`(拣货中)-> `COMPLETED`(装柜完成)。其中 `WAIT_LOADING` 是货柜没有装柜计划时的派生状态;装柜计划持久化状态只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。
## 文件、导出和审计
@@ -43,7 +43,7 @@ public class PlatformContainerController {
}
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) {
@@ -53,7 +53,7 @@ public class PlatformContainerController {
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读")
@Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody TransportUpdateRequest request) {
@@ -2,9 +2,11 @@ package com.baiyue.firstleg.domain;
/** 智能装柜状态,只允许沿此方向推进。 */
public enum SmartLoadingStatus {
/** 编辑中:可以维护货柜信息并增删未拣货明细。 */
/** 待装柜:货柜已创建但尚未创建装柜计划,仅作为货柜聚合查询的派生状态,不写入装柜计划表。 */
WAIT_LOADING,
/** 预装柜中:可以维护货柜信息并增删未拣货明细。 */
EDITING,
/** 装柜中:已生成 PDA 拣货任务,仍可追加或调整未拣货明细。 */
/** 拣货中:已生成拣货任务,仍可追加或调整未拣货明细。 */
LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
COMPLETED;
@@ -12,7 +14,7 @@ public enum SmartLoadingStatus {
/** 装柜完成前可维护集装箱号和航次。 */
public boolean allowsTransportUpdate() { return this != COMPLETED; }
/** 编辑中和装柜中均允许维护尚未拣货的明细。 */
/** 待装柜、预装柜中和拣货中均允许维护尚未拣货的明细。 */
public boolean mutable() { return this != COMPLETED; }
public boolean canAdvanceTo(SmartLoadingStatus next) {
@@ -73,14 +73,14 @@ public final class ContainerDtos {
LocalDateTime actualWarehouseArrivalAt
) {}
@Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑")
@Schema(name = "ContainerFullUpdateRequest", description = "待装柜或预装柜中阶段完整编辑")
public record FullUpdateRequest(
@NotNull @PositiveOrZero Integer version,
@Valid @NotNull SaveRequest data
) {}
@Schema(name = "ContainerTransportUpdateRequest",
description = "LOADING 阶段只允许修改集装箱号和航次")
description = "拣货中阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@@ -13,7 +13,13 @@ import org.apache.ibatis.annotations.Update;
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Select("""
<script>
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status
SELECT b.*, p.id AS plan_id,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0
) THEN 'WAIT_LOADING'
ELSE p.plan_status END AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
@@ -24,7 +30,12 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
AND (#{query.departurePortCode} IS NULL OR b.departure_port_code = #{query.departurePortCode})
AND (#{query.destinationPortCode} IS NULL OR b.destination_port_code = #{query.destinationPortCode})
AND (#{query.destinationWarehouseCode} IS NULL OR b.destination_warehouse_code = #{query.destinationWarehouseCode})
AND (#{query.status} IS NULL OR COALESCE(p.plan_status, 'EDITING') = #{query.status})
AND (#{query.status} IS NULL OR CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0
) THEN 'WAIT_LOADING'
ELSE p.plan_status END = #{query.status})
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at &gt;= #{query.estimatedSailingFrom})
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo})
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -38,7 +49,13 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
@Select("""
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status
SELECT b.*, p.id AS plan_id,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0
) THEN 'WAIT_LOADING'
ELSE p.plan_status END AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE b.id = #{id}
""")
@@ -45,7 +45,10 @@ public interface LoadingListQueryMapper {
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.plan_status = 'LOADING'
WHERE p.preload_pushed_at IS NOT NULL
AND p.plan_status IN ('EDITING', 'LOADING')
AND EXISTS (SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0)
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
<if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -8,10 +8,28 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
""")
int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan SET plan_status='LOADING',push_idempotency_key=#{key},
UPDATE fl_loading_plan SET push_idempotency_key=#{key},
preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3),
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status='EDITING' AND version=#{expectedVersion}
WHERE id=#{planId} AND plan_status='EDITING' AND push_idempotency_key IS NULL AND version=#{expectedVersion}
""")
int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status='EDITING' AND preload_pushed_at IS NOT NULL
""")
int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan plan SET plan_status='EDITING',updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE plan.id=#{planId} AND plan.plan_status='LOADING' AND plan.version=#{expectedVersion}
AND NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id=pick_item.pick_task_id
WHERE pick_task.loading_plan_id=plan.id AND pick_item.picked_package_count>0
)
""")
int recycleUnpicked(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -72,4 +72,10 @@ public class SmartLoadingRepository {
public int push(long planId, String key, int version, long operatorId, String operatorName) {
return plans.pushPreload(planId, key, version, operatorId, operatorName);
}
public int startPicking(long planId, long operatorId, String operatorName) {
return plans.startPicking(planId, operatorId, operatorName);
}
public int recycleUnpicked(long planId, int expectedVersion, long operatorId, String operatorName) {
return plans.recycleUnpicked(planId, expectedVersion, operatorId, operatorName);
}
}
@@ -46,7 +46,7 @@ public class ContainerService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
value.setPlanStatus(SmartLoadingStatus.EDITING.name());
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name());
return value;
}
@@ -55,7 +55,7 @@ public class ContainerService {
FullUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.EDITING) {
if (status(current) != SmartLoadingStatus.WAIT_LOADING && status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
}
validateSchedule(request.data());
@@ -32,7 +32,7 @@ import java.util.Set;
@Service
public class LoadingListService {
private static final Set<String> LOADING_STATUSES = Set.of("LOADING");
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
@Resource
private LoadingListRepository repository;
@Resource
@@ -43,8 +43,8 @@ public class LoadingListService {
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
validateRange(query.createdFrom(), query.createdTo());
String status = trim(query.status());
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
throw badRequest("装柜清单状态只能是 LOADING");
if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) {
throw badRequest("预装清单状态只能是 EDITING 或 LOADING");
}
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status,
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
@@ -59,8 +59,8 @@ public class LoadingListService {
public PreloadDetail preloadDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在");
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
throw notFound("预装清单不存在");
}
return repository.preloadDetail(row);
}
@@ -160,8 +160,8 @@ public class LoadingListService {
}
private static void requireLoadingMutable(AggregateRow row) {
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅装柜中可修改集装箱号与航次");
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
}
}
@@ -22,7 +22,9 @@ import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -48,6 +50,8 @@ public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Resource
private SmartLoadingRepository smartLoadingRepository;
@Resource
private LoadingListService loadingListService;
@Resource
private PdaPickSettlementService settlementService;
@@ -123,6 +127,7 @@ public class LoadingPickService {
throw conflict("PDA任务分配版本已变化");
}
}
startPickingIfNeeded(task, operatorId, operatorName);
insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName);
return view(task, null);
}
@@ -182,6 +187,7 @@ public class LoadingPickService {
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
startPickingIfNeeded(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -207,6 +213,7 @@ public class LoadingPickService {
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
startPickingIfNeeded(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -271,6 +278,9 @@ public class LoadingPickService {
for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName);
}
if (lines.stream().anyMatch(line -> "PICK".equals(line.action()) || "PARTIAL".equals(line.action()))) {
startPickingIfNeeded(task, operatorId, operatorName);
}
if (!repository.allTaskItemsResolved(taskId)) {
throw conflict("整批提交必须处理本任务全部待拣明细");
}
@@ -375,6 +385,25 @@ public class LoadingPickService {
}
}
/** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */
private void startPickingIfNeeded(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (smartLoadingRepository.startPicking(task.getLoadingPlanId(), operatorId, operatorName) != 1) {
return;
}
LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity();
history.setLoadingPlanId(task.getLoadingPlanId());
history.setOperationId("PDA-START-" + task.getId());
history.setActionType("START_LOADING");
history.setFromStatus("EDITING");
history.setToStatus("LOADING");
history.setBeforeSnapshot("{\"source\":\"PDA\"}");
history.setAfterSnapshot("{\"source\":\"PDA\",\"status\":\"LOADING\"}");
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
history.setOperationReason("进入PDA拣货任务");
smartLoadingRepository.insert(history);
}
private LoadingPickTaskEntity requiredTask(long taskId) {
LoadingPickTaskEntity task = repository.task(taskId);
if (task == null) {
@@ -89,7 +89,7 @@ public class SmartLoadingService {
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
@@ -131,7 +131,7 @@ public class SmartLoadingService {
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
@@ -163,7 +163,7 @@ public class SmartLoadingService {
+ '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
@@ -197,7 +197,7 @@ public class SmartLoadingService {
+ request.expectedPlanVersion() + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
loadingPickService.remove(itemId);
@@ -216,7 +216,8 @@ public class SmartLoadingService {
String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
if (key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> items = repository.items(planId);
@@ -228,7 +229,7 @@ public class SmartLoadingService {
try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
history(planId, null, "START_LOADING", "EDITING", "LOADING", key, operatorId, operatorName);
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName);
LoadingPlanEntity pushed = repository.plan(planId);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
@@ -329,7 +330,19 @@ public class SmartLoadingService {
}
return view(plan, booking);
}
private LoadingPlanEntity editing(long id,int version){LoadingPlanEntity p=repository.plan(id);if(p==null)throw new BizException(ResultCode.NOT_FOUND,"装柜计划不存在");if(!Set.of("EDITING", "LOADING").contains(p.getPlanStatus())||!Objects.equals(p.getVersion(),version))throw conflict("计划已完成或版本冲突");return p;}
private LoadingPlanEntity editing(long id, int version, long operatorId, String operatorName) {
LoadingPlanEntity p = repository.plan(id);
if (p == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
if (!Set.of("EDITING", "LOADING").contains(p.getPlanStatus()) || !Objects.equals(p.getVersion(), version)) {
throw conflict("计划已完成或版本冲突");
}
if ("LOADING".equals(p.getPlanStatus())
&& repository.recycleUnpicked(id, version, operatorId, operatorName) == 1) {
p.setPlanStatus("EDITING");
p.setVersion(version + 1);
}
return p;
}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
@@ -79,11 +79,18 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认");
boolean loading = "LOADING".equals(inbound.processStatus());
if (loading) {
// Starting a real loading task advances the inbound before its preload
// confirmation is submitted; retain the linked-container hard gate.
loadingModelBridge.requireStarted(tenantId, inboundId);
} else {
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认");
}
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("INBOUNDED", response(plan)));
new SplitActionResponse(loading ? "LOADING" : "INBOUNDED", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
@@ -91,7 +98,8 @@ public class SplitPreloadService {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("INBOUNDED", response(repository.findPlan(tenantId, inboundId))));
new SplitActionResponse(loading ? "LOADING" : "INBOUNDED",
response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
@@ -602,6 +602,14 @@ BEGIN
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
OR (OLD.plan_status = 'LOADING'
AND NEW.plan_status IN ('LOADING', 'COMPLETED'))
OR (OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_task.loading_plan_id = OLD.id
AND pick_item.picked_package_count > 0
))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
END IF;
@@ -676,6 +684,20 @@ BEGIN
END$$
DELIMITER ;
-- 旧版本在推送预装时即写入 LOADING。仅回收从未发生实际拣货的计划;有实际拣货记录的数据不回退。
UPDATE fl_loading_plan plan
SET plan_status = 'EDITING',
updated_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE plan.plan_status = 'LOADING'
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_task.loading_plan_id = plan.id
AND pick_item.picked_package_count > 0
);
DROP TRIGGER IF EXISTS trg_loading_item_before_insert;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_insert
@@ -136,7 +136,7 @@ paths:
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: status, in: query, schema: {type: string, enum: [EDITING, LOADING, COMPLETED]}}
- {name: status, in: query, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
@@ -146,7 +146,7 @@ paths:
'403': {description: 无货柜查询权限}
post:
summary: 新增平台货柜
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 WAIT_LOADING(待装柜)
requestBody:
required: true
content:
@@ -166,7 +166,7 @@ paths:
'403': {description: 无货柜查询权限}
put:
summary: 草稿阶段完整编辑货柜
description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING(预装柜中)时允许
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
requestBody:
@@ -701,7 +701,7 @@ components:
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]}
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
version: {type: integer, minimum: 0}
ContainerSaveRequest:
type: object
@@ -35,6 +35,7 @@ class ContainerControllerContractTest {
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("platform:firstleg:container:list")
.contains("EDITING")
.contains("WAIT_LOADING")
.contains("LOADING")
.contains("COMPLETED")
.doesNotContain("tenantId")
@@ -24,7 +24,10 @@ class ContainerBookingMapperContractTest {
assertThat(select)
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'")
.contains("plan_status")
.contains("p.plan_status != 'COMPLETED'")
.doesNotContain("&lt;&gt;")
.doesNotContain("tenant_id")
.doesNotContain("account_id");
}
@@ -34,7 +34,9 @@ class LoadingListQueryMapperContractTest {
.contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i")
.contains("p.plan_status = 'LOADING'")
.contains("p.preload_pushed_at IS NOT NULL")
.contains("p.plan_status IN ('EDITING', 'LOADING')")
.contains("active_item.deleted = 0")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("PRELOAD_PUSHED");
@@ -110,6 +110,8 @@ class SmartLoadingSqlContractTest {
.contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))")
.contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')")
.contains("NEW.plan_status IN ('LOADING', 'COMPLETED')")
.contains("OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'")
.contains("旧版本在推送预装时即写入 LOADING")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
@@ -56,7 +56,7 @@ class ContainerServiceTest {
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
}
@Test
@@ -73,6 +73,20 @@ class ContainerServiceTest {
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("待装柜阶段允许完整编辑")
void updatesAllFieldsWhileWaitingForLoading() {
ContainerBookingEntity current = booking("WAIT_LOADING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜 NEW")));
assertThat(current.getCabinetNo()).isEqualTo("柜 NEW");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("进入预装柜中后拒绝完整编辑")
void rejectsFullUpdateAfterPreloadPush() {
@@ -50,13 +50,12 @@ class LoadingListServiceTest {
}
@Test
void shouldRejectEditingStatusFromPreloadList() {
void shouldAcceptEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
assertThatThrownBy(() -> service.preloadPage(query))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.BAD_REQUEST);
service.preloadPage(query);
verify(repository).preloadPage(query);
}
@Test
@@ -7,12 +7,14 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
@@ -24,6 +26,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -36,6 +39,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private LoadingListService loadingListService;
@MockBean
private PdaPickSettlementService settlementService;
@@ -75,6 +80,52 @@ class LoadingPickServiceTest {
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
@Test
void keepsPickAsStartLoadingFallbackForOlderClients() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-start-loading");
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-loading"),
9L, "operator");
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository).insert(any(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class));
}
@Test
void claimStartsLoadingAndSameOperatorReentryDoesNotDuplicateStartHistory() {
LoadingPickTaskEntity task = task();
LoadingPickAssignmentEntity assignment = assignment();
AtomicReference<LoadingPickOperationEntity> operation = new AtomicReference<>();
when(repository.operation("claim-1")).thenAnswer(invocation -> operation.get());
when(repository.operation("claim-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(null, assignment);
when(repository.createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED")).thenReturn(1);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1, 0);
doAnswer(invocation -> {
LoadingPickOperationEntity value = invocation.getArgument(0);
if ("claim-1".equals(value.getOperationId())) operation.set(value);
return null;
}).when(repository).insert(any(LoadingPickOperationEntity.class));
service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(0, "claim-2"), 9L, "operator");
verify(smartLoadingRepository, times(2)).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository, times(1)).insert(argThat(
(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity history) ->
"START_LOADING".equals(history.getActionType())
&& "EDITING".equals(history.getFromStatus())
&& "LOADING".equals(history.getToStatus())
&& "PDA-START-20".equals(history.getOperationId())));
verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED");
}
@Test
void touchesTaskBeforeCompletingLastPartialItem() {
LoadingPickTaskEntity task = task();
@@ -202,6 +202,28 @@ class SmartLoadingServiceTest {
assertThat(captor.getValue().getProductQuantity()).isEqualTo(67);
}
@Test
void recyclesLegacyLoadingPlanBeforeAddingFirstItem() {
LoadingPlanEntity stale = plan("LOADING", 5);
LoadingPlanEntity after = plan("EDITING", 7);
when(repository.plan(1)).thenReturn(stale, after);
when(repository.booking(99)).thenReturn(booking());
when(repository.recycleUnpicked(1, 5, 7, "u")).thenReturn(1);
when(repository.touch(1, 6, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-recycle"), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).recycleUnpicked(1, 5, 7, "u");
verify(repository).touch(1, 6, 7, "u");
}
@Test
void replaysBeforeVersionCheck() {
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
@@ -335,7 +357,7 @@ class SmartLoadingServiceTest {
@Test
void pushesIdempotently() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("LOADING", 4);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
@@ -344,7 +366,7 @@ class SmartLoadingServiceTest {
Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "key", 3, 7, "u");
}
@@ -373,7 +395,7 @@ class SmartLoadingServiceTest {
@Test
void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() {
LoadingPlanEntity editing = plan("EDITING", 1);
LoadingPlanEntity pushed = plan("LOADING", 2);
LoadingPlanEntity pushed = plan("EDITING", 2);
ContainerBookingEntity booking = booking();
booking.setContainerNo(null);
when(repository.plan(1)).thenReturn(editing, pushed);
@@ -383,7 +405,7 @@ class SmartLoadingServiceTest {
Plan result = service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "no-container-key", 1, 7, "u");
verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList());
}
@@ -8,9 +8,11 @@ import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingStatusTest {
@Test
void statusCanOnlyAdvanceOneWay() {
assertThat(SmartLoadingStatus.WAIT_LOADING.canAdvanceTo(SmartLoadingStatus.EDITING)).isTrue();
assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue();
assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue();
assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse();
assertThat(SmartLoadingStatus.WAIT_LOADING.mutable()).isTrue();
assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue();
assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue();
assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue();
@@ -21,6 +21,7 @@ import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -72,4 +73,36 @@ class SplitPreloadServiceTest {
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).beginLoading(10, 20, 7, 4);
}
@Test
void startsPreloadConfirmationAfterLoadingBeginsWithLinkedContainerGate() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("PENDING"); plan.setVersion(3);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
when(repository.changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3)).thenReturn(1);
SplitActionResponse result = service.startConfirming(10, 20, 7, "admin", "k4",
new ConfirmPreloadRequest(3));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3);
}
@Test
void rejectsLoadingPreloadConfirmationWhenLinkedContainersHaveNotStarted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5));
org.mockito.Mockito.doThrow(new IllegalStateException("not started"))
.when(loadingModelBridge).requireStarted(10, 20);
assertThatThrownBy(() -> service.startConfirming(10, 20, 7, "admin", "k5",
new ConfirmPreloadRequest(3)))
.isInstanceOf(IllegalStateException.class)
.hasMessage("not started");
}
}