feat(order): derive batch customer context

This commit is contained in:
2026-09-03 18:22:44 +08:00
parent 66ad9f5c85
commit acdc0943c0
4 changed files with 169 additions and 69 deletions
@@ -1,10 +1,12 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "iam-service", url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface TenantEligibilityClient {
@@ -13,5 +15,14 @@ public interface TenantEligibilityClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("tenantId") long tenantId);
@GetMapping("/finance-customers")
ApiResponse<PageResult<CustomerSummary>> customerSummary(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestParam("page") long page,
@RequestParam("size") long size,
@RequestParam("customerId") long customerId);
record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
record CustomerSummary(Long id, String tenantCode, String tenantName, String status,
String contactName, String contactPhone) { }
}
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.OrderBatchFileClient;
import com.baiyue.order.client.MerchantPricingCatalogClient;
import com.baiyue.order.client.MerchantWarehouseCatalogClient;
import com.baiyue.order.client.TenantEligibilityClient;
import com.baiyue.order.dto.CustomerAddressDtos;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.MerchantBatchImportDtos;
@@ -57,24 +58,32 @@ public class MerchantBatchImportService {
@Resource private CustomerAddressService addresses;
@Resource private MerchantWarehouseCatalogClient warehouses;
@Resource private MerchantPricingCatalogClient pricing;
@Resource private TenantEligibilityClient tenantDirectory;
@Resource private ObjectMapper json;
@Value("${baiyue.security.internal-service-token:}") private String token;
private static final List<Column> COLUMNS = List.of(
col("订单基础", "客户行号", "clientRowId", null, true, "每行唯一,用于错误定位", 16),
col("订单基础", "客户名称(企业名称)", "customerName", null, true, "填写当前下单企业的正式名称,系统不会猜测或自动替换", 24),
col("订单基础", "运输方式", "transportMode", "transport", true, "始终填写,从下拉选择", 14),
col("订单基础", "贸易术语", "tradeTerm", null, false, "有约定时填写,如FOB、EXW", 14),
col("订单基础", "预计出运日期", "expectedShipDate", null, false, "有预计日期时填写,格式yyyy-mm-dd", 18),
col("订单基础", "客户备注", "customerRemark", null, false, "有补充说明时填写", 24),
col("目的地与仓库", "目的类型", "destinationType", "destination", true, "始终填写,并与对应目的仓字段保持一致", 16),
col("目的地与仓库", "FBA目的仓", "$fbaWarehouse", "fbaWarehouses", false, "目的类型为亚马逊FBA时必填", 22),
col("目的地与仓库", "海外仓目的仓", "$overseasWarehouse", "overseasWarehouses", false, "目的类型为海外仓时必填", 24),
col("目的地与仓库", "私人目的仓地址", "$privateAddress", "privateAddresses", false, "目的类型为私人地址时必填", 32),
col("国内提货与运输", "提货方式", "pickupMethod", "pickup", true, "始终填写,从下拉选择", 18),
col("国内提货与运输", "预约提货时间", "requestedPickupAt", null, false, "提货方式为平台上门提货时填写,格式yyyy-mm-dd hh:mm:ss", 23),
col("国内提货与运输", "取货地址", "$senderAddress", "pickupAddresses", false, "提货方式为平台上门提货时必填", 32),
col("国内提货与运输", "国内收货仓", "$receivingWarehouse", "receivingWarehouses", true, "始终填写,从启用的国内仓选择", 24),
col("国内提货与运输", "运输渠道", "$channel", "channels", true, "始终填写;导入时校验与国内收货仓的组合", 24),
col("订单基础", "提货方式", "pickupMethod", "pickup", true, "始终填写,从下拉选择", 18),
col("订单基础", "国内收货仓", "$receivingWarehouse", "receivingWarehouses", true, "始终填写,从启用的国内仓选择", 24),
col("订单基础", "运输渠道", "$channel", "channels", true, "始终填写;导入时校验与国内收货仓的组合", 24),
col("目的地与预约", "目的类型", "destinationType", "destination", true, "始终填写,并与对应目的仓字段保持一致", 16),
col("目的地与预约", "FBA目的仓", "$fbaWarehouse", "fbaWarehouses", false, "目的类型为亚马逊FBA时必填", 22),
col("目的地与预约", "海外仓目的仓", "$overseasWarehouse", "overseasWarehouses", false, "目的类型为海外仓时必填", 24),
col("目的地与预约", "私人目的仓地址", "$privateAddress", "privateAddresses", false, "目的类型为私人地址时必填", 32),
col("目的地与预约", "预约提货时间", "requestedPickupAt", null, false, "提货方式为平台上门提货时填写,格式yyyy-mm-dd hh:mm:ss", 23),
col("货物与包装", "货物名称", "cargo.cargoName", null, true, "填写本行订单的货物名称", 22),
col("货物与包装", "包装类型", "cargo.packageType", "package", true, "始终填写,从下拉选择", 16),
col("货物与包装", "包装数量", "cargo.packageCount", null, true, "正整数", 14),
col("货物与包装", "毛重(千克)", "cargo.grossWeightKg", null, true, "填写大于0的数字", 16),
col("货物与包装", "体积(立方米)", "cargo.volumeCbm", null, true, "填写大于0的数字", 18),
col("货物与包装", "材质", "cargo.material", null, false, "按实际材质填写", 18),
col("货物与包装", "是否木制品", "cargo.woodenProduct", "boolean", true, "始终填写", 16),
col("货物与包装", "是否危险品", "cargo.dangerousGoods", "boolean", true, "始终填写", 16),
col("货物与包装", "SKU", "cargo.skuNo", null, false, "有SKU时填写", 16),
col("货物与包装", "UPC", "cargo.upcNo", null, false, "有UPC时填写", 16),
col("货物与包装", "货物货件编号", "cargo.shipmentId", null, false, "货物级货件编号,有则填写", 20),
col("货物与包装", "货物参考编号", "cargo.referenceId", null, false, "货物级参考编号,有则填写", 20),
col("报关与计价", "报关方式", "taxMode", "tax", true, "始终填写", 16),
col("报关与计价", "计价税模式", "pricingTaxMode", "pricingTax", true, "始终填写", 16),
col("报关与计价", "装箱方式", "containerMode", "container", false, "已确定整柜或散货拼箱时填写", 16),
@@ -82,21 +91,11 @@ public class MerchantBatchImportService {
col("FBA业务信息", "货件编号", "shipmentId", null, false, "FBA业务有货件编号时填写", 18),
col("FBA业务信息", "参考编号", "referenceId", null, false, "FBA业务有参考编号时填写", 18),
col("FBA业务信息", "履约类型", "fulfillmentType", "fulfillment", false, "未填写时按普通头程处理", 16),
col("货物信息", "货物名称", "cargo.cargoName", null, true, "填写本行订单的货物名称", 22),
col("货物信息", "包装类型", "cargo.packageType", "package", true, "始终填写,从下拉选择", 16),
col("货物信息", "包装数量", "cargo.packageCount", null, true, "正整数", 14),
col("货物信息", "毛重(千克)", "cargo.grossWeightKg", null, true, "填写大于0的数字", 16),
col("货物信息", "体积(立方米)", "cargo.volumeCbm", null, true, "填写大于0的数字", 18),
col("货物信息", "材质", "cargo.material", null, false, "按实际材质填写", 18),
col("货物信息", "是否木制品", "cargo.woodenProduct", "boolean", true, "始终填写", 16),
col("货物信息", "是否危险品", "cargo.dangerousGoods", "boolean", true, "始终填写", 16),
col("货物信息", "SKU", "cargo.skuNo", null, false, "有SKU时填写", 16),
col("货物信息", "UPC", "cargo.upcNo", null, false, "有UPC时填写", 16),
col("货物信息", "货物货件编号", "cargo.shipmentId", null, false, "货物级货件编号,有则填写", 20),
col("货物信息", "货物参考编号", "cargo.referenceId", null, false, "货物级参考编号,有则填写", 20));
col("联系人/备注", "取货地址", "$senderAddress", "pickupAddresses", false, "提货方式为平台上门提货时必填", 32),
col("联系人/备注", "客户备注", "customerRemark", null, false, "有补充说明时填写", 24));
private static final int DATA_START_ROW = 2;
private static final int EXPECTED_SHIP_DATE_COLUMN = 4;
private static final int REQUESTED_PICKUP_AT_COLUMN = 11;
private static final int EXPECTED_SHIP_DATE_COLUMN = 1;
private static final int REQUESTED_PICKUP_AT_COLUMN = 9;
private static final ThreadLocal<DataFormatter> DATA_FORMATTER =
ThreadLocal.withInitial(() -> new DataFormatter(Locale.SIMPLIFIED_CHINESE));
private static final DateTimeFormatter DATE = DateTimeFormatter.ISO_LOCAL_DATE;
@@ -184,36 +183,40 @@ public class MerchantBatchImportService {
private static void createHeaders(XSSFWorkbook workbook, org.apache.poi.ss.usermodel.Sheet sheet) {
Font groupFont = workbook.createFont(); groupFont.setFontName("Microsoft YaHei");
groupFont.setBold(true); groupFont.setColor(IndexedColors.WHITE.getIndex());
CellStyle groupStyle = workbook.createCellStyle(); groupStyle.setFont(groupFont);
groupStyle.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex()); groupStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
groupStyle.setAlignment(HorizontalAlignment.CENTER); groupStyle.setVerticalAlignment(VerticalAlignment.CENTER);
groupStyle.setBorderBottom(BorderStyle.THIN); groupStyle.setBorderRight(BorderStyle.THIN);
Font headerFont = workbook.createFont(); headerFont.setFontName("Microsoft YaHei");
headerFont.setBold(true); headerFont.setColor(IndexedColors.DARK_BLUE.getIndex());
CellStyle headerStyle = workbook.createCellStyle(); headerStyle.setFont(headerFont);
headerStyle.setFillForegroundColor(IndexedColors.PALE_BLUE.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setAlignment(HorizontalAlignment.CENTER); headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
headerStyle.setWrapText(true); headerStyle.setBorderBottom(BorderStyle.THIN); headerStyle.setBorderRight(BorderStyle.THIN);
short[] groupColors = {IndexedColors.DARK_BLUE.getIndex(), IndexedColors.DARK_GREEN.getIndex(),
IndexedColors.ORANGE.getIndex(), IndexedColors.VIOLET.getIndex(), IndexedColors.TEAL.getIndex(),
IndexedColors.BROWN.getIndex()};
short[] headerColors = {IndexedColors.PALE_BLUE.getIndex(), IndexedColors.LIGHT_GREEN.getIndex(),
IndexedColors.LIGHT_ORANGE.getIndex(), IndexedColors.LAVENDER.getIndex(), IndexedColors.LIGHT_TURQUOISE.getIndex(),
IndexedColors.TAN.getIndex()};
Map<String, CellStyle> headerStyles = new LinkedHashMap<>();
Row groupRow = sheet.createRow(0); groupRow.setHeightInPoints(26);
Row headerRow = sheet.createRow(1); headerRow.setHeightInPoints(38);
var drawing = sheet.createDrawingPatriarch();
var creation = workbook.getCreationHelper();
int start = 0;
int groupIndex = 0;
while (start < COLUMNS.size()) {
String group = COLUMNS.get(start).group();
int end = start;
while (end + 1 < COLUMNS.size() && group.equals(COLUMNS.get(end + 1).group())) end++;
CellStyle groupStyle = coloredHeaderStyle(workbook, groupFont, groupColors[groupIndex], false);
headerStyles.put(group, coloredHeaderStyle(workbook, headerFont, headerColors[groupIndex], true));
if (end > start) sheet.addMergedRegion(new CellRangeAddress(0, 0, start, end));
for (int col = start; col <= end; col++) {
Cell cell = groupRow.createCell(col); cell.setCellStyle(groupStyle);
if (col == start) cell.setCellValue(group);
}
start = end + 1;
groupIndex++;
}
for (int col = 0; col < COLUMNS.size(); col++) {
Column column = COLUMNS.get(col);
Cell cell = headerRow.createCell(col); cell.setCellValue(column.displayTitle()); cell.setCellStyle(headerStyle);
Cell cell = headerRow.createCell(col); cell.setCellValue(column.displayTitle());
cell.setCellStyle(headerStyles.get(column.group()));
var anchor = creation.createClientAnchor();
anchor.setCol1(col); anchor.setCol2(Math.min(COLUMNS.size(), col + 3)); anchor.setRow1(1); anchor.setRow2(5);
var comment = drawing.createCellComment(anchor);
@@ -225,6 +228,14 @@ public class MerchantBatchImportService {
sheet.setAutoFilter(new CellRangeAddress(1, 1, 0, COLUMNS.size() - 1));
}
private static CellStyle coloredHeaderStyle(XSSFWorkbook workbook, Font font, short color, boolean wrap) {
CellStyle style = workbook.createCellStyle(); style.setFont(font);
style.setFillForegroundColor(color); style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(wrap); style.setBorderBottom(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN);
return style;
}
private static void addDateValidation(org.apache.poi.ss.usermodel.DataValidationHelper helper,
org.apache.poi.ss.usermodel.Sheet sheet, int column, String message) {
var constraint = helper.createDateConstraint(DataValidationConstraint.OperatorType.BETWEEN,
@@ -294,6 +305,7 @@ public class MerchantBatchImportService {
List<MerchantBatchDraftDtos.Item> parse(long tenantId, String authorization, byte[] content) {
Catalog catalog = catalog(tenantId, authorization);
String customerName = authoritativeCustomerName(tenantId);
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheetAt(0);
if (sheet.getLastRowNum() < DATA_START_ROW) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
@@ -307,16 +319,18 @@ public class MerchantBatchImportService {
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int rowNo = DATA_START_ROW; rowNo <= sheet.getLastRowNum(); rowNo++) {
Row row = sheet.getRow(rowNo);
if (row == null || !StringUtils.hasText(text(row.getCell(0)))) continue;
if (!hasData(row)) continue;
if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单");
String rowId = text(row.getCell(0));
String rowId = "excel-row-" + (rowNo + 1);
try {
var draft = json.createObjectNode();
draft.put("customerName", customerName);
draft.put("tradeTerm", "FOB");
var cargo = json.createObjectNode();
boolean fbaSelected = false;
boolean overseasSelected = false;
boolean privateSelected = false;
for (int col = 1; col < COLUMNS.size(); col++) {
for (int col = 0; col < COLUMNS.size(); col++) {
String field = COLUMNS.get(col).field();
String value = value(row.getCell(col), field);
if (!StringUtils.hasText(field) || !StringUtils.hasText(value)) continue;
@@ -352,15 +366,14 @@ public class MerchantBatchImportService {
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("错误报告");
Row header = sheet.createRow(0);
String[] headers = {"Excel行号", "客户行号", "错误代码", "错误说明"};
String[] headers = {"Excel行号", "错误代码", "错误说明"};
for (int i = 0; i < headers.length; i++) header.createCell(i).setCellValue(headers[i]);
int rowNo = 1;
for (var item : result.items()) if (!item.success()) {
Row row = sheet.createRow(rowNo++);
row.createCell(0).setCellValue(item.index() + DATA_START_ROW + 1);
row.createCell(1).setCellValue(item.clientRowId());
row.createCell(2).setCellValue(item.errorCode());
row.createCell(3).setCellValue(item.errorMessage());
row.createCell(0).setCellValue(excelRowNumber(item.clientRowId()));
row.createCell(1).setCellValue(item.errorCode());
row.createCell(2).setCellValue(item.errorMessage());
}
workbook.write(out);
return new BytesMultipartFile("file", "order-batch-errors.xlsx",
@@ -456,6 +469,45 @@ public class MerchantBatchImportService {
}
}
private String authoritativeCustomerName(long tenantId) {
try {
var response = tenantDirectory.customerSummary(token, 1, 1, tenantId);
if (response == null || !response.successful() || response.data() == null
|| response.data().total() != 1 || response.data().records().size() != 1) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "当前客户企业资料暂不可用");
}
var customer = response.data().records().get(0);
if (customer.id() == null || customer.id() != tenantId || !StringUtils.hasText(customer.tenantName())) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "当前客户企业资料不完整");
}
return customer.tenantName().trim();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "当前客户企业资料暂不可用");
}
}
private static boolean hasData(Row row) {
if (row == null) return false;
for (int col = 0; col < COLUMNS.size(); col++) {
if (StringUtils.hasText(text(row.getCell(col)))) return true;
}
return false;
}
private static int excelRowNumber(String clientRowId) {
String prefix = "excel-row-";
if (!StringUtils.hasText(clientRowId) || !clientRowId.startsWith(prefix)) {
throw new BizException(ResultCode.INTERNAL_ERROR, "批量导入行号缺失");
}
try {
return Integer.parseInt(clientRowId.substring(prefix.length()));
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "批量导入行号不正确");
}
}
private static Map<String, CustomerAddressDtos.Response> addressOptions(List<CustomerAddressDtos.Response> values) {
return optionMap(values, value -> String.join("|", value.addressName(), value.contactName(), value.city(), value.addressLine()));
}
@@ -71,10 +71,11 @@ paths:
get:
summary: 下载 Excel 批量下单模板
description: >-
返回两级中文业务表头的 xlsx,按订单基础、目的地与仓库、国内提货与运输、报关与计价、FBA业务信息、货物信息分组。
返回两级中文业务表头的 xlsx,按订单基础、目的地与预约、货物与包装、报关与计价、FBA业务信息、联系人/备注分组并使用不同配色。
静态业务枚举使用中文下拉;仓库按全部 ACTIVE 分页结果构建,FBA目的仓仅包含 FBA,海外仓目的仓仅包含 OVERSEAS,
客户地址按当前客户生成。预计出运日期和预约提货时间提供日期格式及校验。工作表不暴露内部 ID,权威仓库选择自动推导目的国、
目的仓 ID/名称及 FBA 仓库代码。示例与逐列填写规则位于独立说明页,不占用50行编辑区。
目的仓 ID/名称及 FBA 仓库代码。客户归属和企业名称取认证 tenantId 对应的 IAM 企业主档,贸易术语固定为 FOB,均不作为模板列。
示例与逐列填写规则位于独立说明页,不占用50行编辑区。
responses:
'200': {description: xlsx 模板}
'403': {description: 需要 merchant:order:create 权限}
@@ -83,7 +84,7 @@ paths:
summary: 上传 Excel 批量创建订单草稿
description: >-
ATOMIC 在任一行失败时整批回滚;PARTIAL 每行独立事务,错误行不影响成功行。
同一操作者重试必须复用 Idempotency-Key,每行再以 clientRowId 派生稳定幂等键。
同一操作者重试必须复用 Idempotency-Key,每个非空数据行按实际 Excel 行号生成内部稳定幂等键;模板及错误报告不展示该内部键。
仅接受当前中文模板,中文下拉值由服务端映射为权威代码,不兼容历史英文技术表头。
导入支持 Excel 原生日期值,并重新校验动态选项;FBA、OVERSEAS、PRIVATE 必须分别且只能填写对应目的仓字段。
原文件和错误报告分别通过 File 的 ORDER_BATCH_IMPORT_SOURCE、ORDER_BATCH_IMPORT_ERROR_REPORT 契约保存。
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.order.client.MerchantPricingCatalogClient;
import com.baiyue.order.client.MerchantWarehouseCatalogClient;
import com.baiyue.order.client.TenantEligibilityClient;
import com.baiyue.order.dto.CustomerAddressDtos;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
@@ -17,6 +19,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -28,6 +31,7 @@ class MerchantBatchImportServiceTest {
private final CustomerAddressService addresses = mock(CustomerAddressService.class);
private final MerchantWarehouseCatalogClient warehouses = mock(MerchantWarehouseCatalogClient.class);
private final MerchantPricingCatalogClient pricing = mock(MerchantPricingCatalogClient.class);
private final TenantEligibilityClient tenantDirectory = mock(TenantEligibilityClient.class);
private MerchantBatchImportService service;
@BeforeEach
@@ -36,6 +40,8 @@ class MerchantBatchImportServiceTest {
ReflectionTestUtils.setField(service, "addresses", addresses);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "pricing", pricing);
ReflectionTestUtils.setField(service, "tenantDirectory", tenantDirectory);
ReflectionTestUtils.setField(service, "token", "internal-token");
ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules());
when(addresses.list(88L, "PICKUP")).thenReturn(List.of(address(11L, "工厂", "PICKUP")));
when(addresses.list(88L, "PRIVATE_DESTINATION")).thenReturn(List.of(address(12L, "美国门店", "PRIVATE_DESTINATION")));
@@ -46,6 +52,9 @@ class MerchantBatchImportServiceTest {
.thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, fba, overseas), 1, 200, 3, 1)));
when(pricing.channels("Bearer test", 21L)).thenReturn(ApiResponse.success(List.of(
new MerchantPricingCatalogClient.Channel("US-SEA", "美森海运"))));
var customer = new TenantEligibilityClient.CustomerSummary(88L, "C00088", "权威测试企业", "ACTIVE", "张三", "13800000000");
when(tenantDirectory.customerSummary("internal-token", 1, 1, 88L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 1, 1, 1)));
}
@Test
@@ -54,19 +63,26 @@ class MerchantBatchImportServiceTest {
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheet("订单导入");
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("订单基础");
assertThat(sheet.getRow(0).getCell(6).getStringCellValue()).isEqualTo("目的地与仓库");
assertThat(sheet.getRow(1).getCell(0).getStringCellValue()).isEqualTo("客户行号 *");
assertThat(sheet.getRow(1).getCell(6).getStringCellValue()).isEqualTo("目的类型 *");
assertThat(sheet.getRow(1).getCell(7).getStringCellValue()).isEqualTo("FBA目的仓");
assertThat(sheet.getRow(1).getCell(8).getStringCellValue()).isEqualTo("海外仓目的仓");
assertThat(sheet.getRow(1).getLastCellNum()).isEqualTo((short) 34);
assertThat(rowValues(sheet.getRow(1))).doesNotContain("目的国家代码", "目的仓库名称", "亚马逊仓库代码");
assertThat(sheet.getRow(0).getCell(5).getStringCellValue()).isEqualTo("目的地与预约");
assertThat(sheet.getRow(0).getCell(10).getStringCellValue()).isEqualTo("货物与包装");
assertThat(sheet.getRow(0).getCell(22).getStringCellValue()).isEqualTo("报关与计价");
assertThat(sheet.getRow(0).getCell(26).getStringCellValue()).isEqualTo("FBA业务信息");
assertThat(sheet.getRow(0).getCell(29).getStringCellValue()).isEqualTo("联系人/备注");
assertThat(sheet.getRow(1).getCell(0).getStringCellValue()).isEqualTo("运输方式 *");
assertThat(sheet.getRow(1).getCell(5).getStringCellValue()).isEqualTo("目的类型 *");
assertThat(sheet.getRow(1).getCell(6).getStringCellValue()).isEqualTo("FBA目的仓");
assertThat(sheet.getRow(1).getCell(7).getStringCellValue()).isEqualTo("海外仓目的仓");
assertThat(sheet.getRow(1).getLastCellNum()).isEqualTo((short) 31);
assertThat(rowValues(sheet.getRow(1))).doesNotContain("客户行号", "客户名称(企业名称)", "贸易术语",
"目的国家代码", "目的仓库名称", "亚马逊仓库代码");
assertThat(sheet.getRow(2).getCell(0).getStringCellValue()).isEmpty();
assertThat(sheet.getRow(51).getCell(0).getStringCellValue()).isEmpty();
assertThat(sheet.getRow(2).getCell(4).getCellStyle().getDataFormatString()).isEqualTo("yyyy-mm-dd");
assertThat(sheet.getRow(2).getCell(11).getCellStyle().getDataFormatString()).isEqualTo("yyyy-mm-dd hh:mm:ss");
assertThat(sheet.getRow(2).getCell(1).getCellStyle().getDataFormatString()).isEqualTo("yyyy-mm-dd");
assertThat(sheet.getRow(2).getCell(9).getCellStyle().getDataFormatString()).isEqualTo("yyyy-mm-dd hh:mm:ss");
assertThat(sheet.getPaneInformation().isFreezePane()).isTrue();
assertThat(sheet.getMergedRegions()).hasSize(6);
assertThat(Set.of(groupColor(sheet, 0), groupColor(sheet, 5), groupColor(sheet, 10),
groupColor(sheet, 22), groupColor(sheet, 26), groupColor(sheet, 29))).hasSize(6);
assertThat(sheet.getCTWorksheet().isSetAutoFilter()).isTrue();
assertThat(sheet.getRow(1).getCell(0).getCellComment()).isNotNull();
assertThat(sheet.getDataValidations()).hasSizeGreaterThanOrEqualTo(10);
@@ -82,20 +98,22 @@ class MerchantBatchImportServiceTest {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").getRow(2);
row.createCell(0).setCellValue("第1行"); row.createCell(1).setCellValue("测试客户");
row.createCell(2).setCellValue("海运"); row.getCell(4).setCellValue(LocalDate.of(2026, 9, 20));
row.createCell(6).setCellValue("亚马逊FBA"); row.createCell(7).setCellValue("ATL6");
row.createCell(10).setCellValue("平台上门提货");
row.getCell(11).setCellValue(LocalDateTime.of(2026, 9, 10, 14, 30, 0));
row.createCell(12).setCellValue("工厂|张三|深圳|科技园1号");
row.createCell(13).setCellValue("义乌收货仓|CN-YW"); row.createCell(14).setCellValue("美森海运|US-SEA");
row.createCell(15).setCellValue("买单报关"); row.createCell(16).setCellValue("不包税");
row.createCell(22).setCellValue("蓝牙音箱"); row.createCell(23).setCellValue("纸箱");
row.createCell(24).setCellValue("1"); row.createCell(25).setCellValue("1.2"); row.createCell(26).setCellValue("0.1");
row.createCell(28).setCellValue("否"); row.createCell(29).setCellValue("是");
row.createCell(0).setCellValue("海运"); row.getCell(1).setCellValue(LocalDate.of(2026, 9, 20));
row.createCell(2).setCellValue("平台上门提货");
row.createCell(3).setCellValue("义乌收货仓|CN-YW"); row.createCell(4).setCellValue("美森海运|US-SEA");
row.createCell(5).setCellValue("亚马逊FBA"); row.createCell(6).setCellValue("ATL6");
row.getCell(9).setCellValue(LocalDateTime.of(2026, 9, 10, 14, 30, 0));
row.createCell(10).setCellValue("蓝牙音箱"); row.createCell(11).setCellValue("纸箱");
row.createCell(12).setCellValue("1"); row.createCell(13).setCellValue("1.2"); row.createCell(14).setCellValue("0.1");
row.createCell(16).setCellValue("否"); row.createCell(17).setCellValue("是");
row.createCell(22).setCellValue("买单报关"); row.createCell(23).setCellValue("不包税");
row.createCell(29).setCellValue("工厂|张三|深圳|科技园1号");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.parseError()).isNull();
assertThat(item.clientRowId()).isEqualTo("excel-row-3");
assertThat(item.draft().customerName()).isEqualTo("权威测试企业");
assertThat(item.draft().tradeTerm()).isEqualTo("FOB");
assertThat(item.draft().transportMode()).isEqualTo("SEA");
assertThat(item.draft().pickupMethod()).isEqualTo("PICKUP");
assertThat(item.draft().senderAddressId()).isEqualTo(11L);
@@ -110,6 +128,7 @@ class MerchantBatchImportServiceTest {
assertThat(item.draft().requestedPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 10, 14, 30, 0));
assertThat(item.draft().cargoes().get(0).packageType()).isEqualTo("CARTON");
assertThat(item.draft().cargoes().get(0).dangerousGoods()).isTrue();
verify(tenantDirectory).customerSummary("internal-token", 1, 1, 88L);
}
}
@@ -118,8 +137,8 @@ class MerchantBatchImportServiceTest {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").getRow(2);
row.createCell(0).setCellValue("第1行"); row.createCell(6).setCellValue("亚马逊FBA");
row.createCell(8).setCellValue("洛杉矶海外仓|US-LAX");
row.createCell(5).setCellValue("亚马逊FBA");
row.createCell(7).setCellValue("洛杉矶海外仓|US-LAX");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.draft()).isNull();
@@ -127,6 +146,19 @@ class MerchantBatchImportServiceTest {
}
}
@Test
void errorReportShowsOnlyActualExcelRowAndErrors() throws Exception {
var failure = MerchantBatchDraftDtos.Result.failure(0, "excel-row-7", "VALIDATION_FAILED", "字段不正确");
var response = new MerchantBatchDraftDtos.Response(1, 0, 1, List.of(failure));
org.springframework.web.multipart.MultipartFile file = ReflectionTestUtils.invokeMethod(service, "errorReport", response);
try (var workbook = new XSSFWorkbook(file.getInputStream())) {
var sheet = workbook.getSheet("错误报告");
assertThat(rowValues(sheet.getRow(0))).containsExactly("Excel行号", "错误代码", "错误说明");
assertThat(sheet.getRow(1).getCell(0).getNumericCellValue()).isEqualTo(7);
assertThat(sheet.getRow(1).getLastCellNum()).isEqualTo((short) 3);
}
}
@Test
void templateReadsEveryActiveWarehousePageWithoutApplyingBusinessLimit() throws Exception {
List<MerchantWarehouseCatalogClient.Warehouse> firstPage = new ArrayList<>();
@@ -179,6 +211,10 @@ class MerchantBatchImportServiceTest {
return values;
}
private static short groupColor(org.apache.poi.xssf.usermodel.XSSFSheet sheet, int column) {
return sheet.getRow(0).getCell(column).getCellStyle().getFillForegroundColor();
}
private static CustomerAddressDtos.Response address(long id, String name, String type) {
return new CustomerAddressDtos.Response(id, type, name, "测试公司", "张三", "13800000000", null,
"CN", "广东", "深圳", "南山", "科技园1号", "518000", true);