暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -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 {
}
@@ -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 {
}
@@ -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");
}
}
}
@@ -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
) {
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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); }
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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 &gt;=#{startedAt}</if>" +
"<if test='endedAt != null'> AND occurred_at &lt;#{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);
}
@@ -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);
}
@@ -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);
}
@@ -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;
}
@@ -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 {
}
@@ -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());
}
}
@@ -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
) {
}
@@ -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
) {
}
@@ -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
) {
}
@@ -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
) {
}
@@ -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;
}
}
}
@@ -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 &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND in_transit_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 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 &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND b.allocated_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PICKED\"'> AND b.picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND b.in_transit_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND b.receiving_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND b.procurement_packages &gt; 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 &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages &gt; 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 &gt;=#{q.occurredFrom}</if>" +
"<if test='q.occurredTo != null'> AND occurred_at &lt;=#{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);
}
@@ -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;
}
@@ -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("\"", "\"\"") + '"';
}
}