diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 945f00ec2..eec531970 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -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 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) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 7b7aaee31..6386caf43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -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" + " AND customer_id=#{customerId}" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index 2f84aed63..06eb0c14f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -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 positions(String customerId, long balanceId, InventoryQueryRequests.PositionQuery query) { validateStatus(query.inventoryStatus(), POSITION_STATUSES); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 1b74fed3d..4452d4383 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java new file mode 100644 index 000000000..35b8d0222 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java @@ -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()); + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index c2847e1d0..dd20d98ac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -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, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index c6eee959a..5003effd8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -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");