修改提交
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.lastleg.discard.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||
import com.baiyue.lastleg.discard.service.CargoDiscardService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/cargo-discards")
|
||||
public class MerchantCargoDiscardController {
|
||||
@Resource private CargoDiscardService service;
|
||||
@GetMapping("/inventory-candidates")
|
||||
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
|
||||
@PostMapping
|
||||
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
|
||||
@GetMapping("/{discardNo}")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
|
||||
private String customer(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
+2
-2
@@ -35,7 +35,7 @@ public class DispatchQueryController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid DispatchRequests.Query query) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query));
|
||||
return ApiResponse.success(service.platformPage(query));
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@@ -45,7 +45,7 @@ public class DispatchQueryController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo));
|
||||
return ApiResponse.success(service.platformDetail(dispatchNo));
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
|
||||
+60
-3
@@ -20,6 +20,7 @@ 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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -38,13 +39,30 @@ public class SmartDispatchController {
|
||||
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<List<DispatchPalletCandidate>> candidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam List<Long> planIds,
|
||||
@RequestParam(required = false) List<Long> planIds,
|
||||
@RequestParam(required = false) String orderNo,
|
||||
@RequestParam(required = false) String fbaWarehouseCode,
|
||||
@RequestParam(required = false) String sourceStage) {
|
||||
@RequestParam(required = false) String sourceStage,
|
||||
@RequestParam(required = false) String draftToken) {
|
||||
requirePlatform(user);
|
||||
if (planIds == null || planIds.isEmpty()) {
|
||||
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
|
||||
}
|
||||
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
|
||||
fbaWarehouseCode, sourceStage));
|
||||
fbaWarehouseCode, sourceStage, draftToken));
|
||||
}
|
||||
|
||||
@GetMapping("/drafts")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
|
||||
public ApiResponse<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse>> drafts(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user)));
|
||||
}
|
||||
|
||||
@PostMapping("/drafts/snapshot")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
|
||||
public ApiResponse<Void> saveDraft(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody DispatchRequests.DraftSnapshot request) {
|
||||
requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/pre-arrange")
|
||||
@@ -60,6 +78,45 @@ public class SmartDispatchController {
|
||||
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/draft-locks/lock")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
|
||||
@Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft")
|
||||
public ApiResponse<Void> lockDraftPallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody DispatchRequests.DraftPallet request) {
|
||||
requirePlatform(user);
|
||||
String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId()));
|
||||
service.lockDraftPallet(customerId, operatorId(user), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/draft-locks/unlock")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
|
||||
@Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft")
|
||||
public ApiResponse<Void> unlockDraftPallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody DispatchRequests.DraftPallet request) {
|
||||
requirePlatform(user);
|
||||
String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId()));
|
||||
service.unlockDraftPallet(customerId, operatorId(user), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/draft-locks/release")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
|
||||
@Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft")
|
||||
public ApiResponse<Void> releaseDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody DispatchRequests.DraftRelease request) {
|
||||
requirePlatform(user);
|
||||
service.releaseDraft(operatorId(user), request.draftToken());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private String operatorId(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) {
|
||||
}
|
||||
+18
-1
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.dto;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@@ -21,7 +22,23 @@ public final class DispatchRequests {
|
||||
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
|
||||
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus) {
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
@Size(max = 64) String draftToken) {
|
||||
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
|
||||
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
|
||||
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken) {
|
||||
}
|
||||
|
||||
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
|
||||
}
|
||||
|
||||
public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken,
|
||||
@NotEmpty @Size(max = 500) List<@NotNull Long> planIds,
|
||||
@NotBlank @Size(max = 20000) String containersJson) {
|
||||
}
|
||||
|
||||
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class DispatchDraft {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String draftToken;
|
||||
private String customerId;
|
||||
private String operatorId;
|
||||
private String planIdsJson;
|
||||
private String containersJson;
|
||||
private String status;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime releasedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+8
@@ -1,7 +1,9 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -16,13 +18,19 @@ public class DispatchPalletLock {
|
||||
private String customerId;
|
||||
private String sourcePalletKey;
|
||||
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String activeKey;
|
||||
private Long planId;
|
||||
private Long planPalletId;
|
||||
private Long warehousePalletId;
|
||||
private Long dispatchOrderId;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String draftToken;
|
||||
private String operatorId;
|
||||
private String status;
|
||||
private LocalDateTime lockedAt;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime releasedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
+43
-18
@@ -9,12 +9,6 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchCandidateMapper {
|
||||
@Select("SELECT customer_id FROM (" +
|
||||
"SELECT customer_id FROM ll_inbound_plan UNION SELECT customer_id FROM ll_dispatch_order" +
|
||||
") dispatch_customers WHERE customer_id IS NOT NULL AND customer_id != '' " +
|
||||
"ORDER BY customer_id LIMIT 2")
|
||||
List<String> findCustomerScopes();
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
@@ -32,35 +26,51 @@ public interface DispatchCandidateMapper {
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS appointmentAt," +
|
||||
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.plan_id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"WHERE 1=1 " +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
|
||||
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
|
||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
||||
"AND (d.expires_at IS NULL OR d.expires_at > CURRENT_TIMESTAMP(3)) AND (" +
|
||||
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
|
||||
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
|
||||
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " +
|
||||
"AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||
"WHERE ta.customer_id=ip.customer_id AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||
"ORDER BY p.plan_id,p.sequence_no" +
|
||||
@@ -69,11 +79,22 @@ public interface DispatchCandidateMapper {
|
||||
@Param("planIds") List<Long> planIds,
|
||||
@Param("orderNo") String orderNo,
|
||||
@Param("fbaWarehouseCode") String fbaWarehouseCode,
|
||||
@Param("sourceStage") String sourceStage);
|
||||
@Param("sourceStage") String sourceStage,
|
||||
@Param("draftToken") String draftToken);
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS appointmentAt," +
|
||||
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
@@ -82,13 +103,17 @@ public interface DispatchCandidateMapper {
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id) AS cartonMarks " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchDraftMapper extends BaseMapper<DispatchDraft> {
|
||||
@Select("SELECT * FROM ll_dispatch_draft WHERE operator_id=#{operatorId} AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY updated_at DESC")
|
||||
List<DispatchDraft> findActiveByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE")
|
||||
DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId);
|
||||
}
|
||||
+5
@@ -6,11 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
|
||||
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} ORDER BY customer_id LIMIT 2")
|
||||
List<DispatchOrder> findAcrossCustomersByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
}
|
||||
|
||||
+6
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
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 DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
|
||||
@@ -12,4 +13,9 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
|
||||
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
|
||||
DispatchPalletLock lockActive(@Param("customerId") String customerId,
|
||||
@Param("sourcePalletKey") String sourcePalletKey);
|
||||
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET active_key=NULL,updated_at=CURRENT_TIMESTAMP(3) " +
|
||||
"WHERE customer_id=#{customerId} AND active_key=#{activeKey} AND status!='ACTIVE'")
|
||||
int clearInactiveActiveKey(@Param("customerId") String customerId,
|
||||
@Param("activeKey") String activeKey);
|
||||
}
|
||||
|
||||
+25
-3
@@ -1,12 +1,21 @@
|
||||
package com.baiyue.lastleg.dispatch.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record DispatchPalletCandidate(
|
||||
Long planId,
|
||||
Long planPalletId,
|
||||
Long warehousePalletId,
|
||||
Long orderId,
|
||||
String customerId,
|
||||
String customerName,
|
||||
String planNo,
|
||||
String orderType,
|
||||
BigDecimal volumeWeightKg,
|
||||
String containerNo,
|
||||
LocalDateTime appointmentAt,
|
||||
LocalDateTime expectedLoadingAt,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseName,
|
||||
String orderNo,
|
||||
@@ -19,16 +28,29 @@ public record DispatchPalletCandidate(
|
||||
String fbaWarehouseCode,
|
||||
Integer packageCount,
|
||||
BigDecimal volumeCbm,
|
||||
String sourceStage
|
||||
String sourceStage,
|
||||
String cartonMarks
|
||||
) {
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
|
||||
String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm,
|
||||
String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
|
||||
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
|
||||
sourceStage, null);
|
||||
}
|
||||
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
String orderNo, String orderRefs, String inboundCode, String palletNo,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode,
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
|
||||
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage);
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null);
|
||||
}
|
||||
public String sourcePalletKey() {
|
||||
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
|
||||
|
||||
+194
-41
@@ -9,10 +9,13 @@ import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -39,17 +42,9 @@ public class DispatchService {
|
||||
@Resource private DispatchOrderMapper orders;
|
||||
@Resource private DispatchItemMapper items;
|
||||
@Resource private DispatchPalletLockMapper locks;
|
||||
@Resource private DispatchDraftMapper drafts;
|
||||
@Resource private DispatchCandidateMapper candidates;
|
||||
|
||||
public String resolveSingleCustomerScope() {
|
||||
List<String> customerIds = candidates.findCustomerScopes();
|
||||
if (customerIds.size() > 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Dispatch APIs require exactly one customer in the current deployment");
|
||||
}
|
||||
return customerIds.isEmpty() ? null : customerIds.get(0);
|
||||
}
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
|
||||
"Selected inbound plans");
|
||||
@@ -62,14 +57,100 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
|
||||
String fbaWarehouseCode, String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
|
||||
}
|
||||
String fbaWarehouseCode, String sourceStage, String draftToken) {
|
||||
validateSourceStage(sourceStage);
|
||||
if (!StringUtils.hasText(customerId)) return List.of();
|
||||
List<Long> scopedPlanIds = normalizeIds(planIds, "Plan IDs");
|
||||
return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
trimToNull(sourceStage));
|
||||
trimToNull(sourceStage), trimToNull(draftToken));
|
||||
}
|
||||
|
||||
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
|
||||
String sourceStage, String draftToken) {
|
||||
validateSourceStage(sourceStage);
|
||||
return candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
trimToNull(sourceStage), trimToNull(draftToken));
|
||||
}
|
||||
|
||||
public List<DispatchDraftResponse> activeDrafts(String operatorId) {
|
||||
return drafts.findActiveByOperator(trimToNull(operatorId)).stream()
|
||||
.map(item -> new DispatchDraftResponse(item.getDraftToken(), item.getPlanIdsJson(), item.getContainersJson())).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void saveDraftSnapshot(String operatorId, DispatchRequests.DraftSnapshot request) {
|
||||
String customerId = resolveCustomerScopeForPlans(request.planIds());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DispatchDraft draft = drafts.lockActive(request.draftToken(), trimToNull(operatorId));
|
||||
if (draft == null) {
|
||||
draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(request.draftToken());
|
||||
draft.setOperatorId(trimToNull(operatorId)); draft.setCustomerId(customerId); draft.setStatus("ACTIVE");
|
||||
draft.setCreatedAt(now); draft.setExpiresAt(now.plusMinutes(30));
|
||||
}
|
||||
draft.setPlanIdsJson(request.planIds().stream().map(String::valueOf).collect(Collectors.joining(",")));
|
||||
draft.setContainersJson(request.containersJson()); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30));
|
||||
drafts.insertOrUpdate(draft);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void lockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) {
|
||||
DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DispatchPalletLock active = activeLock(customerId, candidate);
|
||||
if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) {
|
||||
releaseLock(active, now);
|
||||
active = null;
|
||||
}
|
||||
if (active != null) {
|
||||
if (request.draftToken().equals(active.getDraftToken())
|
||||
&& Objects.equals(trimToNull(operatorId), active.getOperatorId())) return;
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another dispatch draft: " + candidate.palletNo());
|
||||
}
|
||||
// Older releases did not persist active_key=NULL; clear that inactive unique-key residue before relocking.
|
||||
locks.clearInactiveActiveKey(customerId, candidate.sourcePalletKey());
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setId(IdWorker.getId());
|
||||
lock.setCustomerId(customerId);
|
||||
lock.setSourcePalletKey(candidate.sourcePalletKey());
|
||||
lock.setActiveKey(candidate.sourcePalletKey());
|
||||
lock.setPlanId(candidate.planId());
|
||||
lock.setPlanPalletId(candidate.planPalletId());
|
||||
lock.setWarehousePalletId(candidate.warehousePalletId());
|
||||
lock.setDraftToken(request.draftToken());
|
||||
lock.setOperatorId(trimToNull(operatorId));
|
||||
lock.setStatus("ACTIVE");
|
||||
lock.setLockedAt(now);
|
||||
lock.setExpiresAt(now.plusMinutes(30));
|
||||
lock.setCreatedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
try {
|
||||
locks.insert(lock);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another dispatch draft: " + candidate.palletNo(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) {
|
||||
DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId());
|
||||
DispatchPalletLock active = activeLock(customerId, candidate);
|
||||
if (active == null) return;
|
||||
requireDraftOwner(active, operatorId, request.draftToken());
|
||||
releaseLock(active, LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void releaseDraft(String operatorId, String draftToken) {
|
||||
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
|
||||
.eq(DispatchPalletLock::getDraftToken, draftToken)
|
||||
.eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId))
|
||||
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
activeLocks.forEach(lock -> releaseLock(lock, now));
|
||||
DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId));
|
||||
if (draft != null) { draft.setStatus("RELEASED"); draft.setReleasedAt(now); draft.setUpdatedAt(now); drafts.updateById(draft); }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -86,20 +167,21 @@ public class DispatchService {
|
||||
if (selected.size() != palletIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
|
||||
}
|
||||
if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch");
|
||||
}
|
||||
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
|
||||
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
|
||||
}
|
||||
String draftToken = trimToNull(request.draftToken());
|
||||
for (DispatchPalletCandidate candidate : selected) {
|
||||
boolean planLocked = candidate.planPalletId() != null
|
||||
&& locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null;
|
||||
boolean receivedLocked = candidate.warehousePalletId() != null
|
||||
&& locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null;
|
||||
if (planLocked || receivedLocked) {
|
||||
DispatchPalletLock active = activeLock(customerId, candidate);
|
||||
if (draftToken != null) {
|
||||
if (active == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet draft lock has expired: " + candidate.palletNo());
|
||||
}
|
||||
requireDraftOwner(active, operatorId, draftToken);
|
||||
} else if (active != null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
|
||||
}
|
||||
@@ -129,24 +211,35 @@ public class DispatchService {
|
||||
orders.insert(dispatch);
|
||||
|
||||
for (DispatchPalletCandidate candidate : selected) {
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setId(IdWorker.getId());
|
||||
lock.setCustomerId(customerId);
|
||||
lock.setSourcePalletKey(candidate.sourcePalletKey());
|
||||
lock.setActiveKey(candidate.sourcePalletKey());
|
||||
lock.setPlanId(candidate.planId());
|
||||
lock.setPlanPalletId(candidate.planPalletId());
|
||||
lock.setWarehousePalletId(candidate.warehousePalletId());
|
||||
lock.setDispatchOrderId(dispatch.getId());
|
||||
lock.setStatus("ACTIVE");
|
||||
lock.setLockedAt(now);
|
||||
lock.setCreatedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
try {
|
||||
locks.insert(lock);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
|
||||
DispatchPalletLock lock = activeLock(customerId, candidate);
|
||||
if (draftToken != null) {
|
||||
requireDraftOwner(lock, operatorId, draftToken);
|
||||
lock.setDispatchOrderId(dispatch.getId());
|
||||
lock.setDraftToken(null);
|
||||
lock.setExpiresAt(null);
|
||||
lock.setUpdatedAt(now);
|
||||
locks.updateById(lock);
|
||||
} else {
|
||||
lock = new DispatchPalletLock();
|
||||
lock.setId(IdWorker.getId());
|
||||
lock.setCustomerId(customerId);
|
||||
lock.setSourcePalletKey(candidate.sourcePalletKey());
|
||||
lock.setActiveKey(candidate.sourcePalletKey());
|
||||
lock.setPlanId(candidate.planId());
|
||||
lock.setPlanPalletId(candidate.planPalletId());
|
||||
lock.setWarehousePalletId(candidate.warehousePalletId());
|
||||
lock.setDispatchOrderId(dispatch.getId());
|
||||
lock.setOperatorId(trimToNull(operatorId));
|
||||
lock.setStatus("ACTIVE");
|
||||
lock.setLockedAt(now);
|
||||
lock.setCreatedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
try {
|
||||
locks.insert(lock);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
|
||||
}
|
||||
}
|
||||
DispatchItem item = new DispatchItem();
|
||||
item.setId(IdWorker.getId());
|
||||
@@ -179,8 +272,18 @@ public class DispatchService {
|
||||
if (!StringUtils.hasText(customerId)) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
return page(customerId, query, false);
|
||||
}
|
||||
|
||||
public PageResult<DispatchResponse> platformPage(DispatchRequests.Query query) {
|
||||
return page(null, query, true);
|
||||
}
|
||||
|
||||
private PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query,
|
||||
boolean acrossCustomers) {
|
||||
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
|
||||
new LambdaQueryWrapper<DispatchOrder>()
|
||||
.eq(!acrossCustomers, DispatchOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
|
||||
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
|
||||
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
|
||||
@@ -197,6 +300,19 @@ public class DispatchService {
|
||||
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
public DispatchResponse platformDetail(String dispatchNo) {
|
||||
List<DispatchOrder> matches = orders.findAcrossCustomersByDispatchNo(dispatchNo);
|
||||
if (matches == null || matches.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Dispatch number is not unique across customers");
|
||||
}
|
||||
DispatchOrder dispatch = matches.get(0);
|
||||
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
|
||||
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
|
||||
@@ -279,6 +395,37 @@ public class DispatchService {
|
||||
.distinct().collect(Collectors.joining(" -> "));
|
||||
}
|
||||
|
||||
private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) {
|
||||
List<DispatchPalletCandidate> found = candidates.lockSelected(customerId, List.of(planPalletId));
|
||||
if (found.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Pallet is not available for dispatch");
|
||||
}
|
||||
return found.get(0);
|
||||
}
|
||||
|
||||
private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) {
|
||||
DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey());
|
||||
if (active == null && candidate.warehousePalletId() != null) {
|
||||
active = locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId());
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
private void requireDraftOwner(DispatchPalletLock lock, String operatorId, String draftToken) {
|
||||
if (lock == null || !draftToken.equals(lock.getDraftToken())
|
||||
|| !Objects.equals(trimToNull(operatorId), lock.getOperatorId())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is locked by another dispatch draft");
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseLock(DispatchPalletLock lock, LocalDateTime now) {
|
||||
lock.setStatus("RELEASED");
|
||||
lock.setActiveKey(null);
|
||||
lock.setReleasedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
locks.updateById(lock);
|
||||
}
|
||||
|
||||
private static List<String> splitOrderRefs(String refs, String fallback) {
|
||||
String value = StringUtils.hasText(refs) ? refs : fallback;
|
||||
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
|
||||
@@ -292,6 +439,12 @@ public class DispatchService {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
private static void validateSourceStage(String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Long> normalizeIds(List<Long> ids, String label) {
|
||||
if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty");
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.relabel.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/relabel-applications")
|
||||
public class MerchantRelabelApplicationController {
|
||||
@Resource private RelabelApplicationService service;
|
||||
@PostMapping
|
||||
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
|
||||
@GetMapping("/{relabelNo}")
|
||||
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
|
||||
private String customer(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.transfer.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
|
||||
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers")
|
||||
public class MerchantWarehouseTransferController {
|
||||
@Resource private WarehouseTransferService service;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody WarehouseTransferRequests.Create request) {
|
||||
return ApiResponse.success(service.create(customer(user), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid WarehouseTransferRequests.Query query) {
|
||||
return ApiResponse.success(service.page(customer(user), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String transferNo) {
|
||||
return ApiResponse.success(service.detail(customer(user), transferNo));
|
||||
}
|
||||
|
||||
private String customer(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
+7
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.transfer.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
|
||||
@@ -24,6 +25,12 @@ public class WarehouseTransferController {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String transferNo) {
|
||||
|
||||
+9
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.lastleg.transfer.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -73,4 +75,11 @@ public final class WarehouseTransferRequests {
|
||||
@Positive int packageCount,
|
||||
@Size(max = 255) String shippingMark
|
||||
) {}
|
||||
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String transferNo, String inboundCode, String operatingWarehouseId,
|
||||
String fbaWarehouseCode, String status, Boolean needRelabel,
|
||||
LocalDateTime createdFrom, LocalDateTime createdTo) {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -10,6 +10,10 @@ public final class WarehouseTransferResponses {
|
||||
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
|
||||
Long palletId, String palletNo, int packageCount, String status,
|
||||
Long destinationInventoryPositionId) {}
|
||||
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
|
||||
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
|
||||
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
|
||||
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
|
||||
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
|
||||
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
|
||||
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
|
||||
|
||||
+28
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
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.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
@@ -158,6 +160,32 @@ public class WarehouseTransferService {
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) {
|
||||
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw badRequest("createdFrom must not be after createdTo");
|
||||
}
|
||||
LambdaQueryWrapper<WarehouseTransferOrder> wrapper = new LambdaQueryWrapper<WarehouseTransferOrder>()
|
||||
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo())
|
||||
.like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode())
|
||||
.eq(StringUtils.hasText(query.operatingWarehouseId()), WarehouseTransferOrder::getOperatingWarehouseId, query.operatingWarehouseId())
|
||||
.like(StringUtils.hasText(query.fbaWarehouseCode()), WarehouseTransferOrder::getFbaWarehouseCode, query.fbaWarehouseCode())
|
||||
.eq(StringUtils.hasText(query.status()), WarehouseTransferOrder::getStatus, query.status())
|
||||
.eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel())
|
||||
.ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId);
|
||||
Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
List<WarehouseTransferResponses.Summary> records = result.getRecords().stream().map(value ->
|
||||
new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(),
|
||||
value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(),
|
||||
value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(),
|
||||
Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(),
|
||||
value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
|
||||
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) {
|
||||
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
|
||||
|
||||
Reference in New Issue
Block a user