This commit is contained in:
2026-09-01 11:09:14 +08:00
191 changed files with 6486 additions and 403 deletions
@@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InventoryCommandMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1")
@@ -22,4 +24,17 @@ public interface InventoryCommandMapper {
@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);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
"AND in_transit_packages>0 ORDER BY id FOR UPDATE")
List<InventoryBalance> lockInboundTransitBalances(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
List<InventoryPosition> lockInboundHeldPositions(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId);
}
@@ -27,6 +27,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
@Service
public class InventoryCommandService {
@@ -59,23 +60,38 @@ public class InventoryCommandService {
@Transactional
public InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
return stockIn(customerId, operatorId, request, false);
}
@Transactional
public InventoryCommandResponse stockInHeld(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
return stockIn(customerId, operatorId, request, true);
}
private InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request, boolean held) {
return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> {
List<InventoryBalance> transitBalances = commands.lockInboundTransitBalances(customerId,
request.inboundOrderId(), request.inboundCode());
InventoryBalance balance = balance(customerId, request);
InventoryPosition position = position(customerId, balance, request);
long before = value(position.getAvailablePackages());
position.setAvailablePackages(before + request.packages());
long before = held ? value(position.getHoldPackages()) : value(position.getAvailablePackages());
if (held) position.setHoldPackages(before + request.packages());
else position.setAvailablePackages(before + request.packages());
position.setStatus("OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
long transferred = Math.min(value(balance.getInTransitPackages()), request.packages());
balance.setInTransitPackages(value(balance.getInTransitPackages()) - transferred);
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
long transferred = transferInTransit(customerId, operatorId, operation.getId(), balance,
transitBalances, request);
if (held) balance.setHoldPackages(value(balance.getHoldPackages()) + request.packages());
else balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, sku(request.skuCode()), "STOCK_IN",
transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", "AVAILABLE",
transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", held ? "HOLD" : "AVAILABLE",
before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -83,6 +99,44 @@ public class InventoryCommandService {
});
}
@Transactional
public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId,
String businessOrderNo, String remark) {
for (InventoryPosition position : commands.lockInboundHeldPositions(customerId, inboundOrderId)) {
long packages = value(position.getHoldPackages());
if (packages == 0) continue;
release(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages,
businessOrderNo, null, null, "ORDER_HOLD_RELEASE:" + inboundOrderId + ":" + position.getId(), remark));
}
}
private long transferInTransit(String customerId, String operatorId, Long operationId,
InventoryBalance target, List<InventoryBalance> sources,
InventoryCommandRequests.StockIn request) {
long pending = request.packages();
long transferred = 0;
for (InventoryBalance source : sources) {
if (pending == 0) break;
boolean targetDimension = source.getId().equals(target.getId());
long sourceBefore = value(targetDimension ? target.getInTransitPackages() : source.getInTransitPackages());
long moved = Math.min(sourceBefore, pending);
if (moved == 0) continue;
if (targetDimension) target.setInTransitPackages(sourceBefore - moved);
else source.setInTransitPackages(sourceBefore - moved);
transferred += moved;
pending -= moved;
if (targetDimension) continue;
touch(source);
balances.updateById(source);
ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()),
"STOCK_IN_TRANSIT_RELEASE", "IN_TRANSIT", "STOCK_IN", sourceBefore, -moved,
sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null,
request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(),
"In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId());
}
return transferred;
}
@Transactional
public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
@@ -24,6 +24,7 @@ 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.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -80,28 +81,30 @@ public class InventoryQueryController {
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
public ResponseEntity<StreamingResponseBody> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
String scope = platformScope(user, customerId);
return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers(
public ResponseEntity<StreamingResponseBody> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
String scope = platformScope(user, customerId);
return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
private static ResponseEntity<StreamingResponseBody> csv(StreamingResponseBody stream, String filename) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.body(body);
.body(stream);
}
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
@@ -17,13 +17,14 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
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(
@@ -79,34 +80,74 @@ public class InventoryQueryService {
}
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) {
InventoryBalanceView v = balanceView(value);
output.write((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.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
}
public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output)
throws IOException {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryBalanceView> batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
if (batch.isEmpty()) break;
for (InventoryBalanceView v : batch) output.write((
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.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()),
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);
csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateDateRange(query);
output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) {
InventoryLedgerView v = ledgerView(value);
output.write((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())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
}
public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output)
throws IOException {
validateDateRange(query);
List<String> rows = new ArrayList<>();
rows.add("occurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
output.write(("\uFEFFoccurredAt,customerId,customerName,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(
"beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryLedgerView> batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList();
if (batch.isEmpty()) break;
for (InventoryLedgerView v : batch) output.write((
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.customerName()), 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);
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
private InventoryBalanceView balanceView(InventoryBalance value) {