diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index a19a4a705..4d9c95625 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.client; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -16,8 +17,17 @@ import java.util.List; url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1") public interface WarehousePalletLocationClient { @GetMapping("/pallets/snapshots") - ApiResponse> palletSnapshots( + ApiResponse> palletSnapshots( @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("warehouseId") long warehouseId, + @RequestParam("page") long page, + @RequestParam("size") long size, + @RequestParam(value = "keyword", required = false) String keyword); + + @GetMapping("/pallets/{palletId}/snapshot") + ApiResponse palletSnapshot( + @RequestHeader("X-Internal-Service-Token") String token, + @org.springframework.web.bind.annotation.PathVariable("palletId") long palletId, @RequestParam("warehouseId") long warehouseId); @PostMapping("/pallet-locations/reservations") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 383f8f891..657cdf58f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -4,6 +4,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; @@ -18,8 +19,12 @@ import com.baiyue.firstleg.service.MeasurementSubmissionService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -33,10 +38,10 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; -import java.util.List; /** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */ @Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。") +@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}") public class PlatformInboundMeasurementController { @@ -56,16 +61,19 @@ public class PlatformInboundMeasurementController { } @Operation(summary = "查询称重可选托盘快照", - description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。") + description = "按入库单所属仓库分页返回当前有效托盘候选,支持编码或类型关键词;托盘可复用,不按预占或占用过滤。") @GetMapping("/weight-pallet-options") @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view'," + " 'platform:firstleg:inbound:weight:edit')") - public ApiResponse> weightPalletOptions( + public ApiResponse> weightPalletOptions( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, - @PathVariable long inboundId) { + @PathVariable long inboundId, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size, + @RequestParam(required = false) @Size(max = 50) String keyword) { requirePlatformTenant(user, tenantId); - return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId)); + return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId, page, size, keyword)); } @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 6fecfa3cd..b5a77648f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -4,6 +4,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; @@ -73,14 +74,16 @@ public class InboundMeasurementService { return toDetail(inbound, measurement, items, weights, review, declaration); } - public List weightPalletOptions( - long tenantId, long inboundId) { + public PageResult weightPalletOptions( + long tenantId, long inboundId, long page, long size, String keyword) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - return loadPalletSnapshots(inbound).stream() + PageResult source = + loadPalletSnapshotPage(inbound, page, size, keyword); + return new PageResult<>(source.records().stream() .map(snapshot -> new InboundMeasurementResponses.PalletSnapshot( snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(), snapshot.capacityCbm(), snapshot.tareWeightKg())) - .toList(); + .toList(), source.page(), source.size(), source.total(), source.pages()); } @Transactional @@ -175,11 +178,8 @@ public class InboundMeasurementService { if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); } - WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshots(inbound).stream() - .filter(snapshot -> snapshot.palletId() == request.palletId()) - .findFirst() - .orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED, - "所选托盘不属于当前入库仓库或已被删除")); + WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshot( + inbound, request.palletId()); validatePalletSnapshot(pallet); String unit = rawOcrUnit(request.ocrWeightUnit()); InboundWeightResultEntity weight = new InboundWeightResultEntity(); @@ -527,23 +527,43 @@ public class InboundMeasurementService { weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion()); } - private List loadPalletSnapshots( - InboundOrderEntity inbound) { + private PageResult loadPalletSnapshotPage( + InboundOrderEntity inbound, long page, long size, String keyword) { if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) { throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘"); } try { - ApiResponse> response = - warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId()); + ApiResponse> response = + warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId(), page, size, keyword); if (response == null || !response.successful() || response.data() == null) { String message = response == null || !StringUtils.hasText(response.message()) - ? "托盘快照查询失败" : response.message(); + ? "托盘候选查询失败" : response.message(); throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()), message); } return response.data(); } catch (FeignException exception) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘快照"); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘候选"); + } + } + + private WarehousePalletLocationClient.PalletSnapshot loadPalletSnapshot( + InboundOrderEntity inbound, long palletId) { + if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘"); + } + try { + ApiResponse response = warehousePallets.palletSnapshot( + internalToken, palletId, inbound.getWarehouseId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "所选托盘当前不可用于该入库仓库" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法校验托盘资格"); } } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 6b4dc5e8e..322a8d38f 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -755,11 +755,14 @@ paths: get: summary: 查询称重可选托盘快照 description: >- - 需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 查询 Warehouse 公共托盘快照; - 仅限定同仓且未删除,不校验托盘状态或占用资格,Admin 不需要 platform:pallet:list。 + 需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 分页查询 Warehouse 当前称重托盘候选; + Warehouse 校验平台公共作用域、启用国内仓、同仓归属和当前未删除资格。托盘可复用,不按预占或占用过滤,Admin 不需要 platform:pallet:list。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 100, default: 20}} + - {name: keyword, in: query, required: false, description: 匹配托盘编码或类型, schema: {type: string, maxLength: 50}} responses: '200': description: 成功或业务失败,按响应体 code 判断 @@ -770,7 +773,7 @@ paths: properties: code: {type: string} message: {type: string} - data: {type: array, items: {$ref: '#/components/schemas/WeightPalletSnapshot'}} + data: {$ref: '#/components/schemas/WeightPalletSnapshotPage'} '403': {description: 无入库测量查看或称重编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results: post: @@ -1294,6 +1297,18 @@ components: palletType: {type: string} capacityCbm: {type: number, format: double, minimum: 0.0001} tareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + WeightPalletSnapshotPage: + type: object + required: [records, page, size, total, pages] + properties: + records: + type: array + maxItems: 100 + items: {$ref: '#/components/schemas/WeightPalletSnapshot'} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1, maximum: 100} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} CreateWeightResultRequest: type: object required: [fileId, palletId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java index 1bcd9995c..279e00fe4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.PathVariable; import java.lang.reflect.Method; @@ -16,11 +17,26 @@ class WarehousePalletLocationClientContractTest { assertThat(client.path()).isEqualTo("/internal/warehouse/v1"); Method method = WarehousePalletLocationClient.class.getDeclaredMethod( - "palletSnapshots", String.class, long.class); + "palletSnapshots", String.class, long.class, long.class, long.class, String.class); assertThat(method.getAnnotation(GetMapping.class).value()) .containsExactly("/pallets/snapshots"); assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).value()) .isEqualTo("warehouseId"); + assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()) + .isEqualTo("page"); + assertThat(method.getParameters()[3].getAnnotation(RequestParam.class).value()) + .isEqualTo("size"); + assertThat(method.getParameters()[4].getAnnotation(RequestParam.class).value()) + .isEqualTo("keyword"); + + Method exact = WarehousePalletLocationClient.class.getDeclaredMethod( + "palletSnapshot", String.class, long.class, long.class); + assertThat(exact.getAnnotation(GetMapping.class).value()) + .containsExactly("/pallets/{palletId}/snapshot"); + assertThat(exact.getParameters()[1].getAnnotation(PathVariable.class).value()) + .isEqualTo("palletId"); + assertThat(exact.getParameters()[2].getAnnotation(RequestParam.class).value()) + .isEqualTo("warehouseId"); var snapshot = new WarehousePalletLocationClient.PalletSnapshot( 101L, "PLT-101", "STANDARD", new java.math.BigDecimal("2.0000"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 3299c4100..67cf80c29 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -6,10 +6,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.core.io.ClassPathResource; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; @@ -17,6 +20,19 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("平台入库测量接口权限契约") class PlatformInboundMeasurementControllerContractTest { + @Test + void palletCandidatesArePaginatedAndBoundedInOpenApi() throws IOException { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(openapi) + .contains("/api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:") + .contains("name: keyword") + .contains("maximum: 100") + .contains("maxItems: 100") + .contains("data: {$ref: '#/components/schemas/WeightPalletSnapshotPage'}"); + } + @Test void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() { assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents()) @@ -41,7 +57,7 @@ class PlatformInboundMeasurementControllerContractTest { assertAuthority("weightPalletOptions", "hasAnyAuthority('platform:firstleg:inbound:measure:view'," + " 'platform:firstleg:inbound:weight:edit')", - AuthenticatedUser.class, long.class, long.class); + AuthenticatedUser.class, long.class, long.class, long.class, long.class, String.class); assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.SaveDraftRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 10cb7b82b..22c81459b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; import com.baiyue.firstleg.client.WarehousePalletLocationClient; @@ -59,8 +60,8 @@ class InboundMeasurementServiceTest { @BeforeEach void stubWarehousePalletSnapshots() { when(repository.updateInboundWeightSummary(any())).thenReturn(true); - when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn( - ApiResponse.success(List.of(palletSnapshot()))); + when(warehousePallets.palletSnapshot("internal-token", 501L, 100L)).thenReturn( + ApiResponse.success(palletSnapshot())); when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( "REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123"))); @@ -238,20 +239,28 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); when(repository.findInbound(1L, 10L)).thenReturn(inbound); - List result = - service.weightPalletOptions(1L, 10L); + when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5")) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(palletSnapshot()), 1, 20, 1000, 50))); - assertThat(result).singleElement().satisfies(snapshot -> { + PageResult result = + service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5"); + + assertThat(result.records()).singleElement().satisfies(snapshot -> { assertThat(snapshot.palletId()).isEqualTo(501L); assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500"); }); - verify(warehousePallets).palletSnapshots("internal-token", 100L); + assertThat(result.total()).isEqualTo(1000); + assertThat(result.records()).hasSize(1); + verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"); } @Test void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(warehousePallets.palletSnapshot("internal-token", 999L, 100L)).thenReturn( + ApiResponse.failure(com.baiyue.common.core.api.ResultCode.NOT_FOUND, + "所选托盘不属于当前入库仓库或当前不可用", null, null)); assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L, new InboundMeasurementRequests.CreateWeightResultRequest( @@ -263,6 +272,29 @@ class InboundMeasurementServiceTest { verify(repository, never()).insertWeight(any()); } + @Test + void detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + InboundWeightResultEntity historical = weight(50L, "30.000", "KG", "CONFIRMED", 2); + historical.setPalletCode("HISTORICAL-501"); + historical.setPalletType("LEGACY-STANDARD"); + historical.setPalletTareWeightKg(new BigDecimal("18.500")); + historical.setGrossWeightKg(new BigDecimal("30.000")); + historical.setNetWeightKg(new BigDecimal("11.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(historical)); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.weightResults()).singleElement().satisfies(weight -> { + assertThat(weight.palletCode()).isEqualTo("HISTORICAL-501"); + assertThat(weight.palletType()).isEqualTo("LEGACY-STANDARD"); + assertThat(weight.palletTareWeightKg()).isEqualByComparingTo("18.500"); + }); + verify(warehousePallets, never()).palletSnapshot(any(), anyLong(), anyLong()); + verify(warehousePallets, never()).palletSnapshots(any(), anyLong(), anyLong(), anyLong(), any()); + } + @Test void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs index c26281c7d..d90328480 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -5,7 +5,10 @@ import test from 'node:test' import { confirmedNetSummary, normalizedGrossKg, + palletOptionsPath, selectWeightPallet, + WEIGHT_PALLET_OPTIONS_PAGE_SIZE, + weightPalletOptionRecords, workspaceWeightMatchesBaseline, } from './inbound-weight-ocr.mjs' @@ -42,7 +45,15 @@ test('cleanup restores a non-empty carton fallback when there were no confirmed test('HTTP scenario uses pallet option and net weight contracts without legacy final weight', () => { const source = readFileSync(new URL('inbound-weight-ocr.mjs', import.meta.url), 'utf8') - assert.match(source, /\/weight-pallet-options\?tenantId=/) + const optionsPath = palletOptionsPath('10001', '20001', ' TP-09 ') + assert.match(optionsPath, /\/weight-pallet-options\?tenantId=10001&(?:page=1&size=50|size=50&page=1)/) + assert.match(optionsPath, /keyword=TP-09/) + assert.equal(WEIGHT_PALLET_OPTIONS_PAGE_SIZE, 50) + assert.deepEqual(weightPalletOptionRecords({ + records: [{ palletId: '1' }], page: 1, size: 50, total: 1000, pages: 20, + }), [{ palletId: '1' }]) + assert.equal(weightPalletOptionRecords([{ palletId: '1' }]), null) + assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000 }), null) assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath')) assert.match(source, /form\.append\('file'/) assert.doesNotMatch(source, /form\.append\('palletId'/) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index d26bef16d..8b33d1632 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -4,6 +4,7 @@ import process from 'node:process' const MODULE = 'inbound-weight-ocr' const SCENARIO = 'weight-image-recognition-and-confirmation' +export const WEIGHT_PALLET_OPTIONS_PAGE_SIZE = 50 const DEFAULT_IMAGE = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const EDITABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const OCR_STATUSES = new Set(['RECOGNIZED', 'FAILED']) @@ -202,8 +203,24 @@ function deletePath(tenantId, inboundId, resultId, version) { return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}` } -function palletOptionsPath(tenantId, inboundId) { - return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?tenantId=${encodeURIComponent(tenantId)}` +export function palletOptionsPath(tenantId, inboundId, keyword = '') { + const params = new URLSearchParams({ + tenantId: id(tenantId), + page: '1', + size: String(WEIGHT_PALLET_OPTIONS_PAGE_SIZE), + }) + const normalizedKeyword = String(keyword || '').trim() + if (normalizedKeyword) params.set('keyword', normalizedKeyword) + return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?${params.toString()}` +} + +export function weightPalletOptionRecords(page) { + if (!page || Array.isArray(page) || !Array.isArray(page.records)) return null + if (!Number.isInteger(Number(page.page)) || Number(page.page) < 1) return null + if (!Number.isInteger(Number(page.size)) || Number(page.size) < 1 + || Number(page.size) > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null + if (page.records.length > Number(page.size)) return null + return page.records } async function createUploadedResult(context, target, pallet, image, overrides = {}) { @@ -382,13 +399,14 @@ export async function run(context) { const palletOptionsResponse = await context.request( 'GET', palletOptionsPath(target.tenantId, target.inboundId), ) - const palletOptions = context.apiSuccess(MODULE, '读取同仓称重托盘候选', palletOptionsResponse, + const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse, `inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS) + const palletOptions = weightPalletOptionRecords(palletOptionsPage) const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0 && palletOptions.every(palletSnapshotComplete) - context.assert(MODULE, '托盘候选包含稳定快照字段且无需状态或占用资格', palletOptionsResponse, + context.assert(MODULE, '托盘候选分页受控且包含稳定快照字段', palletOptionsResponse, palletOptionsComplete, - `count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, fields=palletId/palletCode/palletType/capacityCbm/tareWeightKg`, + `page=${palletOptionsPage?.page ?? 'missing'}, size=${palletOptionsPage?.size ?? 'missing'}, count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, maxSize=${WEIGHT_PALLET_OPTIONS_PAGE_SIZE}, fields=palletId/palletCode/palletType/capacityCbm/tareWeightKg`, NEW_CONTRACT_OPTIONS) const pallet = palletOptionsComplete ? selectWeightPallet(palletOptions) : null if (!pallet) { diff --git a/scripts/admin-test-env-modules/weight-pallet-options-p0-contract.test.mjs b/scripts/admin-test-env-modules/weight-pallet-options-p0-contract.test.mjs new file mode 100644 index 000000000..8398dca52 --- /dev/null +++ b/scripts/admin-test-env-modules/weight-pallet-options-p0-contract.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') + +const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java') +const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java') +const warehouseClient = read('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java') +const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml') +const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts') +const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') +const adminBehavior = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts') + +function endpointBlock(source, marker, nextMarker) { + const start = source.indexOf(marker) + assert.notEqual(start, -1, `missing contract marker: ${marker}`) + const end = source.indexOf(nextMarker, start + marker.length) + return source.slice(start, end === -1 ? source.length : end) +} + +test('backend Controller exposes a keyword-filtered bounded page instead of an unbounded array', () => { + const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit') + assert.match(block, /ApiResponse>/) + assert.doesNotMatch(block, /ApiResponse>/) + assert.match(block, /\bpage\b/) + assert.match(block, /\bsize\b/) + assert.match(block, /\bkeyword\b/) + + const boundedSize = [...block.matchAll(/@Max\((\d+)\)/g)].map((match) => Number(match[1])) + assert.ok(boundedSize.some((maximum) => maximum > 0 && maximum <= 200), + 'size must have a finite Controller maximum no greater than 200') +}) + +test('Firstleg forwards the bound to Warehouse instead of loading the full warehouse and slicing in memory', () => { + const clientBlock = endpointBlock(warehouseClient, '@GetMapping("/pallets/snapshots")', '@PostMapping') + assert.match(clientBlock, /ApiResponse>/) + assert.doesNotMatch(clientBlock, /ApiResponse>/) + for (const parameter of ['page', 'size', 'keyword']) assert.match(clientBlock, new RegExp(`"${parameter}"`)) + + const serviceBlock = endpointBlock(service, + 'public PageResult weightPalletOptions(', + '@Transactional') + assert.match(serviceBlock, /loadPalletSnapshotPage\(inbound, page, size, keyword\)/) + assert.match(service, /palletSnapshots\([\s\S]{0,300}?page[\s\S]{0,80}?size[\s\S]{0,80}?keyword/) + assert.doesNotMatch(serviceBlock, /\.stream\(\)[\s\S]*?\.skip\(/) +}) + +test('OpenAPI publishes page, size, keyword and a finite maximum without an array data contract', () => { + const block = endpointBlock(openapi, + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:', + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:') + for (const parameter of ['page', 'size', 'keyword']) { + assert.match(block, new RegExp(`name: ${parameter}`)) + } + const maxima = [...block.matchAll(/maximum:\s*(\d+)/g)].map((match) => Number(match[1])) + assert.ok(maxima.some((maximum) => maximum > 0 && maximum <= 200), + 'OpenAPI size parameter must declare a finite maximum no greater than 200') + const dataLine = block.match(/data:\s*\{[^\n]+/)?.[0] || '' + assert.doesNotMatch(dataLine, /type:\s*array/) + assert.match(block, /(?:records|WeightPalletOptionPage|PageResult)/) +}) + +test('Admin API requests a bounded remote-search page and does not type the response as an array', () => { + const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage') + assert.match(block, /\bpage\b/) + assert.match(block, /\bsize\b/) + assert.match(block, /\bkeyword\b/) + assert.doesNotMatch(block, /ApiEnvelope/) + assert.match(block, /(?:records|PageResult|PageResource|PageResponse)/) +}) + +test('Admin skips candidates without weight edit permission or an editable inbound state', () => { + const loader = endpointBlock(adminPanel, 'async function loadPalletOptions', 'function chooseFile') + const gate = endpointBlock(adminPanel, 'const canLoadPalletOptions', 'const virtualPalletOptions') + assert.match(gate, /canEdit\.value/) + assert.match(gate, /editable\.value/) + assert.match(loader, /!canLoadPalletOptions\.value/) +}) + +test('Admin behavior tests prove both permission and inbound-state request suppression', () => { + const cases = [...adminBehavior.matchAll(/it\((['"`])([\s\S]*?)\1,\s*async\s*\(\)\s*=>\s*\{([\s\S]*?)\n\s*\}\)/g)] + const suppressionCases = cases.filter(([, , title, body]) => /getWeightPalletOptions/.test(body) + && /not\.toHaveBeenCalled/.test(body)) + assert.ok(suppressionCases.some(([, , title]) => /edit|permission|权限/i.test(title)), + 'missing behavior case: no weight:edit must not request pallet candidates') + assert.ok(suppressionCases.some(([, , title]) => /status|editable|状态/i.test(title)), + 'missing behavior case: non-editable inbound status must not request pallet candidates') +}) + +test('Admin remote search is bounded and never accumulates every candidate', () => { + assert.match(adminPanel, /\bremote\b/) + assert.match(adminPanel, /:remote-method=/) + const sizes = [...adminPanel.matchAll(/(?:PALLET|pallet).{0,40}(?:PAGE_SIZE|pageSize)\s*=\s*(\d+)/g)] + .map((match) => Number(match[1])) + assert.ok(sizes.some((size) => size > 0 && size <= 200), + 'Admin remote search page size must be finite and no greater than 200') + assert.doesNotMatch(adminPanel, /palletOptions\.value\.(?:push|unshift)\s*\(/) + assert.doesNotMatch(adminPanel, /palletOptions\.value\s*=\s*\[\s*\.\.\.palletOptions\.value/) + assert.match(adminBehavior, /(?:remote search|远程搜索|keyword).{0,220}(?:size|page)/is) +}) + +test('Admin uses a virtualized selector so a large response cannot create one DOM option per candidate', () => { + assert.match(adminPanel, / { + const cards = endpointBlock(adminPanel, '
') + assert.match(cards, /row\.palletCode/) + assert.match(cards, /row\.palletType/) + assert.match(cards, /row\.palletTareWeightKg/) + assert.match(adminBehavior, /(?:historical|history|历史|失去候选|no longer eligible).{0,220}(?:palletCode|托盘编码|TP-)/is) +}) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java index 921086bec..5843618cb 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java @@ -1,6 +1,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.PalletRequests; @@ -10,6 +11,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Size; import org.springframework.beans.factory.annotation.Value; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -21,7 +24,6 @@ import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.util.List; /** 内部托盘主数据只读接口。 */ @Validated @@ -41,7 +43,8 @@ public class InternalPalletController { } @GetMapping("/{palletId}/snapshot") - @Operation(summary = "查询托盘只读快照", description = "固定按平台公共作用域校验仓库归属,不返回动态状态或可用性") + @Operation(summary = "查询当前可用于称重的托盘快照", + description = "固定按平台公共作用域校验启用中的国内仓归属;托盘必须当前未删除") public ApiResponse snapshot( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @PathVariable @Min(1) long palletId, @RequestParam @Min(1) long warehouseId) { @@ -50,12 +53,17 @@ public class InternalPalletController { } @GetMapping("/snapshots") - @Operation(summary = "查询仓库托盘快照列表", description = "返回该仓全部未删除托盘主数据,不按动态状态或占用过滤") - public ApiResponse> snapshots( + @Operation(summary = "分页查询当前可用于称重的托盘候选", + description = "固定按平台公共作用域、启用中的国内仓和当前未删除资格过滤;托盘可复用,不按预占或占用过滤") + public ApiResponse> snapshots( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @RequestParam @Min(1) long warehouseId) { + @RequestParam @Min(1) long warehouseId, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size, + @RequestParam(required = false) @Size(max = 50) String keyword) { verify(token); - return ApiResponse.success(service.snapshots(0L, warehouseId)); + return ApiResponse.success(service.snapshotCandidates( + 0L, warehouseId, new PalletRequests.SnapshotPageQuery(page, size, keyword))); } private void verify(String token) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index bdc93b4e5..8305d7997 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -84,4 +84,21 @@ public final class PalletRequests { @Schema(description = "皮重,单位kg") BigDecimal tareWeightKg ) { } + + @Schema(name = "InternalPalletCandidatePageQuery", description = "称重托盘候选分页查询") + public record SnapshotPageQuery( + @Min(1) @Schema(description = "页码", defaultValue = "1") long page, + @Min(1) @Max(100) @Schema(description = "每页条数", defaultValue = "20", maximum = "100") long size, + @Size(max = 50) @Schema(description = "关键词;匹配托盘编码或类型") String keyword + ) { + public SnapshotPageQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + keyword = keyword == null ? null : keyword.trim(); + } + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java index eb759f73b..3be57bbe9 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -46,12 +46,18 @@ public class PalletRepository { return mapper.selectIncludingDeleted(tenantId, warehouseId, id); } - public List findAllByWarehouse(long tenantId, long warehouseId) { - return mapper.selectList(new LambdaQueryWrapper() + public PageResult candidatePage(long tenantId, long warehouseId, + PalletRequests.SnapshotPageQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) + .and(StringUtils.hasText(query.keyword()), nested -> nested + .like(PalletEntity::getPalletCode, query.keyword()) + .or() + .like(PalletEntity::getPalletType, query.keyword())) .orderByAsc(PalletEntity::getPalletCode) - .orderByAsc(PalletEntity::getId)); + .orderByAsc(PalletEntity::getId); + return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index 2ac4c7cec..7bfda9710 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -125,16 +125,19 @@ public class PalletApplicationService { } public PalletRequests.Snapshot snapshot(long tenantId, long warehouseId, long palletId) { + requireWarehouse(tenantId, warehouseId, true); PalletEntity entity = owned(tenantId, warehouseId, palletId); return new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getWeightKg()); } - public List snapshots(long tenantId, long warehouseId) { - requireWarehouse(tenantId, warehouseId, false); - return repository.findAllByWarehouse(tenantId, warehouseId).stream() + public PageResult snapshotCandidates( + long tenantId, long warehouseId, PalletRequests.SnapshotPageQuery query) { + requireWarehouse(tenantId, warehouseId, true); + PageResult source = repository.candidatePage(tenantId, warehouseId, query); + return new PageResult<>(source.records().stream() .map(entity -> new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getWeightKg())) - .toList(); + .toList(), source.page(), source.size(), source.total(), source.pages()); } } diff --git a/warehouse/src/main/resources/openapi/pallet-v1.yaml b/warehouse/src/main/resources/openapi/pallet-v1.yaml index a1db02419..a58ee80a1 100644 --- a/warehouse/src/main/resources/openapi/pallet-v1.yaml +++ b/warehouse/src/main/resources/openapi/pallet-v1.yaml @@ -2,12 +2,12 @@ openapi: 3.0.3 info: title: Warehouse Pallet API version: 1.0.0 - description: 托盘是可复用标识和称重主数据,不承载 AVAILABLE、RESERVED 或 OCCUPIED 动态状态。 + description: 托盘是可复用标识和称重主数据;称重候选按当前启用国内仓、同仓归属和未删除资格过滤,不按预占或占用过滤。 paths: /internal/warehouse/v1/pallets/{palletId}/snapshot: get: - summary: 查询单条托盘主数据快照 - description: 固定使用平台公共作用域 tenant_id=0,并按 warehouseId 校验归属;不返回状态或可用性。 + summary: 查询单条当前称重可用托盘快照 + description: 固定使用平台公共作用域 tenant_id=0,并校验启用国内仓、warehouseId 归属和当前未删除资格。 parameters: - $ref: '#/components/parameters/InternalToken' - { in: path, name: palletId, required: true, schema: { type: integer, format: int64, minimum: 1 } } @@ -18,15 +18,18 @@ paths: content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotResponse' } } } /internal/warehouse/v1/pallets/snapshots: get: - summary: 查询仓库全部托盘主数据快照 - description: 固定使用平台公共作用域 tenant_id=0,返回该仓全部未删除主数据,不按状态或占用过滤。 + summary: 分页查询仓库当前称重托盘候选 + description: 固定使用平台公共作用域 tenant_id=0,按启用国内仓、同仓归属和当前未删除资格过滤;托盘可复用,不按预占或占用过滤。 parameters: - $ref: '#/components/parameters/InternalToken' - { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { in: query, name: page, required: false, schema: { type: integer, format: int64, minimum: 1, default: 1 } } + - { in: query, name: size, required: false, schema: { type: integer, format: int64, minimum: 1, maximum: 100, default: 20 } } + - { in: query, name: keyword, required: false, description: 匹配托盘编码或类型, schema: { type: string, maxLength: 50 } } responses: '200': description: 业务响应 - content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotListResponse' } } } + content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotPageResponse' } } } components: parameters: InternalToken: @@ -61,12 +64,21 @@ components: required: [data] properties: data: { $ref: '#/components/schemas/PalletSnapshot' } - PalletSnapshotListResponse: + PalletSnapshotPageResponse: allOf: - { $ref: '#/components/schemas/ApiEnvelope' } - type: object required: [data] properties: data: - type: array - items: { $ref: '#/components/schemas/PalletSnapshot' } + type: object + required: [records, page, size, total, pages] + properties: + records: + type: array + maxItems: 100 + items: { $ref: '#/components/schemas/PalletSnapshot' } + page: { type: integer, format: int64, minimum: 1 } + size: { type: integer, format: int64, minimum: 1, maximum: 100 } + total: { type: integer, format: int64, minimum: 0 } + pages: { type: integer, format: int64, minimum: 0 } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java index c67e122ce..d1750a1af 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java @@ -1,6 +1,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.PageResult; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.service.PalletApplicationService; import org.junit.jupiter.api.BeforeEach; @@ -39,17 +40,20 @@ class InternalPalletControllerTest { } @Test - void listsAllWarehouseSnapshotsFromPlatformScope() { + void pagesWarehouseCandidatesFromPlatformScope() { List expected = List.of(snapshot(30L, "TP-001"), snapshot(31L, "TP-002")); - when(service.snapshots(0L, 20L)).thenReturn(expected); + PalletRequests.SnapshotPageQuery query = new PalletRequests.SnapshotPageQuery(2, 20, "TP"); + PageResult page = new PageResult<>(expected, 2, 20, 1000, 50); + when(service.snapshotCandidates(0L, 20L, query)).thenReturn(page); - assertThat(controller.snapshots(TOKEN, 20L).data()).isEqualTo(expected); - verify(service).snapshots(0L, 20L); + assertThat(controller.snapshots(TOKEN, 20L, 2, 20, "TP").data()).isEqualTo(page); + verify(service).snapshotCandidates(0L, 20L, query); } @Test void rejectsInvalidInternalToken() { - assertThatThrownBy(() -> controller.snapshots("invalid", 20L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.snapshots("invalid", 20L, 1, 20, null)) + .isInstanceOf(BizException.class); verifyNoInteractions(service); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java index bb6b61e2d..39530c909 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java @@ -16,6 +16,10 @@ class PalletOpenApiContractTest { assertThat(yaml) .contains("/internal/warehouse/v1/pallets/{palletId}/snapshot:") .contains("/internal/warehouse/v1/pallets/snapshots:") + .contains("name: keyword") + .contains("maximum: 100") + .contains("maxItems: 100") + .contains("PalletSnapshotPageResponse:") .contains("required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]") .contains("additionalProperties: false") .doesNotContain("tenantId:") diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java index cafcc873a..0bc95d843 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java @@ -64,6 +64,26 @@ class PalletRepositoryTest { .containsExactlyInAnyOrder(10001L, 20L, 30L); } + @Test + void candidatePageIsWarehouseScopedKeywordFilteredAndBounded() { + when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); + + repository.candidatePage(0L, 20L, + new PalletRequests.SnapshotPageQuery(3, 100, " TP-9 ")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> page = ArgumentCaptor.forClass(Page.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectPage(page.capture(), wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(page.getValue().getCurrent()).isEqualTo(3); + assertThat(page.getValue().getSize()).isEqualTo(100); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains(0L, 20L, "%TP-9%"); + } + private Collection queryValues(Long tenantId, Long warehouseId) { when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); repository.page(tenantId, diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java index 0761dbb33..110f24a07 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -18,6 +18,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.math.BigDecimal; +import java.util.List; @SpringJUnitConfig(PalletApplicationService.class) class PalletApplicationServiceTest { @@ -64,21 +65,42 @@ class PalletApplicationServiceTest { } @Test - void returnsReusablePalletSnapshotsWithoutResourceStatus() { + void returnsControlledReusablePalletCandidatePage() { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setWarehouseType("DOMESTIC"); + warehouse.setStatus("ACTIVE"); when(warehouseRepository.findOwned(0L, 20L)).thenReturn(warehouse); PalletEntity entity = pallet(); entity.setPalletCode("TP-001"); entity.setPalletType("STANDARD"); entity.setCapacityCbm(new BigDecimal("1.200000")); entity.setWeightKg(new BigDecimal("8.000")); - when(repository.findAllByWarehouse(0L, 20L)).thenReturn(java.util.List.of(entity)); + PalletRequests.SnapshotPageQuery query = new PalletRequests.SnapshotPageQuery(1, 20, "TP-0"); + when(repository.candidatePage(0L, 20L, query)).thenReturn( + new PageResult<>(List.of(entity), 1, 20, 1000, 50)); - org.assertj.core.api.Assertions.assertThat(service.snapshots(0L, 20L)).singleElement().satisfies(snapshot -> { + PageResult result = service.snapshotCandidates(0L, 20L, query); + + org.assertj.core.api.Assertions.assertThat(result.records()).singleElement().satisfies(snapshot -> { org.assertj.core.api.Assertions.assertThat(snapshot.palletId()).isEqualTo(30L); org.assertj.core.api.Assertions.assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("8.000"); }); + org.assertj.core.api.Assertions.assertThat(result.total()).isEqualTo(1000); + org.assertj.core.api.Assertions.assertThat(result.records()).hasSize(1); + verify(repository).candidatePage(0L, 20L, query); + } + + @Test + void rejectsCandidatesFromInactiveWarehouse() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setWarehouseType("DOMESTIC"); + warehouse.setStatus("INACTIVE"); + when(warehouseRepository.findOwned(0L, 20L)).thenReturn(warehouse); + + assertThatThrownBy(() -> service.snapshotCandidates( + 0L, 20L, new PalletRequests.SnapshotPageQuery(1, 20, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("启用中"); } private static PalletEntity pallet() {