修改提交

This commit is contained in:
2026-08-21 17:21:26 +08:00
parent af97217184
commit bbd303cd65
36 changed files with 1045 additions and 100 deletions
@@ -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()); }
}
@@ -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) {
@@ -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);
@@ -0,0 +1,4 @@
package com.baiyue.lastleg.dispatch.dto;
public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) {
}
@@ -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;
}
@@ -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;
@@ -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 &gt; 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 &gt; 0)) " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 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 &gt; 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 &gt; 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 &gt; 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) " +
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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.
@@ -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");
@@ -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()); }
}
@@ -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()); }
}
@@ -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) {
@@ -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; }
}
}
@@ -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,
@@ -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);