优化
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
||||
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由;`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,未配置时使用服务发现 |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.warehouse.client;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/** Reuses the Print service's existing label renderer; no local PDF template. */
|
||||
@FeignClient(name = "print-service", contextId = "overseasWarehousePrintPdfClient",
|
||||
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
|
||||
public interface PrintPdfClient {
|
||||
@PostMapping(value = "/inbound-marks", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateInboundMarks(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody Request request);
|
||||
|
||||
record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Label> labels) {}
|
||||
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||
record Label(String topCode, String warehouseText, String cartonText, String routeText, String qrContent) {}
|
||||
}
|
||||
+14
@@ -10,6 +10,9 @@ import com.baiyue.warehouse.dto.PalletRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.service.WarehouseResourceService;
|
||||
import com.baiyue.warehouse.service.PalletLabelPrintService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -34,6 +37,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Tag(name = "Platform overseas warehouse resources")
|
||||
public class PlatformWarehouseResourceController {
|
||||
@Resource private WarehouseResourceService service;
|
||||
@Resource private PalletLabelPrintService palletLabels;
|
||||
|
||||
@PostMapping("/locations")
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_CREATE + "')")
|
||||
@@ -134,6 +138,16 @@ public class PlatformWarehouseResourceController {
|
||||
return ApiResponse.success(service.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id));
|
||||
}
|
||||
|
||||
@GetMapping("/pallets/{id}/label")
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')")
|
||||
@Operation(summary = "Print one existing pallet label", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST)
|
||||
public ResponseEntity<byte[]> palletLabel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long warehouseId, @PathVariable long id,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
requirePlatform(user);
|
||||
return palletLabels.print(warehouseId, id, requestId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/pallets/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_DELETE + "')")
|
||||
@Operation(summary = "Delete a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_DELETE)
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.client.PrintPdfClient;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PalletLabelPrintService {
|
||||
@Resource private WarehouseResourceService resources;
|
||||
@Resource private DomesticWarehouseDirectory warehouses;
|
||||
@Resource private PrintPdfClient print;
|
||||
@Value("${baiyue.security.internal-service-token:${baiyue.internal-service.token}}")
|
||||
private String internalToken;
|
||||
|
||||
public ResponseEntity<byte[]> print(long warehouseId, long palletId, String requestId) {
|
||||
var pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, palletId);
|
||||
String code = pallet.getPalletCode();
|
||||
// Keep the stored business code intact, including the QR payload.
|
||||
if (code == null || !code.matches("[\\x20-\\x7E]{1,34}") || code.isBlank()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "当前标签模板要求托盘编码为 1–34 位可打印 ASCII 字符,请核对托盘编码");
|
||||
}
|
||||
var warehouse = warehouses.required(warehouseId);
|
||||
String fbaCode = pallet.getFbaWarehouseCode();
|
||||
String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf";
|
||||
var label = new PrintPdfClient.Label(code, fbaCode == null || fbaCode.isBlank() ? "—" : fbaCode,
|
||||
"PALLET", warehouse.warehouseName(), code);
|
||||
var request = new PrintPdfClient.Request("INBOUND_MARK_V1",
|
||||
new PrintPdfClient.PageSpec(new BigDecimal("100"), new BigDecimal("100")), fileName, List.of(label));
|
||||
try {
|
||||
var response = print.generateInboundMarks(internalToken, requestId, request);
|
||||
byte[] body = response == null ? null : response.getBody();
|
||||
MediaType type = response == null ? null : response.getHeaders().getContentType();
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || body == null || body.length == 0
|
||||
|| type == null || !MediaType.APPLICATION_PDF.isCompatibleWith(type)) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效 PDF,请稍后重试");
|
||||
}
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).contentLength(body.length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(fileName, StandardCharsets.UTF_8).build().toString())
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store").body(body);
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ baiyue:
|
||||
pallet:
|
||||
auto-created-unit-price: ${AUTO_CREATED_PALLET_UNIT_PRICE:1.00}
|
||||
clients:
|
||||
print:
|
||||
url: ${PRINT_SERVICE_URL:}
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Overseas pallet label
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/warehouse-admin/{warehouseId}/pallets/{id}/label:
|
||||
get:
|
||||
operationId: printOverseasPalletLabel
|
||||
summary: 下载指定托盘的一页标签 PDF
|
||||
description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后复用 Print 的 INBOUND_MARK_V1(100×100mm),原样使用托盘编码作为二维码,不创建或修改托盘。当前模板要求编码为 1–34 位可打印 ASCII 字符。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- { name: warehouseId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: X-Request-Id, in: header, required: false, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功返回单页 PDF;业务失败返回 JSON 信封(A0403、A0404、A0422 或 S0503)。
|
||||
content:
|
||||
application/pdf:
|
||||
schema: { type: string, format: binary }
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
requestId: { type: string }
|
||||
'401': { description: 未认证 }
|
||||
'403': { description: 权限不足 }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
+15
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.service.WarehouseResourceService;
|
||||
import com.baiyue.warehouse.service.PalletLabelPrintService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@@ -17,6 +18,20 @@ import static org.mockito.Mockito.when;
|
||||
class WarehouseResourceControllerTest {
|
||||
private final WarehouseResourceService service = mock(WarehouseResourceService.class);
|
||||
|
||||
@Test
|
||||
void palletLabelRejectsNonPublicPlatformScopeAndForwardsSelectedResource() {
|
||||
var controller = platformController();
|
||||
var labels = mock(PalletLabelPrintService.class);
|
||||
ReflectionTestUtils.setField(controller, "palletLabels", labels);
|
||||
assertThatThrownBy(() -> controller.palletLabel(user(10001L, "MERCHANT"), 200L, 9L, "r1"))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.palletLabel(user(10001L, "PLATFORM"), 200L, 9L, "r1"))
|
||||
.isInstanceOf(BizException.class);
|
||||
org.mockito.Mockito.verifyNoInteractions(labels);
|
||||
controller.palletLabel(user(0L, "PLATFORM"), 200L, 9L, "r1");
|
||||
verify(labels).print(200L, 9L, "r1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTenantAlwaysComesFromAuthenticatedUser() {
|
||||
MerchantWarehouseResourceController controller = merchantController();
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ class WarehouseResourcePermissionContractTest {
|
||||
assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete");
|
||||
assertPermission(methods.get("pallets"), PREFIX + "pallet:list");
|
||||
assertPermission(methods.get("pallet"), PREFIX + "pallet:list");
|
||||
assertPermission(methods.get("palletLabel"), PREFIX + "pallet:list");
|
||||
assertPermission(methods.get("createPallet"), PREFIX + "pallet:create");
|
||||
assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit");
|
||||
assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete");
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.client.PrintPdfClient;
|
||||
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class PalletLabelPrintServiceTest {
|
||||
private final WarehouseResourceService resources = mock(WarehouseResourceService.class);
|
||||
private final DomesticWarehouseDirectory warehouses = mock(DomesticWarehouseDirectory.class);
|
||||
private final PrintPdfClient print = mock(PrintPdfClient.class);
|
||||
private final PalletLabelPrintService service = new PalletLabelPrintService();
|
||||
private final WarehousePalletEntity pallet = new WarehousePalletEntity();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
ReflectionTestUtils.setField(service, "resources", resources);
|
||||
ReflectionTestUtils.setField(service, "warehouses", warehouses);
|
||||
ReflectionTestUtils.setField(service, "print", print);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
||||
pallet.setPalletCode("ABCD1234567-ABE3-003");
|
||||
pallet.setFbaWarehouseCode("ABE3");
|
||||
when(resources.pallet(0L, 200L, 9L)).thenReturn(pallet);
|
||||
when(warehouses.required(200L)).thenReturn(new SharedWarehouseReference(200L, "LAX", "洛杉矶仓", "ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusesExistingRendererWithExactlyTheSelectedCode() {
|
||||
byte[] pdf = "%PDF-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf));
|
||||
|
||||
var response = service.print(200L, 9L, "request-1");
|
||||
|
||||
var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
||||
verify(print).generateInboundMarks(eq("test-token"), eq("request-1"), request.capture());
|
||||
assertThat(request.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1");
|
||||
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("100");
|
||||
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("100");
|
||||
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
|
||||
assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003");
|
||||
assertThat(label.qrContent()).isEqualTo(label.topCode());
|
||||
assertThat(label.warehouseText()).isEqualTo("ABE3");
|
||||
assertThat(label.routeText()).isEqualTo("洛杉矶仓");
|
||||
});
|
||||
assertThat(response.getBody()).isEqualTo(pdf);
|
||||
verify(resources).pallet(0L, 200L, 9L);
|
||||
verifyNoMoreInteractions(resources);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesWrongWarehouseBeforeCallingPrint() {
|
||||
when(resources.pallet(0L, 201L, 9L)).thenThrow(new BizException(ResultCode.NOT_FOUND, "托盘不存在"));
|
||||
assertThatThrownBy(() -> service.print(201L, 9L, "request-1")).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(print);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsJsonErrorInsteadOfDownloadingItAsPdf() {
|
||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes()));
|
||||
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效 PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverTruncatesOrSubstitutesUnsupportedPalletCodes() {
|
||||
for (String code : new String[] { "", "托盘001", "A".repeat(35) }) {
|
||||
pallet.setPalletCode(code);
|
||||
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("托盘编码");
|
||||
}
|
||||
verifyNoInteractions(print);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user