This commit is contained in:
2026-08-27 11:07:57 +08:00
parent b4c46280de
commit 4612181ab0
19 changed files with 424 additions and 68 deletions
@@ -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<List<PalletSnapshot>> palletSnapshots(
ApiResponse<PageResult<PalletSnapshot>> 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> 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")
@@ -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<List<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
public ApiResponse<PageResult<InboundMeasurementResponses.PalletSnapshot>> 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",
@@ -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<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
long tenantId, long inboundId) {
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
long tenantId, long inboundId, long page, long size, String keyword) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
return loadPalletSnapshots(inbound).stream()
PageResult<WarehousePalletLocationClient.PalletSnapshot> 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<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshots(
InboundOrderEntity inbound) {
private PageResult<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshotPage(
InboundOrderEntity inbound, long page, long size, String keyword) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<List<WarehousePalletLocationClient.PalletSnapshot>> response =
warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId());
ApiResponse<PageResult<WarehousePalletLocationClient.PalletSnapshot>> 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<WarehousePalletLocationClient.PalletSnapshot> 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, "仓库服务暂不可用,无法校验托盘资格");
}
}
@@ -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]
@@ -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"),
@@ -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);
@@ -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<InboundMeasurementResponses.PalletSnapshot> 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<InboundMeasurementResponses.PalletSnapshot> 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");
@@ -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'/)
@@ -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) {
@@ -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<PageResult<InboundMeasurementResponses\.PalletSnapshot>>/)
assert.doesNotMatch(block, /ApiResponse<List<InboundMeasurementResponses\.PalletSnapshot>>/)
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<PageResult<PalletSnapshot>>/)
assert.doesNotMatch(clientBlock, /ApiResponse<List<PalletSnapshot>>/)
for (const parameter of ['page', 'size', 'keyword']) assert.match(clientBlock, new RegExp(`"${parameter}"`))
const serviceBlock = endpointBlock(service,
'public PageResult<InboundMeasurementResponses.PalletSnapshot> 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<WeightPalletOption\[\]>/)
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, /<el-select-v2\b/)
assert.doesNotMatch(adminPanel, /<el-option[\s\S]*?v-for="option in palletOptions"/)
})
test('historical pallet snapshot remains read-only visible when it is no longer eligible', () => {
const cards = endpointBlock(adminPanel, '<article v-for="(row, index) in rows"', '</article>')
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)
})
@@ -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<PalletRequests.Snapshot> 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<List<PalletRequests.Snapshot>> snapshots(
@Operation(summary = "分页查询当前可用于称重的托盘候选",
description = "固定按平台公共作用域、启用中的国内仓和当前未删除资格过滤;托盘可复用,不按预占或占用过滤")
public ApiResponse<PageResult<PalletRequests.Snapshot>> 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) {
@@ -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();
}
}
}
@@ -46,12 +46,18 @@ public class PalletRepository {
return mapper.selectIncludingDeleted(tenantId, warehouseId, id);
}
public List<PalletEntity> findAllByWarehouse(long tenantId, long warehouseId) {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
public PageResult<PalletEntity> candidatePage(long tenantId, long warehouseId,
PalletRequests.SnapshotPageQuery query) {
LambdaQueryWrapper<PalletEntity> wrapper = new LambdaQueryWrapper<PalletEntity>()
.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) {
@@ -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<PalletRequests.Snapshot> snapshots(long tenantId, long warehouseId) {
requireWarehouse(tenantId, warehouseId, false);
return repository.findAllByWarehouse(tenantId, warehouseId).stream()
public PageResult<PalletRequests.Snapshot> snapshotCandidates(
long tenantId, long warehouseId, PalletRequests.SnapshotPageQuery query) {
requireWarehouse(tenantId, warehouseId, true);
PageResult<PalletEntity> 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());
}
}
@@ -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 }
@@ -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<PalletRequests.Snapshot> 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<PalletRequests.Snapshot> 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);
}
@@ -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:")
@@ -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<PalletEntity>> page = ArgumentCaptor.forClass(Page.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<PalletEntity>> 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<Object> queryValues(Long tenantId, Long warehouseId) {
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
repository.page(tenantId,
@@ -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<PalletRequests.Snapshot> 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() {