This commit is contained in:
2026-08-06 17:48:50 +08:00
parent c68eedd3f3
commit 913eca7b7d
21 changed files with 1767 additions and 77 deletions
@@ -3,7 +3,6 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
@@ -24,7 +23,6 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -70,19 +68,6 @@ public class PlatformLoadingListController {
planId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "完成装柜", description = "状态仅允许 LOADING 推进到 COMPLETED,完成后主单和明细冻结。")
@PostMapping("/loading-plans/{planId}/complete")
@PreAuthorize("hasAuthority('platform:firstleg:preload:complete')")
public ApiResponse<ConfirmedDetail> complete(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody AdvanceRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.complete(
planId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
@GetMapping("/loading-lists")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
@@ -18,4 +19,24 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version);
/**
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
*/
@Select("""
SELECT COUNT(*)
FROM fl_loading_plan_item plan_item
WHERE plan_item.loading_plan_id = #{planId}
AND plan_item.deleted = 0
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_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND pick_item.picked_package_count = plan_item.package_count
)
""")
long unfinishedCount(@Param("planId") long planId);
}
@@ -7,7 +7,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
@@ -15,10 +16,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
LEFT JOIN fl_loading_plan_item l
ON l.account_id=o.tenant_id AND l.active_inbound_detail_id=i.inbound_detail_id
WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id IS NOT NULL AND l.id IS NULL
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
""")
List<Candidate> candidates(@Param("accountId") long accountId);
@@ -29,7 +30,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
@@ -39,7 +41,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
@@ -67,6 +67,10 @@ public class LoadingPickRepository {
return items.pick(itemId, count, version);
}
public boolean allPlanItemsPicked(long planId) {
return items.unfinishedCount(planId) == 0;
}
public LoadingPickHistoryEntity history(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
@@ -48,8 +48,9 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanItemEntity activeItem(long accountId, long detailId) {
public LoadingPlanItemEntity activeItem(long planId, long accountId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getAccountId, accountId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
@@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
@@ -15,6 +14,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -36,6 +36,8 @@ public class LoadingListService {
@Resource
private LoadingListRepository repository;
@Resource
private LoadingPickRepository pickRepository;
@Resource
private ObjectMapper objectMapper;
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
@@ -101,40 +103,40 @@ public class LoadingListService {
return repository.preloadDetail(requirePlan(planId));
}
/**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@Transactional
@BusinessAudit(event = "PRELOAD_COMPLETE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "完成预装")
public ConfirmedDetail complete(long planId, long operatorId, String operatorName,
String idempotencyKey, AdvanceRequest request) {
advance(planId, operatorId, operatorName, idempotencyKey, request,
"LOADING", "COMPLETED");
return repository.confirmedDetail(requirePlan(planId));
}
private void advance(long planId, long operatorId, String operatorName, String idempotencyKey,
AdvanceRequest request, String fromStatus, String toStatus) {
String requestHash = sha256(toStatus + '|' + planId + '|' + request.expectedVersion()
+ '|' + trim(request.operationReason()));
String operationId = operationId(toStatus, planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
requireSameRequest(replay, requestHash);
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单")
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) {
return;
}
if (!pickRepository.allPlanItemsPicked(planId)) {
throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单");
}
AggregateRow before = requirePlan(planId);
if (!fromStatus.equals(before.getPlanStatus())) {
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus);
if ("COMPLETED".equals(before.getPlanStatus())) {
return;
}
if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(),
if (!"LOADING".equals(before.getPlanStatus())) {
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜");
}
if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(),
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试");
AggregateRow current = requirePlan(planId);
if ("COMPLETED".equals(current.getPlanStatus())) {
return;
}
throw conflict("装柜计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "ADVANCE_PRELOAD_STATUS",
fromStatus, toStatus, json(snapshot(before)),
json(values("requestHash", requestHash, "status", toStatus,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason()));
repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING",
"LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货");
}
private AggregateRow requirePlan(long planId) {
@@ -26,6 +26,8 @@ import java.util.List;
public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Resource
private LoadingListService loadingListService;
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
@@ -104,6 +106,9 @@ public class LoadingPickService {
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
}
return view(requiredTask(taskId));
}
@@ -91,10 +91,11 @@ public class SmartLoadingService {
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden();
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(request.accountId(), request.inboundDetailId());
if (existing != null && !Objects.equals(existing.getLoadingPlanId(), planId)) throw conflict("货物明细已被有效计划锁定");
LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
if (total > candidate.availablePackageCount()) throw validation("累计分配箱数超过可用箱数");
int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount());
if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
@@ -167,7 +168,7 @@ public class SmartLoadingService {
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount()) throw validation("调整箱数超过可用箱数");
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));