This commit is contained in:
2026-08-07 10:57:06 +08:00
parent 913eca7b7d
commit ebea3a1813
45 changed files with 1271 additions and 582 deletions
@@ -8,7 +8,11 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan("com.baiyue.firstleg.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")
public class BaiyueFirstlegApplication {
@@ -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) {
}
}
@@ -26,6 +26,7 @@ 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;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@@ -50,9 +51,10 @@ public class PlatformLoadingListController {
@GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadDetail(planId));
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
}
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
@@ -61,11 +63,12 @@ public class PlatformLoadingListController {
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, user.userId(), user.username(), idempotencyKey, request));
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
@@ -81,8 +84,9 @@ public class PlatformLoadingListController {
@GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) {
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedDetail(planId));
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
}
@@ -3,6 +3,8 @@ 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;
@@ -31,9 +33,10 @@ public class PlatformLoadingPdaController {
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo) {
@RequestParam String containerNo,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo));
return ApiResponse.success(service.task(containerNo, warehouseId));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@@ -45,4 +48,24 @@ public class PlatformLoadingPdaController {
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()));
}
}
@@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp
public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service;
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));}
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')")
@@ -17,13 +17,13 @@ public class PlatformSmartLoadingController {
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
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,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
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,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
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,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
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()));}
}
@@ -48,7 +48,7 @@ public final class ContainerDtos {
@Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@@ -17,8 +17,7 @@ public final class LoadingListDtos {
public record PreloadQuery(
@Size(max = 128) String keyword,
String status,
@Min(1) Long accountId,
@Min(1) Long warehouseId,
@NotNull @Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@@ -32,8 +31,7 @@ public final class LoadingListDtos {
public record ConfirmedQuery(
@Size(max = 128) String keyword,
@Min(1) Long accountId,
@Min(1) Long warehouseId,
@NotNull @Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@@ -14,6 +14,7 @@ public final class 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,
@@ -21,6 +22,25 @@ public final class LoadingPickDtos {
) {
}
/** 部分拣货为该 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) {
@@ -11,13 +11,13 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
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 @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion,
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
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 @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion,
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) {}
@@ -17,7 +17,9 @@ public class LoadingPickHistoryEntity {
private Long pickItemId;
private String operationId;
private String requestHash;
private String actionType;
private Integer pickedPackageCount;
private String operationReason;
private Long operatorId;
private String operatorName;
}
@@ -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,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) {
}
}
@@ -53,11 +53,6 @@ public interface LoadingListQueryMapper {
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
@@ -81,11 +76,6 @@ public interface LoadingListQueryMapper {
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.accountId != null">
AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai
WHERE ai.loading_plan_id = p.id AND ai.deleted = 0
AND ai.account_id = #{query.accountId})
</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
@@ -9,6 +9,32 @@ 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 picked_package_count = picked_package_count + #{count},
@@ -16,9 +42,52 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
THEN 'PICKED' ELSE 'PICKING' END,
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);
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 任务中的明细。
@@ -3,7 +3,21 @@ 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);
}
@@ -8,7 +8,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
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_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
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
@@ -16,13 +23,20 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED'
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_detail_id=i.inbound_detail_id AND li.deleted=0),0)
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("accountId") long accountId);
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
@@ -31,7 +45,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
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_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
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
@@ -39,10 +60,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
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("accountId") long accountId,@Param("inboundId") long inboundId,
Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
}
@@ -38,6 +38,10 @@ public class LoadingPickRepository {
.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)
@@ -50,6 +54,14 @@ public class LoadingPickRepository {
.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);
}
@@ -63,8 +75,20 @@ public class LoadingPickRepository {
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, int version) {
return items.pick(itemId, count, version);
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) {
@@ -27,9 +27,9 @@ public class SmartLoadingRepository {
@Resource private ContainerBookingMapper bookings;
@Resource private SmartLoadingCandidateMapper candidates;
public List<Candidate> candidates(long accountId) { return candidates.candidates(accountId); }
public Candidate candidate(long accountId, long inboundId, long detailId) {
return candidates.candidate(accountId, inboundId, detailId);
public 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) {
@@ -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;
@@ -2,8 +2,10 @@ 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;
@@ -12,6 +14,7 @@ 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;
@@ -21,6 +24,9 @@ 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));
@@ -30,11 +36,13 @@ public class ContainerService {
@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(); }
@@ -51,8 +59,10 @@ public class ContainerService {
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("货柜状态或版本已变化,请刷新后重试");
@@ -85,6 +95,31 @@ public class ContainerService {
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("货柜已被其他人修改,请刷新后重试");
}
@@ -94,7 +129,7 @@ public class ContainerService {
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim());
v.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()));
@@ -46,26 +46,28 @@ public class LoadingListService {
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
throw badRequest("装柜清单状态只能是 LOADING");
}
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(),
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.accountId(),
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(),
query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PreloadDetail preloadDetail(long planId) {
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) {
public ConfirmedDetail confirmedDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!"COMPLETED".equals(row.getPlanStatus())) {
throw notFound("装柜清单不存在");
}
@@ -75,20 +77,21 @@ public class LoadingListService {
@Transactional
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long operatorId, String operatorName,
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(requirePlan(planId));
return repository.preloadDetail(before);
}
AggregateRow before = requirePlan(planId);
requireLoadingMutable(before);
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
@@ -147,6 +150,15 @@ public class LoadingListService {
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("仅装柜中可修改集装箱号与航次");
@@ -4,6 +4,8 @@ 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;
@@ -63,38 +65,42 @@ public class LoadingPickService {
}
}
public Task task(String containerNo) {
public Task task(String containerNo, long warehouseId) {
if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim());
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);
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.pickedPackageCount());
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));
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickItemEntity item = repository.item(taskId, itemId);
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()) != 1) {
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
@@ -102,6 +108,7 @@ public class LoadingPickService {
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setActionType("PICK");
history.setPickedPackageCount(request.pickedPackageCount());
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
@@ -109,7 +116,49 @@ public class LoadingPickService {
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
}
return view(requiredTask(taskId));
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
@@ -155,8 +204,22 @@ public class LoadingPickService {
return task;
}
private Task view(LoadingPickTaskEntity task) {
List<PickItem> items = repository.items(task.getId()).stream()
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();
@@ -164,6 +227,30 @@ public class LoadingPickService {
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")
@@ -173,6 +260,10 @@ public class LoadingPickService {
}
}
private static String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -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) {
}
}
@@ -36,9 +36,11 @@ public class SmartLoadingService {
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
public List<Candidate> candidates(long accountId) {
requireAccount(accountId);
return repository.candidates(accountId);
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) {
@@ -81,22 +83,21 @@ public class SmartLoadingService {
resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|'
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());
Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden();
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId());
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("累计分配箱数超过可用箱数");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
checkRouteAndCapacity(booking, lines, candidate, total);
@@ -126,20 +127,19 @@ public class SmartLoadingService {
resourceId = "#planId", description = "执行智能装柜推荐")
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId()
+ '|' + request.expectedVersion());
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(request.accountId())) {
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(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(),
assign(planId, new AssignRequest(candidate.inboundId(), candidate.inboundDetailId(),
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
+ '|' + candidate.inboundDetailId())), operatorId, operatorName);
}
@@ -157,22 +157,22 @@ public class SmartLoadingService {
resourceId = "#itemId", description = "调整智能装柜明细")
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId()
+ '|' + request.packageCount() + '|' + request.expectedPlanVersion()
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.getAccountId(), request.accountId())) throw forbidden();
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId());
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(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount());
checkRouteAndCapacity(booking, others, candidate, request.packageCount());
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
@@ -192,14 +192,13 @@ public class SmartLoadingService {
resourceId = "#itemId", description = "删除智能装柜明细")
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId()
+ '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion());
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, "装柜明细不存在");
if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden();
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");