This commit is contained in:
2026-08-25 09:52:49 +08:00
parent b211121365
commit b67217332a
37 changed files with 1009 additions and 343 deletions
@@ -1,52 +0,0 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
public class InboundPalletAssignmentController {
@Resource private InboundPdaWorkflowService service;
@PostMapping("/{planPalletId}/assign")
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable long planPalletId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Assign request) {
requireOperate(user);
service.assign(scope(user, customerId), planNo, planPalletId, request);
return ApiResponse.success(null);
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(InboundPdaController.OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
InboundPdaController.OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -1,146 +0,0 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}")
public class InboundPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private InboundPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<PdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), planNo, request));
}
@GetMapping
public ApiResponse<PdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.task(scope(user, customerId), planNo));
}
@PostMapping("/allocations/{allocationId}/count")
public ApiResponse<PdaResponses.Operation> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
requireOperate(user);
return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/allocations/{allocationId}/pick")
public ApiResponse<PdaResponses.Operation> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/cartons/scan")
public ApiResponse<PdaResponses.Carton> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ScanCarton request) {
requireOperate(user);
return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request));
}
@PostMapping("/cartons/remove")
public ApiResponse<PdaResponses.Carton> removeCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.RemoveCarton request) {
requireOperate(user);
return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request));
}
@GetMapping("/allocations/{allocationId}/merge-candidates")
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@PathVariable long allocationId, @RequestParam(required = false) String customerId) {
return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId));
}
@PostMapping("/allocations/{allocationId}/merge")
public ApiResponse<PdaResponses.Merge> merge(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ConfirmMerge request) {
requireOperate(user);
return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/complete")
public ApiResponse<PdaResponses.Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
requireOperate(user);
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
}
@PostMapping("/unload-photos")
public ApiResponse<PdaResponses.Task> uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.UnloadPhotos request) {
requireOperate(user);
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
}
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PdaResponses.UnloadPhotoFile> uploadUnloadPhotoFile(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
requireOperate(user);
return ApiResponse.success(service.uploadUnloadPhotoFile(
scope(user, customerId), planNo, user.userId(), file));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -30,14 +31,14 @@ import java.util.List;
@Tag(name = "Overseas PDA container receiving")
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
public class ReceivingContainerController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
@Resource private ReceivingContainerService service;
@GetMapping("/candidates")
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
Identity identity = identity(user);
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@@ -45,14 +46,14 @@ public class ReceivingContainerController {
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo) {
Identity identity = identity(user);
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
}
@PostMapping("/{containerNo}/prepare")
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
Identity identity = identity(user);
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
containerNo, request));
}
@@ -60,7 +61,7 @@ public class ReceivingContainerController {
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@@ -68,13 +69,13 @@ public class ReceivingContainerController {
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
}
@GetMapping("/current")
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.current(identity.operatorKey()));
}
@@ -82,7 +83,7 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@@ -90,7 +91,7 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@@ -98,53 +99,38 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
}
@PostMapping("/current/receipts/bulk")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/receipts/carton")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
Identity identity = currentIdentity(user);
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
private static Identity identity(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|| !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required");
}
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new Identity(operator,
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
private static PdaWarehouseIdentity identity(AuthenticatedUser user) {
return PdaWarehouseIdentity.require(user);
}
private Identity currentIdentity(AuthenticatedUser user) {
Identity identity = identity(user);
private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
return identity;
}
private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { }
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.security;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
public static PdaWarehouseIdentity require(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|| !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"Authenticated platform warehouse PDA identity is required");
}
if (!user.permissions().contains(PICKER_ROLE)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required");
}
if (!user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new PdaWarehouseIdentity(operator,
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.warehouse.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class PdaWarehouseRequests {
private PdaWarehouseRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) {
public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.pda.warehouse.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaWarehouseResponses {
private PdaWarehouseResponses() { }
public record InventoryScan(String queryCode, List<InventoryItem> items) { }
public record InventoryItem(String customerId, String customerName, long balanceId, long positionId,
String skuCode, String productName, String inboundCode, String cartonMark,
long palletId, String palletCode, Long locationId, String locationCode,
long availablePackages, long holdPackages, long receivingPackages,
String status, long version) { }
public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg,
String status, long occupiedPackages, int inventoryPositionCount) { }
public record Pallet(long palletId, String palletCode, String status, int version,
long locationId, String locationCode, long occupiedPackages,
int inventoryPositionCount, List<InventoryItem> inventory) { }
public record Operation(long operationId, String operationType, String businessKey, String status,
String summary, String customerId, LocalDateTime occurredAt) { }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_operation_history")
public class PdaOperationHistory {
@TableId(type = IdType.INPUT)
private Long id;
private Long overseasWarehouseId;
private String customerId;
private String operatorKey;
private String operationType;
private String businessKey;
private String status;
private String idempotencyKey;
private String requestHash;
private String summary;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.pda.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
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 PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
"COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
"COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
"c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," +
"p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " +
"ON p.balance_id=b.id AND p.customer_id=b.customer_id " +
"LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " +
"LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " +
"AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' ";
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
"AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " +
"OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
List<PdaInventoryProjection> scanInventory(@Param("warehouseId") String warehouseId,
@Param("code") String code);
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PdaInventoryProjection> inventoryByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId,
@Param("code") String ignored);
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
"AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
List<PdaInventoryProjection> inventoryByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode,
@Param("code") String ignored);
@Select("<script>SELECT operation_id,operation_type,business_key,status,summary,customer_id,occurred_at FROM (" +
"SELECT h.id operation_id,h.operation_type,h.business_key,h.status,h.summary,h.customer_id,h.occurred_at " +
"FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," +
"CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " +
"FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>" +
") x ORDER BY occurred_at DESC,operation_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses.Operation> history(
@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT h.id FROM ll_pda_operation_history h " +
"WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>) x</script>")
long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.warehouse.model;
import lombok.Data;
@Data
public class PdaInventoryProjection {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String inboundCode;
private String cartonMark;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long availablePackages;
private Long holdPackages;
private Long receivingPackages;
private String status;
private Long version;
}
@@ -75,6 +75,15 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/review")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.Review request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/receive-pallets")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
@Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
@@ -85,6 +94,66 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/review-pallet-overage")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> reviewPalletOverage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReviewPalletOverage request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.reviewPalletOverage(
requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/allocate-pallets")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> allocatePallets(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.AllocatePallets request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.allocatePallets(
requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/prepare-dispatch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> prepareDispatch(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.prepareDispatch(
requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/dispatch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> dispatch(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.dispatch(
requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/start-transit")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> startTransit(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.startTransit(
requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/complete")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
public ApiResponse<UsTruckOrderResponses.Detail> complete(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.complete(
requiredCustomer(customerId), orderNo, operator(user), request));
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
@@ -4,7 +4,10 @@ import jakarta.validation.constraints.DecimalMin;
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;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
@@ -71,7 +74,31 @@ public final class UsTruckOrderRequests {
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@NotBlank @Pattern(regexp = "CANCELLED") String status,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Review(
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record ReviewPalletOverage(
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record AllocatePallets(
@NotNull @Min(0) Long version,
@NotEmpty @Size(max = 500) List<@NotNull @Positive Long> palletIds,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Command(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record ReceivePallets(
@@ -11,13 +11,14 @@ public final class UsTruckOrderResponses {
public record Summary(
Long id, String customerId, String orderNo, String transportType,
String origin, String destination, String containerType, int palletCount, int packageCount,
int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount,
int receivedPalletCount, int allocatedPalletCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record History(
String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
String operationType, String fromStatus, String toStatus, String operatorId,
String idempotencyKey, String remark, LocalDateTime createdAt) {}
public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status,
Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {}
@@ -25,7 +25,6 @@ public class UsTruckOrder {
private Integer packageCount;
private Integer receivedPalletCount;
private Integer allocatedPalletCount;
private Integer shippedPalletCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
@@ -37,8 +36,6 @@ public class UsTruckOrder {
private String remark;
private String idempotencyKey;
private String requestHash;
private String receiptIdempotencyKey;
private String receiptRequestHash;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.trucking.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_us_truck_order_operation")
public class UsTruckOrderOperation {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private Long resultVersion;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -13,9 +13,11 @@ public class UsTruckOrderStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String operationType;
private String fromStatus;
private String toStatus;
private String operatorId;
private String idempotencyKey;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.trucking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UsTruckOrderOperationMapper extends BaseMapper<UsTruckOrderOperation> {
@Select("SELECT * FROM ll_us_truck_order_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
UsTruckOrderOperation findKey(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("key") String key);
}
@@ -7,9 +7,11 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.baiyue.lastleg.security.OverseasPermissions;
@@ -30,6 +32,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
@@ -55,20 +58,12 @@ public class UsTruckOrderService {
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final java.util.Map<String, Set<String>> TRANSITIONS = java.util.Map.ofEntries(
java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)),
java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)),
java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)),
java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)),
java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)),
java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)),
java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)),
java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)),
java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)),
java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED)));
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
@Resource private UsTruckOrderMapper orders;
@Resource private UsTruckOrderOperationMapper operations;
@Resource private UsTruckOrderPalletMapper pallets;
@Resource private UsTruckOrderStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@@ -90,7 +85,7 @@ public class UsTruckOrderService {
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
@@ -103,7 +98,7 @@ public class UsTruckOrderService {
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
@@ -150,7 +145,7 @@ public class UsTruckOrderService {
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
touch(order); orders.updateById(order);
history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now());
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now());
return detail(customerId, orderNo);
}
@@ -158,34 +153,37 @@ public class UsTruckOrderService {
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ChangeStatus request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
String from = order.getStatus();
if (!next.equals(from)) {
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
throw conflict("Unsupported truck order transition: " + from + " -> " + next);
}
order.setStatus(next);
if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order);
touch(order); orders.updateById(order);
history(order, from, next, operatorId, request.remark(), LocalDateTime.now());
UsTruckOrderResponses.Detail replay = replayOperation(order, "CANCEL", request.idempotencyKey(), request);
if (replay != null) return replay;
requireVersion(order, request.version());
if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) {
throw conflict("Only cancellable truck orders can use the generic status endpoint");
}
return detail(customerId, orderNo);
return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request,
order.getStatus(), CANCELLED, request.remark());
}
@Transactional
public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Review request) {
UsTruckOrder order = lock(customerId, orderNo);
UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return replay;
requireVersion(order, request.version());
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed");
String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED;
if (WAITING_INBOUND.equals(next)) createPlannedPallets(order);
return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request,
PENDING_REVIEW, next, request.remark());
}
@Transactional
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReceivePallets request) {
UsTruckOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (key.equals(order.getReceiptIdempotencyKey())) {
if (!requestHash.equals(order.getReceiptRequestHash())) {
throw conflict("Receipt idempotency key was reused with different pallet data");
}
return detail(customerId, orderNo);
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
UsTruckOrderResponses.Detail replay = replayOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request);
if (replay != null) return replay;
requireVersion(order, request.version());
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
throw conflict("Only waiting or receiving orders can receive pallets");
}
@@ -220,18 +218,118 @@ public class UsTruckOrderService {
}
}
order.setReceivedPalletCount(actual);
order.setReceiptIdempotencyKey(key);
order.setReceiptRequestHash(requestHash);
String from = order.getStatus();
String next = actual > planned ? PALLET_OVERAGE_REVIEW
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
order.setStatus(next);
touch(order);
orders.updateById(order);
history(order, from, next, operatorId, request.remark(), now);
recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now);
history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReviewPalletOverage request) {
UsTruckOrder order = lock(customerId, orderNo);
UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW_PALLET_OVERAGE",
request.idempotencyKey(), request);
if (replay != null) return replay;
requireVersion(order, request.version());
if (!PALLET_OVERAGE_REVIEW.equals(order.getStatus())) {
throw conflict("Only pallet-overage-review orders can be reviewed");
}
boolean approve = "APPROVE".equals(norm(request.decision()));
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
LocalDateTime now = LocalDateTime.now();
int accepted = value(order.getPalletCount());
for (UsTruckOrderPallet pallet : rows) {
if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue;
pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED");
pallet.setUpdatedAt(now);
pallets.updateById(pallet);
}
if (approve) {
accepted = value(order.getReceivedPalletCount());
order.setPalletCount(accepted);
} else {
order.setReceivedPalletCount(accepted);
}
order.setStatus(WAITING_CONSOLIDATION);
touch(order);
orders.updateById(order);
recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now);
history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION,
operatorId, request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.AllocatePallets request) {
UsTruckOrder order = lock(customerId, orderNo);
UsTruckOrderResponses.Detail replay = replayOperation(order, "ALLOCATE_PALLETS",
request.idempotencyKey(), request);
if (replay != null) return replay;
requireVersion(order, request.version());
if (!Set.of(WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED).contains(order.getStatus())) {
throw conflict("Only waiting-consolidation or partially-allocated orders can allocate pallets");
}
Set<Long> requested = new HashSet<>(request.palletIds());
if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique");
List<UsTruckOrderPallet> rows = pallets.listOrder(customerId, order.getId());
List<UsTruckOrderPallet> selected = rows.stream().filter(row -> requested.contains(row.getId())).toList();
if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) {
throw conflict("Selected pallets must belong to the order and be received but unallocated");
}
LocalDateTime now = LocalDateTime.now();
for (UsTruckOrderPallet pallet : selected) {
pallet.setStatus("ALLOCATED");
pallet.setUpdatedAt(now);
pallets.updateById(pallet);
}
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count()
+ selected.size();
int accepted = value(order.getReceivedPalletCount());
if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets");
String from = order.getStatus();
String next = allocated == accepted ? ALLOCATED : PARTIALLY_ALLOCATED;
order.setAllocatedPalletCount(allocated);
order.setStatus(next);
touch(order);
orders.updateById(order);
recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now);
history(order, "ALLOCATE_PALLETS", from, next, operatorId,
request.idempotencyKey(), request.remark(), now);
return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "PREPARE_DISPATCH", ALLOCATED,
PENDING_DISPATCH, request);
}
@Transactional
public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request);
}
@Transactional
public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request);
}
@Transactional
public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.Command request) {
return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
}
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {