暂存提交
This commit is contained in:
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inventory;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.inventory.mapper")
|
||||
public class InventoryMapperConfiguration {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inventory.command;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.inventory.command.mapper")
|
||||
public class InventoryCommandMapperConfiguration {
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.lastleg.inventory.command.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/inventory")
|
||||
@Tag(name = "Tail-leg inbound-order package inventory commands")
|
||||
public class InventoryCommandController {
|
||||
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
|
||||
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
|
||||
@Resource private InventoryCommandService service;
|
||||
|
||||
@PostMapping("/hold")
|
||||
@Operation(summary = "Move available inbound-order packages to hold")
|
||||
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/release")
|
||||
@Operation(summary = "Release held inbound-order packages")
|
||||
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/allocate")
|
||||
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
|
||||
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/unallocate")
|
||||
@Operation(summary = "Cancel an outbound allocation")
|
||||
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/pick")
|
||||
@Operation(summary = "Confirm picked inbound-order packages")
|
||||
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/ship")
|
||||
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
|
||||
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Move request) {
|
||||
requirePermission(user, MANAGE_PERMISSION);
|
||||
return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/adjust")
|
||||
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
|
||||
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
|
||||
requirePermission(user, ADJUST_PERMISSION);
|
||||
return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request));
|
||||
}
|
||||
|
||||
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.trim();
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
|
||||
private void requirePermission(AuthenticatedUser user, String permission) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (user.permissions() == null || !user.permissions().contains(permission)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.inventory.command.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class InventoryCommandRequests {
|
||||
private InventoryCommandRequests() {
|
||||
}
|
||||
|
||||
public record StockIn(
|
||||
String customerId,
|
||||
String customerName,
|
||||
@NotBlank String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String destinationWarehouseId,
|
||||
@NotBlank String destinationWarehouseCode,
|
||||
String destinationWarehouseName,
|
||||
@NotNull Long inboundOrderId,
|
||||
@NotBlank String inboundOrderNo,
|
||||
@NotBlank String inboundCode,
|
||||
@NotNull Long palletId,
|
||||
@NotBlank String palletNo,
|
||||
String batchNo,
|
||||
Long warehouseLocationId,
|
||||
String warehouseLocationCode,
|
||||
@Min(0) long packages,
|
||||
String businessOrderNo,
|
||||
String sourceEventId,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark
|
||||
) {
|
||||
}
|
||||
|
||||
public record Move(
|
||||
String customerId,
|
||||
@NotNull Long positionId,
|
||||
@Positive long packages,
|
||||
String businessOrderNo,
|
||||
String outboundOrderNo,
|
||||
String sourceEventId,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark
|
||||
) {
|
||||
}
|
||||
|
||||
public record Adjustment(
|
||||
String customerId,
|
||||
@NotNull Long positionId,
|
||||
long packageDelta,
|
||||
@NotBlank String reason,
|
||||
String businessOrderNo,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark
|
||||
) {
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.inventory.command.dto;
|
||||
|
||||
public record InventoryCommandResponse(
|
||||
Long operationId,
|
||||
String operationType,
|
||||
Long balanceId,
|
||||
Long positionId,
|
||||
long availablePackages,
|
||||
long holdPackages,
|
||||
long allocatedPackages,
|
||||
long pickedPackages,
|
||||
boolean palletEmpty,
|
||||
boolean replayed
|
||||
) {
|
||||
public InventoryCommandResponse asReplay() {
|
||||
return new InventoryCommandResponse(operationId, operationType, balanceId, positionId,
|
||||
availablePackages, holdPackages, allocatedPackages, pickedPackages, palletEmpty, true);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inventory.command.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface InventoryCommandMapper {
|
||||
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE")
|
||||
InventoryPosition lockPosition(@Param("customerId") String customerId,
|
||||
@Param("positionId") Long positionId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1 FOR UPDATE")
|
||||
InventoryBalance lockBalance(@Param("customerId") String customerId,
|
||||
@Param("balanceId") Long balanceId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
|
||||
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
|
||||
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
package com.baiyue.lastleg.inventory.command.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
|
||||
@Service
|
||||
public class InventoryCommandService {
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryLedgerMapper ledgers;
|
||||
@Resource private InventoryOperationMapper operations;
|
||||
@Resource private InventoryCommandMapper commands;
|
||||
@Resource private WarehouseResourceGateway warehouseGateway;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse stockIn(String customerId, String operatorId,
|
||||
InventoryCommandRequests.StockIn request) {
|
||||
return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> {
|
||||
InventoryBalance balance = balance(customerId, request);
|
||||
InventoryPosition position = position(customerId, balance, request);
|
||||
long before = value(position.getAvailablePackages());
|
||||
position.setAvailablePackages(before + request.packages());
|
||||
position.setStatus("OCCUPIED");
|
||||
position.setVersion(value(position.getVersion()) + 1);
|
||||
position.setUpdatedAt(LocalDateTime.now());
|
||||
positions.updateById(position);
|
||||
|
||||
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
|
||||
touch(balance);
|
||||
balances.updateById(balance);
|
||||
ledger(customerId, operatorId, operation.getId(), balance, position, "STOCK_IN",
|
||||
"OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(),
|
||||
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
|
||||
request.idempotencyKey(), request.remark());
|
||||
return response(operation, balance, position, false);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse unallocate(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
return move(customerId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse pick(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> {
|
||||
Locked locked = locked(customerId, request.positionId());
|
||||
InventoryPosition position = locked.position();
|
||||
InventoryBalance balance = locked.balance();
|
||||
requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked");
|
||||
requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked");
|
||||
long before = value(position.getPickedPackages());
|
||||
position.setPickedPackages(before - request.packages());
|
||||
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
|
||||
position.setVersion(value(position.getVersion()) + 1);
|
||||
position.setUpdatedAt(LocalDateTime.now());
|
||||
positions.updateById(position);
|
||||
|
||||
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
|
||||
touch(balance);
|
||||
balances.updateById(balance);
|
||||
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
|
||||
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
|
||||
ledger(customerId, operatorId, operation.getId(), balance, position, "SHIP",
|
||||
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
|
||||
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
|
||||
request.idempotencyKey(), request.remark());
|
||||
return response(operation, balance, position, palletEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse adjust(String customerId, String operatorId,
|
||||
InventoryCommandRequests.Adjustment request) {
|
||||
if (request.packageDelta() == 0) {
|
||||
throw badRequest("Adjustment package delta cannot be zero");
|
||||
}
|
||||
return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> {
|
||||
Locked locked = locked(customerId, request.positionId());
|
||||
InventoryPosition position = locked.position();
|
||||
InventoryBalance balance = locked.balance();
|
||||
long before = value(position.getAvailablePackages());
|
||||
long after = before + request.packageDelta();
|
||||
long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta();
|
||||
if (after < 0 || balanceAfter < 0) {
|
||||
throw conflict("Adjustment would make inventory negative");
|
||||
}
|
||||
position.setAvailablePackages(after);
|
||||
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
|
||||
position.setVersion(value(position.getVersion()) + 1);
|
||||
position.setUpdatedAt(LocalDateTime.now());
|
||||
positions.updateById(position);
|
||||
balance.setAvailablePackages(balanceAfter);
|
||||
touch(balance);
|
||||
balances.updateById(balance);
|
||||
ledger(customerId, operatorId, operation.getId(), balance, position, "ADJUST",
|
||||
"AVAILABLE", "AVAILABLE", before, request.packageDelta(), after,
|
||||
request.businessOrderNo(), null, null, null, request.idempotencyKey(),
|
||||
request.reason() + remarkSuffix(request.remark()));
|
||||
return response(operation, balance, position, false);
|
||||
});
|
||||
}
|
||||
|
||||
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
|
||||
Bucket from, Bucket to, InventoryCommandRequests.Move request) {
|
||||
return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
|
||||
Locked locked = locked(customerId, request.positionId());
|
||||
InventoryPosition position = locked.position();
|
||||
InventoryBalance balance = locked.balance();
|
||||
requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase());
|
||||
requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase());
|
||||
long beforeTarget = packages(position, to);
|
||||
packages(position, from, packages(position, from) - request.packages());
|
||||
packages(position, to, beforeTarget + request.packages());
|
||||
position.setVersion(value(position.getVersion()) + 1);
|
||||
position.setUpdatedAt(LocalDateTime.now());
|
||||
positions.updateById(position);
|
||||
|
||||
packages(balance, from, packages(balance, from) - request.packages());
|
||||
packages(balance, to, packages(balance, to) + request.packages());
|
||||
touch(balance);
|
||||
balances.updateById(balance);
|
||||
ledger(customerId, operatorId, operation.getId(), balance, position, operationType,
|
||||
from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(),
|
||||
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
|
||||
request.idempotencyKey(), request.remark());
|
||||
return response(operation, balance, position, false);
|
||||
});
|
||||
}
|
||||
|
||||
private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) {
|
||||
InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(),
|
||||
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
|
||||
if (balance != null) return balance;
|
||||
balance = new InventoryBalance();
|
||||
balance.setId(IdWorker.getId());
|
||||
balance.setCustomerId(customerId);
|
||||
balance.setCustomerName(trim(request.customerName()));
|
||||
balance.setOverseasWarehouseId(request.overseasWarehouseId().trim());
|
||||
balance.setOverseasWarehouseCode(trim(request.overseasWarehouseCode()));
|
||||
balance.setOverseasWarehouseName(trim(request.overseasWarehouseName()));
|
||||
balance.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
|
||||
balance.setDestinationWarehouseCode(request.destinationWarehouseCode().trim());
|
||||
balance.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
|
||||
balance.setInboundOrderId(request.inboundOrderId());
|
||||
balance.setInboundOrderNo(request.inboundOrderNo().trim());
|
||||
balance.setInboundCode(request.inboundCode().trim());
|
||||
zero(balance);
|
||||
balance.setVersion(0L);
|
||||
balance.setCreatedAt(LocalDateTime.now());
|
||||
balance.setUpdatedAt(balance.getCreatedAt());
|
||||
try {
|
||||
balances.insert(balance);
|
||||
return balance;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(),
|
||||
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private InventoryPosition position(String customerId, InventoryBalance balance,
|
||||
InventoryCommandRequests.StockIn request) {
|
||||
String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim();
|
||||
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo);
|
||||
if (position != null) return position;
|
||||
position = new InventoryPosition();
|
||||
position.setId(IdWorker.getId());
|
||||
position.setCustomerId(customerId);
|
||||
position.setBalanceId(balance.getId());
|
||||
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
|
||||
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
|
||||
position.setPalletId(request.palletId());
|
||||
position.setPalletNo(request.palletNo().trim());
|
||||
position.setBatchNo(batchNo);
|
||||
position.setWarehouseLocationId(request.warehouseLocationId());
|
||||
String locationCode = trim(request.warehouseLocationCode());
|
||||
position.setWarehouseLocationCode(locationCode == null
|
||||
? "LOC-" + defaultCode(balance) + "-DEFAULT" : locationCode);
|
||||
position.setAvailablePackages(0L);
|
||||
position.setHoldPackages(0L);
|
||||
position.setAllocatedPackages(0L);
|
||||
position.setPickedPackages(0L);
|
||||
position.setReceivingPackages(0L);
|
||||
position.setStatus("OCCUPIED");
|
||||
position.setVersion(0L);
|
||||
position.setCreatedAt(LocalDateTime.now());
|
||||
position.setUpdatedAt(position.getCreatedAt());
|
||||
try {
|
||||
positions.insert(position);
|
||||
return position;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private Locked locked(String customerId, Long positionId) {
|
||||
InventoryPosition position = commands.lockPosition(customerId, positionId);
|
||||
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
|
||||
InventoryBalance balance = commands.lockBalance(customerId, position.getBalanceId());
|
||||
if (balance == null) throw new IllegalStateException("Inventory position balance is missing");
|
||||
return new Locked(balance, position);
|
||||
}
|
||||
|
||||
private InventoryCommandResponse execute(String customerId, String operatorId, String operationType,
|
||||
String key, Object request, Work work) {
|
||||
String normalizedKey = required(key, "Idempotency key is required");
|
||||
String requestHash = hash(request);
|
||||
InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey);
|
||||
if (existing != null) return replay(existing, operationType, requestHash);
|
||||
|
||||
InventoryOperation operation = new InventoryOperation();
|
||||
operation.setId(IdWorker.getId());
|
||||
operation.setCustomerId(customerId);
|
||||
operation.setIdempotencyKey(normalizedKey);
|
||||
operation.setOperationType(operationType);
|
||||
operation.setRequestHash(requestHash);
|
||||
operation.setStatus("PROCESSING");
|
||||
operation.setCreatedAt(LocalDateTime.now());
|
||||
operation.setUpdatedAt(operation.getCreatedAt());
|
||||
try {
|
||||
operations.insert(operation);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey);
|
||||
if (concurrent != null) return replay(concurrent, operationType, requestHash);
|
||||
throw exception;
|
||||
}
|
||||
|
||||
InventoryCommandResponse response = work.run(operation);
|
||||
operation.setStatus("COMPLETED");
|
||||
operation.setResultJson(write(response));
|
||||
operation.setUpdatedAt(LocalDateTime.now());
|
||||
operations.updateById(operation);
|
||||
return response;
|
||||
}
|
||||
|
||||
private InventoryCommandResponse replay(InventoryOperation operation, String type, String requestHash) {
|
||||
if (!type.equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) {
|
||||
throw conflict("Idempotency key was already used with a different inventory request");
|
||||
}
|
||||
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
|
||||
throw conflict("Inventory operation is still processing");
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(operation.getResultJson(), InventoryCommandResponse.class).asReplay();
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Cannot read inventory idempotency result", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
|
||||
InventoryPosition position, String businessType, String from, String to,
|
||||
long beforePackages, long deltaPackages, long afterPackages,
|
||||
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
|
||||
String sourceEventId, String idempotencyKey, String remark) {
|
||||
InventoryLedger ledger = new InventoryLedger();
|
||||
ledger.setId(IdWorker.getId());
|
||||
ledger.setCustomerId(customerId);
|
||||
ledger.setBalanceId(balance.getId());
|
||||
ledger.setPositionId(position.getId());
|
||||
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
|
||||
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
|
||||
ledger.setInboundOrderId(balance.getInboundOrderId());
|
||||
ledger.setPalletId(position.getPalletId());
|
||||
ledger.setPalletNo(position.getPalletNo());
|
||||
ledger.setBatchNo(position.getBatchNo());
|
||||
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
|
||||
ledger.setBusinessType(businessType);
|
||||
ledger.setBusinessOrderId(balance.getInboundOrderId());
|
||||
ledger.setBusinessOrderNo(trim(businessOrderNo));
|
||||
ledger.setInboundCode(balance.getInboundCode());
|
||||
ledger.setInboundOrderNo(trim(inboundOrderNo) == null
|
||||
? balance.getInboundOrderNo() : trim(inboundOrderNo));
|
||||
ledger.setOutboundOrderNo(trim(outboundOrderNo));
|
||||
ledger.setSourceEventId(trim(sourceEventId));
|
||||
ledger.setIdempotencyKey(idempotencyKey + ":" + operationId);
|
||||
ledger.setStatusFrom(from);
|
||||
ledger.setStatusTo(to);
|
||||
ledger.setBeforePackages(beforePackages);
|
||||
ledger.setDeltaPackages(deltaPackages);
|
||||
ledger.setAfterPackages(afterPackages);
|
||||
ledger.setBeforeQuantity(0L);
|
||||
ledger.setDeltaQuantity(0L);
|
||||
ledger.setAfterQuantity(0L);
|
||||
ledger.setOperatorId(trim(operatorId));
|
||||
ledger.setRemark(trim(remark));
|
||||
ledger.setOccurredAt(LocalDateTime.now());
|
||||
ledger.setCreatedAt(ledger.getOccurredAt());
|
||||
ledgers.insert(ledger);
|
||||
}
|
||||
|
||||
private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance,
|
||||
InventoryPosition position, boolean palletEmpty) {
|
||||
return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(),
|
||||
position.getId(), value(balance.getAvailablePackages()), value(balance.getHoldPackages()),
|
||||
value(balance.getAllocatedPackages()), value(balance.getPickedPackages()), palletEmpty, false);
|
||||
}
|
||||
|
||||
private static void touch(InventoryBalance balance) {
|
||||
balance.setVersion(value(balance.getVersion()) + 1);
|
||||
balance.setUpdatedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private static void zero(InventoryBalance balance) {
|
||||
balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L);
|
||||
balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L);
|
||||
balance.setProcurementPackages(0L);
|
||||
}
|
||||
|
||||
private static long positionPackages(InventoryPosition position) {
|
||||
return value(position.getAvailablePackages()) + value(position.getHoldPackages())
|
||||
+ value(position.getAllocatedPackages()) + value(position.getPickedPackages())
|
||||
+ value(position.getReceivingPackages());
|
||||
}
|
||||
|
||||
private static long packages(InventoryPosition position, Bucket bucket) {
|
||||
return switch (bucket) {
|
||||
case AVAILABLE -> value(position.getAvailablePackages());
|
||||
case HOLD -> value(position.getHoldPackages());
|
||||
case ALLOCATED -> value(position.getAllocatedPackages());
|
||||
case PICKED -> value(position.getPickedPackages());
|
||||
};
|
||||
}
|
||||
|
||||
private static void packages(InventoryPosition position, Bucket bucket, long value) {
|
||||
switch (bucket) {
|
||||
case AVAILABLE -> position.setAvailablePackages(value);
|
||||
case HOLD -> position.setHoldPackages(value);
|
||||
case ALLOCATED -> position.setAllocatedPackages(value);
|
||||
case PICKED -> position.setPickedPackages(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static long packages(InventoryBalance balance, Bucket bucket) {
|
||||
return switch (bucket) {
|
||||
case AVAILABLE -> value(balance.getAvailablePackages());
|
||||
case HOLD -> value(balance.getHoldPackages());
|
||||
case ALLOCATED -> value(balance.getAllocatedPackages());
|
||||
case PICKED -> value(balance.getPickedPackages());
|
||||
};
|
||||
}
|
||||
|
||||
private static void packages(InventoryBalance balance, Bucket bucket, long value) {
|
||||
switch (bucket) {
|
||||
case AVAILABLE -> balance.setAvailablePackages(value);
|
||||
case HOLD -> balance.setHoldPackages(value);
|
||||
case ALLOCATED -> balance.setAllocatedPackages(value);
|
||||
case PICKED -> balance.setPickedPackages(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(write(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String write(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Cannot serialize inventory operation", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultCode(InventoryBalance balance) {
|
||||
String code = trim(balance.getOverseasWarehouseCode());
|
||||
return code == null ? balance.getOverseasWarehouseId() : code;
|
||||
}
|
||||
|
||||
private static void requireAtLeast(long actual, long requested, String field) {
|
||||
if (actual < requested) throw conflict("Insufficient " + field + " packages");
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
String normalized = trim(value);
|
||||
if (normalized == null) throw badRequest(message);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private static String remarkSuffix(String remark) {
|
||||
String value = trim(remark);
|
||||
return value == null ? "" : "; " + value;
|
||||
}
|
||||
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
private static ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
private enum Bucket { AVAILABLE, HOLD, ALLOCATED, PICKED }
|
||||
private record Locked(InventoryBalance balance, InventoryPosition position) {}
|
||||
@FunctionalInterface private interface Work { InventoryCommandResponse run(InventoryOperation operation); }
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.inventory.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;
|
||||
|
||||
/** Real-time package balance for one inbound order at one overseas warehouse. */
|
||||
@Data
|
||||
@TableName("ll_inventory_balance")
|
||||
public class InventoryBalance {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String customerName;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
private String overseasWarehouseName;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private Long inboundOrderId;
|
||||
private String inboundOrderNo;
|
||||
private String inboundCode;
|
||||
private Long availablePackages;
|
||||
private Long holdPackages;
|
||||
private Long allocatedPackages;
|
||||
private Long pickedPackages;
|
||||
private Long inTransitPackages;
|
||||
private Long receivingPackages;
|
||||
private Long procurementPackages;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.inventory.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;
|
||||
|
||||
/** Append-only inventory fact. Never update or delete this entity. */
|
||||
@Data
|
||||
@TableName("ll_inventory_ledger")
|
||||
public class InventoryLedger {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private Long balanceId;
|
||||
private Long positionId;
|
||||
private String overseasWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private Long inboundOrderId;
|
||||
private Long palletId;
|
||||
private String palletNo;
|
||||
private String batchNo;
|
||||
private String skuCode;
|
||||
private String cartonMark;
|
||||
private Long cartonPackages;
|
||||
private String warehouseLocationCode;
|
||||
private String businessType;
|
||||
private Long businessOrderId;
|
||||
private String businessOrderNo;
|
||||
private String inboundCode;
|
||||
private String inboundOrderNo;
|
||||
private String outboundOrderNo;
|
||||
private String sourceEventId;
|
||||
private String idempotencyKey;
|
||||
private String statusFrom;
|
||||
private String statusTo;
|
||||
private Long beforePackages;
|
||||
private Long deltaPackages;
|
||||
private Long afterPackages;
|
||||
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
|
||||
private Long beforeQuantity;
|
||||
private Long deltaQuantity;
|
||||
private Long afterQuantity;
|
||||
private String operatorId;
|
||||
private String remark;
|
||||
private LocalDateTime occurredAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inventory.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;
|
||||
|
||||
/** Command-level idempotency record for inventory mutations. */
|
||||
@Data
|
||||
@TableName("ll_inventory_operation")
|
||||
public class InventoryOperation {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String idempotencyKey;
|
||||
private String operationType;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private String lastError;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.lastleg.inventory.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;
|
||||
|
||||
/** Physical stock snapshot. A location is currently informational only. */
|
||||
@Data
|
||||
@TableName("ll_inventory_position")
|
||||
public class InventoryPosition {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private Long balanceId;
|
||||
private String overseasWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private Long palletId;
|
||||
private String palletNo;
|
||||
private String batchNo;
|
||||
private Long warehouseLocationId;
|
||||
private String warehouseLocationCode;
|
||||
private Long availablePackages;
|
||||
private Long holdPackages;
|
||||
private Long allocatedPackages;
|
||||
private Long pickedPackages;
|
||||
private Long receivingPackages;
|
||||
private String status;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.lastleg.inventory.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.model.InventorySummary;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
|
||||
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1")
|
||||
InventoryBalance findDimension(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("destinationWarehouseCode") String destinationWarehouseCode,
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
|
||||
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE")
|
||||
InventoryBalance lockDimension(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("destinationWarehouseCode") String destinationWarehouseCode,
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
|
||||
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
|
||||
"<if test='destinationWarehouseCode != null and destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{destinationWarehouseCode}</if>" +
|
||||
"<if test='inboundCode != null and inboundCode != \"\"'> AND inbound_code=#{inboundCode}</if>" +
|
||||
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InventoryBalance> listTenant(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("destinationWarehouseCode") String destinationWarehouseCode,
|
||||
@Param("inboundCode") String inboundCode,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) balance_count," +
|
||||
"COALESCE(SUM(available_packages),0) available_packages,COALESCE(SUM(hold_packages),0) hold_packages," +
|
||||
"COALESCE(SUM(allocated_packages),0) allocated_packages,COALESCE(SUM(picked_packages),0) picked_packages," +
|
||||
"COALESCE(SUM(in_transit_packages),0) in_transit_packages,COALESCE(SUM(receiving_packages),0) receiving_packages," +
|
||||
"COALESCE(SUM(procurement_packages),0) procurement_packages FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
|
||||
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if></script>")
|
||||
InventorySummary summarize(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.inventory.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface InventoryLedgerMapper extends BaseMapper<InventoryLedger> {
|
||||
@Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
|
||||
InventoryLedger findIdempotency(@Param("customerId") String customerId,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId}" +
|
||||
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
|
||||
"<if test='inboundCode != null and inboundCode != \"\"'> AND inbound_code=#{inboundCode}</if>" +
|
||||
"<if test='businessType != null and businessType != \"\"'> AND business_type=#{businessType}</if>" +
|
||||
"<if test='businessOrderNo != null and businessOrderNo != \"\"'> AND business_order_no=#{businessOrderNo}</if>" +
|
||||
"<if test='startedAt != null'> AND occurred_at >=#{startedAt}</if>" +
|
||||
"<if test='endedAt != null'> AND occurred_at <#{endedAt}</if>" +
|
||||
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InventoryLedger> listTenant(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("inboundCode") String inboundCode,
|
||||
@Param("businessType") String businessType,
|
||||
@Param("businessOrderNo") String businessOrderNo,
|
||||
@Param("startedAt") LocalDateTime startedAt,
|
||||
@Param("endedAt") LocalDateTime endedAt,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.inventory.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
public interface InventoryOperationMapper extends BaseMapper<InventoryOperation> {
|
||||
@Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
|
||||
InventoryOperation findIdempotency(@Param("customerId") String customerId,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE")
|
||||
InventoryOperation lockIdempotency(@Param("customerId") String customerId,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.inventory.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
|
||||
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
|
||||
"WHERE b.customer_id=#{customerId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no")
|
||||
List<InventoryPosition> listBalance(@Param("customerId") String customerId,
|
||||
@Param("balanceId") Long balanceId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
|
||||
"ORDER BY balance_id,batch_no")
|
||||
List<InventoryPosition> listPallet(@Param("customerId") String customerId,
|
||||
@Param("palletId") Long palletId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
|
||||
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
|
||||
InventoryPosition lockPosition(@Param("customerId") String customerId,
|
||||
@Param("balanceId") Long balanceId,
|
||||
@Param("palletId") Long palletId,
|
||||
@Param("batchNo") String batchNo);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.inventory.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class InventorySummary {
|
||||
private Long balanceCount;
|
||||
private Long availablePackages;
|
||||
private Long holdPackages;
|
||||
private Long allocatedPackages;
|
||||
private Long pickedPackages;
|
||||
private Long inTransitPackages;
|
||||
private Long receivingPackages;
|
||||
private Long procurementPackages;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inventory.query;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.inventory.query.mapper")
|
||||
public class InventoryQueryMapperConfiguration {
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.baiyue.lastleg.inventory.query.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.inventory.query.dto.InventoryBalanceView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Inventory queries")
|
||||
public class InventoryQueryController {
|
||||
private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory";
|
||||
|
||||
@Resource private InventoryQueryService service;
|
||||
|
||||
@GetMapping(BASE_PATH + "/summary")
|
||||
@Operation(summary = "Get inbound-order package inventory totals")
|
||||
public ApiResponse<InventoryDashboardResponse> summary(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||
return ApiResponse.success(service.dashboard(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances")
|
||||
@Operation(summary = "Page inventory by customer, warehouse and inbound order")
|
||||
public ApiResponse<PageResult<InventoryBalanceView>> balances(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||
return ApiResponse.success(service.balances(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
|
||||
@Operation(summary = "List pallet and batch positions for one inbound-order balance")
|
||||
public ApiResponse<List<InventoryPositionView>> positions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long balanceId,
|
||||
@Valid InventoryQueryRequests.PositionQuery query) {
|
||||
return ApiResponse.success(service.positions(scope(user, customerId), balanceId, query));
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/ledger")
|
||||
@Operation(summary = "Page append-only inventory movements")
|
||||
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InventoryQueryRequests.LedgerQuery query) {
|
||||
return ApiResponse.success(service.ledgers(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV")
|
||||
public ResponseEntity<byte[]> exportBalances(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||
return csv(service.exportBalances(scope(user, customerId), query), "inventory-balances.csv");
|
||||
}
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV")
|
||||
public ResponseEntity<byte[]> exportLedgers(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InventoryQueryRequests.LedgerQuery query) {
|
||||
return csv(service.exportLedgers(scope(user, customerId), query), "inventory-ledger.csv");
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
|
||||
.body(body);
|
||||
}
|
||||
|
||||
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 queries must specify a customer");
|
||||
}
|
||||
return requestedCustomer;
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.inventory.query.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record InventoryBalanceView(
|
||||
Long id,
|
||||
String customerId,
|
||||
String customerName,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String destinationWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String destinationWarehouseName,
|
||||
Long inboundOrderId,
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
long totalPackages,
|
||||
long availablePackages,
|
||||
long holdPackages,
|
||||
long allocatedPackages,
|
||||
long pickedPackages,
|
||||
long inTransitPackages,
|
||||
long receivingPackages,
|
||||
long procurementPackages,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.inventory.query.dto;
|
||||
|
||||
public record InventoryDashboardResponse(
|
||||
long inboundOrderCount,
|
||||
long palletCount,
|
||||
long totalPackages,
|
||||
long availablePackages,
|
||||
long holdPackages,
|
||||
long allocatedPackages,
|
||||
long pickedPackages,
|
||||
long inTransitPackages,
|
||||
long receivingPackages,
|
||||
long procurementPackages
|
||||
) {
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.inventory.query.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record InventoryLedgerView(
|
||||
Long id,
|
||||
String customerId,
|
||||
String overseasWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
Long inboundOrderId,
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
String palletNo,
|
||||
String batchNo,
|
||||
String warehouseLocationCode,
|
||||
String businessType,
|
||||
String businessOrderNo,
|
||||
String outboundOrderNo,
|
||||
String statusFrom,
|
||||
String statusTo,
|
||||
long beforePackages,
|
||||
long deltaPackages,
|
||||
long afterPackages,
|
||||
String operatorId,
|
||||
String remark,
|
||||
LocalDateTime occurredAt
|
||||
) {
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.inventory.query.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record InventoryPositionView(
|
||||
Long id,
|
||||
Long balanceId,
|
||||
String customerId,
|
||||
String overseasWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
Long inboundOrderId,
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
Long palletId,
|
||||
String palletNo,
|
||||
String batchNo,
|
||||
Long warehouseLocationId,
|
||||
String warehouseLocationCode,
|
||||
long totalPackages,
|
||||
long availablePackages,
|
||||
long holdPackages,
|
||||
long allocatedPackages,
|
||||
long pickedPackages,
|
||||
long receivingPackages,
|
||||
String status,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.inventory.query.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class InventoryQueryRequests {
|
||||
private InventoryQueryRequests() {
|
||||
}
|
||||
|
||||
public record BalanceQuery(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String customerName,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String destinationWarehouseCode,
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
String businessOrderNo,
|
||||
String inventoryStatus
|
||||
) {
|
||||
public BalanceQuery {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
|
||||
public record PositionQuery(String palletNo, String batchNo, String inventoryStatus) {
|
||||
}
|
||||
|
||||
public record LedgerQuery(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String overseasWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String businessType,
|
||||
String businessOrderNo,
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
String outboundOrderNo,
|
||||
String palletNo,
|
||||
String batchNo,
|
||||
LocalDateTime occurredFrom,
|
||||
LocalDateTime occurredTo
|
||||
) {
|
||||
public LedgerQuery {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.baiyue.lastleg.inventory.query.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface InventoryQueryMapper {
|
||||
String BALANCE_FILTER = " WHERE customer_id=#{customerId}" +
|
||||
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
|
||||
"<choose>" +
|
||||
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND in_transit_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages > 0</when>" +
|
||||
"</choose>";
|
||||
|
||||
String JOINED_BALANCE_FILTER = " WHERE b.customer_id=#{customerId}" +
|
||||
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND b.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
|
||||
"<choose>" +
|
||||
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND b.allocated_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PICKED\"'> AND b.picked_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND b.in_transit_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND b.receiving_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND b.procurement_packages > 0</when>" +
|
||||
"</choose>";
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
|
||||
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InventoryBalance> balances(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.BalanceQuery query,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") long limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
|
||||
long countBalances(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.BalanceQuery query);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) dimension_count," +
|
||||
"COALESCE(SUM(available_packages),0) available_packages," +
|
||||
"COALESCE(SUM(hold_packages),0) hold_packages," +
|
||||
"COALESCE(SUM(allocated_packages),0) allocated_packages," +
|
||||
"COALESCE(SUM(picked_packages),0) picked_packages," +
|
||||
"COALESCE(SUM(in_transit_packages),0) in_transit_packages," +
|
||||
"COALESCE(SUM(receiving_packages),0) receiving_packages," +
|
||||
"COALESCE(SUM(procurement_packages),0) procurement_packages " +
|
||||
"FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
|
||||
InventoryAggregateProjection aggregate(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.BalanceQuery query);
|
||||
|
||||
@Select("<script>SELECT COUNT(DISTINCT p.pallet_id) FROM ll_inventory_position p " +
|
||||
"JOIN ll_inventory_balance b ON b.id=p.balance_id" +
|
||||
JOINED_BALANCE_FILTER + "</script>")
|
||||
long countPallets(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.BalanceQuery query);
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}" +
|
||||
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
|
||||
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
|
||||
"<choose>" +
|
||||
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages > 0</when>" +
|
||||
"</choose> ORDER BY pallet_no,batch_no,id</script>")
|
||||
List<InventoryPosition> positions(@Param("customerId") String customerId,
|
||||
@Param("balanceId") long balanceId,
|
||||
@Param("q") InventoryQueryRequests.PositionQuery query);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1")
|
||||
InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId);
|
||||
|
||||
String LEDGER_FILTER = " WHERE customer_id=#{customerId}" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.businessType != null and q.businessType != \"\"'> AND business_type=#{q.businessType}</if>" +
|
||||
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')</if>" +
|
||||
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.outboundOrderNo != null and q.outboundOrderNo != \"\"'> AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')</if>" +
|
||||
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
|
||||
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
|
||||
"<if test='q.occurredFrom != null'> AND occurred_at >=#{q.occurredFrom}</if>" +
|
||||
"<if test='q.occurredTo != null'> AND occurred_at <=#{q.occurredTo}</if>";
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_ledger" + LEDGER_FILTER +
|
||||
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InventoryLedger> ledgers(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.LedgerQuery query,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") long limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inventory_ledger" + LEDGER_FILTER + "</script>")
|
||||
long countLedgers(@Param("customerId") String customerId,
|
||||
@Param("q") InventoryQueryRequests.LedgerQuery query);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.inventory.query.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class InventoryAggregateProjection {
|
||||
private Long dimensionCount;
|
||||
private Long availablePackages;
|
||||
private Long holdPackages;
|
||||
private Long allocatedPackages;
|
||||
private Long pickedPackages;
|
||||
private Long inTransitPackages;
|
||||
private Long receivingPackages;
|
||||
private Long procurementPackages;
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.baiyue.lastleg.inventory.query.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
|
||||
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class InventoryQueryService {
|
||||
private static final int EXPORT_LIMIT = 10_000;
|
||||
private static final Set<String> BALANCE_STATUSES = Set.of(
|
||||
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT");
|
||||
private static final Set<String> POSITION_STATUSES = Set.of(
|
||||
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "RECEIVING");
|
||||
|
||||
@Resource private InventoryQueryMapper mapper;
|
||||
|
||||
public InventoryDashboardResponse dashboard(String customerId, InventoryQueryRequests.BalanceQuery query) {
|
||||
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
|
||||
InventoryAggregateProjection value = mapper.aggregate(customerId, query);
|
||||
long availablePackages = number(value.getAvailablePackages());
|
||||
long holdPackages = number(value.getHoldPackages());
|
||||
long allocatedPackages = number(value.getAllocatedPackages());
|
||||
long pickedPackages = number(value.getPickedPackages());
|
||||
long inTransitPackages = number(value.getInTransitPackages());
|
||||
long receivingPackages = number(value.getReceivingPackages());
|
||||
long procurementPackages = number(value.getProcurementPackages());
|
||||
return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(customerId, query),
|
||||
availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages
|
||||
+ receivingPackages + procurementPackages,
|
||||
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
|
||||
receivingPackages, procurementPackages);
|
||||
}
|
||||
|
||||
public PageResult<InventoryBalanceView> balances(String customerId, InventoryQueryRequests.BalanceQuery query) {
|
||||
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
|
||||
long total = mapper.countBalances(customerId, query);
|
||||
List<InventoryBalanceView> records = total == 0 ? List.of() : mapper.balances(
|
||||
customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList();
|
||||
return page(records, query.page(), query.size(), total);
|
||||
}
|
||||
|
||||
public List<InventoryPositionView> positions(String customerId, long balanceId,
|
||||
InventoryQueryRequests.PositionQuery query) {
|
||||
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
|
||||
InventoryBalance balance = mapper.ownedBalance(customerId, balanceId);
|
||||
if (balance == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
|
||||
}
|
||||
return mapper.positions(customerId, balanceId, query).stream().map(value -> positionView(value, balance)).toList();
|
||||
}
|
||||
|
||||
public PageResult<InventoryLedgerView> ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
|
||||
validateDateRange(query);
|
||||
long total = mapper.countLedgers(customerId, query);
|
||||
List<InventoryLedgerView> records = total == 0 ? List.of() : mapper.ledgers(
|
||||
customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList();
|
||||
return page(records, query.page(), query.size(), total);
|
||||
}
|
||||
|
||||
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
|
||||
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
|
||||
List<String> rows = new ArrayList<>();
|
||||
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
|
||||
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," +
|
||||
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
|
||||
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
|
||||
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
|
||||
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
|
||||
csv(v.inboundOrderNo()), csv(v.inboundCode()),
|
||||
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
|
||||
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
|
||||
csv(v.procurementPackages()))));
|
||||
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
|
||||
validateDateRange(query);
|
||||
List<String> rows = new ArrayList<>();
|
||||
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
|
||||
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
|
||||
"beforePackages,deltaPackages,afterPackages,operatorId,remark");
|
||||
mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
|
||||
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()),
|
||||
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
|
||||
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
|
||||
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
|
||||
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark()))));
|
||||
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private InventoryBalanceView balanceView(InventoryBalance value) {
|
||||
long availablePackages = number(value.getAvailablePackages());
|
||||
long holdPackages = number(value.getHoldPackages());
|
||||
long allocatedPackages = number(value.getAllocatedPackages());
|
||||
long pickedPackages = number(value.getPickedPackages());
|
||||
long inTransitPackages = number(value.getInTransitPackages());
|
||||
long receivingPackages = number(value.getReceivingPackages());
|
||||
long procurementPackages = number(value.getProcurementPackages());
|
||||
return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
|
||||
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
|
||||
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(),
|
||||
value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages
|
||||
+ inTransitPackages + receivingPackages + procurementPackages,
|
||||
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
|
||||
receivingPackages, procurementPackages, value.getUpdatedAt());
|
||||
}
|
||||
|
||||
private InventoryPositionView positionView(InventoryPosition value, InventoryBalance balance) {
|
||||
long available = number(value.getAvailablePackages());
|
||||
long hold = number(value.getHoldPackages());
|
||||
long allocated = number(value.getAllocatedPackages());
|
||||
long picked = number(value.getPickedPackages());
|
||||
long receiving = number(value.getReceivingPackages());
|
||||
return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getCustomerId(),
|
||||
value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(),
|
||||
balance.getInboundOrderNo(), balance.getInboundCode(),
|
||||
value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(),
|
||||
value.getWarehouseLocationCode(), available + hold + allocated + picked + receiving,
|
||||
available, hold, allocated, picked, receiving, value.getStatus(),
|
||||
value.getUpdatedAt());
|
||||
}
|
||||
|
||||
private InventoryLedgerView ledgerView(InventoryLedger value) {
|
||||
return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(),
|
||||
value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(),
|
||||
value.getPalletNo(), value.getBatchNo(),
|
||||
value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(),
|
||||
value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(),
|
||||
number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()),
|
||||
value.getOperatorId(), value.getRemark(), value.getOccurredAt());
|
||||
}
|
||||
|
||||
private static void validateStatus(String status, Set<String> accepted) {
|
||||
if (status != null && !status.isBlank() && !accepted.contains(status)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported inventory status: " + status);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDateRange(InventoryQueryRequests.LedgerQuery query) {
|
||||
if (query.occurredFrom() != null && query.occurredTo() != null
|
||||
&& query.occurredFrom().isAfter(query.occurredTo())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "occurredFrom must not be after occurredTo");
|
||||
}
|
||||
}
|
||||
|
||||
private static long offset(long page, long size) {
|
||||
return Math.multiplyExact(page - 1, size);
|
||||
}
|
||||
|
||||
private static long number(Long value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private static <T> PageResult<T> page(List<T> records, long page, long size, long total) {
|
||||
long pages = total == 0 ? 0 : (total + size - 1) / size;
|
||||
return new PageResult<>(records, page, size, total, pages);
|
||||
}
|
||||
|
||||
private static String csv(Object value) {
|
||||
if (value == null) return "";
|
||||
String text = String.valueOf(value);
|
||||
if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text;
|
||||
return '"' + text.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user