第三期装柜验收优化修改

This commit is contained in:
2026-08-12 13:34:26 +08:00
parent d2704f68a6
commit c2b1ab92ff
27 changed files with 258 additions and 71 deletions
@@ -38,7 +38,12 @@ public interface WarehousePalletLocationClient {
@PostMapping("/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@RequestBody SettlementRequest request);
@GetMapping("/settlements/target")
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("sourceBusinessId") long sourceBusinessId,
@RequestParam("idempotencyKey") String idempotencyKey);
@GetMapping("/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
@@ -137,6 +138,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.review(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/complete")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody CompleteLoadingRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.complete(taskId, request, user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/operations")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user,
@@ -8,7 +8,7 @@ public enum SmartLoadingStatus {
EDITING,
/** 拣货中:已生成拣货任务,仍可追加或调整未拣货明细。 */
LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
/** 装柜完成:PDA 人工确认装柜完成后进入,计划、明细和货柜信息只读。 */
COMPLETED;
/** 装柜完成前可维护集装箱号和航次。 */
@@ -98,6 +98,15 @@ public final class LoadingPickDtos {
) {
}
/** PDA 人工确认装柜完成。拣货记录只会锁定货物,确认后才结算资源并完成装柜。 */
public record CompleteLoadingRequest(
@NotNull @Min(0) Integer expectedTaskVersion,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String remark
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId,
String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
Integer plannedPackageCount, Integer pickedPackageCount,
@@ -182,8 +182,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count &lt; #{finalCount}
AND #{finalCount} &lt; planned_package_count
AND picked_package_count < #{finalCount}
AND #{finalCount} < planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
@Param("releaseMode") String releaseMode,
@@ -12,7 +12,9 @@ import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
@@ -25,6 +27,8 @@ import java.time.LocalDateTime;
public class ContainerService {
@Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickService loadingPickService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
@@ -83,7 +87,19 @@ public class ContainerService {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
return required(id);
ContainerBookingEntity updated = required(id);
beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName);
return updated;
}
/** 预装清单先推送、后录入集装箱号时,补建对应的 PDA 拣货任务。 */
private void beginPdaTaskAfterContainerBound(ContainerBookingEntity booking, String containerNo,
long operatorId, String operatorName) {
if (containerNo == null) return;
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId());
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName,
smartLoadingRepository.items(plan.getId()));
}
private ContainerBookingEntity required(long id) {
@@ -107,11 +107,11 @@ public class LoadingListService {
}
/**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
* PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@Transactional
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单")
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) {
@@ -139,7 +139,7 @@ public class LoadingListService {
"LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货");
"PDA已人工确认装柜完成");
}
private AggregateRow requirePlan(long planId) {
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.Review;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
@@ -327,39 +328,37 @@ public class LoadingPickService {
@Transactional
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(),
request.remark()), operatorId, operatorName);
}
/**
* PDA 人工点击装柜完成后的唯一结算入口。
* 逐项拣货仅记录和锁定库存;此处才执行拆单、资源释放、计划完成和装柜清单生成。
*/
@Transactional
public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
String remark = trim(request.remark());
String requestHash = hash("REVIEW|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经复核");
List<LoadingPickItemEntity> taskItems = repository.items(taskId);
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (List<PdaPickSettlementPlan.SplitLine> lines : resolution.partialByInbound().values()) {
for (PdaPickSettlementPlan.SplitLine line : lines) {
if (repository.settlePartial(line.loadingPlanItemId(), line.pickItemId(),
line.pickedPackageCount(), line.pickedPackageCount() + line.remainingPackageCount(),
operatorId, operatorName) != 1) {
throw conflict("部分拣货结算版本已变化");
}
}
}
for (Long planItemId : resolution.skippedPlanItemIds()) {
if (repository.settleSkipped(planItemId, operatorId, operatorName) != 1) {
throw conflict("跳拣结算版本已变化");
}
if (!repository.allTaskItemsResolved(taskId)) {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
settlementService.settle(taskId, operatorId, operatorName);
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
}
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
throw conflict("PDA任务已经复核");
}
insertOperation(taskId, operationId, requestHash, "REVIEW", operatorId, operatorName);
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
repository.syncInboundProjections(taskId, operatorId, operatorName);
return view(requiredTask(taskId), null);
@@ -408,13 +407,7 @@ public class LoadingPickService {
}
private void settleAndFinish(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (!repository.allTaskItemsResolved(task.getId())) {
return;
}
settlementService.settle(task.getId(), operatorId, operatorName);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName);
}
// 拣货完成仅锁定明细。资源结算与装柜完成必须由 PDA 人工点击“装柜完成”触发。
}
/** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */
@@ -736,8 +729,7 @@ public class LoadingPickService {
}
/**
* 先触碰仍处于 LOADING 的任务,再结算并推进装柜计划。计划完成触发器会同步把任务改为
* COMPLETED,因此绝不能在 finishFromPda 之后再以 LOADING 状态触碰任务。
* 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。
*/
private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
@@ -161,7 +161,11 @@ public class PdaPickSettlementService {
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"pda-task-" + taskId, operatorId, operatorName);
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
String settlementKey = "PDA-SETTLE-" + taskId + '-' + parentInboundId;
long remainderChildId = childPackageCount > 0 ? existingSettlementTarget(parentInboundId, settlementKey) : 0L;
if (childPackageCount > 0 && remainderChildId == 0L) {
remainderChildId = IdWorker.getId();
}
long batchId = IdWorker.getId();
String idempotencyKey = "pda-task-" + taskId;
int rootVersion = root.version() == null ? 0 : root.version();
@@ -421,6 +425,16 @@ public class PdaPickSettlementService {
}
}
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
try {
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
return response != null && response.successful() && response.data() != null
? response.data() : 0L;
} catch (FeignException exception) {
return 0L;
}
}
private static boolean sameReleasedReservations(List<Long> expected,
List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) {
return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId)