This commit is contained in:
2026-09-14 15:33:29 +08:00
parent febe265c50
commit 46b4d14898
30 changed files with 502 additions and 200 deletions
@@ -331,6 +331,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
customer_nickname VARCHAR(32) NOT NULL COMMENT '客户昵称:非法定展示及打印使用,创建时默认取公司名称中文拼音大写首字母',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', 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已停用', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
@@ -1245,15 +1246,24 @@ SET @ddl = IF(
); );
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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 ( 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, unified_social_credit_code, contact_name, contact_phone, contact_email,
approved_at, approved_by, created_by, deleted, version approved_at, approved_by, created_by, deleted, version
) VALUES ) VALUES
(0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', (0, 'PLATFORM', '虫虫出海平台', '平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台',
NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0)
ON DUPLICATE KEY UPDATE 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), status = 'ACTIVE', legal_name = VALUES(legal_name),
unified_social_credit_code = VALUES(unified_social_credit_code), unified_social_credit_code = VALUES(unified_social_credit_code),
contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone), 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', '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_code', '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_name', '客户企业名称'); 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', 'tenant_type', '主体类型:MERCHANT企业客户');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'updated_at', '更新时间');
@@ -15848,6 +15859,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' WHERE table_schema='baiyue_iam' AND table_name='iam_tenant'
AND column_name='dimension_initialization_status') <> 1 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 OR (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_iam' WHERE table_schema='baiyue_iam'
AND table_name='iam_customer_dimension_initialization_task') <> 1 AND table_name='iam_customer_dimension_initialization_task') <> 1
@@ -19,6 +19,6 @@ public interface IamCustomerSnapshotClient {
@RequestParam(value = "status", required = false) String status, @RequestParam(value = "status", required = false) String status,
@RequestParam(value = "customerId", required = false) Long customerId); @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) {} String contactName, String contactPhone) {}
} }
@@ -54,11 +54,16 @@ public interface PrintPdfClient {
record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec, record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<DropshipPalletDocument> documents) {} String downloadFileName, List<DropshipPalletDocument> documents) {}
record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount, record DropshipPalletDocument(String palletNo, String destinationWarehouse,
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm, int palletSequence, int totalPallets,
String barcodeContent, List<DropshipPalletSkuRow> skuRows) {} String barcodeContent,
List<DropshipPalletInboundRow> inboundRows,
List<DropshipPalletSkuRow> skuRows) {}
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName, record DropshipPalletInboundRow(String inboundCode, String customerNickname,
int cartonCount, int skuQuantity) {} int cartonCount, int itemQuantity) {}
record DropshipPalletSkuRow(String skuCode, int cartonCount,
int itemQuantity, String unitsPerCarton) {}
} }
@@ -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 JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId}""") WHERE c.pallet_id=#{palletId}""")
PalletMetrics metrics(@Param("palletId") long 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<PrintInboundRow> 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<PrintSkuRow> 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("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count, 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 (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 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; } @Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; }
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} 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; } @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; }
} }
@@ -35,4 +35,21 @@ public class IamCustomerSnapshotService {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用"); 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, "客户主数据服务暂不可用");
}
}
} }
@@ -5,9 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient; import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException; import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -19,6 +21,9 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
/** Allocates stable carton codes and forwards immutable label facts to Print. */ /** Allocates stable carton codes and forwards immutable label facts to Print. */
@@ -34,6 +39,8 @@ public class InboundCartonLabelPrintService {
private final InboundCartonLabelService labelService; private final InboundCartonLabelService labelService;
private final PrintPdfClient printClient; private final PrintPdfClient printClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Resource private DropshipPalletizationMapper palletizationMapper;
@Resource private IamCustomerSnapshotService customerSnapshots;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
@@ -115,7 +122,7 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody()); return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
} }
/** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */ /** 一件代发 A4 托盘信息单:托盘、目的仓、入仓码和 SKU 汇总。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) { public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
return generateDropshipPallets(List.of(pallet), inboundCode, requestId); return generateDropshipPallets(List.of(pallet), inboundCode, requestId);
} }
@@ -125,13 +132,14 @@ public class InboundCartonLabelPrintService {
if (pallets == null || pallets.isEmpty()) { if (pallets == null || pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
} }
Map<Long, String> customerNicknameCache = new HashMap<>();
List<PrintPdfClient.DropshipPalletDocument> documents = pallets.stream() List<PrintPdfClient.DropshipPalletDocument> documents = pallets.stream()
.map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList(); .map(pallet -> dropshipPalletDocument(pallet, customerNicknameCache)).toList();
String fileName = pallets.size() == 1 String fileName = pallets.size() == 1
? pallets.get(0).palletNo() + "-A4托盘信息单.pdf" ? pallets.get(0).palletNo() + "-A4托盘信息单.pdf"
: inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf"; : inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf";
PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest( 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); fileName, documents);
try { try {
return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request)); return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
@@ -140,20 +148,42 @@ public class InboundCartonLabelPrintService {
} }
} }
private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) { private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) { Pallet pallet, Map<Long, String> customerNicknameCache) {
if (pallet == null || pallet.palletId() == null || !StringUtils.hasText(pallet.palletNo())) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
} }
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode()) List<DropshipPalletizationMapper.PrintInboundRow> inboundFacts =
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/")); palletizationMapper.printInboundRows(pallet.palletId());
if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode; if (inboundFacts == null || inboundFacts.isEmpty()) {
List<PrintPdfClient.DropshipPalletSkuRow> skuRows = pallet.items().stream() throw new BizException(ResultCode.CONFLICT, "托盘未关联入仓单,不能打印");
.map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(), }
item.cargoName(), number(item.cartonCount()), number(item.skuQuantity()))) List<PrintPdfClient.DropshipPalletInboundRow> inboundRows = inboundFacts.stream()
.map(row -> new PrintPdfClient.DropshipPalletInboundRow(
row.getInboundCode(),
customerNicknameCache.computeIfAbsent(row.getTenantId(), customerSnapshots::customerNickname),
number(row.getCartonCount()), number(row.getItemQuantity())))
.toList(); .toList();
String destinationWarehouse = inboundFacts.stream()
.map(DropshipPalletizationMapper.PrintInboundRow::getDestinationWarehouse)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(" / "));
if (!StringUtils.hasText(destinationWarehouse)) destinationWarehouse = "—";
List<PrintPdfClient.DropshipPalletSkuRow> 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( return new PrintPdfClient.DropshipPalletDocument(
pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()), pallet.palletNo(), destinationWarehouse,
pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows); 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) { private int number(Integer value) {
@@ -68,7 +68,7 @@ paths:
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
get: get:
summary: PDA 获取预生成托盘码的 A4 托盘信息单 summary: PDA 获取预生成托盘码的 A4 托盘信息单
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、入仓码、箱数以及已归托 SKU、商品名称和实点数量,不展示作业状态。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。 description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、目的仓、托盘序号、入仓码/客户昵称/箱数/件数汇总以及 SKU/箱数/件数/每箱件数汇总,不展示作业状态、重量或体积。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
x-required-authority: platform:firstleg:pda:view x-required-authority: platform:firstleg:pda:view
parameters: parameters:
- {$ref: '#/components/parameters/TaskId'} - {$ref: '#/components/parameters/TaskId'}
@@ -91,7 +91,7 @@ paths:
responses: {'200': {description: OK}} responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
get: get:
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单 summary: 获取包含托盘码、目的仓、入仓码和 SKU 汇总的 A4 托盘信息单
x-required-authority: platform:firstleg:inbound:list x-required-authority: platform:firstleg:inbound:list
responses: {'200': {description: PDF}} responses: {'200': {description: PDF}}
components: components:
@@ -32,7 +32,7 @@ class IamCustomerSnapshotServiceTest {
@Test @Test
void resolvesExactlyOneActiveCustomerAndUsesServerName() { void resolvesExactlyOneActiveCustomerAndUsesServerName() {
var customer = new IamCustomerSnapshotClient.Customer( 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)) when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
@@ -50,10 +50,20 @@ class IamCustomerSnapshotServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用"); .isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用");
var customer = new IamCustomerSnapshotClient.Customer( 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)) when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
assertThatThrownBy(() -> service.active(7L, "Forged Customer")) assertThatThrownBy(() -> service.active(7L, "Forged Customer"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致"); .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");
}
} }
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -29,17 +30,22 @@ import static org.mockito.Mockito.when;
class InboundCartonLabelPrintServiceTest { class InboundCartonLabelPrintServiceTest {
private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class); private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class);
private final PrintPdfClient print = mock(PrintPdfClient.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; private InboundCartonLabelPrintService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper()); service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "internal-token"); ReflectionTestUtils.setField(service, "internalToken", "internal-token");
ReflectionTestUtils.setField(service, "palletizationMapper", palletization);
ReflectionTestUtils.setField(service, "customerSnapshots", customerSnapshots);
} }
@Test @Test
void dropshipPalletLabelIsAvailableBeforeSeal() { void dropshipPalletLabelIsAvailableBeforeSeal() {
Pallet open = dropshipPallet("OPEN"); Pallet open = dropshipPallet("OPEN");
stubPalletFacts(90001L, 1, 2);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
@@ -54,17 +60,25 @@ class InboundCartonLabelPrintServiceTest {
ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class); ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class);
verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"), verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq("request-open"), request.capture()); 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().widthMm()).isEqualByComparingTo("210");
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("297"); assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("297");
assertThat(request.getValue().documents()).singleElement().satisfies(document -> { assertThat(request.getValue().documents()).singleElement().satisfies(document -> {
assertThat(document.palletNo()).isEqualTo("DP-260911-0001"); 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(document.skuRows()).singleElement().satisfies(row -> {
assertThat(row.skuCode()).isEqualTo("SKU-1"); assertThat(row.skuCode()).isEqualTo("SKU-1");
assertThat(row.cargoName()).isEqualTo("音箱");
assertThat(row.cartonCount()).isEqualTo(1); 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 first = dropshipPallet("OPEN");
Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0, Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0,
BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null); BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null);
stubPalletFacts(90001L, 1, 2);
stubPalletFacts(90002L, 2, 2);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
@@ -225,4 +241,27 @@ class InboundCartonLabelPrintServiceTest {
return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0, return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0,
BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null); 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("小虫科技");
}
} }
+1
View File
@@ -16,6 +16,7 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency> <dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency> <dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency> <dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>com.belerweb</groupId><artifactId>pinyin4j</artifactId><version>2.5.1</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency> <dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency> <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
@@ -11,7 +11,7 @@ public final class CustomerExportDtos {
public record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { } public record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
public record CustomerExportRow( 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, String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
String contactName, String contactPhone, String contactEmail, String contactWechat, String contactName, String contactPhone, String contactEmail, String contactWechat,
@@ -24,6 +24,7 @@ public final class TenantDtos {
@Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料") @Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料")
public record RegisterRequest( public record RegisterRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, @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 entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative, @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, @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") @Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile")
public record UpdateTenantRequest( public record UpdateTenantRequest(
@NotBlank String tenantName, @NotBlank String tenantName,
@Schema(description = "Customer nickname used by non-legal displays and printing / 非法定展示及打印使用的客户昵称") @NotBlank @Size(max = 32) String customerNickname,
@NotBlank String legalName, @NotBlank String legalName,
@Size(max = 64) String legalRepresentative, @Size(max = 64) String legalRepresentative,
@NotBlank String contactName, @NotBlank String contactName,
@@ -134,7 +136,7 @@ public final class TenantDtos {
) { } ) { }
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") @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 dimensionInitializationStatus,
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
String contactPhone, String contactEmail, String contactWechat, String contactPhone, String contactEmail, String contactWechat,
@@ -144,6 +146,6 @@ public final class TenantDtos {
String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { }
/** Full customer contact summary for trusted internal finance read models. */ /** 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) { } String contactName, String contactPhone) { }
} }
@@ -20,6 +20,7 @@ public class IamTenant extends BaseEntity {
@TableId private Long id; @TableId private Long id;
private String tenantCode; private String tenantCode;
private String tenantName; private String tenantName;
private String customerNickname;
private String tenantType; private String tenantType;
private String status; private String status;
private String dimensionInitializationStatus; private String dimensionInitializationStatus;
@@ -37,6 +37,7 @@ public class CustomerListExportProviderService {
column("id", "客户ID", ExportColumnType.STRING, 20), column("id", "客户ID", ExportColumnType.STRING, 20),
column("tenantCode", "客户编码", ExportColumnType.STRING, 18), column("tenantCode", "客户编码", ExportColumnType.STRING, 18),
column("tenantName", "公司名称", ExportColumnType.STRING, 28), column("tenantName", "公司名称", ExportColumnType.STRING, 28),
column("customerNickname", "客户昵称", ExportColumnType.STRING, 14),
column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28), column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28),
column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24), column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24),
column("enterpriseType", "企业类型", ExportColumnType.STRING, 16), column("enterpriseType", "企业类型", ExportColumnType.STRING, 16),
@@ -177,6 +178,7 @@ public class CustomerListExportProviderService {
row.put("id", String.valueOf(tenant.getId())); row.put("id", String.valueOf(tenant.getId()));
row.put("tenantCode", tenant.getTenantCode()); row.put("tenantCode", tenant.getTenantCode());
row.put("tenantName", tenant.getTenantName()); row.put("tenantName", tenant.getTenantName());
row.put("customerNickname", tenant.getCustomerNickname());
row.put("legalName", tenant.getLegalName()); row.put("legalName", tenant.getLegalName());
row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode()); row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode());
row.put("enterpriseType", tenant.getEnterpriseType()); row.put("enterpriseType", tenant.getEnterpriseType());
@@ -70,6 +70,7 @@ public class MerchantOnboardingService {
tenant.setId(tenantId); tenant.setId(tenantId);
tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantCode(customerCodes.nextCode());
tenant.setTenantName(request.companyName()); tenant.setTenantName(request.companyName());
tenant.setCustomerNickname(TenantService.resolveCustomerNickname(null, request.companyName()));
tenant.setTenantType("MERCHANT"); tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED"); tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING"); tenant.setDimensionInitializationStatus("PENDING");
@@ -40,6 +40,7 @@ import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import feign.FeignException; import feign.FeignException;
import net.sourceforge.pinyin4j.PinyinHelper;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -170,6 +171,7 @@ public class TenantService {
tenant.setId(IdWorker.getId()); tenant.setId(IdWorker.getId());
tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantCode(customerCodes.nextCode());
tenant.setTenantName(request.tenantName()); tenant.setTenantName(request.tenantName());
tenant.setCustomerNickname(resolveCustomerNickname(request.customerNickname(), request.tenantName()));
tenant.setTenantType("MERCHANT"); tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED"); tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING"); tenant.setDimensionInitializationStatus("PENDING");
@@ -222,7 +224,7 @@ public class TenantService {
public PageResult<FinanceCustomerSummary> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { public PageResult<FinanceCustomerSummary> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) {
PageResult<IamTenant> source = repository.pageFinanceCustomers(page, size, keyword, status, customerId); PageResult<IamTenant> source = repository.pageFinanceCustomers(page, size, keyword, status, customerId);
return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary( 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(), tenant.getContactName(), tenant.getContactPhone())).toList(),
source.page(), source.size(), source.total(), source.pages()); source.page(), source.size(), source.total(), source.pages());
} }
@@ -239,7 +241,7 @@ public class TenantService {
private CustomerExportRow exportRow(IamTenant tenant) { private CustomerExportRow exportRow(IamTenant tenant) {
return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(), 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.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(),
tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(), tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
@@ -390,6 +392,7 @@ public class TenantService {
ensureNotArchived(tenant); ensureNotArchived(tenant);
String contactPhone = normalizeContactPhone(request.contactPhone()); String contactPhone = normalizeContactPhone(request.contactPhone());
tenant.setTenantName(request.tenantName()); tenant.setTenantName(request.tenantName());
tenant.setCustomerNickname(requireCustomerNickname(request.customerNickname()));
tenant.setLegalName(request.legalName()); tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setContactName(request.contactName()); tenant.setContactName(request.contactName());
@@ -489,6 +492,33 @@ public class TenantService {
return value == null || value.isBlank() ? null : value.trim(); 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) { private void ensureAdministratorPhoneAvailable(String phone) {
if (!administratorPhoneAvailable(phone)) { if (!administratorPhoneAvailable(phone)) {
throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用"); throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
@@ -522,7 +552,7 @@ public class TenantService {
private TenantResponse response(IamTenant tenant) { private TenantResponse response(IamTenant tenant) {
return new TenantResponse( return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getStatus(),
tenant.getDimensionInitializationStatus(), tenant.getDimensionInitializationStatus(),
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
@@ -545,7 +575,7 @@ public class TenantService {
private TenantResponse summaryResponse(IamTenant tenant) { private TenantResponse summaryResponse(IamTenant tenant) {
TenantResponse response = response(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.dimensionInitializationStatus(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
@@ -556,7 +586,7 @@ public class TenantService {
private TenantResponse merchantVisibleResponse(IamTenant tenant) { private TenantResponse merchantVisibleResponse(IamTenant tenant) {
TenantResponse response = response(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.dimensionInitializationStatus(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
+1
View File
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
customer_nickname VARCHAR(32) NOT NULL COMMENT '客户昵称:非法定展示及打印使用,创建时默认取公司名称中文拼音大写首字母',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', 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已归档', 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失败', dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败',
+7 -3
View File
@@ -1306,11 +1306,12 @@ components:
pages: {type: integer, minimum: 0} pages: {type: integer, minimum: 0}
TenantResponse: TenantResponse:
type: object type: object
required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] required: [id, tenantCode, tenantName, customerNickname, status, dimensionInitializationStatus, servicePackage]
properties: properties:
id: {type: string, pattern: '^\d+$', example: '190000000000000001'} id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
tenantName: {type: string} tenantName: {type: string}
customerNickname: {type: string, maxLength: 32, description: 客户昵称;非法定展示及打印使用}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
legalName: {type: string} legalName: {type: string}
@@ -1344,11 +1345,12 @@ components:
updatedAt: {type: string, format: date-time} updatedAt: {type: string, format: date-time}
FinanceCustomerSummary: FinanceCustomerSummary:
type: object type: object
required: [id, tenantCode, tenantName, status] required: [id, tenantCode, tenantName, customerNickname, status]
properties: properties:
id: {type: string, pattern: '^\d+$'} id: {type: string, pattern: '^\d+$'}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
tenantName: {type: string} tenantName: {type: string}
customerNickname: {type: string, maxLength: 32, description: 非法定展示及打印使用的客户昵称}
status: {type: string, enum: [ACTIVE, DISABLED]} status: {type: string, enum: [ACTIVE, DISABLED]}
contactName: {type: string, nullable: true} contactName: {type: string, nullable: true}
contactPhone: {type: string, nullable: true} contactPhone: {type: string, nullable: true}
@@ -1459,6 +1461,7 @@ components:
required: [tenantName, legalName, contactName, contactPhone, servicePackage] required: [tenantName, legalName, contactName, contactPhone, servicePackage]
properties: properties:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司} tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
customerNickname: {type: string, maxLength: 32, example: SZWL, description: 选填;不填时默认取公司名称各中文字符的拼音大写首字母,没有中文时取首个非空字符并转大写}
legalName: {type: string, example: 深圳虫虫出海科技有限公司} legalName: {type: string, example: 深圳虫虫出海科技有限公司}
legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填} legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填}
contactName: {type: string, example: 李四} contactName: {type: string, example: 李四}
@@ -1486,9 +1489,10 @@ components:
contactEmail: {type: string, format: email} contactEmail: {type: string, format: email}
UpdateTenantRequest: UpdateTenantRequest:
type: object type: object
required: [tenantName, legalName, contactName, contactPhone] required: [tenantName, customerNickname, legalName, contactName, contactPhone]
properties: properties:
tenantName: {type: string} tenantName: {type: string}
customerNickname: {type: string, maxLength: 32, description: 客户昵称;非法定展示及打印使用}
legalName: {type: string} legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填} legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
contactName: {type: string} contactName: {type: string}
@@ -60,6 +60,8 @@ class CustomerProfileMigrationContractTest {
String instructions = Files.readString(readme, StandardCharsets.UTF_8); String instructions = Files.readString(readme, StandardCharsets.UTF_8);
assertThat(sql) 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("ALTER TABLE iam_tenant MODIFY COLUMN status")
.contains("DEFAULT 'PROFILE_REQUIRED'") .contains("DEFAULT 'PROFILE_REQUIRED'")
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'") .contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'")
@@ -50,13 +50,14 @@ class CustomerListExportProviderServiceTest {
assertEquals(1, response.schemaVersion()); assertEquals(1, response.schemaVersion());
assertThat(response.schemaHash()).matches("sha256:[0-9a-f]{64}"); assertThat(response.schemaHash()).matches("sha256:[0-9a-f]{64}");
assertThat(response.snapshotToken()).matches("v1\\.103\\.[A-Za-z0-9_-]+"); 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("tenantCode", response.columns().get(1).key());
assertEquals(ExportColumnType.DECIMAL, assertEquals(ExportColumnType.DECIMAL,
response.columns().stream().filter(column -> "registeredCapital".equals(column.key())) response.columns().stream().filter(column -> "registeredCapital".equals(column.key()))
.findFirst().orElseThrow().type()); .findFirst().orElseThrow().type());
assertEquals(2, response.rows().size()); assertEquals(2, response.rows().size());
assertEquals("101", response.rows().get(0).get("id")); 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(new BigDecimal("1000000.00"), response.rows().get(0).get("registeredCapital"));
assertEquals("启用", response.rows().get(0).get("status")); assertEquals("启用", response.rows().get(0).get("status"));
assertEquals("102", response.nextCursor()); assertEquals("102", response.nextCursor());
@@ -142,6 +143,7 @@ class CustomerListExportProviderServiceTest {
tenant.setId(id); tenant.setId(id);
tenant.setTenantCode(String.format("%05d", id)); tenant.setTenantCode(String.format("%05d", id));
tenant.setTenantName("柏悦物流" + id); tenant.setTenantName("柏悦物流" + id);
tenant.setCustomerNickname("柏");
tenant.setLegalName("柏悦物流有限公司"); tenant.setLegalName("柏悦物流有限公司");
tenant.setUnifiedSocialCreditCode("91440000123456789X"); tenant.setUnifiedSocialCreditCode("91440000123456789X");
tenant.setEnterpriseType("有限责任公司"); tenant.setEnterpriseType("有限责任公司");
@@ -160,7 +160,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(tenant); when(repository.findTenant(10001L)).thenReturn(tenant);
var updated = service.updateMerchant(10001L, 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), "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2),
new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户"));
var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"));
@@ -187,7 +187,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(tenant); when(repository.findTenant(10001L)).thenReturn(tenant);
var updated = service.updateMerchant(10001L, 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)); "基础版", null, null, 30, null, null, null, "CNY", null, null));
assertEquals("13900139000", updated.contactPhone()); assertEquals("13900139000", updated.contactPhone());
@@ -282,7 +282,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(archived); when(repository.findTenant(10001L)).thenReturn(archived);
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, 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))); "基础版", null, null, 30, null, null, null, "CNY", null, null)));
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
@@ -317,7 +317,7 @@ class TenantServiceTest {
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest( 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", "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY",
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
9001L); 9001L);
@@ -325,6 +325,7 @@ class TenantServiceTest {
assertEquals("PROFILE_REQUIRED", created.tenant().status()); assertEquals("PROFILE_REQUIRED", created.tenant().status());
assertEquals("00001", created.tenant().tenantCode()); assertEquals("00001", created.tenant().tenantCode());
assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
assertEquals("E", created.tenant().customerNickname());
assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.tenant().servicePackage()); assertEquals("基础版", created.tenant().servicePackage());
assertEquals("merchant_81", created.administratorUsername()); assertEquals("merchant_81", created.administratorUsername());
@@ -337,13 +338,21 @@ class TenantServiceTest {
verify(repository, never()).insertReview(any()); 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 @Test
void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() {
doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
.when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class));
BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( 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, "alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L)); null, null, "CNY", null, null), 9001L));
@@ -357,7 +366,7 @@ class TenantServiceTest {
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); .when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest( 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, "alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L); null, null, "CNY", null, null), 9001L);
@@ -524,6 +533,7 @@ class TenantServiceTest {
tenant.setStatus(status); tenant.setStatus(status);
tenant.setDimensionInitializationStatus("READY"); tenant.setDimensionInitializationStatus("READY");
tenant.setTenantName("Example Merchant"); tenant.setTenantName("Example Merchant");
tenant.setCustomerNickname("E");
tenant.setLegalName("Example Merchant Ltd"); tenant.setLegalName("Example Merchant Ltd");
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
tenant.setContactName("Alice"); tenant.setContactName("Alice");
+1 -1
View File
@@ -4,7 +4,7 @@
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。 - 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。 - 不保存模板、任务、历史或文件,不连接数据库、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 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。
## 快速索引 ## 快速索引
+1 -1
View File
@@ -8,7 +8,7 @@
- 服务发现名:`print-service`。 - 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。 - 内部地址:`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`。 - 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 纵向合并显示。 - 海外仓排车 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`。 - 本地直连:`http://127.0.0.1:9207`。
Binary file not shown.
@@ -5,7 +5,7 @@ import java.util.List;
/** 一件代发托盘 A4 信息单的内部渲染契约。 */ /** 一件代发托盘 A4 信息单的内部渲染契约。 */
public final class DropshipPalletA4PdfDtos { public final class DropshipPalletA4PdfDtos {
public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V1"; public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V2";
private DropshipPalletA4PdfDtos() {} private DropshipPalletA4PdfDtos() {}
@@ -14,10 +14,14 @@ public final class DropshipPalletA4PdfDtos {
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {} public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
public record Document(String palletNo, String inboundCodes, int cartonCount, public record Document(String palletNo, String destinationWarehouse,
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm, int palletSequence, int totalPallets,
String barcodeContent, List<SkuRow> skuRows) {} String barcodeContent, List<InboundRow> inboundRows,
List<SkuRow> skuRows) {}
public record SkuRow(String inboundCode, String skuCode, String cargoName, public record InboundRow(String inboundCode, String customerNickname,
int cartonCount, int skuQuantity) {} int cartonCount, int itemQuantity) {}
public record SkuRow(String skuCode, int cartonCount,
int itemQuantity, String unitsPerCarton) {}
} }
@@ -13,17 +13,18 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** 每个托盘一页 A4,Code 128 托盘码与完整 SKU 归托信息同页展示。 */ /** 每个托盘一页 A4:托盘码、目的仓、入仓码汇总和 SKU 汇总。 */
@Component @Component
public class DropshipPalletA4Renderer { public class DropshipPalletA4Renderer {
private static final float[] COLS = {8, 18, 57, 98, 161, 181, 202}; private static final Color BLUE = new Color(20, 89, 156);
private static final String[] HEADERS = {"序号", "入仓码", "SKU", "商品名称", "箱数", "实点数量"}; private static final Color LIGHT_BLUE = new Color(225, 239, 251);
private static final double TABLE_TOP = 105; private static final Color TABLE_HEADER = new Color(238, 242, 247);
private static final double HEADER_HEIGHT = 8; private static final Color RED = new Color(180, 43, 36);
private static final double REGULAR_ROW_HEIGHT = 7; private static final float[] INBOUND_COLS = {18, 27, 79, 146, 169, 192};
private static final double DENSE_ROW_HEIGHT = 5.2; private static final String[] INBOUND_HEADERS = {"#", "入仓码", "客户", "箱数", "件数"};
private static final float BARCODE_WIDTH_PT = PdfUnits.mmToPt(158); private static final float[] SKU_COLS = {18, 132, 154, 176, 192};
private static final float BARCODE_HEIGHT_PT = PdfUnits.mmToPt(22); private static final String[] SKU_HEADERS = {"SKU", "箱数", "件数", "每箱件数"};
@Resource private PdfBarcodeRenderer barcodeRenderer; @Resource private PdfBarcodeRenderer barcodeRenderer;
public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data, public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data,
@@ -32,93 +33,144 @@ public class DropshipPalletA4Renderer {
pdf.addPage(page); pdf.addPage(page);
PdfUnits units = new PdfUnits(297); PdfUnits units = new PdfUnits(297);
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) { try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
stream.setNonStrokingColor(Color.BLACK); stream.setLineWidth(PdfUnits.mmToPt(.25));
stream.setLineWidth(PdfUnits.mmToPt(.3)); border(stream, units);
centered(stream, units, font, "一件代发托盘信息单", 15, 105, 13); colorText(stream, units, font, "一件代发备货托", 14, 18, 22, BLUE);
centered(stream, units, font, data.palletNo(), 22, 105, 25); rightText(stream, units, font,
barcode(stream, units, barcodeMatrix); "第 " + data.palletSequence() + " 托 / 共 " + data.totalPallets() + " 托",
fill(stream, units, new Color(245, 247, 250), 8, 58, 202, 94); 10, 192, 22, new Color(75, 75, 75));
inboundCodes(stream, units, font, data.inboundCodes());
line(stream, units, 8, 78, 202, 78); barcodeRenderer.draw(stream, barcodeMatrix, PdfUnits.mmToPt(18), units.topDownMmToPdfY(51),
metric(stream, units, font, "总箱数", Integer.toString(data.cartonCount()), 8, 56.5); PdfUnits.mmToPt(80), PdfUnits.mmToPt(22));
metric(stream, units, font, "SKU 数", Integer.toString(data.skuCount()), 56.5, 105); fittedText(stream, units, font, data.palletNo(), 25, 105, 44, 87, Color.BLACK);
metric(stream, units, font, "重量", decimal(data.weightKg()) + " kg", 105, 153.5);
metric(stream, units, font, "体积", decimal(data.volumeCbm()) + " m³", 153.5, 202); fill(stream, units, LIGHT_BLUE, 10, 57, 200, 82);
line(stream, units, 56.5, 78, 56.5, 94); colorText(stream, units, font, "目的仓", 7.5f, 18, 66, BLUE);
line(stream, units, 105, 78, 105, 94); fittedText(stream, units, font, data.destinationWarehouse(), 20, 18, 77, 174, BLUE);
line(stream, units, 153.5, 78, 153.5, 94);
line(stream, units, 8, 99, 202, 99); int rowCount = data.inboundRows().size() + 1 + Math.max(1, data.skuRows().size());
text(stream, units, font, "SKU 明细", 11, 8, 104); double rowHeight = rowCount > 36 ? 2.9 : rowCount > 24 ? 3.6 : 6.0;
table(stream, units, font, data.skuRows()); 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 { private double inboundTable(PDPageContentStream s, PdfUnits u, PDFont f,
float left = PdfUnits.mmToPt(26); List<DropshipPalletA4PdfDtos.InboundRow> rows,
float bottom = units.topDownMmToPdfY(54); int totalCartons, int totalItems, double top,
barcodeRenderer.draw(stream, matrix, left, bottom, BARCODE_WIDTH_PT, BARCODE_HEIGHT_PT); 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, private void skuTable(PDPageContentStream s, PdfUnits u, PDFont f,
List<DropshipPalletA4PdfDtos.SkuRow> rows) throws IOException { List<DropshipPalletA4PdfDtos.SkuRow> rows, double top,
double rowHeight, float rowFont) throws IOException {
double headerHeight = 7;
int visibleRows = Math.max(1, rows.size()); int visibleRows = Math.max(1, rows.size());
double rowHeight = rows.size() > 24 ? DENSE_ROW_HEIGHT : REGULAR_ROW_HEIGHT; double bottom = top + headerHeight + visibleRows * rowHeight;
float rowFontSize = rows.size() > 24 ? 5.3f : 6.4f; tableGrid(s, u, SKU_COLS, top, bottom, headerHeight, visibleRows, rowHeight);
double headerBottom = TABLE_TOP + HEADER_HEIGHT; tableHeaders(s, u, f, SKU_COLS, SKU_HEADERS, top, headerHeight);
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);
}
if (rows.isEmpty()) { 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; return;
} }
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { for (int index = 0; index < rows.size(); index++) {
DropshipPalletA4PdfDtos.SkuRow row = rows.get(rowIndex); var row = rows.get(index);
String[] values = {Integer.toString(rowIndex + 1), row.inboundCode(), row.skuCode(), row.cargoName(), String[] values = {row.skuCode(), Integer.toString(row.cartonCount()),
Integer.toString(row.cartonCount()), Integer.toString(row.skuQuantity())}; Integer.toString(row.itemQuantity()), row.unitsPerCarton()};
double baseline = headerBottom + rowIndex * rowHeight + rowHeight * .7; tableRow(s, u, f, SKU_COLS, values, top + headerHeight + index * rowHeight,
for (int column = 0; column < values.length; column++) { rowHeight, rowFont);
centeredCell(stream, units, font, values[column], rowFontSize,
COLS[column] + .7, COLS[column + 1] - .7, baseline);
}
} }
} }
private String decimal(java.math.BigDecimal value) { private void tableGrid(PDPageContentStream s, PdfUnits u, float[] columns, double top,
return value == null ? "0" : value.stripTrailingZeros().toPlainString(); double bottom, double headerHeight, int rows, double rowHeight) throws IOException {
} fill(s, u, TABLE_HEADER, columns[0], top, columns[columns.length - 1], top + headerHeight);
private void inboundCodes(PDPageContentStream s, PdfUnits u, PDFont f, String value) throws IOException { for (float column : columns) line(s, u, column, top, column, bottom);
text(s, u, f, "入仓码:", 8, 14, 66); line(s, u, columns[0], top, columns[columns.length - 1], top);
List<String> lines = wrap(f, value, 8, PdfUnits.mmToPt(163), 2); line(s, u, columns[0], top + headerHeight, columns[columns.length - 1], top + headerHeight);
for (int index = 0; index < lines.size(); index++) { for (int index = 1; index <= rows; index++) {
text(s, u, f, lines.get(index), 8, 33, 66 + index * 6); 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 { private void tableHeaders(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns,
centeredCell(s, u, f, label, 7.2f, x1 + 1, x2 - 1, 84); String[] headers, double top, double headerHeight) throws IOException {
centeredCell(s, u, f, value, 10.5f, x1 + 1, x2 - 1, 91); 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 { private void tableRow(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns,
float width = f.getStringWidth(value) / 1000 * size; String[] values, double top, double height, float size) throws IOException {
text(s, u, f, value, size, centerX - width / PdfUnits.mmToPt(1) / 2, y); 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, private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
double x1, double x2, double y) throws IOException { double x1, double x2, double y) throws IOException {
String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1)); String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1));
float width = f.getStringWidth(fitted) / 1000 * size; float width = f.getStringWidth(fitted) / 1000 * size;
text(s, u, f, fitted, size, x1 + (x2 - x1 - width / PdfUnits.mmToPt(1)) / 2, y); 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 { private String fit(PDFont font, String raw, float size, float maxWidth) throws IOException {
String value = raw == null || raw.isBlank() ? "—" : raw; String value = raw == null || raw.isBlank() ? "—" : raw;
if (font.getStringWidth(value) / 1000 * size <= maxWidth) return value; 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--; while (end > 0 && font.getStringWidth(value.substring(0, end) + ellipsis) / 1000 * size > maxWidth) end--;
return end == 0 ? ellipsis : value.substring(0, end) + ellipsis; return end == 0 ? ellipsis : value.substring(0, end) + ellipsis;
} }
private List<String> wrap(PDFont font, String raw, float size, float maxWidth,
int maxLines) throws IOException {
String remaining = raw == null || raw.isBlank() ? "—" : raw;
List<String> 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, private void fill(PDPageContentStream s, PdfUnits u, Color color, double x1, double y1,
double x2, double y2) throws IOException { double x2, double y2) throws IOException {
s.setNonStrokingColor(color); s.setNonStrokingColor(color);
@@ -154,14 +187,27 @@ public class DropshipPalletA4Renderer {
s.fill(); s.fill();
s.setNonStrokingColor(Color.BLACK); 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, private void text(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
double x, double y) throws IOException { double x, double y) throws IOException {
s.beginText(); s.setFont(f, size); s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y)); s.beginText();
s.showText(value == null ? "—" : value); s.endText(); 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, private void line(PDPageContentStream s, PdfUnits u, double x1, double y1, double x2,
double y2) throws IOException { double y2) throws IOException {
s.moveTo(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y1)); 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();
} }
} }
@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
public class DropshipPalletA4PdfService { public class DropshipPalletA4PdfService {
private static final BigDecimal A4_WIDTH = new BigDecimal("210"); private static final BigDecimal A4_WIDTH = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT = new BigDecimal("297"); 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 int MAX_SKU_ROWS = 34;
private static final Set<Character> FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|'); private static final Set<Character> FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
@@ -67,21 +68,28 @@ public class DropshipPalletA4PdfService {
private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) { private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) {
if (value == null) invalid("documents[" + index + "] is required"); 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"); if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
List<DropshipPalletA4PdfDtos.InboundRow> 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<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>(); List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) { for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) {
if (row == null || row.cartonCount() < 0 || row.skuQuantity() < 0) invalid("SKU row is invalid"); if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("SKU row is invalid");
rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.inboundCode(), 80), text(row.skuCode(), 128), rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.skuCode(), 128), row.cartonCount(),
text(row.cargoName(), 200), row.cartonCount(), row.skuQuantity())); row.itemQuantity(), text(row.unitsPerCarton(), 30)));
} }
return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), text(value.inboundCodes(), 300), return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80),
value.cartonCount(), value.skuCount(), decimal(value.weightKg()), decimal(value.volumeCbm()), text(value.destinationWarehouse(), 200), value.palletSequence(), value.totalPallets(),
barcode(value.barcodeContent()), List.copyOf(rows)); 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) { private String barcode(String value) {
String normalized = text(value, 80); String normalized = text(value, 80);
if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only"); if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only");
@@ -279,7 +279,7 @@ components:
type: object type: object
required: [templateCode, pageSpec, downloadFileName, documents] required: [templateCode, pageSpec, downloadFileName, documents]
properties: properties:
templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] } templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V2] }
pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' } pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
downloadFileName: { type: string, maxLength: 120 } downloadFileName: { type: string, maxLength: 120 }
documents: documents:
@@ -290,33 +290,43 @@ components:
items: { $ref: '#/components/schemas/DropshipPalletA4Document' } items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
DropshipPalletA4Document: DropshipPalletA4Document:
type: object type: object
required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, skuRows] required: [palletNo, destinationWarehouse, palletSequence, totalPallets, barcodeContent, inboundRows, skuRows]
properties: properties:
palletNo: { type: string, maxLength: 80 } palletNo: { type: string, maxLength: 80 }
inboundCodes: { type: string, maxLength: 300 } destinationWarehouse: { type: string, maxLength: 200 }
cartonCount: { type: integer, minimum: 0 } palletSequence: { type: integer, minimum: 1 }
skuCount: { type: integer, minimum: 0 } totalPallets: { type: integer, minimum: 1 }
weightKg: { type: number, minimum: 0 }
volumeCbm: { type: number, minimum: 0 }
barcodeContent: barcodeContent:
type: string type: string
maxLength: 80 maxLength: 80
pattern: '^[\\x20-\\x7E]+$' pattern: '^[\\x20-\\x7E]+$'
description: Code 128 一维码内容,当前固定为稳定托盘码 description: Code 128 一维码内容,当前固定为稳定托盘码
inboundRows:
type: array
minItems: 1
maxItems: 10
items: { $ref: '#/components/schemas/DropshipPalletInboundRow' }
skuRows: skuRows:
type: array type: array
minItems: 0 minItems: 0
maxItems: 34 maxItems: 34
items: { $ref: '#/components/schemas/DropshipPalletSkuRow' } items: { $ref: '#/components/schemas/DropshipPalletSkuRow' }
DropshipPalletSkuRow: DropshipPalletInboundRow:
type: object type: object
required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity] required: [inboundCode, customerNickname, cartonCount, itemQuantity]
properties: properties:
inboundCode: { type: string, maxLength: 80 } inboundCode: { type: string, maxLength: 80 }
skuCode: { type: string, maxLength: 128 } customerNickname: { type: string, maxLength: 80 }
cargoName: { type: string, maxLength: 200 }
cartonCount: { type: integer, minimum: 0 } 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: FreightA4PageSpec:
type: object type: object
required: [widthMm, heightMm] required: [widthMm, heightMm]
@@ -216,7 +216,7 @@ class InternalPdfControllerTest {
.contains("name: X-Internal-Service-Token") .contains("name: X-Internal-Service-Token")
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:") .contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:") .contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
.contains("DROPSHIP_PALLET_A4_V1") .contains("DROPSHIP_PALLET_A4_V2")
.contains("additionalProperties: false") .contains("additionalProperties: false")
.contains("minItems: 1") .contains("minItems: 1")
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]") .contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
@@ -268,23 +268,26 @@ class InternalPdfControllerTest {
private String validDropshipPalletA4RequestJson() { private String validDropshipPalletA4RequestJson() {
return """ return """
{ {
"templateCode": "DROPSHIP_PALLET_A4_V1", "templateCode": "DROPSHIP_PALLET_A4_V2",
"pageSpec": {"widthMm": 210, "heightMm": 297}, "pageSpec": {"widthMm": 210, "heightMm": 297},
"downloadFileName": "DP-260911-0034-A4托盘信息单.pdf", "downloadFileName": "DP-260911-0034-A4托盘信息单.pdf",
"documents": [{ "documents": [{
"palletNo": "DP-260911-0034", "palletNo": "DP-260911-0034",
"inboundCodes": "NYEXX2609110249", "destinationWarehouse": "纽约 N.JDF",
"cartonCount": 3, "palletSequence": 1,
"skuCount": 1, "totalPallets": 3,
"weightKg": 90,
"volumeCbm": 0.534,
"barcodeContent": "DP-260911-0034", "barcodeContent": "DP-260911-0034",
"skuRows": [{ "inboundRows": [{
"inboundCode": "NYEXX2609110249", "inboundCode": "NYEXX2609110249",
"skuCode": "SKU-001", "customerNickname": "小虫科技",
"cargoName": "蓝牙音箱",
"cartonCount": 3, "cartonCount": 3,
"skuQuantity": 90 "itemQuantity": 90
}],
"skuRows": [{
"skuCode": "SKU-001",
"cartonCount": 3,
"itemQuantity": 90,
"unitsPerCarton": "30"
}] }]
}] }]
} }
@@ -27,11 +27,12 @@ class DropshipPalletA4PdfServiceGenerationTest {
@Test @Test
void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception { void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception {
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document( DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
"DP-260911-0034", "NYEXX2609110249", 3, 2, "DP-260911-0034", "纽约 N.JDF", 1, 3, "DP-260911-0034",
new BigDecimal("90"), new BigDecimal("0.534"), "DP-260911-0034", List.of(new DropshipPalletA4PdfDtos.InboundRow(
"NYEXX2609110249", "小虫科技", 3, 90)),
List.of( List.of(
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40), new DropshipPalletA4PdfDtos.SkuRow("SKU-001", 2, 40, "20"),
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50))); new DropshipPalletA4PdfDtos.SkuRow("SKU-002", 1, 50, "50")));
var generated = service.generate(new DropshipPalletA4PdfDtos.Request( var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
DropshipPalletA4PdfDtos.TEMPLATE, DropshipPalletA4PdfDtos.TEMPLATE,
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), 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().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(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
assertThat(new PDFTextStripper().getText(pdf)) 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") .containsOnlyOnce("DP-260911-0034")
.doesNotContain("状态", "待封托", "已封托"); .doesNotContain("状态", "待封托", "已封托", "重量", "体积");
} }
} }
@Test @Test
void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception { void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception {
List<String> inboundCodes = new ArrayList<>(); List<DropshipPalletA4PdfDtos.InboundRow> inboundRows = new ArrayList<>();
for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index)); for (int index = 1; index <= 10; index++) {
inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow(
"NYEXX260911" + String.format("%04d", 240 + index), "客户" + index, 2, index * 5));
}
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>(); List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
for (int index = 1; index <= 32; index++) { for (int index = 1; index <= 32; index++) {
rows.add(new DropshipPalletA4PdfDtos.SkuRow(inboundCodes.get((index - 1) % inboundCodes.size()), rows.add(new DropshipPalletA4PdfDtos.SkuRow(
"SKU-" + String.format("%03d", index), "测试商品" + index, 2, index * 5)); "SKU-" + String.format("%03d", index), 2, index * 5, "5-10"));
} }
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document( DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
"DP-260911-0034", String.join("/", inboundCodes), 64, 32, "DP-260911-0034", "纽约 N.JDF", 1, 1,
new BigDecimal("860.5"), new BigDecimal("6.728"), "DP-260911-0034", rows); "DP-260911-0034", inboundRows, rows);
var generated = service.generate(new DropshipPalletA4PdfDtos.Request( var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
DropshipPalletA4PdfDtos.TEMPLATE, DropshipPalletA4PdfDtos.TEMPLATE,
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
@@ -77,11 +82,11 @@ class DropshipPalletA4PdfServiceGenerationTest {
void rejectsSkuRowsThatCannotFitOnOneA4Page() { void rejectsSkuRowsThatCannotFitOnOneA4Page() {
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>(); List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
for (int index = 0; index < 35; index++) { 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( DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
"DP-260911-0034", "INBOUND", 35, 35, "DP-260911-0034", "纽约 N.JDF", 1, 1, "DP-260911-0034",
BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows); List.of(new DropshipPalletA4PdfDtos.InboundRow("INBOUND", "客户", 35, 35)), rows);
assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request( assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
DropshipPalletA4PdfDtos.TEMPLATE, DropshipPalletA4PdfDtos.TEMPLATE,