Merge branch 'test-02huogui' into test

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
This commit is contained in:
2026-08-07 23:46:44 +08:00
135 changed files with 9649 additions and 370 deletions
@@ -8,10 +8,10 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan({
"com.baiyue.firstleg.mapper",
"com.baiyue.firstleg.loading.mapper",
"com.baiyue.firstleg.split.mapper"
@MapperScan(basePackages = {
"com.baiyue.firstleg.mapper",
"com.baiyue.firstleg.split.mapper",
"com.baiyue.firstleg.loading.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
@@ -26,7 +26,15 @@ 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);
}
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
public interface WarehouseMasterDataClient {
@GetMapping("/{tenantId}/{warehouseId}/active")
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long warehouseId);
record Detail(Warehouse warehouse) {
}
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String status) {
}
}
@@ -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));
}
}
@@ -51,7 +51,7 @@ public class PlatformInboundMeasurementReviewController {
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
// 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
// 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
return ApiResponse.success(null);
}
@@ -0,0 +1,92 @@
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.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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
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,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
}
@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,
@RequestParam @Positive long warehouseId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(
planId, warehouseId, 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,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
}
@@ -0,0 +1,71 @@
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.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
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,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo, warehouseId));
}
@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()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody PartialPickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.partial(taskId, itemId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/skip")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody SkipRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.skip(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 warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));}
@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);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);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);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);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()));}
}
@@ -4,9 +4,13 @@ import java.util.List;
/** 入库列表页签及其流程状态映射。 */
public enum InboundListTab {
/** 待测量:包含待测量、测量中和测量审核驳回的入库单。 */
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
/** 待审核:测量数据已经提交,等待审核结论。 */
WAIT_REVIEW(List.of("MEASURE_PENDING")),
/** 待发送费用:等待计费、费用确认或正在完成入库。 */
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
/** 已入库:费用已发送且入库完成。 */
INBOUNDED(List.of("INBOUNDED"));
private final List<String> processStatuses;
@@ -4,10 +4,35 @@ import java.util.Set;
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
public final class InboundProcessStatus {
/** 等待录入或接收测量数据。 */
public static final String WAIT_MEASURE = "WAIT_MEASURE";
/** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
public static final String MEASURING = "MEASURING";
/** 测量已提交,等待平台审核。 */
public static final String MEASURE_PENDING = "MEASURE_PENDING";
/** 测量审核被驳回,等待修正或异常放行。 */
public static final String MEASURE_REJECTED = "MEASURE_REJECTED";
/** 测量已通过或异常放行,等待计费。 */
public static final String WAIT_PRICING = "WAIT_PRICING";
/** 已生成费用,等待费用确认和发送。 */
public static final String FEE_CONFIRMING = "FEE_CONFIRMING";
/** 费用已确认,正在执行托盘、库位占用和入库收口。 */
public static final String INBOUNDING = "INBOUNDING";
/** 已入库完成,费用已发送给客户。 */
public static final String INBOUNDED = "INBOUNDED";
/** 已进入装柜作业,PDA 可执行拣货。 */
public static final String LOADING = "LOADING";
/** 装柜完成,等待派送完成。 */
public static final String LOADED = "LOADED";
/** 已完成派送。 */
public static final String DELIVERED = "DELIVERED";
/** 已作废,不再允许继续处理。 */
public static final String VOIDED = "VOIDED";
public static final Set<String> VALUES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED",
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED"
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED,
LOADING, LOADED, DELIVERED, VOIDED
);
private InboundProcessStatus() {
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.domain;
/** 智能装柜状态,只允许沿此方向推进。 */
public enum SmartLoadingStatus {
/** 编辑中:可以维护货柜信息并增删未拣货明细。 */
EDITING,
/** 装柜中:已生成 PDA 拣货任务,仍可追加或调整未拣货明细。 */
LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
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,
@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
) {}
}
@@ -35,6 +35,7 @@ public final class InboundMeasurementRequests {
}
public record ItemRequest(
@NotNull @Positive Long inboundDetailId,
@NotBlank @Size(max = 32) String itemNo,
@NotBlank @Size(max = 64) String boxSpecification,
@NotNull @Positive @Max(1000000) Integer packageCount,
@@ -47,6 +47,7 @@ public final class InboundMeasurementResponses {
public record Item(
Long id,
Long inboundDetailId,
String itemNo,
String boxSpecification,
Integer packageCount,
@@ -0,0 +1,167 @@
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,
@NotNull @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,
@NotNull @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,52 @@
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) Long warehouseId,
@NotNull @Min(1) Integer pickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer finalPickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
/** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
public record SkipRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String reason
) {
}
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) {
}
}
@@ -28,6 +28,7 @@ public record MeasurementBatchCommand(
List<Item> items
) {
public record Item(
Long inboundDetailId,
String itemNo,
String boxSpecification,
int packageCount,
@@ -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 inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@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,25 @@
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 String actionType;
private Integer pickedPackageCount;
private String operationReason;
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;
}
@@ -6,10 +6,15 @@ import java.util.Set;
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
*/
public final class LoadingEvidenceType {
/** 装柜过程第 1 步照片。 */
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
/** 装柜过程第 2 步照片。 */
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
/** 装柜过程第 3 步照片。 */
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
/** 装柜过程第 4 步照片。 */
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
/** 柜门铅封或封条证据。 */
public static final String SEAL = "SEAL";
public static final Set<String> VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
@@ -1,8 +1,11 @@
package com.baiyue.firstleg.loading.domain;
public final class PickingStatus {
/** PDA 拣货尚未开始。 */
public static final String PENDING = "PENDING";
/** PDA 正在执行拣货。 */
public static final String IN_PROGRESS = "IN_PROGRESS";
/** PDA 拣货已完成。 */
public static final String COMPLETED = "COMPLETED";
private PickingStatus() {}
}
@@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
public interface InboundLoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -19,7 +19,7 @@ public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations;
@Resource private LoadingPickTaskMapper picks;
@Resource private InboundLoadingPickTaskMapper picks;
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
@@ -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);
}
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED',
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
@@ -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,56 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** PDA部分拣货结算的客户归属锁定查询。 */
@Mapper
public interface InboundSplitSettlementMapper {
@Select("""
SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
process_status AS processStatus, version
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0
FOR UPDATE
""")
ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId);
@Select("""
SELECT id
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId}
AND inbound_role = 'CHILD' AND deleted = 0
LIMIT 1
FOR UPDATE
""")
Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
@Select({"<script>", """
SELECT item.id AS measurementItemId, measurement.id AS measurementId,
item.inbound_detail_id AS inboundDetailId, item.package_count AS packageCount
FROM fl_inbound_measurement measurement
JOIN fl_inbound_measurement_item item ON item.measurement_id = measurement.id
WHERE measurement.tenant_id = #{accountId} AND measurement.inbound_id = #{inboundId}
AND measurement.status = 'APPROVED' AND item.package_count > 0
AND measurement.id = (
SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = #{accountId} AND latest.inbound_id = #{inboundId}
AND latest.status = 'APPROVED')
AND item.inbound_detail_id IN
""", "<foreach collection='detailIds' item='detailId' open='(' separator=',' close=')'>",
"#{detailId}", "</foreach>", "FOR UPDATE", "</script>"})
List<MeasurementItemRow> lockLatestApprovedItems(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("detailIds") List<Long> detailIds);
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
String processStatus, Integer version) {
}
record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
Integer packageCount) {
}
}
@@ -0,0 +1,287 @@
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.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.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,111 @@
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.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
AND plan_item.deleted = 0
ORDER BY pick_item.id ASC
""")
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
""")
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
@Param("itemId") long itemId,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
THEN 'PICKED' ELSE 'PICKING' END,
picked_package_count = picked_package_count + #{count},
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count &lt; #{finalCount}
AND #{finalCount} &lt; planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET item_status = 'SKIPPED', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status = 'PENDING' AND picked_package_count = 0
""")
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
@Select("""
SELECT COUNT(*)
FROM fl_loading_pick_item
WHERE pick_task_id = #{taskId}
AND item_status NOT IN ('PICKED', 'PARTIAL', 'SKIPPED')
""")
long unresolvedCount(@Param("taskId") long taskId);
/**
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
*/
@Select("""
SELECT COUNT(*)
FROM fl_loading_plan_item plan_item
WHERE plan_item.loading_plan_id = #{planId}
AND plan_item.deleted = 0
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND pick_item.picked_package_count = plan_item.package_count
)
""")
long unfinishedCount(@Param("planId") long planId);
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Select("""
SELECT DISTINCT task.*
FROM fl_loading_pick_task task
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
@Param("warehouseId") long warehouseId);
}
@@ -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,69 @@
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-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
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.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
""")
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@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-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
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.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
Candidate candidate(@Param("warehouseId") long warehouseId,@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);
}
}
@@ -209,7 +209,7 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!"WAIT_SPLIT".equals(processStatus)) {
if (!"INBOUNDED".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
@@ -229,7 +229,7 @@ public class FbaPricingRepository {
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "INBOUNDED"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
@@ -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,114 @@
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 LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) {
return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId);
}
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 List<LoadingPickItemEntity> items(long taskId, long warehouseId) {
return items.selectTaskItemsWithAccount(taskId, warehouseId);
}
public LoadingPickItemEntity item(long taskId, long itemId, long warehouseId) {
return items.selectTaskItemWithAccount(taskId, itemId, warehouseId);
}
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, long warehouseId) {
return items.pick(itemId, count, version, warehouseId);
}
public int partial(long itemId, int finalCount, int version, long warehouseId) {
return items.partial(itemId, finalCount, version, warehouseId);
}
public int skip(long itemId, int version, long warehouseId) {
return items.skip(itemId, version, warehouseId);
}
public boolean allTaskItemsResolved(long taskId) {
return items.unresolvedCount(taskId) == 0;
}
public boolean allPlanItemsPicked(long planId) {
return items.unfinishedCount(planId) == 0;
}
public LoadingPickHistoryEntity history(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
}
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,75 @@
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 warehouseId) { return candidates.candidates(warehouseId); }
public Candidate candidate(long warehouseId, long inboundId, long detailId) {
return candidates.candidate(warehouseId, 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 planId, long accountId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getAccountId, accountId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
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);
}
}
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
@Repository
public class UnifiedInboundReadRepository {
@Resource private PreloadPlanMapper preloads;
@Resource private LoadingPickTaskMapper picks;
@Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports;
@@ -0,0 +1,169 @@
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.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
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.beans.factory.annotation.Value;
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;
@Resource private WarehouseMasterDataClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
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);
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
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.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
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());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
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 WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
internalToken, publicWarehouseTenantId, warehouseId);
if (response == null || !response.successful() || response.data() == null
|| response.data().warehouse() == null) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id())
|| !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status())) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
if (!"DOMESTIC".equals(warehouse.warehouseType()) || warehouse.warehouseName() == null
|| warehouse.warehouseName().isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "虚拟柜只能选择启用中的国内中转仓");
}
return warehouse;
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
}
}
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(trim(r.warehouseName()));
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); }
}
@@ -81,9 +81,9 @@ public class FbaPricingService {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus())
? "WAIT_SPLIT" : "FEE_CONFIRMING";
return response(inbound, fee, estimate.unitPrice(), nextStatus);
String nextStatus = "INBOUNDED".equals(inbound.getProcessStatus())
? "INBOUNDED" : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
}
@Transactional
@@ -223,7 +223,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus())
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDED").contains(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
@@ -32,9 +33,13 @@ import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@Service
public class InboundMeasurementService {
/** 测量批次已提交,等待测量审核。 */
private static final String STATUS_SUBMITTED = "SUBMITTED";
/** OCR 或人工修正的重量已确认,可参与计费。 */
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
/** OCR 已识别到有效重量,等待人工确认。 */
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
@@ -47,9 +52,11 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
private MeasurementImageClient imageClient;
@Value("${baiyue.security.internal-service-token}")
private OrderCargoClient orderCargoClient;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
@Resource
private MeasurementImageClient imageClient;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -66,13 +73,14 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items());
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
LocalDateTime.now(), null, request.originalDocumentNo(),
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.remark())).toList());
@@ -354,7 +362,7 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
return new InboundMeasurementResponses.Item(
item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
@@ -397,6 +405,27 @@ public class InboundMeasurementService {
return inbound;
}
private void validateInboundDetails(long tenantId, InboundOrderEntity inbound,
List<InboundMeasurementRequests.ItemRequest> items) {
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,不能保存测量明细");
}
for (InboundMeasurementRequests.ItemRequest item : items) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(
internalToken, tenantId, inbound.getOrderId(), item.inboundDetailId());
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "订单货物明细校验失败" : response.message();
throw new BizException(ResultCode.of(response == null
? ResultCode.SERVICE_UNAVAILABLE.code() : response.code()), message);
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入库明细");
}
}
}
private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) {
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) {
@@ -0,0 +1,242 @@
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.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.baiyue.firstleg.repository.LoadingPickRepository;
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 LoadingPickRepository pickRepository;
@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.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.warehouseId(),
query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PreloadDetail preloadDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在");
}
return repository.preloadDetail(row);
}
public ConfirmedDetail confirmedDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
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 warehouseId, 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);
AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
requireSameRequest(replay, requestHash);
return repository.preloadDetail(before);
}
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));
}
/**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@Transactional
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单")
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) {
return;
}
if (!pickRepository.allPlanItemsPicked(planId)) {
throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单");
}
AggregateRow before = requirePlan(planId);
if ("COMPLETED".equals(before.getPlanStatus())) {
return;
}
if (!"LOADING".equals(before.getPlanStatus())) {
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜");
}
if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(),
operatorId, trim(operatorName)) != 1) {
AggregateRow current = requirePlan(planId);
if ("COMPLETED".equals(current.getPlanStatus())) {
return;
}
throw conflict("装柜计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING",
"LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货");
}
private AggregateRow requirePlan(long planId) {
AggregateRow row = repository.aggregate(planId);
if (row == null) {
throw notFound("装柜计划不存在");
}
return row;
}
private static void requireWarehouse(AggregateRow row, long warehouseId) {
if (warehouseId <= 0) {
throw badRequest("warehouseId必须大于0");
}
if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) {
throw notFound("装柜计划不属于当前仓库");
}
}
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,270 @@
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.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
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;
@Resource
private LoadingListService loadingListService;
@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, long warehouseId) {
if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
}
if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
}
return view(task, warehouseId);
}
@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.warehouseId()
+ "|" + 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), request.warehouseId());
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setActionType("PICK");
history.setPickedPackageCount(request.pickedPackageCount());
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
}
return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + reason);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
requiredLoadingTask(taskId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@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 LoadingPickTaskEntity requiredLoadingTask(long taskId) {
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
return task;
}
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
}
}
private Task view(LoadingPickTaskEntity task, long warehouseId) {
List<PickItem> items = repository.items(task.getId(), warehouseId).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 void insertHistory(long taskId, long itemId, String operationId, String requestHash,
String actionType, int pickedPackageCount, String reason,
long operatorId, String operatorName) {
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setActionType(actionType);
history.setPickedPackageCount(pickedPackageCount);
history.setOperationReason(reason);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
}
private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId,
String requestHash) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId已用于不同拣货请求");
}
}
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 String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -26,9 +26,13 @@ import java.util.Set;
*/
@Service
public class MeasurementBatchService {
/** 后台人工录入的测量来源。 */
static final String SOURCE_MANUAL = "MANUAL";
/** 第三方设备或开放接口回传的测量来源。 */
static final String SOURCE_THIRD_PARTY = "THIRD_PARTY";
/** 测量草稿,可继续编辑。 */
static final String STATUS_DRAFT = "DRAFT";
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
@@ -136,6 +140,7 @@ public class MeasurementBatchService {
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setInboundDetailId(source.inboundDetailId());
item.setItemNo(source.itemNo());
item.setBoxSpecification(source.boxSpecification());
item.setPackageCount(source.packageCount());
@@ -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) {
@@ -20,7 +20,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED;
* 计费、库位和其他资料不作为本版本的强制门禁。
*/
@Service
@@ -91,7 +91,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
approved ? "WAIT_SPLIT" : "MEASURE_REJECTED",
approved ? "INBOUNDED" : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId);
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** PDA任务结束时的纯结算规则,不包含数据库写入。 */
final class PdaPickSettlementPlan {
private PdaPickSettlementPlan() {
}
static Resolution resolve(List<LoadingPickItemEntity> items) {
if (items == null || items.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "PDA任务没有可结算的拣货明细");
}
for (LoadingPickItemEntity item : items) {
if (item == null || !isFinal(item.getItemStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA任务仍存在未结束的拣货明细");
}
}
Map<Long, List<SplitLine>> partialByInbound = items.stream()
.filter(item -> "PARTIAL".equals(item.getItemStatus()))
.map(PdaPickSettlementPlan::splitLine)
.collect(Collectors.groupingBy(SplitLine::inboundId));
List<Long> skippedPlanItemIds = items.stream()
.filter(item -> "SKIPPED".equals(item.getItemStatus()))
.map(LoadingPickItemEntity::getLoadingPlanItemId)
.toList();
return new Resolution(partialByInbound, skippedPlanItemIds);
}
private static SplitLine splitLine(LoadingPickItemEntity item) {
int planned = requireCount(item.getPlannedPackageCount(), "计划箱数");
int picked = requireCount(item.getPickedPackageCount(), "实拣箱数");
if (picked <= 0 || picked >= planned || item.getInboundId() == null
|| item.getInboundDetailId() == null || item.getId() == null
|| item.getLoadingPlanItemId() == null) {
throw new BizException(ResultCode.CONFLICT, "部分拣货结算数据不完整或箱数不合法");
}
return new SplitLine(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), picked, planned - picked);
}
private static int requireCount(Integer value, String field) {
if (value == null || value < 0) {
throw new BizException(ResultCode.CONFLICT, field + "不合法");
}
return value;
}
private static boolean isFinal(String status) {
return "PICKED".equals(status) || "PARTIAL".equals(status) || "SKIPPED".equals(status);
}
record Resolution(Map<Long, List<SplitLine>> partialByInbound, List<Long> skippedPlanItemIds) {
}
record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId,
int pickedPackageCount, int remainingPackageCount) {
}
}
@@ -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 warehouseId) {
if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
return repository.candidates(warehouseId);
}
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 view(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.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());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount());
if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
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.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(booking.getWarehouseId())) {
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(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.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.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(booking, 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.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, "装柜明细不存在");
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,"客户归属不匹配");}
}
@@ -24,11 +24,11 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** Read-only adapter from internal inbound states to the seven product main statuses. */
/** Read-only adapter from internal inbound states to the product main statuses. */
@Service
public class UnifiedInboundQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds;
@@ -120,8 +120,8 @@ public class UnifiedInboundQueryService {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
case "INBOUNDED" -> "INBOUNDED";
// WAIT_SPLIT is retained only to normalize records created before status unification.
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
@@ -132,7 +132,7 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
case "INBOUNDED" -> !List.of("INBOUNDED", "WAIT_SPLIT").contains(processStatus) ? null
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING";
@@ -146,8 +146,8 @@ public class UnifiedInboundQueryService {
if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED");
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__");
};
}
@@ -4,8 +4,11 @@ import java.util.Set;
/** 预装清单子状态,固定为三态。 */
public final class PreloadStatus {
/** 预装清单草稿,尚未开始确认。 */
public static final String PENDING = "PENDING";
/** 正在确认预装明细。 */
public static final String CONFIRMING = "CONFIRMING";
/** 预装清单已确认,可进入装柜作业。 */
public static final String CONFIRMED = "CONFIRMED";
public static final Set<String> VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED);
private PreloadStatus() {}
@@ -13,7 +13,7 @@ public interface SplitInboundMapper {
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
+ "AND process_status='INBOUNDED' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@@ -42,7 +42,7 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if (!"WAIT_SPLIT".equals(inbound.processStatus())) {
if (!"INBOUNDED".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
}
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
@@ -67,7 +67,7 @@ public class SplitPreloadService {
}
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
new SplitActionResponse("WAIT_SPLIT", result));
new SplitActionResponse("INBOUNDED", result));
}
@Transactional
@@ -77,11 +77,11 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认");
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(plan)));
new SplitActionResponse("INBOUNDED", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
@@ -89,7 +89,7 @@ public class SplitPreloadService {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId))));
new SplitActionResponse("INBOUNDED", response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
@@ -103,7 +103,7 @@ public class SplitPreloadService {
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单");
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
@@ -115,7 +115,7 @@ public class SplitPreloadService {
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent();
}
addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD,
addHistory(tenantId, inboundId, "INBOUNDED", "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));