From 46b4d1489816765bc9152fc88c008c92de9527d7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 15:33:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 20 +- .../client/IamCustomerSnapshotClient.java | 2 +- .../firstleg/client/PrintPdfClient.java | 15 +- .../mapper/DropshipPalletizationMapper.java | 55 ++++ .../service/IamCustomerSnapshotService.java | 17 ++ .../InboundCartonLabelPrintService.java | 56 +++- .../openapi/dropship-palletization-v1.yaml | 4 +- .../IamCustomerSnapshotServiceTest.java | 14 +- .../InboundCartonLabelPrintServiceTest.java | 47 +++- iam/pom.xml | 1 + .../baiyue/iam/dto/CustomerExportDtos.java | 2 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 6 +- .../java/com/baiyue/iam/entity/IamTenant.java | 1 + .../CustomerListExportProviderService.java | 2 + .../service/MerchantOnboardingService.java | 1 + .../com/baiyue/iam/service/TenantService.java | 40 ++- iam/src/main/resources/db/iam.sql | 1 + iam/src/main/resources/openapi/iam-v1.yaml | 10 +- .../CustomerProfileMigrationContractTest.java | 2 + ...CustomerListExportProviderServiceTest.java | 4 +- .../baiyue/iam/service/TenantServiceTest.java | 22 +- print/AGENTS.md | 2 +- print/README.md | 2 +- .../pdf/dropship-pallet-a4-v2-preview.pdf | Bin 0 -> 15467 bytes .../print/dto/DropshipPalletA4PdfDtos.java | 16 +- .../renderer/DropshipPalletA4Renderer.java | 240 +++++++++++------- .../service/DropshipPalletA4PdfService.java | 26 +- .../resources/openapi/print-internal-v1.yaml | 34 ++- .../controller/InternalPdfControllerTest.java | 25 +- ...pshipPalletA4PdfServiceGenerationTest.java | 35 +-- 30 files changed, 502 insertions(+), 200 deletions(-) create mode 100644 print/output/pdf/dropship-pallet-a4-v2-preview.pdf diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 66d3779f3..f9f3ad1b0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -331,6 +331,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', + customer_nickname VARCHAR(32) NOT NULL COMMENT '客户昵称:非法定展示及打印使用,创建时默认取公司名称中文拼音大写首字母', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', @@ -1245,15 +1246,24 @@ SET @ddl = IF( ); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @customer_nickname_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='customer_nickname'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32) NULL COMMENT ''客户昵称:非法定展示及打印使用,创建时默认取公司名称中文拼音大写首字母'' AFTER tenant_name')); +PREPARE customer_nickname_stmt FROM @customer_nickname_ddl; +EXECUTE customer_nickname_stmt; +DEALLOCATE PREPARE customer_nickname_stmt; + INSERT INTO iam_tenant ( - id, tenant_code, tenant_name, tenant_type, status, legal_name, + id, tenant_code, tenant_name, customer_nickname, tenant_type, status, legal_name, unified_social_credit_code, contact_name, contact_phone, contact_email, approved_at, approved_by, created_by, deleted, version ) VALUES - (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', + (0, 'PLATFORM', '虫虫出海平台', '平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE - tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), + tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), customer_nickname = VALUES(customer_nickname), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), unified_social_credit_code = VALUES(unified_social_credit_code), contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone), @@ -15256,6 +15266,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'service_package', '客户 CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'status', '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_name', '客户企业名称'); +CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'customer_nickname', '客户昵称:非法定展示及打印使用,创建时默认取公司名称中文拼音大写首字母'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_type', '主体类型:MERCHANT企业客户'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'updated_at', '更新时间'); @@ -15848,6 +15859,9 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' AND column_name='dimension_initialization_status') <> 1 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' + AND column_name='customer_nickname') <> 1 OR (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_iam' AND table_name='iam_customer_dimension_initialization_task') <> 1 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java index 775caebbf..7581947dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java @@ -19,6 +19,6 @@ public interface IamCustomerSnapshotClient { @RequestParam(value = "status", required = false) String status, @RequestParam(value = "customerId", required = false) Long customerId); - record Customer(long id, String tenantCode, String tenantName, String status, + record Customer(long id, String tenantCode, String tenantName, String customerNickname, String status, String contactName, String contactPhone) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java index ed265fbca..8eaf88622 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -54,11 +54,16 @@ public interface PrintPdfClient { record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List documents) {} - record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount, - int skuCount, BigDecimal weightKg, BigDecimal volumeCbm, - String barcodeContent, List skuRows) {} + record DropshipPalletDocument(String palletNo, String destinationWarehouse, + int palletSequence, int totalPallets, + String barcodeContent, + List inboundRows, + List skuRows) {} - record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName, - int cartonCount, int skuQuantity) {} + record DropshipPalletInboundRow(String inboundCode, String customerNickname, + int cartonCount, int itemQuantity) {} + + record DropshipPalletSkuRow(String skuCode, int cartonCount, + int itemQuantity, String unitsPerCarton) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index b591cf946..b4bdb608a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -203,6 +203,58 @@ public interface DropshipPalletizationMapper { JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE c.pallet_id=#{palletId}""") PalletMetrics metrics(@Param("palletId") long palletId); + + @Select(""" + SELECT c.tenant_id,c.inbound_id,o.inbound_code, + COALESCE(NULLIF(o.destination_name,''),NULLIF(o.destination_code,''),'—') destination_warehouse, + COUNT(DISTINCT c.id) carton_count, + SUM(COALESCE(q.item_quantity,c.actual_sku_quantity,0)) item_quantity + FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + LEFT JOIN (SELECT carton_id,SUM(actual_quantity) item_quantity + FROM fl_dropship_pallet_carton_sku GROUP BY carton_id) q ON q.carton_id=c.id + WHERE c.pallet_id=#{palletId} + GROUP BY c.tenant_id,c.inbound_id,o.inbound_code,o.destination_name,o.destination_code + UNION ALL + SELECT p.tenant_id,p.inbound_id,o.inbound_code, + COALESCE(NULLIF(o.destination_name,''),NULLIF(o.destination_code,''),'—') destination_warehouse, + 0 carton_count,0 item_quantity + FROM fl_dropship_pallet p + JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id + WHERE p.id=#{palletId} + AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id) + ORDER BY inbound_code + """) + List printInboundRows(@Param("palletId") long palletId); + + @Select(""" + SELECT sku_code,COUNT(DISTINCT carton_id) carton_count,SUM(item_quantity) item_quantity, + MIN(item_quantity) min_units_per_carton,MAX(item_quantity) max_units_per_carton + FROM ( + SELECT c.id carton_id,s.sku_code,SUM(s.actual_quantity) item_quantity + FROM fl_dropship_pallet_carton c + JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id + WHERE c.pallet_id=#{palletId} + GROUP BY c.id,s.sku_code + UNION ALL + SELECT c.id carton_id,c.sku_code,c.actual_sku_quantity item_quantity + FROM fl_dropship_pallet_carton c + WHERE c.pallet_id=#{palletId} + AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton_sku s WHERE s.carton_id=c.id) + ) pallet_sku + GROUP BY sku_code ORDER BY sku_code + """) + List printSkuRows(@Param("palletId") long palletId); + + @Select(""" + SELECT (SELECT COUNT(*) FROM fl_dropship_pallet listed + WHERE listed.task_id=target.task_id AND listed.id<=target.id) sequence_no, + (SELECT COUNT(*) FROM fl_dropship_pallet listed + WHERE listed.task_id=target.task_id) total + FROM fl_dropship_pallet target + WHERE target.id=#{palletId} + """) + PalletPosition palletPosition(@Param("palletId") long palletId); @Select(""" SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count, (SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc @@ -244,5 +296,8 @@ public interface DropshipPalletizationMapper { @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } @Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; } record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} + @Getter @Setter class PrintInboundRow { private Long tenantId,inboundId; private String inboundCode,destinationWarehouse; private Integer cartonCount,itemQuantity; } + @Getter @Setter class PrintSkuRow { private String skuCode; private Integer cartonCount,itemQuantity,minUnitsPerCarton,maxUnitsPerCarton; } + record PalletPosition(int sequenceNo,int total) {} @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java index a94b2e9bd..fa64873bf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java @@ -35,4 +35,21 @@ public class IamCustomerSnapshotService { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用"); } } + + public String customerNickname(long customerId) { + try { + var response = client.page(internalToken, 1, 2, null, null, customerId); + if (response == null || !response.successful() || response.data() == null + || response.data().records() == null || response.data().records().size() != 1) { + throw new BizException(ResultCode.CONFLICT, "客户昵称不存在,请先完善客户资料"); + } + var customer = response.data().records().get(0); + if (customer.id() != customerId || !StringUtils.hasText(customer.customerNickname())) { + throw new BizException(ResultCode.CONFLICT, "客户昵称不存在,请先完善客户资料"); + } + return customer.customerNickname().trim(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用"); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java index a2cd51904..b959c9aac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -5,9 +5,11 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.PrintPdfClient; import com.baiyue.firstleg.dto.InboundCartonLabels; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.HttpHeaders; @@ -19,6 +21,9 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.util.List; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; /** Allocates stable carton codes and forwards immutable label facts to Print. */ @@ -34,6 +39,8 @@ public class InboundCartonLabelPrintService { private final InboundCartonLabelService labelService; private final PrintPdfClient printClient; private final ObjectMapper objectMapper; + @Resource private DropshipPalletizationMapper palletizationMapper; + @Resource private IamCustomerSnapshotService customerSnapshots; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -115,7 +122,7 @@ public class InboundCartonLabelPrintService { return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody()); } - /** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */ + /** 一件代发 A4 托盘信息单:托盘、目的仓、入仓码和 SKU 汇总。 */ public ResponseEntity generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) { return generateDropshipPallets(List.of(pallet), inboundCode, requestId); } @@ -125,13 +132,14 @@ public class InboundCartonLabelPrintService { if (pallets == null || pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); } + Map customerNicknameCache = new HashMap<>(); List documents = pallets.stream() - .map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList(); + .map(pallet -> dropshipPalletDocument(pallet, customerNicknameCache)).toList(); String fileName = pallets.size() == 1 ? pallets.get(0).palletNo() + "-A4托盘信息单.pdf" : inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf"; PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest( - "DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM), + "DROPSHIP_PALLET_A4_V2", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM), fileName, documents); try { return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request)); @@ -140,20 +148,42 @@ public class InboundCartonLabelPrintService { } } - private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) { - if (pallet == null || !StringUtils.hasText(pallet.palletNo())) { + private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument( + Pallet pallet, Map customerNicknameCache) { + if (pallet == null || pallet.palletId() == null || !StringUtils.hasText(pallet.palletNo())) { throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); } - String inboundCodes = pallet.items().stream().map(item -> item.inboundCode()) - .filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/")); - if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode; - List skuRows = pallet.items().stream() - .map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(), - item.cargoName(), number(item.cartonCount()), number(item.skuQuantity()))) + List inboundFacts = + palletizationMapper.printInboundRows(pallet.palletId()); + if (inboundFacts == null || inboundFacts.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "托盘未关联入仓单,不能打印"); + } + List inboundRows = inboundFacts.stream() + .map(row -> new PrintPdfClient.DropshipPalletInboundRow( + row.getInboundCode(), + customerNicknameCache.computeIfAbsent(row.getTenantId(), customerSnapshots::customerNickname), + number(row.getCartonCount()), number(row.getItemQuantity()))) .toList(); + String destinationWarehouse = inboundFacts.stream() + .map(DropshipPalletizationMapper.PrintInboundRow::getDestinationWarehouse) + .filter(StringUtils::hasText).distinct().collect(Collectors.joining(" / ")); + if (!StringUtils.hasText(destinationWarehouse)) destinationWarehouse = "—"; + List skuRows = palletizationMapper.printSkuRows(pallet.palletId()) + .stream().map(row -> new PrintPdfClient.DropshipPalletSkuRow( + row.getSkuCode(), number(row.getCartonCount()), number(row.getItemQuantity()), + unitsPerCarton(row))).toList(); + DropshipPalletizationMapper.PalletPosition position = + palletizationMapper.palletPosition(pallet.palletId()); return new PrintPdfClient.DropshipPalletDocument( - pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()), - pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows); + pallet.palletNo(), destinationWarehouse, + position == null ? 1 : position.sequenceNo(), position == null ? 1 : position.total(), + pallet.palletNo(), inboundRows, skuRows); + } + + private String unitsPerCarton(DropshipPalletizationMapper.PrintSkuRow row) { + int min = number(row.getMinUnitsPerCarton()); + int max = number(row.getMaxUnitsPerCarton()); + return min == max ? Integer.toString(min) : min + "-" + max; } private int number(Integer value) { diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index 89a97b6e6..d2011e866 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -68,7 +68,7 @@ paths: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print: get: summary: PDA 获取预生成托盘码的 A4 托盘信息单 - description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、入仓码、箱数以及已归托 SKU、商品名称和实点数量,不展示作业状态。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。 + description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、目的仓、托盘序号、入仓码/客户昵称/箱数/件数汇总以及 SKU/箱数/件数/每箱件数汇总,不展示作业状态、重量或体积。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。 x-required-authority: platform:firstleg:pda:view parameters: - {$ref: '#/components/parameters/TaskId'} @@ -91,7 +91,7 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: get: - summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单 + summary: 获取包含托盘码、目的仓、入仓码和 SKU 汇总的 A4 托盘信息单 x-required-authority: platform:firstleg:inbound:list responses: {'200': {description: PDF}} components: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java index 821a4164d..e1d8457e9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java @@ -32,7 +32,7 @@ class IamCustomerSnapshotServiceTest { @Test void resolvesExactlyOneActiveCustomerAndUsesServerName() { var customer = new IamCustomerSnapshotClient.Customer( - 7L, "C-7", "Canonical Customer", "ACTIVE", null, null); + 7L, "C-7", "Canonical Customer", "Short Name", "ACTIVE", null, null); when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); @@ -50,10 +50,20 @@ class IamCustomerSnapshotServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用"); var customer = new IamCustomerSnapshotClient.Customer( - 7L, "C-7", "Canonical Customer", "ACTIVE", null, null); + 7L, "C-7", "Canonical Customer", "Short Name", "ACTIVE", null, null); when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); assertThatThrownBy(() -> service.active(7L, "Forged Customer")) .isInstanceOf(BizException.class).hasMessageContaining("不一致"); } + + @Test + void resolvesCustomerNicknameForPalletDocument() { + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", " Short Name ", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, null, 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + + assertThat(service.customerNickname(7L)).isEqualTo("Short Name"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java index 469493151..39514f66b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.client.PrintPdfClient; import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,17 +30,22 @@ import static org.mockito.Mockito.when; class InboundCartonLabelPrintServiceTest { private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class); private final PrintPdfClient print = mock(PrintPdfClient.class); + private final DropshipPalletizationMapper palletization = mock(DropshipPalletizationMapper.class); + private final IamCustomerSnapshotService customerSnapshots = mock(IamCustomerSnapshotService.class); private InboundCartonLabelPrintService service; @BeforeEach void setUp() { service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper()); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + ReflectionTestUtils.setField(service, "palletizationMapper", palletization); + ReflectionTestUtils.setField(service, "customerSnapshots", customerSnapshots); } @Test void dropshipPalletLabelIsAvailableBeforeSeal() { Pallet open = dropshipPallet("OPEN"); + stubPalletFacts(90001L, 1, 2); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), @@ -54,17 +60,25 @@ class InboundCartonLabelPrintServiceTest { ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class); verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.eq("request-open"), request.capture()); - assertThat(request.getValue().templateCode()).isEqualTo("DROPSHIP_PALLET_A4_V1"); + assertThat(request.getValue().templateCode()).isEqualTo("DROPSHIP_PALLET_A4_V2"); assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("210"); assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("297"); assertThat(request.getValue().documents()).singleElement().satisfies(document -> { assertThat(document.palletNo()).isEqualTo("DP-260911-0001"); - assertThat(document.inboundCodes()).isEqualTo("PCHK2608040174"); + assertThat(document.destinationWarehouse()).isEqualTo("纽约 N.JDF"); + assertThat(document.palletSequence()).isEqualTo(1); + assertThat(document.totalPallets()).isEqualTo(2); + assertThat(document.inboundRows()).singleElement().satisfies(row -> { + assertThat(row.inboundCode()).isEqualTo("PCHK2608040174"); + assertThat(row.customerNickname()).isEqualTo("小虫科技"); + assertThat(row.cartonCount()).isEqualTo(1); + assertThat(row.itemQuantity()).isEqualTo(19); + }); assertThat(document.skuRows()).singleElement().satisfies(row -> { assertThat(row.skuCode()).isEqualTo("SKU-1"); - assertThat(row.cargoName()).isEqualTo("音箱"); assertThat(row.cartonCount()).isEqualTo(1); - assertThat(row.skuQuantity()).isEqualTo(19); + assertThat(row.itemQuantity()).isEqualTo(19); + assertThat(row.unitsPerCarton()).isEqualTo("19"); }); }); } @@ -74,6 +88,8 @@ class InboundCartonLabelPrintServiceTest { Pallet first = dropshipPallet("OPEN"); Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0, BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null); + stubPalletFacts(90001L, 1, 2); + stubPalletFacts(90002L, 2, 2); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), @@ -225,4 +241,27 @@ class InboundCartonLabelPrintServiceTest { return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0, BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null); } + + private void stubPalletFacts(long palletId, int sequence, int total) { + DropshipPalletizationMapper.PrintInboundRow inbound = + new DropshipPalletizationMapper.PrintInboundRow(); + inbound.setTenantId(10001L); + inbound.setInboundId(70001L); + inbound.setInboundCode("PCHK2608040174"); + inbound.setDestinationWarehouse("纽约 N.JDF"); + inbound.setCartonCount(palletId == 90001L ? 1 : 0); + inbound.setItemQuantity(palletId == 90001L ? 19 : 0); + when(palletization.printInboundRows(palletId)).thenReturn(List.of(inbound)); + DropshipPalletizationMapper.PrintSkuRow sku = new DropshipPalletizationMapper.PrintSkuRow(); + sku.setSkuCode("SKU-1"); + sku.setCartonCount(1); + sku.setItemQuantity(19); + sku.setMinUnitsPerCarton(19); + sku.setMaxUnitsPerCarton(19); + when(palletization.printSkuRows(palletId)) + .thenReturn(palletId == 90001L ? List.of(sku) : List.of()); + when(palletization.palletPosition(palletId)) + .thenReturn(new DropshipPalletizationMapper.PalletPosition(sequence, total)); + when(customerSnapshots.customerNickname(10001L)).thenReturn("小虫科技"); + } } diff --git a/iam/pom.xml b/iam/pom.xml index fcfc28a60..20770d35f 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -16,6 +16,7 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.cloudspring-cloud-starter-openfeign org.springframework.cloudspring-cloud-starter-loadbalancer + com.belerwebpinyin4j2.5.1 org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime com.h2databaseh2test diff --git a/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java index 578e798a7..5a6fbbfc4 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java @@ -11,7 +11,7 @@ public final class CustomerExportDtos { public record CustomerExportBatch(List records, Long nextCursor, boolean hasMore) { } public record CustomerExportRow( - String id, String tenantCode, String tenantName, String legalName, + String id, String tenantCode, String tenantName, String customerNickname, String legalName, String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate, BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, String contactName, String contactPhone, String contactEmail, String contactWechat, diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index ae59df536..1ab9de92a 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -24,6 +24,7 @@ public final class TenantDtos { @Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料") public record RegisterRequest( @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, + @Schema(description = "Customer nickname; defaults to uppercase pinyin initials of Chinese characters in tenantName / 客户昵称,不填时默认取公司名称各中文字符的拼音大写首字母", example = "SZWL") @Size(max = 32) String customerNickname, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, @@ -57,6 +58,7 @@ public final class TenantDtos { @Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile") public record UpdateTenantRequest( @NotBlank String tenantName, + @Schema(description = "Customer nickname used by non-legal displays and printing / 非法定展示及打印使用的客户昵称") @NotBlank @Size(max = 32) String customerNickname, @NotBlank String legalName, @Size(max = 64) String legalRepresentative, @NotBlank String contactName, @@ -134,7 +136,7 @@ public final class TenantDtos { ) { } @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") - public record TenantResponse(Long id, String tenantCode, String tenantName, String status, + public record TenantResponse(Long id, String tenantCode, String tenantName, String customerNickname, String status, String dimensionInitializationStatus, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String contactPhone, String contactEmail, String contactWechat, @@ -144,6 +146,6 @@ public final class TenantDtos { String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } /** Full customer contact summary for trusted internal finance read models. */ - public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status, + public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String customerNickname, String status, String contactName, String contactPhone) { } } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index bc3971d9b..b253094bc 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -20,6 +20,7 @@ public class IamTenant extends BaseEntity { @TableId private Long id; private String tenantCode; private String tenantName; + private String customerNickname; private String tenantType; private String status; private String dimensionInitializationStatus; diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java index 403039863..a6f640149 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -37,6 +37,7 @@ public class CustomerListExportProviderService { column("id", "客户ID", ExportColumnType.STRING, 20), column("tenantCode", "客户编码", ExportColumnType.STRING, 18), column("tenantName", "公司名称", ExportColumnType.STRING, 28), + column("customerNickname", "客户昵称", ExportColumnType.STRING, 14), column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28), column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24), column("enterpriseType", "企业类型", ExportColumnType.STRING, 16), @@ -177,6 +178,7 @@ public class CustomerListExportProviderService { row.put("id", String.valueOf(tenant.getId())); row.put("tenantCode", tenant.getTenantCode()); row.put("tenantName", tenant.getTenantName()); + row.put("customerNickname", tenant.getCustomerNickname()); row.put("legalName", tenant.getLegalName()); row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode()); row.put("enterpriseType", tenant.getEnterpriseType()); diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 5dd4d22fa..2b2748c2b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -70,6 +70,7 @@ public class MerchantOnboardingService { tenant.setId(tenantId); tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.companyName()); + tenant.setCustomerNickname(TenantService.resolveCustomerNickname(null, request.companyName())); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); tenant.setDimensionInitializationStatus("PENDING"); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 5659ec3de..f4c4abecd 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -40,6 +40,7 @@ import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; import feign.FeignException; +import net.sourceforge.pinyin4j.PinyinHelper; import java.time.LocalDateTime; import java.util.List; @@ -170,6 +171,7 @@ public class TenantService { tenant.setId(IdWorker.getId()); tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.tenantName()); + tenant.setCustomerNickname(resolveCustomerNickname(request.customerNickname(), request.tenantName())); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); tenant.setDimensionInitializationStatus("PENDING"); @@ -222,7 +224,7 @@ public class TenantService { public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { PageResult source = repository.pageFinanceCustomers(page, size, keyword, status, customerId); return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary( - tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getStatus(), tenant.getContactName(), tenant.getContactPhone())).toList(), source.page(), source.size(), source.total(), source.pages()); } @@ -239,7 +241,7 @@ public class TenantService { private CustomerExportRow exportRow(IamTenant tenant) { return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(), - tenant.getTenantName(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), + tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), @@ -390,6 +392,7 @@ public class TenantService { ensureNotArchived(tenant); String contactPhone = normalizeContactPhone(request.contactPhone()); tenant.setTenantName(request.tenantName()); + tenant.setCustomerNickname(requireCustomerNickname(request.customerNickname())); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); tenant.setContactName(request.contactName()); @@ -489,6 +492,33 @@ public class TenantService { return value == null || value.isBlank() ? null : value.trim(); } + static String resolveCustomerNickname(String nickname, String tenantName) { + if (nickname != null && !nickname.isBlank()) return nickname.trim(); + String normalizedName = tenantName == null ? "" : tenantName.trim(); + if (normalizedName.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "公司名称不能为空"); + } + StringBuilder initials = new StringBuilder(); + normalizedName.codePoints() + .filter(codePoint -> Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN) + .filter(Character::isBmpCodePoint) + .forEach(codePoint -> { + String[] pinyin = PinyinHelper.toHanyuPinyinStringArray((char) codePoint); + if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) { + initials.append(Character.toUpperCase(pinyin[0].charAt(0))); + } + }); + if (!initials.isEmpty()) return initials.substring(0, Math.min(initials.length(), 32)); + return new String(Character.toChars(normalizedName.codePointAt(0))).toUpperCase(java.util.Locale.ROOT); + } + + private String requireCustomerNickname(String nickname) { + if (nickname == null || nickname.isBlank()) { + throw new BizException(ResultCode.BAD_REQUEST, "客户昵称不能为空"); + } + return nickname.trim(); + } + private void ensureAdministratorPhoneAvailable(String phone) { if (!administratorPhoneAvailable(phone)) { throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用"); @@ -522,7 +552,7 @@ public class TenantService { private TenantResponse response(IamTenant tenant) { return new TenantResponse( - tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getStatus(), tenant.getDimensionInitializationStatus(), tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), @@ -545,7 +575,7 @@ public class TenantService { private TenantResponse summaryResponse(IamTenant tenant) { TenantResponse response = response(tenant); - return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), response.status(), response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), @@ -556,7 +586,7 @@ public class TenantService { private TenantResponse merchantVisibleResponse(IamTenant tenant) { TenantResponse response = response(tenant); - return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), response.status(), response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 0d7e51849..5adbae391 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', + customer_nickname VARCHAR(32) NOT NULL COMMENT '客户昵称:非法定展示及打印使用,创建时默认取公司名称中文拼音大写首字母', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index f5c939631..eb7c7d831 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1306,11 +1306,12 @@ components: pages: {type: integer, minimum: 0} TenantResponse: type: object - required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] + required: [id, tenantCode, tenantName, customerNickname, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantName: {type: string} + customerNickname: {type: string, maxLength: 32, description: 客户昵称;非法定展示及打印使用} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} legalName: {type: string} @@ -1344,11 +1345,12 @@ components: updatedAt: {type: string, format: date-time} FinanceCustomerSummary: type: object - required: [id, tenantCode, tenantName, status] + required: [id, tenantCode, tenantName, customerNickname, status] properties: id: {type: string, pattern: '^\d+$'} tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantName: {type: string} + customerNickname: {type: string, maxLength: 32, description: 非法定展示及打印使用的客户昵称} status: {type: string, enum: [ACTIVE, DISABLED]} contactName: {type: string, nullable: true} contactPhone: {type: string, nullable: true} @@ -1459,6 +1461,7 @@ components: required: [tenantName, legalName, contactName, contactPhone, servicePackage] properties: tenantName: {type: string, example: 深圳虫虫出海科技有限公司} + customerNickname: {type: string, maxLength: 32, example: SZWL, description: 选填;不填时默认取公司名称各中文字符的拼音大写首字母,没有中文时取首个非空字符并转大写} legalName: {type: string, example: 深圳虫虫出海科技有限公司} legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填} contactName: {type: string, example: 李四} @@ -1486,9 +1489,10 @@ components: contactEmail: {type: string, format: email} UpdateTenantRequest: type: object - required: [tenantName, legalName, contactName, contactPhone] + required: [tenantName, customerNickname, legalName, contactName, contactPhone] properties: tenantName: {type: string} + customerNickname: {type: string, maxLength: 32, description: 客户昵称;非法定展示及打印使用} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填} contactName: {type: string} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java index 5fbd42587..adf1dd423 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -60,6 +60,8 @@ class CustomerProfileMigrationContractTest { String instructions = Files.readString(readme, StandardCharsets.UTF_8); assertThat(sql) + .contains("customer_nickname VARCHAR(32)") + .contains("ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32)") .contains("ALTER TABLE iam_tenant MODIFY COLUMN status") .contains("DEFAULT 'PROFILE_REQUIRED'") .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'") diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java index 68a289288..3c4459c92 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java @@ -50,13 +50,14 @@ class CustomerListExportProviderServiceTest { assertEquals(1, response.schemaVersion()); assertThat(response.schemaHash()).matches("sha256:[0-9a-f]{64}"); assertThat(response.snapshotToken()).matches("v1\\.103\\.[A-Za-z0-9_-]+"); - assertEquals(20, response.columns().size()); + assertEquals(21, response.columns().size()); assertEquals("tenantCode", response.columns().get(1).key()); assertEquals(ExportColumnType.DECIMAL, response.columns().stream().filter(column -> "registeredCapital".equals(column.key())) .findFirst().orElseThrow().type()); assertEquals(2, response.rows().size()); assertEquals("101", response.rows().get(0).get("id")); + assertEquals("柏", response.rows().get(0).get("customerNickname")); assertEquals(new BigDecimal("1000000.00"), response.rows().get(0).get("registeredCapital")); assertEquals("启用", response.rows().get(0).get("status")); assertEquals("102", response.nextCursor()); @@ -142,6 +143,7 @@ class CustomerListExportProviderServiceTest { tenant.setId(id); tenant.setTenantCode(String.format("%05d", id)); tenant.setTenantName("柏悦物流" + id); + tenant.setCustomerNickname("柏"); tenant.setLegalName("柏悦物流有限公司"); tenant.setUnifiedSocialCreditCode("91440000123456789X"); tenant.setEnterpriseType("有限责任公司"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ce6928eb8..d6582f012 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -160,7 +160,7 @@ class TenantServiceTest { when(repository.findTenant(10001L)).thenReturn(tenant); var updated = service.updateMerchant(10001L, - new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", + new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); @@ -187,7 +187,7 @@ class TenantServiceTest { when(repository.findTenant(10001L)).thenReturn(tenant); var updated = service.updateMerchant(10001L, - new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, + new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13900139000", null, "基础版", null, null, 30, null, null, null, "CNY", null, null)); assertEquals("13900139000", updated.contactPhone()); @@ -282,7 +282,7 @@ class TenantServiceTest { when(repository.findTenant(10001L)).thenReturn(archived); BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, - new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, + new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", null, "基础版", null, null, 30, null, null, null, "CNY", null, null))); BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", @@ -317,7 +317,7 @@ class TenantServiceTest { .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); var created = service.register(new RegisterRequest( - "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000", "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), 9001L); @@ -325,6 +325,7 @@ class TenantServiceTest { assertEquals("PROFILE_REQUIRED", created.tenant().status()); assertEquals("00001", created.tenant().tenantCode()); assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); + assertEquals("E", created.tenant().customerNickname()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); assertEquals("merchant_81", created.administratorUsername()); @@ -337,13 +338,21 @@ class TenantServiceTest { verify(repository, never()).insertReview(any()); } + @Test + void customerNicknameDefaultsToFirstChineseCharacterAndAcceptsManualValue() { + assertEquals("SZWLYXGS", TenantService.resolveCustomerNickname(null, "ABC 深圳物流有限公司")); + assertEquals("SZWL", TenantService.resolveCustomerNickname(null, "深圳物流")); + assertEquals("A", TenantService.resolveCustomerNickname(" ", "ABC Logistics")); + assertEquals("虫虫", TenantService.resolveCustomerNickname(" 虫虫 ", "深圳物流有限公司")); + } + @Test void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( - "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000", "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L)); @@ -357,7 +366,7 @@ class TenantServiceTest { .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); var created = service.register(new RegisterRequest( - "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000", "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L); @@ -524,6 +533,7 @@ class TenantServiceTest { tenant.setStatus(status); tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); + tenant.setCustomerNickname("E"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); tenant.setContactName("Alice"); diff --git a/print/AGENTS.md b/print/AGENTS.md index 7f87e386e..51489cc3f 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -4,7 +4,7 @@ - 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。 - 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。 -- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V1`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、最多两行入仓码和最多 34 行完整 SKU 归托信息,不展示作业状态。新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。 +- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。 ## 快速索引 diff --git a/print/README.md b/print/README.md index 4f45ee9af..974181371 100644 --- a/print/README.md +++ b/print/README.md @@ -8,7 +8,7 @@ - 服务发现名:`print-service`。 - 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。 -- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V1`,每托一页 A4,包含托盘 Code 128 一维码、最多两行入仓码、总箱数和最多 34 行 SKU/商品名称/箱数/实点数量,不展示作业状态。 +- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,每托一页 A4,包含托盘 Code 128 一维码、目的仓、托盘序号、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。 - A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。 - 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。 - 本地直连:`http://127.0.0.1:9207`。 diff --git a/print/output/pdf/dropship-pallet-a4-v2-preview.pdf b/print/output/pdf/dropship-pallet-a4-v2-preview.pdf new file mode 100644 index 0000000000000000000000000000000000000000..80d212ce219c0a30a8bfc80331ce732c5cd345de GIT binary patch literal 15467 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 skuRows) {} + public record Document(String palletNo, String destinationWarehouse, + int palletSequence, int totalPallets, + String barcodeContent, List inboundRows, + List skuRows) {} - public record SkuRow(String inboundCode, String skuCode, String cargoName, - int cartonCount, int skuQuantity) {} + public record InboundRow(String inboundCode, String customerNickname, + int cartonCount, int itemQuantity) {} + + public record SkuRow(String skuCode, int cartonCount, + int itemQuantity, String unitsPerCarton) {} } diff --git a/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java index 5cd8c08f4..af9f18a0e 100644 --- a/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java @@ -13,17 +13,18 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.springframework.stereotype.Component; -/** 每个托盘一页 A4,Code 128 托盘码与完整 SKU 归托信息同页展示。 */ +/** 每个托盘一页 A4:托盘码、目的仓、入仓码汇总和 SKU 汇总。 */ @Component public class DropshipPalletA4Renderer { - private static final float[] COLS = {8, 18, 57, 98, 161, 181, 202}; - private static final String[] HEADERS = {"序号", "入仓码", "SKU", "商品名称", "箱数", "实点数量"}; - private static final double TABLE_TOP = 105; - private static final double HEADER_HEIGHT = 8; - private static final double REGULAR_ROW_HEIGHT = 7; - private static final double DENSE_ROW_HEIGHT = 5.2; - private static final float BARCODE_WIDTH_PT = PdfUnits.mmToPt(158); - private static final float BARCODE_HEIGHT_PT = PdfUnits.mmToPt(22); + private static final Color BLUE = new Color(20, 89, 156); + private static final Color LIGHT_BLUE = new Color(225, 239, 251); + private static final Color TABLE_HEADER = new Color(238, 242, 247); + private static final Color RED = new Color(180, 43, 36); + private static final float[] INBOUND_COLS = {18, 27, 79, 146, 169, 192}; + private static final String[] INBOUND_HEADERS = {"#", "入仓码", "客户", "箱数", "件数"}; + private static final float[] SKU_COLS = {18, 132, 154, 176, 192}; + private static final String[] SKU_HEADERS = {"SKU", "箱数", "件数", "每箱件数"}; + @Resource private PdfBarcodeRenderer barcodeRenderer; public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data, @@ -32,93 +33,144 @@ public class DropshipPalletA4Renderer { pdf.addPage(page); PdfUnits units = new PdfUnits(297); try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) { - stream.setNonStrokingColor(Color.BLACK); - stream.setLineWidth(PdfUnits.mmToPt(.3)); - centered(stream, units, font, "一件代发托盘信息单", 15, 105, 13); - centered(stream, units, font, data.palletNo(), 22, 105, 25); - barcode(stream, units, barcodeMatrix); - fill(stream, units, new Color(245, 247, 250), 8, 58, 202, 94); - inboundCodes(stream, units, font, data.inboundCodes()); - line(stream, units, 8, 78, 202, 78); - metric(stream, units, font, "总箱数", Integer.toString(data.cartonCount()), 8, 56.5); - metric(stream, units, font, "SKU 数", Integer.toString(data.skuCount()), 56.5, 105); - metric(stream, units, font, "重量", decimal(data.weightKg()) + " kg", 105, 153.5); - metric(stream, units, font, "体积", decimal(data.volumeCbm()) + " m³", 153.5, 202); - line(stream, units, 56.5, 78, 56.5, 94); - line(stream, units, 105, 78, 105, 94); - line(stream, units, 153.5, 78, 153.5, 94); - line(stream, units, 8, 99, 202, 99); - text(stream, units, font, "SKU 明细", 11, 8, 104); - table(stream, units, font, data.skuRows()); + stream.setLineWidth(PdfUnits.mmToPt(.25)); + border(stream, units); + colorText(stream, units, font, "一件代发备货托", 14, 18, 22, BLUE); + rightText(stream, units, font, + "第 " + data.palletSequence() + " 托 / 共 " + data.totalPallets() + " 托", + 10, 192, 22, new Color(75, 75, 75)); + + barcodeRenderer.draw(stream, barcodeMatrix, PdfUnits.mmToPt(18), units.topDownMmToPdfY(51), + PdfUnits.mmToPt(80), PdfUnits.mmToPt(22)); + fittedText(stream, units, font, data.palletNo(), 25, 105, 44, 87, Color.BLACK); + + fill(stream, units, LIGHT_BLUE, 10, 57, 200, 82); + colorText(stream, units, font, "目的仓", 7.5f, 18, 66, BLUE); + fittedText(stream, units, font, data.destinationWarehouse(), 20, 18, 77, 174, BLUE); + + int rowCount = data.inboundRows().size() + 1 + Math.max(1, data.skuRows().size()); + double rowHeight = rowCount > 36 ? 2.9 : rowCount > 24 ? 3.6 : 6.0; + float rowFont = rowCount > 36 ? 4.4f : rowCount > 24 ? 5.0f : 7.0f; + + int inboundCartons = data.inboundRows().stream().mapToInt(DropshipPalletA4PdfDtos.InboundRow::cartonCount).sum(); + int inboundItems = data.inboundRows().stream().mapToInt(DropshipPalletA4PdfDtos.InboundRow::itemQuantity).sum(); + colorText(stream, units, font, + "入仓码信息 · 共 " + data.inboundRows().size() + " 票", + 9, 18, 92, BLUE); + double inboundBottom = inboundTable(stream, units, font, data.inboundRows(), + inboundCartons, inboundItems, 97, rowHeight, rowFont); + + double skuTitleY = inboundBottom + 8; + colorText(stream, units, font, "SKU 数:" + data.skuRows().size(), 9, 18, skuTitleY, new Color(70, 70, 70)); + skuTable(stream, units, font, data.skuRows(), skuTitleY + 5, rowHeight, rowFont); + + line(stream, units, 10, 280, 200, 280); + colorText(stream, units, font, "拆托 / 合托后此标作废,须重打", 8.5f, 18, 288, RED); } } - private void barcode(PDPageContentStream stream, PdfUnits units, BitMatrix matrix) throws IOException { - float left = PdfUnits.mmToPt(26); - float bottom = units.topDownMmToPdfY(54); - barcodeRenderer.draw(stream, matrix, left, bottom, BARCODE_WIDTH_PT, BARCODE_HEIGHT_PT); + private double inboundTable(PDPageContentStream s, PdfUnits u, PDFont f, + List rows, + int totalCartons, int totalItems, double top, + double rowHeight, float rowFont) throws IOException { + double headerHeight = 7; + double bottom = top + headerHeight + (rows.size() + 1) * rowHeight; + tableGrid(s, u, INBOUND_COLS, top, bottom, headerHeight, rows.size() + 1, rowHeight); + tableHeaders(s, u, f, INBOUND_COLS, INBOUND_HEADERS, top, headerHeight); + for (int index = 0; index < rows.size(); index++) { + var row = rows.get(index); + String[] values = {Integer.toString(index + 1), row.inboundCode(), row.customerNickname(), + Integer.toString(row.cartonCount()), Integer.toString(row.itemQuantity())}; + tableRow(s, u, f, INBOUND_COLS, values, top + headerHeight + index * rowHeight, + rowHeight, rowFont); + } + String[] totals = {"计", "跨 " + rows.size() + " 票合计", "", Integer.toString(totalCartons), + Integer.toString(totalItems)}; + tableRow(s, u, f, INBOUND_COLS, totals, top + headerHeight + rows.size() * rowHeight, + rowHeight, rowFont); + return bottom; } - private void table(PDPageContentStream stream, PdfUnits units, PDFont font, - List rows) throws IOException { + private void skuTable(PDPageContentStream s, PdfUnits u, PDFont f, + List rows, double top, + double rowHeight, float rowFont) throws IOException { + double headerHeight = 7; int visibleRows = Math.max(1, rows.size()); - double rowHeight = rows.size() > 24 ? DENSE_ROW_HEIGHT : REGULAR_ROW_HEIGHT; - float rowFontSize = rows.size() > 24 ? 5.3f : 6.4f; - double headerBottom = TABLE_TOP + HEADER_HEIGHT; - double bottom = headerBottom + visibleRows * rowHeight; - fill(stream, units, new Color(235, 239, 245), COLS[0], TABLE_TOP, COLS[COLS.length - 1], headerBottom); - for (float x : COLS) line(stream, units, x, TABLE_TOP, x, bottom); - for (int boundary = 0; boundary <= visibleRows; boundary++) { - line(stream, units, COLS[0], headerBottom + boundary * rowHeight, - COLS[COLS.length - 1], headerBottom + boundary * rowHeight); - } - line(stream, units, COLS[0], TABLE_TOP, COLS[COLS.length - 1], TABLE_TOP); - for (int column = 0; column < HEADERS.length; column++) { - centeredCell(stream, units, font, HEADERS[column], 7.2f, - COLS[column] + .5, COLS[column + 1] - .5, TABLE_TOP + 5.5); - } + double bottom = top + headerHeight + visibleRows * rowHeight; + tableGrid(s, u, SKU_COLS, top, bottom, headerHeight, visibleRows, rowHeight); + tableHeaders(s, u, f, SKU_COLS, SKU_HEADERS, top, headerHeight); if (rows.isEmpty()) { - centeredCell(stream, units, font, "暂无已归托 SKU", 7.2f, COLS[0], COLS[COLS.length - 1], headerBottom + 4.8); + tableRow(s, u, f, SKU_COLS, new String[]{"暂无已归托 SKU", "", "", ""}, + top + headerHeight, rowHeight, rowFont); return; } - for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { - DropshipPalletA4PdfDtos.SkuRow row = rows.get(rowIndex); - String[] values = {Integer.toString(rowIndex + 1), row.inboundCode(), row.skuCode(), row.cargoName(), - Integer.toString(row.cartonCount()), Integer.toString(row.skuQuantity())}; - double baseline = headerBottom + rowIndex * rowHeight + rowHeight * .7; - for (int column = 0; column < values.length; column++) { - centeredCell(stream, units, font, values[column], rowFontSize, - COLS[column] + .7, COLS[column + 1] - .7, baseline); - } + for (int index = 0; index < rows.size(); index++) { + var row = rows.get(index); + String[] values = {row.skuCode(), Integer.toString(row.cartonCount()), + Integer.toString(row.itemQuantity()), row.unitsPerCarton()}; + tableRow(s, u, f, SKU_COLS, values, top + headerHeight + index * rowHeight, + rowHeight, rowFont); } } - private String decimal(java.math.BigDecimal value) { - return value == null ? "0" : value.stripTrailingZeros().toPlainString(); - } - private void inboundCodes(PDPageContentStream s, PdfUnits u, PDFont f, String value) throws IOException { - text(s, u, f, "入仓码:", 8, 14, 66); - List lines = wrap(f, value, 8, PdfUnits.mmToPt(163), 2); - for (int index = 0; index < lines.size(); index++) { - text(s, u, f, lines.get(index), 8, 33, 66 + index * 6); + private void tableGrid(PDPageContentStream s, PdfUnits u, float[] columns, double top, + double bottom, double headerHeight, int rows, double rowHeight) throws IOException { + fill(s, u, TABLE_HEADER, columns[0], top, columns[columns.length - 1], top + headerHeight); + for (float column : columns) line(s, u, column, top, column, bottom); + line(s, u, columns[0], top, columns[columns.length - 1], top); + line(s, u, columns[0], top + headerHeight, columns[columns.length - 1], top + headerHeight); + for (int index = 1; index <= rows; index++) { + line(s, u, columns[0], top + headerHeight + index * rowHeight, + columns[columns.length - 1], top + headerHeight + index * rowHeight); } } - private void metric(PDPageContentStream s, PdfUnits u, PDFont f, String label, String value, - double x1, double x2) throws IOException { - centeredCell(s, u, f, label, 7.2f, x1 + 1, x2 - 1, 84); - centeredCell(s, u, f, value, 10.5f, x1 + 1, x2 - 1, 91); + + private void tableHeaders(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns, + String[] headers, double top, double headerHeight) throws IOException { + for (int index = 0; index < headers.length; index++) { + centeredCell(s, u, f, headers[index], 7, columns[index] + .5, + columns[index + 1] - .5, top + headerHeight * .68); + } } - private void centered(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size, - double centerX, double y) throws IOException { - float width = f.getStringWidth(value) / 1000 * size; - text(s, u, f, value, size, centerX - width / PdfUnits.mmToPt(1) / 2, y); + + private void tableRow(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns, + String[] values, double top, double height, float size) throws IOException { + for (int index = 0; index < values.length; index++) { + centeredCell(s, u, f, values[index], size, columns[index] + .7, + columns[index + 1] - .7, top + height * .7); + } } + + private void border(PDPageContentStream s, PdfUnits u) throws IOException { + s.setStrokingColor(BLUE); + s.setLineWidth(PdfUnits.mmToPt(.55)); + s.addRect(PdfUnits.mmToPt(10), u.topDownMmToPdfY(290), PdfUnits.mmToPt(190), PdfUnits.mmToPt(280)); + s.stroke(); + s.setStrokingColor(new Color(140, 140, 140)); + s.setLineWidth(PdfUnits.mmToPt(.25)); + } + + private void fittedText(PDPageContentStream s, PdfUnits u, PDFont f, String value, + float preferredSize, double x, double y, double widthMm, + Color color) throws IOException { + float size = preferredSize; + while (size > 9 && f.getStringWidth(value) / 1000 * size > PdfUnits.mmToPt(widthMm)) size -= .5f; + colorText(s, u, f, fit(f, value, size, PdfUnits.mmToPt(widthMm)), size, x, y, color); + } + + private void rightText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size, + double right, double y, Color color) throws IOException { + double widthMm = f.getStringWidth(value) / 1000 * size / PdfUnits.mmToPt(1); + colorText(s, u, f, value, size, right - widthMm, y, color); + } + private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size, double x1, double x2, double y) throws IOException { String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1)); float width = f.getStringWidth(fitted) / 1000 * size; text(s, u, f, fitted, size, x1 + (x2 - x1 - width / PdfUnits.mmToPt(1)) / 2, y); } + private String fit(PDFont font, String raw, float size, float maxWidth) throws IOException { String value = raw == null || raw.isBlank() ? "—" : raw; if (font.getStringWidth(value) / 1000 * size <= maxWidth) return value; @@ -127,26 +179,7 @@ public class DropshipPalletA4Renderer { while (end > 0 && font.getStringWidth(value.substring(0, end) + ellipsis) / 1000 * size > maxWidth) end--; return end == 0 ? ellipsis : value.substring(0, end) + ellipsis; } - private List wrap(PDFont font, String raw, float size, float maxWidth, - int maxLines) throws IOException { - String remaining = raw == null || raw.isBlank() ? "—" : raw; - List lines = new java.util.ArrayList<>(); - while (!remaining.isEmpty() && lines.size() < maxLines) { - if (font.getStringWidth(remaining) / 1000 * size <= maxWidth) { - lines.add(remaining); - break; - } - int split = remaining.length(); - while (split > 0 && font.getStringWidth(remaining.substring(0, split)) / 1000 * size > maxWidth) split--; - int delimiter = remaining.lastIndexOf('/', split - 1); - if (delimiter > 0) split = delimiter + 1; - String line = remaining.substring(0, Math.max(1, split)); - remaining = remaining.substring(Math.max(1, split)); - if (lines.size() == maxLines - 1 && !remaining.isEmpty()) line = fit(font, line + remaining, size, maxWidth); - lines.add(line); - } - return lines; - } + private void fill(PDPageContentStream s, PdfUnits u, Color color, double x1, double y1, double x2, double y2) throws IOException { s.setNonStrokingColor(color); @@ -154,14 +187,27 @@ public class DropshipPalletA4Renderer { s.fill(); s.setNonStrokingColor(Color.BLACK); } + + private void colorText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size, + double x, double y, Color color) throws IOException { + s.setNonStrokingColor(color); + text(s, u, f, value, size, x, y); + s.setNonStrokingColor(Color.BLACK); + } + private void text(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size, double x, double y) throws IOException { - s.beginText(); s.setFont(f, size); s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y)); - s.showText(value == null ? "—" : value); s.endText(); + s.beginText(); + s.setFont(f, size); + s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y)); + s.showText(value == null ? "—" : value); + s.endText(); } + private void line(PDPageContentStream s, PdfUnits u, double x1, double y1, double x2, double y2) throws IOException { s.moveTo(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y1)); - s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2)); s.stroke(); + s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2)); + s.stroke(); } } diff --git a/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java index b95a7d278..139baa94b 100644 --- a/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java +++ b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java @@ -25,6 +25,7 @@ import org.springframework.stereotype.Service; public class DropshipPalletA4PdfService { private static final BigDecimal A4_WIDTH = new BigDecimal("210"); private static final BigDecimal A4_HEIGHT = new BigDecimal("297"); + private static final int MAX_INBOUND_ROWS = 10; private static final int MAX_SKU_ROWS = 34; private static final Set FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|'); @@ -67,21 +68,28 @@ public class DropshipPalletA4PdfService { private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) { if (value == null) invalid("documents[" + index + "] is required"); - if (value.cartonCount() < 0 || value.skuCount() < 0 || negative(value.weightKg()) || negative(value.volumeCbm())) invalid("documents[" + index + "] totals are invalid"); + if (value.palletSequence() <= 0 || value.totalPallets() <= 0 + || value.palletSequence() > value.totalPallets()) invalid("documents[" + index + "] pallet position is invalid"); + if (value.inboundRows() == null || value.inboundRows().isEmpty() + || value.inboundRows().size() > MAX_INBOUND_ROWS) invalid("documents[" + index + "].inboundRows exceeds the one-page A4 capacity"); if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity"); + List inboundRows = new ArrayList<>(); + for (DropshipPalletA4PdfDtos.InboundRow row : value.inboundRows()) { + if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("inbound row is invalid"); + inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow(text(row.inboundCode(), 80), + text(row.customerNickname(), 80), row.cartonCount(), row.itemQuantity())); + } List rows = new ArrayList<>(); for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) { - if (row == null || row.cartonCount() < 0 || row.skuQuantity() < 0) invalid("SKU row is invalid"); - rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.inboundCode(), 80), text(row.skuCode(), 128), - text(row.cargoName(), 200), row.cartonCount(), row.skuQuantity())); + if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("SKU row is invalid"); + rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.skuCode(), 128), row.cartonCount(), + row.itemQuantity(), text(row.unitsPerCarton(), 30))); } - return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), text(value.inboundCodes(), 300), - value.cartonCount(), value.skuCount(), decimal(value.weightKg()), decimal(value.volumeCbm()), - barcode(value.barcodeContent()), List.copyOf(rows)); + return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), + text(value.destinationWarehouse(), 200), value.palletSequence(), value.totalPallets(), + barcode(value.barcodeContent()), List.copyOf(inboundRows), List.copyOf(rows)); } - private BigDecimal decimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } - private boolean negative(BigDecimal value) { return value != null && value.signum() < 0; } private String barcode(String value) { String normalized = text(value, 80); if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only"); diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index d71e12cd5..c9c2adcb6 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -279,7 +279,7 @@ components: type: object required: [templateCode, pageSpec, downloadFileName, documents] properties: - templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] } + templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V2] } pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' } downloadFileName: { type: string, maxLength: 120 } documents: @@ -290,33 +290,43 @@ components: items: { $ref: '#/components/schemas/DropshipPalletA4Document' } DropshipPalletA4Document: type: object - required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, skuRows] + required: [palletNo, destinationWarehouse, palletSequence, totalPallets, barcodeContent, inboundRows, skuRows] properties: palletNo: { type: string, maxLength: 80 } - inboundCodes: { type: string, maxLength: 300 } - cartonCount: { type: integer, minimum: 0 } - skuCount: { type: integer, minimum: 0 } - weightKg: { type: number, minimum: 0 } - volumeCbm: { type: number, minimum: 0 } + destinationWarehouse: { type: string, maxLength: 200 } + palletSequence: { type: integer, minimum: 1 } + totalPallets: { type: integer, minimum: 1 } barcodeContent: type: string maxLength: 80 pattern: '^[\\x20-\\x7E]+$' description: Code 128 一维码内容,当前固定为稳定托盘码 + inboundRows: + type: array + minItems: 1 + maxItems: 10 + items: { $ref: '#/components/schemas/DropshipPalletInboundRow' } skuRows: type: array minItems: 0 maxItems: 34 items: { $ref: '#/components/schemas/DropshipPalletSkuRow' } - DropshipPalletSkuRow: + DropshipPalletInboundRow: type: object - required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity] + required: [inboundCode, customerNickname, cartonCount, itemQuantity] properties: inboundCode: { type: string, maxLength: 80 } - skuCode: { type: string, maxLength: 128 } - cargoName: { type: string, maxLength: 200 } + customerNickname: { type: string, maxLength: 80 } cartonCount: { type: integer, minimum: 0 } - skuQuantity: { type: integer, minimum: 0 } + itemQuantity: { type: integer, minimum: 0 } + DropshipPalletSkuRow: + type: object + required: [skuCode, cartonCount, itemQuantity, unitsPerCarton] + properties: + skuCode: { type: string, maxLength: 128 } + cartonCount: { type: integer, minimum: 0 } + itemQuantity: { type: integer, minimum: 0 } + unitsPerCarton: { type: string, maxLength: 30 } FreightA4PageSpec: type: object required: [widthMm, heightMm] diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index 798c269f4..c4c30dd46 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -216,7 +216,7 @@ class InternalPdfControllerTest { .contains("name: X-Internal-Service-Token") .contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:") .contains("BILL_OF_LADING_CONFIRMATION_A4_V1:") - .contains("DROPSHIP_PALLET_A4_V1") + .contains("DROPSHIP_PALLET_A4_V2") .contains("additionalProperties: false") .contains("minItems: 1") .contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]") @@ -268,23 +268,26 @@ class InternalPdfControllerTest { private String validDropshipPalletA4RequestJson() { return """ { - "templateCode": "DROPSHIP_PALLET_A4_V1", + "templateCode": "DROPSHIP_PALLET_A4_V2", "pageSpec": {"widthMm": 210, "heightMm": 297}, "downloadFileName": "DP-260911-0034-A4托盘信息单.pdf", "documents": [{ "palletNo": "DP-260911-0034", - "inboundCodes": "NYEXX2609110249", - "cartonCount": 3, - "skuCount": 1, - "weightKg": 90, - "volumeCbm": 0.534, + "destinationWarehouse": "纽约 N.JDF", + "palletSequence": 1, + "totalPallets": 3, "barcodeContent": "DP-260911-0034", - "skuRows": [{ + "inboundRows": [{ "inboundCode": "NYEXX2609110249", - "skuCode": "SKU-001", - "cargoName": "蓝牙音箱", + "customerNickname": "小虫科技", "cartonCount": 3, - "skuQuantity": 90 + "itemQuantity": 90 + }], + "skuRows": [{ + "skuCode": "SKU-001", + "cartonCount": 3, + "itemQuantity": 90, + "unitsPerCarton": "30" }] }] } diff --git a/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java index 3b7076e93..2fc14607a 100644 --- a/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java @@ -27,11 +27,12 @@ class DropshipPalletA4PdfServiceGenerationTest { @Test void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception { DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document( - "DP-260911-0034", "NYEXX2609110249", 3, 2, - new BigDecimal("90"), new BigDecimal("0.534"), "DP-260911-0034", + "DP-260911-0034", "纽约 N.JDF", 1, 3, "DP-260911-0034", + List.of(new DropshipPalletA4PdfDtos.InboundRow( + "NYEXX2609110249", "小虫科技", 3, 90)), List.of( - new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40), - new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50))); + new DropshipPalletA4PdfDtos.SkuRow("SKU-001", 2, 40, "20"), + new DropshipPalletA4PdfDtos.SkuRow("SKU-002", 1, 50, "50"))); var generated = service.generate(new DropshipPalletA4PdfDtos.Request( DropshipPalletA4PdfDtos.TEMPLATE, new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), @@ -43,24 +44,28 @@ class DropshipPalletA4PdfServiceGenerationTest { assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f)); assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f)); assertThat(new PDFTextStripper().getText(pdf)) - .contains("DP-260911-0034", "NYEXX2609110249", "SKU 明细", "SKU-001", "SKU-002", "40", "50") + .contains("DP-260911-0034", "N.JDF", "NYEXX2609110249", + "SKU-001", "SKU-002", "40", "50") .containsOnlyOnce("DP-260911-0034") - .doesNotContain("状态", "待封托", "已封托"); + .doesNotContain("状态", "待封托", "已封托", "重量", "体积"); } } @Test void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception { - List inboundCodes = new ArrayList<>(); - for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index)); + List inboundRows = new ArrayList<>(); + for (int index = 1; index <= 10; index++) { + inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow( + "NYEXX260911" + String.format("%04d", 240 + index), "客户" + index, 2, index * 5)); + } List rows = new ArrayList<>(); for (int index = 1; index <= 32; index++) { - rows.add(new DropshipPalletA4PdfDtos.SkuRow(inboundCodes.get((index - 1) % inboundCodes.size()), - "SKU-" + String.format("%03d", index), "测试商品" + index, 2, index * 5)); + rows.add(new DropshipPalletA4PdfDtos.SkuRow( + "SKU-" + String.format("%03d", index), 2, index * 5, "5-10")); } DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document( - "DP-260911-0034", String.join("/", inboundCodes), 64, 32, - new BigDecimal("860.5"), new BigDecimal("6.728"), "DP-260911-0034", rows); + "DP-260911-0034", "纽约 N.JDF", 1, 1, + "DP-260911-0034", inboundRows, rows); var generated = service.generate(new DropshipPalletA4PdfDtos.Request( DropshipPalletA4PdfDtos.TEMPLATE, new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), @@ -77,11 +82,11 @@ class DropshipPalletA4PdfServiceGenerationTest { void rejectsSkuRowsThatCannotFitOnOneA4Page() { List rows = new ArrayList<>(); for (int index = 0; index < 35; index++) { - rows.add(new DropshipPalletA4PdfDtos.SkuRow("INBOUND", "SKU-" + index, "商品", 1, 1)); + rows.add(new DropshipPalletA4PdfDtos.SkuRow("SKU-" + index, 1, 1, "1")); } DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document( - "DP-260911-0034", "INBOUND", 35, 35, - BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows); + "DP-260911-0034", "纽约 N.JDF", 1, 1, "DP-260911-0034", + List.of(new DropshipPalletA4PdfDtos.InboundRow("INBOUND", "客户", 35, 35)), rows); assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request( DropshipPalletA4PdfDtos.TEMPLATE,