feat(lastleg): add direct inventory balance detail

This commit is contained in:
2026-09-08 18:47:15 +08:00
parent 6514634dcb
commit d42629d289
7 changed files with 184 additions and 0 deletions
@@ -56,6 +56,16 @@ public class InventoryQueryController {
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Get one inventory balance", description = "Returns one non-zero inventory balance by ID and optional customer scope. Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryBalanceView> balance(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long balanceId,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.balance(platformScope(user, customerId), balanceId));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
@@ -111,6 +111,17 @@ public interface InventoryQueryMapper {
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1")
InventoryBalance findBalance(@Param("balanceId") long balanceId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " +
"AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" +
"receiving_packages+procurement_packages)>0 LIMIT 1")
InventoryBalance ownedVisibleBalance(@Param("customerId") String customerId,
@Param("balanceId") long balanceId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} " +
"AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" +
"receiving_packages+procurement_packages)>0 LIMIT 1")
InventoryBalance visibleBalance(@Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE 1=1" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
@@ -57,6 +57,16 @@ public class InventoryQueryService {
return page(records, query.page(), query.size(), total);
}
public InventoryBalanceView balance(String customerId, long balanceId) {
InventoryBalance balance = customerId == null
? mapper.visibleBalance(balanceId)
: mapper.ownedVisibleBalance(customerId, balanceId);
if (balance == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
}
return balanceView(balance);
}
public List<InventoryPositionView> positions(String customerId, long balanceId,
InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
@@ -3,6 +3,18 @@ info:
title: Lastleg Overseas Business API
version: 1.0.0
paths:
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
get:
summary: 查询单条库存余额详情
description: 仅平台且要求 lastleg:inventory:list;按 balanceId 直接查询非零库存余额,不依赖列表分页。可选 customerId 用于精确客户隔离,越权或不存在统一返回404。
security: [{ bearerAuth: [] }]
parameters:
- { in: path, name: balanceId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } }
- { in: query, name: customerId, required: false, schema: { type: string } }
responses:
'200': { description: 单条库存余额, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryBalanceApiResponse' } } } }
'403': { description: 非平台身份或缺少库存查看权限 }
'404': { description: 库存余额不存在、已归零或不属于指定客户 }
/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}:
get:
summary: 按入仓码查询海外关系摘要
@@ -1103,6 +1115,39 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
InventoryBalance:
type: object
required: [id, customerId, totalPackages, availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, receivingPackages, procurementPackages]
properties:
id: { type: string, pattern: '^[1-9][0-9]*$', description: 库存余额长整型ID,按字符串传输 }
customerId: { type: string }
customerName: { type: string, nullable: true }
overseasWarehouseId: { type: string, nullable: true }
overseasWarehouseCode: { type: string, nullable: true }
overseasWarehouseName: { type: string, nullable: true }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
inboundOrderId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 }
inboundOrderNo: { type: string, nullable: true }
inboundCode: { type: string, nullable: true }
skuCode: { type: string, nullable: true }
productNameZh: { type: string, nullable: true }
productNameEn: { type: string, nullable: true }
totalPackages: { type: integer, format: int64, minimum: 0 }
availablePackages: { type: integer, format: int64, minimum: 0 }
holdPackages: { type: integer, format: int64, minimum: 0 }
allocatedPackages: { type: integer, format: int64, minimum: 0 }
pickedPackages: { type: integer, format: int64, minimum: 0 }
inTransitPackages: { type: integer, format: int64, minimum: 0 }
receivingPackages: { type: integer, format: int64, minimum: 0 }
procurementPackages: { type: integer, format: int64, minimum: 0 }
updatedAt: { type: string, format: date-time, nullable: true }
InventoryBalanceApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
properties: { data: { $ref: '#/components/schemas/InventoryBalance' } }
InboundCodeRelationship:
type: object
required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory]
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.inventory.query;
import com.baiyue.common.web.LongIdJacksonConfiguration;
import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController;
import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InventoryBalanceDetailContractTest {
private static final long BALANCE_ID = 19_000_000_000_000_001L;
private static final long INBOUND_ORDER_ID = 19_000_000_000_000_002L;
@Test
void controllerFreezesDetailPathAndInventoryListPermission() throws Exception {
var method = InventoryQueryController.class.getMethod("balance",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class);
GetMapping mapping = method.getAnnotation(GetMapping.class);
PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
assertNotNull(mapping);
assertEquals("/api/platform/v1/last-leg/inventory/balances/{balanceId}", mapping.value()[0]);
assertNotNull(permission);
assertTrue(permission.value().contains(OverseasPermissions.INVENTORY_LIST));
}
@Test
void openApiDefinesDirectDetailResponseAndStringLongIds() throws Exception {
String yaml;
try (var stream = getClass().getClassLoader().getResourceAsStream("openapi/overseas-business-v1.yaml")) {
assertNotNull(stream);
yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(yaml.contains("/api/platform/v1/last-leg/inventory/balances/{balanceId}:"));
assertTrue(yaml.contains("$ref: '#/components/schemas/InventoryBalanceApiResponse'"));
assertTrue(yaml.contains("id: { type: string, pattern: '^[1-9][0-9]*$'"));
}
@Test
void balanceAndInboundOrderIdsSerializeAsJsonStrings() {
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)).run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
InventoryBalanceView view = new InventoryBalanceView(BALANCE_ID, "100", "Customer", "900",
"US-LAX-01", "Los Angeles", "800", "ONT8", "ONT8", INBOUND_ORDER_ID,
"IBP-1", "RK-1", "SKU-1", "产品", "Product", 3, 3, 0, 0, 0, 0, 0, 0, null);
JsonNode json = mapper.readTree(mapper.writeValueAsString(view));
assertEquals(Long.toString(BALANCE_ID), json.path("id").textValue());
assertEquals(Long.toString(INBOUND_ORDER_ID), json.path("inboundOrderId").textValue());
});
}
}
@@ -34,6 +34,20 @@ class InventoryQueryControllerTest {
verify(service).balances(eq("10001"), eq(query));
}
@Test
void platformBalanceDetailQueriesByIdWithoutPaging() {
controller.balance(user(0L, "PLATFORM"), 9007199254740993L, null);
verify(service).balance(null, 9007199254740993L);
}
@Test
void platformBalanceDetailUsesExplicitCustomerScope() {
controller.balance(user(0L, "PLATFORM"), 99L, " 10001 ");
verify(service).balance("10001", 99L);
}
@Test
void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class,
@@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -115,6 +116,32 @@ class InventoryQueryServiceTest {
verify(mapper).countBalances(null, query);
}
@Test
void platformBalanceDetailReadsOneVisibleBalanceWithoutPaging() {
InventoryBalance balance = new InventoryBalance();
balance.setId(99L);
balance.setCustomerId("200");
balance.setAvailablePackages(3L);
when(mapper.visibleBalance(99L)).thenReturn(balance);
var result = service.balance(null, 99L);
assertEquals(99L, result.id());
assertEquals("200", result.customerId());
assertEquals(3L, result.totalPackages());
verify(mapper).visibleBalance(99L);
verify(mapper, never()).countBalances(any(), any());
verify(mapper, never()).balances(any(), any(), anyLong(), anyLong());
}
@Test
void scopedBalanceDetailDoesNotLeakAnotherCustomer() {
assertThrows(ResponseStatusException.class, () -> service.balance("100", 99L));
verify(mapper).ownedVisibleBalance("100", 99L);
verify(mapper, never()).visibleBalance(99L);
}
@Test
void unknownStatusIsRejectedBeforeDatabaseQuery() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");