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')) (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
OR (OLD.plan_status = 'LOADING' OR (OLD.plan_status = 'LOADING'
AND NEW.plan_status IN ('LOADING', 'COMPLETED')) 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 ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
END IF; END IF;
@@ -13770,6 +13778,20 @@ DROP PROCEDURE IF EXISTS assert_unified_final_schema;
CALL assert_loading_pick_task_shapes(1); CALL assert_loading_pick_task_shapes(1);
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes; 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,统一迁移不改写主表或历史状态。 -- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
+5 -4
View File
@@ -134,9 +134,10 @@
| 字段 | 状态值 | 中文含义 | | 字段 | 状态值 | 中文含义 |
| --- | --- | --- | | --- | --- | --- |
| 装柜计划 | `EDITING` | 编辑中,可维护货柜信息,并可增删未拣货明细。 | | 货柜聚合 | `WAIT_LOADING` | 待装柜。货柜已创建但尚未创建装柜计划的派生状态,只在查询结果中返回,不写入装柜计划表。 |
| 装柜计划 | `LOADING` | 装柜中,已生成 PDA 拣货任务;未拣货明细仍可追加、修改或删除。 | | 装柜计划 | `EDITING` | 预装柜中,可维护货柜信息,并可增删未拣货明细。 |
| 装柜计划 | `COMPLETED` | PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 | | 装柜计划 | `LOADING` | 拣货中,已生成拣货任务;未拣货明细仍可追加、修改或删除。 |
| 装柜计划 | `COMPLETED` | 装柜完成。拣货完成后自动完成,计划、明细和货柜信息只读。 |
| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 | | PDA 拣货任务 | `LOADING` | 拣货作业进行中。 |
| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 | | PDA 拣货任务 | `COMPLETED` | 拣货已完成。 |
| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 | | PDA 拣货明细 | `PENDING` | 尚未开始拣货。 |
@@ -153,7 +154,7 @@
| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 | | 旧版拆单记录 | `LOCKED` | 已锁定待完成。 |
| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 | | 旧版拆单记录 | `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')") @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许") @Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id, @PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) { @Valid @RequestBody FullUpdateRequest request) {
@@ -53,7 +53,7 @@ public class PlatformContainerController {
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id", @BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次") description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读") @Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id, @PathVariable @Positive long id,
@Valid @RequestBody TransportUpdateRequest request) { @Valid @RequestBody TransportUpdateRequest request) {
@@ -2,9 +2,11 @@ package com.baiyue.firstleg.domain;
/** 智能装柜状态,只允许沿此方向推进。 */ /** 智能装柜状态,只允许沿此方向推进。 */
public enum SmartLoadingStatus { public enum SmartLoadingStatus {
/** 编辑中:可以维护货柜信息并增删未拣货明细。 */ /** 待装柜:货柜已创建但尚未创建装柜计划,仅作为货柜聚合查询的派生状态,不写入装柜计划表。 */
WAIT_LOADING,
/** 预装柜中:可以维护货柜信息并增删未拣货明细。 */
EDITING, EDITING,
/** 装柜中:已生成 PDA 拣货任务,仍可追加或调整未拣货明细。 */ /** 拣货中:已生成拣货任务,仍可追加或调整未拣货明细。 */
LOADING, LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */ /** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
COMPLETED; COMPLETED;
@@ -12,7 +14,7 @@ public enum SmartLoadingStatus {
/** 装柜完成前可维护集装箱号和航次。 */ /** 装柜完成前可维护集装箱号和航次。 */
public boolean allowsTransportUpdate() { return this != COMPLETED; } public boolean allowsTransportUpdate() { return this != COMPLETED; }
/** 编辑中和装柜中均允许维护尚未拣货的明细。 */ /** 待装柜、预装柜中和拣货中均允许维护尚未拣货的明细。 */
public boolean mutable() { return this != COMPLETED; } public boolean mutable() { return this != COMPLETED; }
public boolean canAdvanceTo(SmartLoadingStatus next) { public boolean canAdvanceTo(SmartLoadingStatus next) {
@@ -73,14 +73,14 @@ public final class ContainerDtos {
LocalDateTime actualWarehouseArrivalAt LocalDateTime actualWarehouseArrivalAt
) {} ) {}
@Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑") @Schema(name = "ContainerFullUpdateRequest", description = "待装柜或预装柜中阶段完整编辑")
public record FullUpdateRequest( public record FullUpdateRequest(
@NotNull @PositiveOrZero Integer version, @NotNull @PositiveOrZero Integer version,
@Valid @NotNull SaveRequest data @Valid @NotNull SaveRequest data
) {} ) {}
@Schema(name = "ContainerTransportUpdateRequest", @Schema(name = "ContainerTransportUpdateRequest",
description = "LOADING 阶段只允许修改集装箱号和航次") description = "拣货中阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest( public record TransportUpdateRequest(
@Size(max = 64) String containerNo, @Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo, @Size(max = 64) String voyageNo,
@@ -13,7 +13,13 @@ import org.apache.ibatis.annotations.Update;
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> { public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Select(""" @Select("""
<script> <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 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}) WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo}) 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.departurePortCode} IS NULL OR b.departure_port_code = #{query.departurePortCode})
AND (#{query.destinationPortCode} IS NULL OR b.destination_port_code = #{query.destinationPortCode}) 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.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.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.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo})
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') 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); Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
@Select(""" @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 FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE b.id = #{id} WHERE b.id = #{id}
""") """)
@@ -45,7 +45,10 @@ public interface LoadingListQueryMapper {
FROM fl_loading_plan p FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id 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 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.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
<if test="query.keyword != null and query.keyword != ''"> <if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{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); int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update(""" @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), 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 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); 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) { public int push(long planId, String key, int version, long operatorId, String operatorName) {
return plans.pushPreload(planId, key, version, operatorId, 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.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
value.setPlanStatus(SmartLoadingStatus.EDITING.name()); value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name());
return value; return value;
} }
@@ -55,7 +55,7 @@ public class ContainerService {
FullUpdateRequest request) { FullUpdateRequest request) {
ContainerBookingEntity current = required(id); ContainerBookingEntity current = required(id);
requireVersion(current, request.version()); requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.EDITING) { if (status(current) != SmartLoadingStatus.WAIT_LOADING && status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
} }
validateSchedule(request.data()); validateSchedule(request.data());
@@ -32,7 +32,7 @@ import java.util.Set;
@Service @Service
public class LoadingListService { 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 @Resource
private LoadingListRepository repository; private LoadingListRepository repository;
@Resource @Resource
@@ -43,8 +43,8 @@ public class LoadingListService {
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) { public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
validateRange(query.createdFrom(), query.createdTo()); validateRange(query.createdFrom(), query.createdTo());
String status = trim(query.status()); String status = trim(query.status());
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) { if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) {
throw badRequest("装柜清单状态只能是 LOADING"); throw badRequest("预装清单状态只能是 EDITING 或 LOADING");
} }
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status,
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
@@ -59,8 +59,8 @@ public class LoadingListService {
public PreloadDetail preloadDetail(long planId, long warehouseId) { public PreloadDetail preloadDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId); AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId); requireWarehouse(row, warehouseId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) { if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在"); throw notFound("预装清单不存在");
} }
return repository.preloadDetail(row); return repository.preloadDetail(row);
} }
@@ -160,8 +160,8 @@ public class LoadingListService {
} }
private static void requireLoadingMutable(AggregateRow row) { private static void requireLoadingMutable(AggregateRow row) {
if (!LOADING_STATUSES.contains(row.getPlanStatus())) { if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅装柜中可修改集装箱号与航次"); throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
} }
} }
@@ -22,7 +22,9 @@ import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -48,6 +50,8 @@ public class LoadingPickService {
@Resource @Resource
private LoadingPickRepository repository; private LoadingPickRepository repository;
@Resource @Resource
private SmartLoadingRepository smartLoadingRepository;
@Resource
private LoadingListService loadingListService; private LoadingListService loadingListService;
@Resource @Resource
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@@ -123,6 +127,7 @@ public class LoadingPickService {
throw conflict("PDA任务分配版本已变化"); throw conflict("PDA任务分配版本已变化");
} }
} }
startPickingIfNeeded(task, operatorId, operatorName);
insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName); insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName);
return view(task, null); return view(task, null);
} }
@@ -182,6 +187,7 @@ public class LoadingPickService {
history.setOperatorId(operatorId); history.setOperatorId(operatorId);
history.setOperatorName(operatorName); history.setOperatorName(operatorName);
repository.insert(history); repository.insert(history);
startPickingIfNeeded(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName); touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
@@ -207,6 +213,7 @@ public class LoadingPickService {
} }
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL", insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName); request.finalPickedPackageCount(), null, operatorId, operatorName);
startPickingIfNeeded(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName); touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
@@ -271,6 +278,9 @@ public class LoadingPickService {
for (BatchLine line : lines) { for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName); 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)) { if (!repository.allTaskItemsResolved(taskId)) {
throw conflict("整批提交必须处理本任务全部待拣明细"); 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) { private LoadingPickTaskEntity requiredTask(long taskId) {
LoadingPickTaskEntity task = repository.task(taskId); LoadingPickTaskEntity task = repository.task(taskId);
if (task == null) { if (task == null) {
@@ -89,7 +89,7 @@ public class SmartLoadingService {
+ '|' + request.expectedVersion()); + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash); Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion()); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
@@ -131,7 +131,7 @@ public class SmartLoadingService {
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion()); String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash); Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion()); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(booking.getWarehouseId())) { for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue; if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
@@ -163,7 +163,7 @@ public class SmartLoadingService {
+ '|' + request.expectedItemVersion()); + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash); Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
if (replay != null) return replay; 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); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
@@ -197,7 +197,7 @@ public class SmartLoadingService {
+ request.expectedPlanVersion() + '|' + request.expectedItemVersion()); + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash); Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
if (replay != null) return replay; 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); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
loadingPickService.remove(itemId); loadingPickService.remove(itemId);
@@ -216,7 +216,8 @@ public class SmartLoadingService {
String key = trim(idempotencyKey); String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); 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, "装柜计划不存在"); 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("仅编辑中的计划可开始装柜且版本必须匹配"); if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> items = repository.items(planId); List<LoadingPlanItemEntity> items = repository.items(planId);
@@ -228,7 +229,7 @@ public class SmartLoadingService {
try { try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { 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); LoadingPlanEntity pushed = repository.plan(planId);
return view(pushed, requireBooking(pushed.getContainerBookingId())); return view(pushed, requireBooking(pushed.getContainerBookingId()));
} }
@@ -329,7 +330,19 @@ public class SmartLoadingService {
} }
return view(plan, booking); 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 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 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) { 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); SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay; if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId); 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); PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, 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()) if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
@@ -91,7 +98,8 @@ public class SplitPreloadService {
throw concurrent(); throw concurrent();
} }
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, 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 @Transactional
@@ -602,6 +602,14 @@ BEGIN
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')) (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
OR (OLD.plan_status = 'LOADING' OR (OLD.plan_status = 'LOADING'
AND NEW.plan_status IN ('LOADING', 'COMPLETED')) 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 ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
END IF; END IF;
@@ -676,6 +684,20 @@ BEGIN
END$$ END$$
DELIMITER ; 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; DROP TRIGGER IF EXISTS trg_loading_item_before_insert;
DELIMITER $$ DELIMITER $$
CREATE TRIGGER trg_loading_item_before_insert CREATE TRIGGER trg_loading_item_before_insert
@@ -136,7 +136,7 @@ paths:
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}} - {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, 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: 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: responses:
'200': '200':
description: 成功或业务失败,按响应体 code 判断 description: 成功或业务失败,按响应体 code 判断
@@ -146,7 +146,7 @@ paths:
'403': {description: 无货柜查询权限} '403': {description: 无货柜查询权限}
post: post:
summary: 新增平台货柜 summary: 新增平台货柜
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 WAIT_LOADING(待装柜)
requestBody: requestBody:
required: true required: true
content: content:
@@ -166,7 +166,7 @@ paths:
'403': {description: 无货柜查询权限} '403': {description: 无货柜查询权限}
put: put:
summary: 草稿阶段完整编辑货柜 summary: 草稿阶段完整编辑货柜
description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许 description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING(预装柜中)时允许
parameters: parameters:
- {$ref: '#/components/parameters/ContainerBookingId'} - {$ref: '#/components/parameters/ContainerBookingId'}
requestBody: requestBody:
@@ -701,7 +701,7 @@ components:
actualPortArrivalAt: {type: string, format: date-time, nullable: true} actualPortArrivalAt: {type: string, format: date-time, nullable: true}
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {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} version: {type: integer, minimum: 0}
ContainerSaveRequest: ContainerSaveRequest:
type: object type: object
@@ -35,6 +35,7 @@ class ContainerControllerContractTest {
.contains("/api/platform/v1/containers/{id}/transport:") .contains("/api/platform/v1/containers/{id}/transport:")
.contains("platform:firstleg:container:list") .contains("platform:firstleg:container:list")
.contains("EDITING") .contains("EDITING")
.contains("WAIT_LOADING")
.contains("LOADING") .contains("LOADING")
.contains("COMPLETED") .contains("COMPLETED")
.doesNotContain("tenantId") .doesNotContain("tenantId")
@@ -24,7 +24,10 @@ class ContainerBookingMapperContractTest {
assertThat(select) assertThat(select)
.contains("fl_loading_plan") .contains("fl_loading_plan")
.contains("p.id AS plan_id") .contains("p.id AS plan_id")
.contains("CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'")
.contains("plan_status") .contains("plan_status")
.contains("p.plan_status != 'COMPLETED'")
.doesNotContain("&lt;&gt;")
.doesNotContain("tenant_id") .doesNotContain("tenant_id")
.doesNotContain("account_id"); .doesNotContain("account_id");
} }
@@ -34,7 +34,9 @@ class LoadingListQueryMapperContractTest {
.contains("FROM fl_loading_plan p") .contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b") .contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i") .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}") .contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId") .doesNotContain("query.accountId")
.doesNotContain("PRELOAD_PUSHED"); .doesNotContain("PRELOAD_PUSHED");
@@ -110,6 +110,8 @@ class SmartLoadingSqlContractTest {
.contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))") .contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))")
.contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')") .contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')")
.contains("NEW.plan_status IN ('LOADING', 'COMPLETED')") .contains("NEW.plan_status IN ('LOADING', 'COMPLETED')")
.contains("OLD.plan_status = 'LOADING' AND NEW.plan_status = 'EDITING'")
.contains("旧版本在推送预装时即写入 LOADING")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' 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.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
} }
@Test @Test
@@ -73,6 +73,20 @@ class ContainerServiceTest {
verify(repository).updateDraft(current, 2, 9L, "admin"); 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 @Test
@DisplayName("进入预装柜中后拒绝完整编辑") @DisplayName("进入预装柜中后拒绝完整编辑")
void rejectsFullUpdateAfterPreloadPush() { void rejectsFullUpdateAfterPreloadPush() {
@@ -50,13 +50,12 @@ class LoadingListServiceTest {
} }
@Test @Test
void shouldRejectEditingStatusFromPreloadList() { void shouldAcceptEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20); PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
assertThatThrownBy(() -> service.preloadPage(query)) service.preloadPage(query);
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()) verify(repository).preloadPage(query);
.isEqualTo(ResultCode.BAD_REQUEST);
} }
@Test @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.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; 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.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InOrder; 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.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -36,6 +39,8 @@ class LoadingPickServiceTest {
@MockBean @MockBean
private LoadingPickRepository repository; private LoadingPickRepository repository;
@MockBean @MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private LoadingListService loadingListService; private LoadingListService loadingListService;
@MockBean @MockBean
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@@ -75,6 +80,52 @@ class LoadingPickServiceTest {
verify(repository).insert(any(LoadingPickHistoryEntity.class)); 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 @Test
void touchesTaskBeforeCompletingLastPartialItem() { void touchesTaskBeforeCompletingLastPartialItem() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
@@ -202,6 +202,28 @@ class SmartLoadingServiceTest {
assertThat(captor.getValue().getProductQuantity()).isEqualTo(67); 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 @Test
void replaysBeforeVersionCheck() { void replaysBeforeVersionCheck() {
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay"); AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
@@ -335,7 +357,7 @@ class SmartLoadingServiceTest {
@Test @Test
void pushesIdempotently() { void pushesIdempotently() {
LoadingPlanEntity editing = plan("EDITING", 3); LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("LOADING", 4); LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key"); pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed); when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); 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"); 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"); verify(repository).push(1, "key", 3, 7, "u");
} }
@@ -373,7 +395,7 @@ class SmartLoadingServiceTest {
@Test @Test
void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() { void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() {
LoadingPlanEntity editing = plan("EDITING", 1); LoadingPlanEntity editing = plan("EDITING", 1);
LoadingPlanEntity pushed = plan("LOADING", 2); LoadingPlanEntity pushed = plan("EDITING", 2);
ContainerBookingEntity booking = booking(); ContainerBookingEntity booking = booking();
booking.setContainerNo(null); booking.setContainerNo(null);
when(repository.plan(1)).thenReturn(editing, pushed); 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"); 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(repository).push(1, "no-container-key", 1, 7, "u");
verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList());
} }
@@ -8,9 +8,11 @@ import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingStatusTest { class SmartLoadingStatusTest {
@Test @Test
void statusCanOnlyAdvanceOneWay() { void statusCanOnlyAdvanceOneWay() {
assertThat(SmartLoadingStatus.WAIT_LOADING.canAdvanceTo(SmartLoadingStatus.EDITING)).isTrue();
assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue(); assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue();
assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue(); assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue();
assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse(); assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse();
assertThat(SmartLoadingStatus.WAIT_LOADING.mutable()).isTrue();
assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue(); assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue();
assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue(); assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue();
assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue(); assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue();
@@ -21,6 +21,7 @@ import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
@@ -72,4 +73,36 @@ class SplitPreloadServiceTest {
verify(loadingModelBridge).requireStarted(10, 20); verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).beginLoading(10, 20, 7, 4); 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");
}
} }