This commit is contained in:
2026-08-06 15:48:21 +08:00
parent 4df8b8c355
commit c3a978b497
71 changed files with 6268 additions and 13 deletions
@@ -19,6 +19,14 @@ public interface OrderCargoClient {
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
String cargoRemark) {
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
}
}
}
@@ -25,6 +25,11 @@ public interface WarehousePalletLocationClient {
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
@@ -33,10 +38,16 @@ public interface WarehousePalletLocationClient {
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
long operatorId, String reason, int totalPackageCount,
List<Long> locationIds, Integer releaseLocationCount) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo,
long palletId, long locationId, String locationCode,
int releasedPackageCount) {}
}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
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.ContainerDtos.FullUpdateRequest;
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.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Tag(name = "货柜基础资料") @Validated @RestController
@RequestMapping("/api/platform/v1/containers")
public class PlatformContainerController {
@Resource private ContainerService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody SaveRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request));
}
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
}
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
}
}
@@ -0,0 +1,103 @@
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;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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;
import org.springframework.web.bind.annotation.RestController;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg")
public class PlatformLoadingListController {
@Resource
private LoadingListService service;
@Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans")
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
public ApiResponse<PageResult<PreloadSummary>> preloadPage(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PreloadQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadPage(query));
}
@Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。")
@GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadDetail(planId));
}
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(
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')")
public ApiResponse<PageResult<ConfirmedSummary>> confirmedPage(
@AuthenticationPrincipal AuthenticatedUser user, @Valid ConfirmedQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedPage(query));
}
@Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。")
@GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedDetail(planId));
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** PDA 按集装箱号执行装柜拣货。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg/pda-loading")
public class PlatformLoadingPdaController {
@Resource
private LoadingPickService service;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody PickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService;
import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List;
@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading")
public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service;
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));}
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
}
@@ -0,0 +1,18 @@
package com.baiyue.firstleg.domain;
/** 智能装柜状态,只允许沿此方向推进。 */
public enum SmartLoadingStatus {
EDITING,
LOADING,
COMPLETED;
/** 装柜完成前可维护集装箱号和航次。 */
public boolean allowsTransportUpdate() { return this != COMPLETED; }
/** 编辑中和装柜中均允许维护尚未拣货的明细。 */
public boolean mutable() { return this != COMPLETED; }
public boolean canAdvanceTo(SmartLoadingStatus next) {
return next != null && next.ordinal() == ordinal() + 1;
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 平台级货柜基础资料请求模型。 */
public final class ContainerDtos {
private ContainerDtos() {}
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
public record Query(
@Size(max = 64) String containerNo,
@Size(max = 64) String cabinetNo,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String keyword,
@Positive Long warehouseId,
@Size(max = 32) String containerType,
@Positive Long shippingCompanyId,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 64) String destinationWarehouseCode,
SmartLoadingStatus status,
LocalDateTime estimatedSailingFrom,
LocalDateTime estimatedSailingTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest(
@Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotBlank @Size(max = 32) String containerType,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 64) String destinationWarehouseCode,
@Size(max = 128) String destinationWarehouseName,
@DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@Size(max = 128) String terminalTruckingCompany,
LocalDateTime loadingAt,
LocalDateTime estimatedSailingAt,
LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt,
LocalDateTime actualPortArrivalAt,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt
) {}
@Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑")
public record FullUpdateRequest(
@NotNull @PositiveOrZero Integer version,
@Valid @NotNull SaveRequest data
) {}
@Schema(name = "ContainerTransportUpdateRequest",
description = "LOADING 阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version
) {}
}
@@ -0,0 +1,169 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 同一装柜计划聚合的预装清单和已确认装柜清单接口模型。 */
public final class LoadingListDtos {
private LoadingListDtos() {
}
public record PreloadQuery(
@Size(max = 128) String keyword,
String status,
@Min(1) Long accountId,
@Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public PreloadQuery {
page = page == 0 ? 1 : page;
size = size == 0 ? 20 : size;
}
}
public record ConfirmedQuery(
@Size(max = 128) String keyword,
@Min(1) Long accountId,
@Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public ConfirmedQuery {
page = page == 0 ? 1 : page;
size = size == 0 ? 20 : size;
}
}
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
) {
}
public record AdvanceRequest(
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
) {
}
public record Customer(Long accountId, String customerName) {
}
public record PreloadSummary(
Long planId,
String preloadNo,
String containerNo,
String internalContainerNo,
List<Customer> customers,
Long warehouseId,
String warehouseName,
String vesselName,
String voyageNo,
String containerType,
String status,
long skuCount,
long totalPackageCount,
BigDecimal totalVolumeCbm,
BigDecimal totalGrossWeightKg,
LocalDateTime createdAt,
Integer version
) {
public PreloadSummary {
customers = customers == null ? List.of() : List.copyOf(customers);
}
}
public record ConfirmedSummary(
Long planId,
String loadingListNo,
Long warehouseId,
String warehouseName,
String containerNo,
String internalContainerNo,
String status,
long totalPackageCount,
BigDecimal totalGrossWeightKg,
BigDecimal totalNetWeightKg,
BigDecimal totalVolumeCbm,
long totalQuantity,
String remark,
Long createdBy,
String createdByName,
LocalDateTime createdAt,
Integer version
) {
}
public record Item(
Long itemId,
Long accountId,
String customerName,
Long inboundId,
Long inboundDetailId,
String inboundCode,
String skuCode,
String destinationWarehouse,
String chineseName,
String englishName,
String goodsImageUrl,
int packageCount,
int palletCount,
BigDecimal grossWeightKg,
BigDecimal netWeightKg,
BigDecimal volumeCbm,
BigDecimal dimensionalWeightKg,
long productQuantity,
int unitsPerPackage,
String fbaTrackingNo,
String shipmentNo,
String shippingMark,
String internalRemark,
String customerRemark,
String warehouseRemark,
int loadSequence,
LocalDateTime frozenAt
) {
}
public record History(
Long historyId,
Long itemId,
String actionType,
String fromStatus,
String toStatus,
String beforeSnapshot,
String afterSnapshot,
Long operatorId,
String operatorName,
String operationReason,
LocalDateTime occurredAt
) {
}
public record PreloadDetail(PreloadSummary summary, List<Item> items, List<History> histories) {
public PreloadDetail {
items = items == null ? List.of() : List.copyOf(items);
histories = histories == null ? List.of() : List.copyOf(histories);
}
}
public record ConfirmedDetail(ConfirmedSummary summary, List<Item> items, List<History> histories) {
public ConfirmedDetail {
items = items == null ? List.of() : List.copyOf(items);
histories = histories == null ? List.of() : List.copyOf(histories);
}
}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** PDA 装柜拣货接口模型。 */
public final class LoadingPickDtos {
private LoadingPickDtos() {
}
public record PickRequest(
@NotNull @Min(1) Integer pickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
Integer version, List<PickItem> items) {
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 平台级货柜预订资料;预装状态由关联装柜计划聚合。 */
@Getter @Setter
@TableName("fl_container_booking")
public class ContainerBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
private String vesselName;
private String voyageNo;
private String containerType;
private Long shippingCompanyId;
private String shippingCompanyName;
private String departurePortCode;
private String departurePortName;
private String destinationPortCode;
private String destinationPortName;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal capacityCbm;
private BigDecimal maxWeightKg;
private String terminalTruckingCompany;
private LocalDateTime loadingAt;
private LocalDateTime estimatedSailingAt;
private LocalDateTime actualSailingAt;
private LocalDateTime estimatedPortArrivalAt;
private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private LocalDateTime actualWarehouseArrivalAt;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
@TableField(exist = false) private Long planId;
@TableField(exist = false) private String planStatus;
}
@@ -26,6 +26,7 @@ public class InboundPalletAllocationEntity {
private BigDecimal palletCapacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private Integer pickedPackageCount;
private Long locationId;
private String locationCode;
private String reservationNo;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/** PDA 拣货操作幂等历史。 */
@Getter
@Setter
@TableName("fl_loading_pick_history")
public class LoadingPickHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long pickItemId;
private String operationId;
private String requestHash;
private Integer pickedPackageCount;
private Long operatorId;
private String operatorName;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 装柜拣货明细。 */
@Getter
@Setter
@TableName("fl_loading_pick_item")
public class LoadingPickItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long loadingPlanItemId;
private Long inboundId;
private Long inboundDetailId;
private Integer plannedPackageCount;
private Integer pickedPackageCount;
private String itemStatus;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 装柜拣货任务。 */
@Getter
@Setter
@TableName("fl_loading_pick_task")
public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long loadingPlanId;
private String containerNo;
private String taskStatus;
private Long startedBy;
private String startedByName;
private LocalDateTime startedAt;
private Long completedBy;
private String completedByName;
private LocalDateTime completedAt;
@Version
private Integer version;
}
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan")
public class LoadingPlanEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus;
private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt;
private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy;
private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName;
private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan_history")
public class LoadingPlanHistoryEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long loadingPlanItemId; private String operationId;
private String actionType; private String fromStatus; private String toStatus; private String beforeSnapshot; private String afterSnapshot;
private Long operatorId; private String operatorName; private String operationReason; private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan_item")
public class LoadingPlanItemEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long accountId; private String customerName;
private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn;
private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton;
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
@TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt;
private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
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
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Select("""
<script>
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') 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})
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
AND (#{query.warehouseId} IS NULL OR b.warehouse_id = #{query.warehouseId})
AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType})
AND (#{query.shippingCompanyId} IS NULL OR b.shipping_company_id = #{query.shippingCompanyId})
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.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}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.vessel_name LIKE CONCAT('%', #{query.keyword}, '%')
OR b.voyage_no LIKE CONCAT('%', #{query.keyword}, '%'))
ORDER BY b.updated_at DESC, b.id DESC
</script>
""")
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
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE b.id = #{id}
""")
ContainerBookingEntity selectBooking(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo},
container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId},
shipping_company_name=#{value.shippingCompanyName}, departure_port_code=#{value.departurePortCode},
departure_port_name=#{value.departurePortName}, destination_port_code=#{value.destinationPortCode},
destination_port_name=#{value.destinationPortName}, destination_warehouse_code=#{value.destinationWarehouseCode},
destination_warehouse_name=#{value.destinationWarehouseName}, capacity_cbm=#{value.capacityCbm},
max_weight_kg=#{value.maxWeightKg}, terminal_trucking_company=#{value.terminalTruckingCompany},
loading_at=#{value.loadingAt}, estimated_sailing_at=#{value.estimatedSailingAt},
actual_sailing_at=#{value.actualSailingAt}, estimated_port_arrival_at=#{value.estimatedPortArrivalAt},
actual_port_arrival_at=#{value.actualPortArrivalAt},
estimated_warehouse_arrival_at=#{value.estimatedWarehouseArrivalAt},
actual_warehouse_arrival_at=#{value.actualWarehouseArrivalAt}, updated_by=#{operatorId},
updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{value.id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status&lt;&gt;'EDITING')
""")
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
""")
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -3,7 +3,38 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
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
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
@Select("""
SELECT *
FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId}
AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED'
ORDER BY allocation_no ASC
FOR UPDATE
""")
java.util.List<InboundPalletAllocationEntity> selectConfirmedForUpdate(
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET picked_package_count = picked_package_count + #{count},
allocation_status = CASE
WHEN picked_package_count + #{count} = allocated_package_count THEN 'RELEASED'
ELSE 'CONFIRMED'
END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED'
AND picked_package_count + #{count} <= allocated_package_count
""")
int consume(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("count") int count);
}
@@ -0,0 +1,297 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */
@Mapper
public interface LoadingListQueryMapper {
String AGGREGATE_COLUMNS = """
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type,
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
COALESCE(SUM(i.package_count), 0) AS total_package_count,
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg,
COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm,
COALESCE(SUM(i.product_quantity), 0) AS total_quantity
""";
String AGGREGATE_GROUP = """
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type
""";
@Select("""
<script>
SELECT
""" + AGGREGATE_COLUMNS + """
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'
<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}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
""" + AGGREGATE_GROUP + """
ORDER BY p.created_at DESC, p.id DESC
</script>
""")
Page<AggregateRow> selectPreloadPage(Page<?> page, @Param("query") PreloadQuery query);
@Select("""
<script>
SELECT
""" + AGGREGATE_COLUMNS + """
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 = 'COMPLETED'
<if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
""" + AGGREGATE_GROUP + """
ORDER BY p.created_at DESC, p.id DESC
</script>
""")
Page<AggregateRow> selectConfirmedPage(Page<?> page, @Param("query") ConfirmedQuery query);
@Select("""
SELECT
""" + AGGREGATE_COLUMNS + """
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.id = #{planId}
GROUP BY
""" + AGGREGATE_GROUP)
AggregateRow selectAggregate(@Param("planId") long planId);
@Select("""
SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id,
inbound_code, sku_code, destination_warehouse_name, product_name_zh,
product_name_en, product_image_url, package_count, pallet_count,
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
load_sequence, frozen_at
FROM fl_loading_plan_item
WHERE loading_plan_id = #{planId} AND deleted = 0
ORDER BY load_sequence, id
""")
List<ItemRow> selectItems(@Param("planId") long planId);
@Select("""
<script>
SELECT DISTINCT loading_plan_id AS plan_id, account_id, customer_name
FROM fl_loading_plan_item
WHERE deleted = 0 AND loading_plan_id IN
<foreach collection="planIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY loading_plan_id, account_id
</script>
""")
List<CustomerRow> selectCustomers(@Param("planIds") List<Long> planIds);
@Select("""
SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id,
action_type, from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at AS occurred_at
FROM fl_loading_plan_history
WHERE loading_plan_id = #{planId}
ORDER BY created_at DESC, id DESC
""")
List<HistoryRow> selectHistories(@Param("planId") long planId);
@Select("""
SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id,
action_type, from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at AS occurred_at
FROM fl_loading_plan_history WHERE operation_id = #{operationId}
""")
HistoryRow selectHistoryByOperation(@Param("operationId") String operationId);
@Update("""
UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'LOADING'
AND version = #{expectedVersion}
""")
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking b
JOIN fl_loading_plan p ON p.container_booking_id = b.id
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status = 'LOADING'
""")
int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan
SET plan_status = #{toStatus},
preload_completed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId}
ELSE preload_completed_by END,
preload_completed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName}
ELSE preload_completed_by_name END,
preload_completed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3)
ELSE preload_completed_at END,
confirmed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} ELSE confirmed_by END,
confirmed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName}
ELSE confirmed_by_name END,
confirmed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3)
ELSE confirmed_at END,
updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = #{fromStatus} AND version = #{expectedVersion}
""")
int advance(@Param("planId") long planId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_loading_plan_history
(id, loading_plan_id, loading_plan_item_id, operation_id, action_type,
from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at)
VALUES
(#{id}, #{planId}, NULL, #{operationId}, #{actionType},
#{fromStatus}, #{toStatus}, #{beforeSnapshot}, #{afterSnapshot},
#{operatorId}, #{operatorName}, #{operationReason}, CURRENT_TIMESTAMP(3))
""")
int insertHistory(HistoryWrite value);
@Getter
@Setter
class AggregateRow {
private Long planId;
private String planNo;
private Long containerBookingId;
private String planStatus;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Integer version;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String vesselName;
private String voyageNo;
private String containerType;
private Long skuCount;
private Long totalPackageCount;
private BigDecimal totalGrossWeightKg;
private BigDecimal totalNetWeightKg;
private BigDecimal totalVolumeCbm;
private Long totalQuantity;
}
@Getter
@Setter
class ItemRow {
private Long itemId;
private Long accountId;
private String customerName;
private Long inboundId;
private Long inboundDetailId;
private String inboundCode;
private String skuCode;
private String destinationWarehouseName;
private String productNameZh;
private String productNameEn;
private String productImageUrl;
private Integer packageCount;
private Integer palletCount;
private BigDecimal grossWeightKg;
private BigDecimal netWeightKg;
private BigDecimal volumeCbm;
private BigDecimal dimensionalWeightKg;
private Long productQuantity;
private Integer unitsPerCarton;
private String fbaTrackingNo;
private String shipmentNo;
private String shippingMark;
private String internalRemark;
private String customerRemark;
private String warehouseRemark;
private Integer loadSequence;
private LocalDateTime frozenAt;
}
@Getter
@Setter
class HistoryRow {
private Long historyId;
private Long itemId;
private String operationId;
private String actionType;
private String fromStatus;
private String toStatus;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
private String operationReason;
private LocalDateTime occurredAt;
}
@Getter
@Setter
class CustomerRow {
private Long planId;
private Long accountId;
private String customerName;
}
record HistoryWrite(long id, long planId, String operationId, String actionType,
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
}
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoadingPickHistoryMapper extends BaseMapper<LoadingPickHistoryEntity> {
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.mapper;
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.Update;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = picked_package_count + #{count},
item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
THEN 'PICKED' ELSE 'PICKING' END,
version = version + 1
WHERE id = #{id} AND version = #{version}
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
}
@@ -0,0 +1,3 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface LoadingPlanHistoryMapper extends BaseMapper<LoadingPlanHistoryEntity> {}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface LoadingPlanItemMapper extends BaseMapper<LoadingPlanItemEntity> {
@Update("""
UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName},
removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{itemId} AND loading_plan_id=#{planId} AND deleted=0 AND frozen_at IS NULL
AND version=#{expectedVersion}
""")
int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface LoadingPlanMapper extends BaseMapper<LoadingPlanEntity> {
@Update("""
UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion}
""")
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},
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}
""")
int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
@Mapper public interface SmartLoadingCandidateMapper {
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
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,
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
AND m.status='APPROVED'
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
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
""")
List<Candidate> candidates(@Param("accountId") long accountId);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
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,
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
AND m.status='APPROVED'
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
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
""")
Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@Repository
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
public PageResult<ContainerBookingEntity> page(Query query) {
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
}
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getContainerNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public boolean cabinetNoExists(String no, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getCabinetNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
}
}
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -21,8 +22,7 @@ public class InboundPalletAllocationRepository {
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId,
BigDecimal capacity,
BatchResult result) {
BigDecimal capacity, int totalPackageCount, BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
@@ -32,7 +32,11 @@ public class InboundPalletAllocationRepository {
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
result.allocations().forEach(source -> {
int allocationCount = result.allocations().size();
int perAllocation = allocationCount == 0 ? 0
: (int) Math.ceil((double) totalPackageCount / allocationCount);
int remaining = totalPackageCount;
for (var source : result.allocations()) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
@@ -43,6 +47,10 @@ public class InboundPalletAllocationRepository {
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(capacity);
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
int allocatedPackages = Math.min(perAllocation, remaining);
value.setAllocatedPackageCount(allocatedPackages);
value.setPickedPackageCount(0);
remaining -= allocatedPackages;
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
@@ -52,7 +60,17 @@ public class InboundPalletAllocationRepository {
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
});
}
}
public List<InboundPalletAllocationEntity> lockConfirmed(long tenantId, long inboundId) {
return mapper.selectConfirmedForUpdate(tenantId, inboundId);
}
public void consume(long tenantId, long inboundId, long allocationId, int packageCount) {
if (mapper.consume(allocationId, tenantId, inboundId, packageCount) != 1) {
throw new IllegalStateException("库位箱数已被并发操作修改");
}
}
private static String normalize(String state) {
@@ -0,0 +1,150 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.Customer;
import com.baiyue.firstleg.dto.LoadingListDtos.History;
import com.baiyue.firstleg.dto.LoadingListDtos.Item;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryWrite;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Repository
public class LoadingListRepository {
@Resource
private LoadingListQueryMapper mapper;
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
Page<AggregateRow> page = mapper.selectPreloadPage(new Page<>(query.page(), query.size()), query);
Map<Long, List<Customer>> customers = customerMap(page.getRecords());
List<PreloadSummary> records = page.getRecords().stream()
.map(row -> preload(row, customers.get(row.getPlanId())))
.toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
Page<AggregateRow> page = mapper.selectConfirmedPage(new Page<>(query.page(), query.size()), query);
List<ConfirmedSummary> records = page.getRecords().stream().map(this::confirmed).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public AggregateRow aggregate(long planId) {
return mapper.selectAggregate(planId);
}
public PreloadDetail preloadDetail(AggregateRow row) {
List<ItemRow> items = mapper.selectItems(row.getPlanId());
List<Customer> customers = items.stream()
.collect(Collectors.toMap(ItemRow::getAccountId, ItemRow::getCustomerName,
(left, right) -> left, java.util.LinkedHashMap::new))
.entrySet().stream().map(entry -> new Customer(entry.getKey(), entry.getValue())).toList();
return new PreloadDetail(preload(row, customers), items(items), histories(row.getPlanId()));
}
public ConfirmedDetail confirmedDetail(AggregateRow row) {
return new ConfirmedDetail(confirmed(row), items(mapper.selectItems(row.getPlanId())),
histories(row.getPlanId()));
}
public HistoryRow history(String operationId) {
return mapper.selectHistoryByOperation(operationId);
}
public int updateTransport(long planId, int expectedVersion, String containerNo, String voyageNo,
long operatorId, String operatorName) {
if (mapper.touchEditable(planId, expectedVersion, operatorId, operatorName) != 1) {
return 0;
}
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public int advance(long planId, String fromStatus, String toStatus, int expectedVersion,
long operatorId, String operatorName) {
return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName);
}
public void appendHistory(long planId, String operationId, String actionType,
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName,
operationReason));
}
private Map<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
List<Long> ids = rows.stream().map(AggregateRow::getPlanId).toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.selectCustomers(ids).stream().collect(Collectors.groupingBy(
CustomerRow::getPlanId, java.util.LinkedHashMap::new,
Collectors.mapping(row -> new Customer(row.getAccountId(), row.getCustomerName()),
Collectors.toList())));
}
private PreloadSummary preload(AggregateRow row, List<Customer> customers) {
return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(),
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion());
}
private ConfirmedSummary confirmed(AggregateRow row) {
return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(),
row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()),
decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()),
decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(),
row.getCreatedBy(), row.getCreatedByName(), row.getCreatedAt(), row.getVersion());
}
private List<Item> items(List<ItemRow> rows) {
return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(),
row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()),
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(),
integer(row.getLoadSequence()), row.getFrozenAt())).toList();
}
private List<History> histories(long planId) {
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
row.getOccurredAt())).toList();
}
private static long value(Long value) {
return value == null ? 0 : value;
}
private static int integer(Integer value) {
return value == null ? 0 : value;
}
private static BigDecimal decimal(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -0,0 +1,86 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** PDA 拣货任务持久化边界。 */
@Repository
public class LoadingPickRepository {
@Resource
private LoadingPickTaskMapper tasks;
@Resource
private LoadingPickItemMapper items;
@Resource
private LoadingPickHistoryMapper histories;
public LoadingPickTaskEntity task(long taskId) {
return tasks.selectById(taskId);
}
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
}
public LoadingPickTaskEntity taskByContainer(String containerNo) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getContainerNo, containerNo)
.eq(LoadingPickTaskEntity::getTaskStatus, "LOADING"));
}
public LoadingPickItemEntity item(long taskId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.eq(LoadingPickItemEntity::getId, itemId));
}
public List<LoadingPickItemEntity> items(long taskId) {
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickItemEntity::getId));
}
public void insert(LoadingPickTaskEntity task) {
tasks.insert(task);
}
public void insert(LoadingPickItemEntity item) {
items.insert(item);
}
public LoadingPickItemEntity itemByPlanItem(long planItemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, int version) {
return items.pick(itemId, count, version);
}
public LoadingPickHistoryEntity history(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
}
public void insert(LoadingPickHistoryEntity history) {
histories.insert(history);
}
public void update(LoadingPickItemEntity item) {
items.updateById(item);
}
public void deleteItem(long itemId) {
items.deleteById(itemId);
}
}
@@ -0,0 +1,74 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.List;
/** 智能装柜聚合的真实 MySQL 持久化边界。 */
@Repository
@ConditionalOnProperty(name="firstleg.smart-loading.repository.enabled", havingValue="true", matchIfMissing=true)
public class SmartLoadingRepository {
@Resource private LoadingPlanMapper plans;
@Resource private LoadingPlanItemMapper items;
@Resource private LoadingPlanHistoryMapper histories;
@Resource private ContainerBookingMapper bookings;
@Resource private SmartLoadingCandidateMapper candidates;
public List<Candidate> candidates(long accountId) { return candidates.candidates(accountId); }
public Candidate candidate(long accountId, long inboundId, long detailId) {
return candidates.candidate(accountId, inboundId, detailId);
}
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
.eq(LoadingPlanEntity::getContainerBookingId, bookingId));
}
public ContainerBookingEntity booking(long bookingId) { return bookings.selectById(bookingId); }
public List<LoadingPlanItemEntity> items(long planId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getDeleted, false)
.orderByAsc(LoadingPlanItemEntity::getLoadSequence).orderByAsc(LoadingPlanItemEntity::getId));
}
public LoadingPlanItemEntity item(long planId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanItemEntity activeItem(long accountId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getAccountId, accountId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanHistoryEntity operation(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
.eq(LoadingPlanHistoryEntity::getOperationId, operationId));
}
public void insert(LoadingPlanEntity value) { plans.insert(value); }
public void insert(LoadingPlanItemEntity value) { items.insert(value); }
public void insert(LoadingPlanHistoryEntity value) { histories.insert(value); }
public int update(LoadingPlanItemEntity value) { return items.updateById(value); }
public int touch(long planId, int version, long operatorId, String operatorName) {
return plans.touchEditing(planId, version, operatorId, operatorName);
}
public int remove(long planId, long itemId, int version, long operatorId, String operatorName) {
return items.removeEditing(planId, itemId, version, operatorId, operatorName);
}
public int push(long planId, String key, int version, long operatorId, String operatorName) {
return plans.pushPreload(planId, key, version, operatorId, operatorName);
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.domain.SmartLoadingStatus;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
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.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class ContainerService {
@Resource private ContainerRepository repository;
public PageResult<ContainerBookingEntity> page(Query query) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
return repository.page(normalize(query));
}
public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
validateSchedule(request);
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId());
copy(value, request);
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());
return value;
}
@Transactional
public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName,
FullUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
}
validateSchedule(request.data());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
copy(current, request.data());
try {
if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
} catch (DuplicateKeyException ex) { throw duplicate(); }
return required(id);
}
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
TransportUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
String containerNo = trim(request.containerNo());
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
return required(id);
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) {
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
}
private static void requireVersion(ContainerBookingEntity value, Integer version) {
if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
}
private static SmartLoadingStatus status(ContainerBookingEntity value) {
try { return SmartLoadingStatus.valueOf(value.getPlanStatus()); }
catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); }
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim());
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName()));
v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName()));
v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode()));
v.setDestinationWarehouseName(trim(r.destinationWarehouseName()));
v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg());
v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt());
v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt());
v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt());
v.setEstimatedWarehouseArrivalAt(r.estimatedWarehouseArrivalAt());
v.setActualWarehouseArrivalAt(r.actualWarehouseArrivalAt());
}
private static void validateSchedule(SaveRequest r) {
validateRange(r.estimatedSailingAt(), r.estimatedPortArrivalAt(), "预计船期和预计到港时间");
validateRange(r.estimatedPortArrivalAt(), r.estimatedWarehouseArrivalAt(), "预计到港和预计到库时间");
validateRange(r.actualSailingAt(), r.actualPortArrivalAt(), "实际船期和实际到港时间");
validateRange(r.actualPortArrivalAt(), r.actualWarehouseArrivalAt(), "实际到港和实际到库时间");
}
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
if (from != null && to != null && to.isBefore(from))
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
}
private static Query normalize(Query q) {
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
q.estimatedSailingTo(), q.page(), q.size());
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim(); return result.isEmpty() ? null : result;
}
private static BizException duplicate() { return conflict("集装箱号或内部柜号已存在"); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
@@ -0,0 +1,228 @@
package com.baiyue.firstleg.service;
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;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@Service
public class LoadingListService {
private static final Set<String> LOADING_STATUSES = Set.of("LOADING");
@Resource
private LoadingListRepository repository;
@Resource
private ObjectMapper objectMapper;
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");
}
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(),
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
validateRange(query.createdFrom(), query.createdTo());
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(),
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PreloadDetail preloadDetail(long planId) {
AggregateRow row = requirePlan(planId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在");
}
return repository.preloadDetail(row);
}
public ConfirmedDetail confirmedDetail(long planId) {
AggregateRow row = requirePlan(planId);
if (!"COMPLETED".equals(row.getPlanStatus())) {
throw notFound("装柜清单不存在");
}
return repository.confirmedDetail(row);
}
@Transactional
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) {
String containerNo = nullableTrim(request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
requireSameRequest(replay, requestHash);
return repository.preloadDetail(requirePlan(planId));
}
AggregateRow before = requirePlan(planId);
requireLoadingMutable(before);
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
json(values("requestHash", requestHash, "status", before.getPlanStatus(),
"containerNo", containerNo, "voyageNo", voyageNo,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason()));
return repository.preloadDetail(requirePlan(planId));
}
@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);
return;
}
AggregateRow before = requirePlan(planId);
if (!fromStatus.equals(before.getPlanStatus())) {
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus);
}
if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(),
operatorId, trim(operatorName)) != 1) {
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()));
}
private AggregateRow requirePlan(long planId) {
AggregateRow row = repository.aggregate(planId);
if (row == null) {
throw notFound("装柜计划不存在");
}
return row;
}
private static void requireLoadingMutable(AggregateRow row) {
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅装柜中可修改集装箱号与航次");
}
}
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
if (from != null && to != null && from.isAfter(to)) {
throw badRequest("创建开始时间不能晚于结束时间");
}
}
private String operationId(String operation, long planId, String key) {
String normalized = trim(key);
if (!StringUtils.hasText(normalized)) {
throw badRequest("必须提供 Idempotency-Key");
}
if (normalized.length() > 128) {
throw badRequest("Idempotency-Key 长度不能超过128");
}
return "PL-" + sha256(operation + '|' + planId + '|' + normalized);
}
private static void requireSameRequest(HistoryRow replay, String requestHash) {
String snapshot = replay.getAfterSnapshot();
if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) {
throw conflict("Idempotency-Key 已用于不同请求");
}
}
private Map<String, Object> snapshot(AggregateRow row) {
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
}
private static Map<String, Object> values(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
result.put((String) values[index], values[index + 1]);
}
return result;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法生成装柜审计快照", exception);
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
private static String nullableTrim(String value) {
String trimmed = trim(value);
return StringUtils.hasText(trimmed) ? trimmed : null;
}
private static String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException badRequest(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
private static BizException notFound(String message) {
return new BizException(ResultCode.NOT_FOUND, message);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,174 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) {
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
task.setContainerNo(containerNo);
task.setTaskStatus("LOADING");
task.setStartedBy(operatorId);
task.setStartedByName(operatorName);
task.setVersion(0);
try {
repository.insert(task);
} catch (DuplicateKeyException exception) {
task = repository.taskByPlan(planId);
}
}
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续同步明细");
}
for (LoadingPlanItemEntity planItem : planItems) {
syncItem(task.getId(), planItem);
}
}
@Transactional
public void sync(long planId, LoadingPlanItemEntity planItem) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task != null && "LOADING".equals(task.getTaskStatus())) {
syncItem(task.getId(), planItem);
}
}
public Task task(String containerNo) {
if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim());
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
}
return view(task);
}
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId 已用于不同拣货请求");
}
return view(requiredTask(taskId));
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickItemEntity item = repository.item(taskId, itemId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setPickedPackageCount(request.pickedPackageCount());
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
return view(requiredTask(taskId));
}
@Transactional
public void remove(long planItemId) {
LoadingPickItemEntity item = repository.itemByPlanItem(planItemId);
if (item == null) {
return;
}
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
throw conflict("已有拣货记录的明细不能删除");
}
repository.deleteItem(item.getId());
}
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId());
if (item == null) {
item = new LoadingPickItemEntity();
item.setPickTaskId(taskId);
item.setLoadingPlanItemId(planItem.getId());
item.setInboundId(planItem.getInboundId());
item.setInboundDetailId(planItem.getInboundDetailId());
item.setPlannedPackageCount(planItem.getPackageCount());
item.setPickedPackageCount(0);
item.setItemStatus("PENDING");
item.setVersion(0);
repository.insert(item);
return;
}
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
throw conflict("已拣货箱数不能低于计划箱数");
}
item.setPlannedPackageCount(planItem.getPackageCount());
item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING");
repository.update(item);
}
private LoadingPickTaskEntity requiredTask(long taskId) {
LoadingPickTaskEntity task = repository.task(taskId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
}
return task;
}
private Task view(LoadingPickTaskEntity task) {
List<PickItem> items = repository.items(task.getId()).stream()
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList();
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
task.getVersion(), items);
}
private static String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JVM 不支持 SHA-256", exception);
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -45,15 +45,16 @@ public class MeasurementLocationOccupationService {
properties.getDefaultPalletCapacityCbm(), "STANDARD",
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
"测量完成后库位预占失败");
int packageCount = requirePackageCount(order);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), reserved);
properties.getDefaultPalletCapacityCbm(), packageCount, reserved);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume,
properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), occupied);
properties.getDefaultPalletCapacityCbm(), packageCount, occupied);
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
@@ -85,6 +86,13 @@ public class MeasurementLocationOccupationService {
return order.getActualVolumeCbm();
}
private static int requirePackageCount(InboundOrderEntity order) {
if (order.getTotalPackageCount() == null || order.getTotalPackageCount() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效总箱数");
}
return order.getTotalPackageCount();
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); }
catch (FeignException exception) {
@@ -0,0 +1,379 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.*;
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */
@Service
public class SmartLoadingService {
@Resource private SmartLoadingRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private LoadingPickService loadingPickService;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
public List<Candidate> candidates(long accountId) {
requireAccount(accountId);
return repository.candidates(accountId);
}
public Plan draftByBooking(long bookingId) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity plan = repository.planByBooking(bookingId);
return draftView(plan, booking);
}
public Plan detail(long planId) {
LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
return draftView(plan, requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN",
description = "创建智能装柜计划", captureBefore = false)
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0);
try {
repository.insert(plan);
} catch (DuplicateKeyException exception) {
LoadingPlanEntity concurrent = repository.planByBooking(bookingId);
if (concurrent != null) return draftView(concurrent, booking);
throw conflict("装柜计划创建冲突,请刷新后重试");
}
history(plan.getId(), null, "CREATE_PLAN", null, "EDITING", UUID.randomUUID().toString(), operatorId, operatorName);
return view(plan, booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId());
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("货物明细已被有效计划锁定");
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
if (total > candidate.availablePackageCount()) 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()));
checkRouteAndCapacity(booking, lines, candidate, total);
touch(plan, operatorId, operatorName);
LoadingPlanItemEntity changed;
if (existing == null) {
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1);
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
changed = item;
} else {
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total);
existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName);
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
changed = existing;
}
history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "itemId", changed.getId(),
"packageCount", changed.getPackageCount())), operatorId, operatorName);
loadingPickService.sync(planId, changed);
return view(repository.plan(planId), booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "执行智能装柜推荐")
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId()
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(request.accountId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate);
if (count > 0) {
plan = repository.plan(planId);
assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(),
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
+ '|' + candidate.inboundDetailId())), operatorId, operatorName);
}
}
LoadingPlanEntity recommended = repository.plan(planId);
history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "version", recommended.getVersion())),
operatorId, operatorName);
return view(recommended, requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "调整智能装柜明细")
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId()
+ '|' + request.packageCount() + '|' + request.expectedPlanVersion()
+ '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
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("调整箱数超过可用箱数");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount());
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
operatorId, operatorName);
loadingPickService.sync(planId, item);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "删除智能装柜明细")
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId()
+ '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "推送智能装柜计划到预装清单")
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
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 (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号");
if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空");
loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId));
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);
LoadingPlanEntity pushed = repository.plan(planId);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode());
i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count);
i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
}
private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) {
item.setSkuCode(cargo.skuNo());
item.setProductNameEn(cargo.cargoNameEn());
item.setUnitsPerCarton(cargo.unitsPerPackage());
item.setInternalRemark(cargo.cargoRemark());
int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0
? boxes : cargo.originalPackageCount();
if (cargo.unitsPerPackage() != null) {
item.setProductQuantity(Math.multiplyExact(cargo.unitsPerPackage(), boxes));
} else if (cargo.totalQuantity() != null) {
item.setProductQuantity(BigDecimal.valueOf(cargo.totalQuantity())
.multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact());
}
if (cargo.netWeightKg() != null) {
item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP));
}
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(internalToken,
candidate.accountId(), candidate.orderId(), candidate.inboundDetailId());
if (response == null) {
throw unavailable();
}
if (!response.successful()) {
throw new BizException(ResultCode.of(response.code()), response.message());
}
OrderCargoClient.CargoBinding cargo = response.data();
if (cargo == null || !Objects.equals(cargo.cargoId(), candidate.inboundDetailId())
|| !Objects.equals(cargo.orderId(), candidate.orderId())) {
throw conflict("订单货物明细返回与入库单不匹配");
}
return cargo;
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,int count){
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致");
BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count)));
if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置");
if (v.compareTo(b.getCapacityCbm())>0 || w.compareTo(b.getMaxWeightKg())>0) throw validation("超过货柜容积或重量上限");
}
private int fittingBoxes(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c){
if(b.getCapacityCbm()==null||b.getMaxWeightKg()==null)return 0;
BigDecimal usedV=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal usedW=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal leftV=b.getCapacityCbm().subtract(usedV),leftW=b.getMaxWeightKg().subtract(usedW);
int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue();
int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue();
return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW)));
}
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));}
private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) {
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
if (!"EDITING".equals(plan.getPlanStatus())) throw conflict("装柜计划已推送预装,请前往预装清单查看");
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 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) {
LoadingPlanHistoryEntity history = repository.operation(operation);
if (history == null) return null;
if (!Objects.equals(history.getLoadingPlanId(), planId)
|| !Objects.equals(history.getActionType(), action)
|| itemId != null && !Objects.equals(history.getLoadingPlanItemId(), itemId)
|| history.getAfterSnapshot() == null
|| !history.getAfterSnapshot().contains("\"requestHash\":\"" + requestHash + "\"")) {
throw conflict("operationId 已用于不同请求");
}
LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
return view(plan, requireBooking(plan.getContainerBookingId()));
}
private void history(long planId, Long itemId, String action, String from, String to,
String operation, long operatorId, String operatorName) {
history(planId, itemId, action, from, to, operation, null, null, operatorId, operatorName);
}
private void history(long planId, Long itemId, String action, String from, String to,
String operation, String before, String after,
long operatorId, String operatorName) {
LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity();
history.setLoadingPlanId(planId); history.setLoadingPlanItemId(itemId);
history.setActionType(action); history.setFromStatus(from); history.setToStatus(to);
history.setOperationId(operation); history.setBeforeSnapshot(before); history.setAfterSnapshot(after);
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
history.setCreatedAt(LocalDateTime.now()); repository.insert(history);
}
private static String operation(String value) {
String normalized = trim(value);
if (!StringUtils.hasText(normalized)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 operationId");
if (normalized.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId 长度不能超过128");
return normalized;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法生成智能装柜审计快照", exception);
}
}
private static Map<String, Object> values(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
result.put((String) values[index], values[index + 1]);
}
return result;
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用");
}
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
}