feat(order): refine batch import workbook

This commit is contained in:
2026-09-03 18:03:42 +08:00
parent 8911d4a242
commit 61adfc7818
3 changed files with 325 additions and 69 deletions
@@ -12,8 +12,18 @@ import com.baiyue.order.dto.OrderRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
@@ -25,9 +35,15 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
@@ -44,24 +60,48 @@ public class MerchantBatchImportService {
@Resource private ObjectMapper json;
@Value("${baiyue.security.internal-service-token:}") private String token;
private static final List<Column> COLUMNS = List.of(
new Column("客户行号", "clientRowId", null), new Column("客户名称", "customerName", null),
new Column("运输方式", "transportMode", "transport"), new Column("贸易术语", "tradeTerm", null),
new Column("目的国家代码", "destinationCountryCode", null), new Column("目的仓库", "$destinationWarehouse", "destinationWarehouses"),
new Column("预计出运日期", "expectedShipDate", null), new Column("客户备注", "customerRemark", null),
new Column("提货方式", "pickupMethod", "pickup"), new Column("预约提货时间", "requestedPickupAt", null),
new Column("取货地址", "$senderAddress", "pickupAddresses"), new Column("国内收货仓", "$receivingWarehouse", "receivingWarehouses"),
new Column("运输渠道", "$channel", "channels"), new Column("报关方式", "taxMode", "tax"),
new Column("计价税模式", "pricingTaxMode", "pricingTax"), new Column("装箱方式", "containerMode", "container"),
new Column("是否需要商检", "customsInspection", "boolean"), new Column("目的类型", "destinationType", "destination"),
new Column("私人目的仓地址", "$privateAddress", "privateAddresses"), new Column("亚马逊仓库代码", "amazonWarehouseCode", null),
new Column("货件编号", "shipmentId", null), new Column("参考编号", "referenceId", null),
new Column("履约类型", "fulfillmentType", "fulfillment"), new Column("货物名称", "cargo.cargoName", null),
new Column("包装类型", "cargo.packageType", "package"), new Column("包装数量", "cargo.packageCount", null),
new Column("毛重(千克)", "cargo.grossWeightKg", null), new Column("体积(立方米)", "cargo.volumeCbm", null),
new Column("材质", "cargo.material", null), new Column("是否木制品", "cargo.woodenProduct", "boolean"),
new Column("是否危险品", "cargo.dangerousGoods", "boolean"), new Column("SKU", "cargo.skuNo", null),
new Column("UPC", "cargo.upcNo", null), new Column("货物货件编号", "cargo.shipmentId", null),
new Column("货物参考编号", "cargo.referenceId", null));
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("报关与计价", "报关方式", "taxMode", "tax", true, "始终填写", 16),
col("报关与计价", "计价税模式", "pricingTaxMode", "pricingTax", true, "始终填写", 16),
col("报关与计价", "装箱方式", "containerMode", "container", false, "已确定整柜或散货拼箱时填写", 16),
col("报关与计价", "是否需要商检", "customsInspection", "boolean", false, "木制品或业务要求商检时填写", 18),
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));
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 ThreadLocal<DataFormatter> DATA_FORMATTER =
ThreadLocal.withInitial(() -> new DataFormatter(Locale.SIMPLIFIED_CHINESE));
private static final DateTimeFormatter DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private static final DateTimeFormatter DATE_TIME =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
private static final Map<String, List<String>> STATIC_OPTIONS = Map.of(
"transport", List.of("海运", "空运", "铁路", "卡车"),
"pickup", List.of("自行快递", "平台上门提货", "自行送仓"),
@@ -82,17 +122,30 @@ public class MerchantBatchImportService {
public byte[] template(long tenantId, String authorization) {
Catalog catalog = catalog(tenantId, authorization);
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
workbook.getFontAt(0).setFontName("Microsoft YaHei");
var sheet = workbook.createSheet("订单导入");
Row header = sheet.createRow(0);
for (int i = 0; i < COLUMNS.size(); i++) header.createCell(i).setCellValue(COLUMNS.get(i).title());
sheet.createFreezePane(0, 1);
for (int i = 0; i < COLUMNS.size(); i++) sheet.setColumnWidth(i, 20 * 256);
createHeaders(workbook, sheet);
CellStyle baseStyle = workbook.createCellStyle();
baseStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle dateStyle = workbook.createCellStyle(); dateStyle.cloneStyleFrom(baseStyle);
dateStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd"));
CellStyle dateTimeStyle = workbook.createCellStyle(); dateTimeStyle.cloneStyleFrom(baseStyle);
dateTimeStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss"));
for (int rowNo = DATA_START_ROW; rowNo < DATA_START_ROW + MAX_ROWS; rowNo++) {
Row row = sheet.createRow(rowNo); row.setHeightInPoints(22);
for (int col = 0; col < COLUMNS.size(); col++) {
Cell cell = row.createCell(col);
cell.setCellStyle(col == EXPECTED_SHIP_DATE_COLUMN ? dateStyle
: col == REQUESTED_PICKUP_AT_COLUMN ? dateTimeStyle : baseStyle);
}
}
var options = workbook.createSheet("选项数据");
Map<String, List<String>> optionValues = new LinkedHashMap<>(STATIC_OPTIONS);
optionValues.put("pickupAddresses", catalog.pickupAddresses().keySet().stream().toList());
optionValues.put("privateAddresses", catalog.privateAddresses().keySet().stream().toList());
optionValues.put("receivingWarehouses", catalog.receivingWarehouses().keySet().stream().toList());
optionValues.put("destinationWarehouses", catalog.destinationWarehouses().keySet().stream().toList());
optionValues.put("fbaWarehouses", catalog.fbaWarehouses().keySet().stream().toList());
optionValues.put("overseasWarehouses", catalog.overseasWarehouses().keySet().stream().toList());
optionValues.put("channels", catalog.channels().keySet().stream().toList());
int optionCol = 0;
Map<String, String> ranges = new LinkedHashMap<>();
@@ -112,17 +165,15 @@ public class MerchantBatchImportService {
String range = ranges.get(COLUMNS.get(col).optionKey());
if (range == null) continue;
DataValidationConstraint constraint = helper.createFormulaListConstraint(range);
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, col, col));
var validation = helper.createValidation(constraint,
new CellRangeAddressList(DATA_START_ROW, DATA_START_ROW + MAX_ROWS - 1, col, col));
validation.setShowErrorBox(true); validation.createErrorBox("选项不正确", "请从下拉列表中选择");
sheet.addValidationData(validation);
}
addDateValidation(helper, sheet, EXPECTED_SHIP_DATE_COLUMN, "请输入有效日期(yyyy-mm-dd)");
addDateValidation(helper, sheet, REQUESTED_PICKUP_AT_COLUMN, "请输入有效日期时间(yyyy-mm-dd hh:mm:ss)");
workbook.setSheetHidden(workbook.getSheetIndex(options), true);
var guide = workbook.createSheet("填写说明");
String[] guides = {"每一行代表一张订单,最多填写50行。", "客户行号由您自定义且在文件内唯一,用于定位错误行。",
"带下拉的字段必须从列表选择;系统导入时会重新核验仓库、渠道和地址的状态及客户归属。",
"日期格式:yyyy-MM-dd;预约提货时间格式:yyyy-MM-dd HH:mm:ss。", "本说明页不会被导入,请勿在订单导入页放置示例行。"};
for (int i = 0; i < guides.length; i++) guide.createRow(i).createCell(0).setCellValue(guides[i]);
guide.setColumnWidth(0, 100 * 256);
createGuide(workbook);
workbook.write(out);
return out.toByteArray();
} catch (Exception exception) {
@@ -130,6 +181,97 @@ 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);
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;
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++;
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;
}
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);
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);
comment.setString(creation.createRichTextString(column.instruction())); comment.setAuthor("订单服务");
cell.setCellComment(comment);
sheet.setColumnWidth(col, Math.min(60, column.width()) * 256);
}
sheet.createFreezePane(1, 2);
sheet.setAutoFilter(new CellRangeAddress(1, 1, 0, COLUMNS.size() - 1));
}
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,
"DATE(2000,1,1)", "DATE(2100,12,31)", "yyyy-mm-dd");
var validation = helper.createValidation(constraint,
new CellRangeAddressList(DATA_START_ROW, DATA_START_ROW + MAX_ROWS - 1, column, column));
validation.setEmptyCellAllowed(true); validation.setShowErrorBox(true);
validation.createErrorBox("日期格式不正确", message); sheet.addValidationData(validation);
}
private static void createGuide(XSSFWorkbook workbook) {
var guide = workbook.createSheet("填写说明");
Font titleFont = workbook.createFont(); titleFont.setFontName("Microsoft YaHei");
titleFont.setBold(true); titleFont.setFontHeightInPoints((short) 14);
CellStyle titleStyle = workbook.createCellStyle(); titleStyle.setFont(titleFont);
guide.createRow(0).createCell(0).setCellValue("头程订单批量导入填写说明");
guide.getRow(0).getCell(0).setCellStyle(titleStyle);
guide.createRow(1).createCell(0).setCellValue("订单导入页每行代表一张订单,最多50行;示例不会放入导入区域。动态选项在导入时按当前客户重新校验。");
Row header = guide.createRow(3);
String[] titles = {"字段", "什么时候填写 / 说明", "格式", "是否必填"};
for (int col = 0; col < titles.length; col++) header.createCell(col).setCellValue(titles[col]);
int rowNo = 4;
for (Column column : COLUMNS) {
Row row = guide.createRow(rowNo++);
row.createCell(0).setCellValue(column.title()); row.createCell(1).setCellValue(column.instruction());
row.createCell(2).setCellValue(formatDescription(column));
row.createCell(3).setCellValue(column.required() ? "必填" : "按条件/选填");
}
guide.setColumnWidth(0, 26 * 256); guide.setColumnWidth(1, 62 * 256);
guide.setColumnWidth(2, 28 * 256); guide.setColumnWidth(3, 16 * 256);
guide.createFreezePane(0, 4); guide.setAutoFilter(new CellRangeAddress(3, 3, 0, 3));
}
private static String formatDescription(Column column) {
if ("expectedShipDate".equals(column.field())) return "日期:yyyy-mm-dd";
if ("requestedPickupAt".equals(column.field())) return "日期时间:yyyy-mm-dd hh:mm:ss";
if (column.optionKey() != null) return "从下拉列表选择";
if (column.field().contains("Count")) return "正整数";
if (column.field().contains("Weight") || column.field().contains("volume")) return "数字";
return "文本";
}
private static Column col(String group, String title, String field, String optionKey, boolean required,
String instruction, int width) {
return new Column(group, title, field, optionKey, required, instruction, width);
}
public MerchantBatchImportDtos.Response importFile(long tenantId, long userId, String username, String authorization,
String idempotencyKey, MerchantBatchImportDtos.Mode mode,
MultipartFile file) {
@@ -154,16 +296,16 @@ public class MerchantBatchImportService {
Catalog catalog = catalog(tenantId, authorization);
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheetAt(0);
if (sheet.getLastRowNum() < 1) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
if (sheet.getLastRowNum() < DATA_START_ROW) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
List<MerchantBatchDraftDtos.Item> items = new ArrayList<>();
Row header = sheet.getRow(0);
if (header == null || !COLUMNS.get(0).title().equals(text(header.getCell(0)))) {
Row header = sheet.getRow(1);
if (header == null || !COLUMNS.get(0).displayTitle().equals(text(header.getCell(0)))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
}
if (header.getLastCellNum() != COLUMNS.size()) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int col = 0; col < COLUMNS.size(); col++) if (!COLUMNS.get(col).title().equals(text(header.getCell(col))))
for (int col = 0; col < COLUMNS.size(); col++) if (!COLUMNS.get(col).displayTitle().equals(text(header.getCell(col))))
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int rowNo = 1; rowNo <= sheet.getLastRowNum(); rowNo++) {
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 (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单");
@@ -171,14 +313,26 @@ public class MerchantBatchImportService {
try {
var draft = json.createObjectNode();
var cargo = json.createObjectNode();
boolean fbaSelected = false;
boolean overseasSelected = false;
boolean privateSelected = false;
for (int col = 1; col < COLUMNS.size(); col++) {
String field = COLUMNS.get(col).field(); String value = text(row.getCell(col));
String field = COLUMNS.get(col).field();
String value = value(row.getCell(col), field);
if (!StringUtils.hasText(field) || !StringUtils.hasText(value)) continue;
value = VALUE_CODES.getOrDefault(value, value);
if (field.startsWith("$")) { applySelection(draft, field, value, catalog); continue; }
if (field.startsWith("$")) {
applySelection(draft, field, value, catalog);
fbaSelected |= "$fbaWarehouse".equals(field);
overseasSelected |= "$overseasWarehouse".equals(field);
privateSelected |= "$privateAddress".equals(field);
continue;
}
if (field.startsWith("cargo.")) cargo.put(field.substring("cargo.".length()), value);
else draft.put(field, value);
}
validateDestination(draft.path("destinationType").asText(null),
fbaSelected, overseasSelected, privateSelected);
draft.set("cargoes", json.createArrayNode().add(cargo));
items.add(new MerchantBatchDraftDtos.Item(rowId,
json.treeToValue(draft, OrderRequests.SaveRequest.class)));
@@ -203,7 +357,7 @@ public class MerchantBatchImportService {
int rowNo = 1;
for (var item : result.items()) if (!item.success()) {
Row row = sheet.createRow(rowNo++);
row.createCell(0).setCellValue(item.index() + 2);
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());
@@ -221,7 +375,9 @@ public class MerchantBatchImportService {
var receiving = optionMap(allWarehouses.stream().filter(value -> "DOMESTIC".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
if (receiving.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "暂无可用国内收货仓");
var destination = optionMap(allWarehouses.stream().filter(value -> !"DOMESTIC".equals(value.warehouseType())).toList(),
var fba = optionMap(allWarehouses.stream().filter(value -> "FBA".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
var overseas = optionMap(allWarehouses.stream().filter(value -> "OVERSEAS".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
Map<String, MerchantPricingCatalogClient.Channel> channels = new LinkedHashMap<>();
for (var warehouse : receiving.values()) {
@@ -241,7 +397,7 @@ public class MerchantBatchImportService {
return new Catalog(
addressOptions(addresses.list(tenantId, "PICKUP")),
addressOptions(addresses.list(tenantId, "PRIVATE_DESTINATION")),
receiving, destination, channels);
receiving, fba, overseas, channels);
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
@@ -319,15 +475,19 @@ public class MerchantBatchImportService {
String value, Catalog catalog) {
switch (field) {
case "$senderAddress" -> draft.put("senderAddressId", required(catalog.pickupAddresses(), value, "取货地址").id());
case "$privateAddress" -> draft.put("privateDestinationAddressId", required(catalog.privateAddresses(), value, "私人目的仓地址").id());
case "$privateAddress" -> {
var address = required(catalog.privateAddresses(), value, "私人目的仓地址");
draft.put("privateDestinationAddressId", address.id());
draft.put("destinationCountryCode", address.countryCode());
}
case "$receivingWarehouse" -> {
var warehouse = required(catalog.receivingWarehouses(), value, "国内收货仓");
draft.put("receivingWarehouseId", warehouse.id()); draft.put("receivingWarehouseName", warehouse.warehouseName());
}
case "$destinationWarehouse" -> {
var warehouse = required(catalog.destinationWarehouses(), value, "目的仓库");
draft.put("destinationWarehouseId", warehouse.id()); draft.put("destinationWarehouseName", warehouse.warehouseName());
}
case "$fbaWarehouse" -> applyDestinationWarehouse(draft,
required(catalog.fbaWarehouses(), value, "FBA目的仓"), true);
case "$overseasWarehouse" -> applyDestinationWarehouse(draft,
required(catalog.overseasWarehouses(), value, "海外仓目的仓"), false);
case "$channel" -> {
var channel = required(catalog.channels(), value, "运输渠道");
draft.put("channelCode", channel.channelCode()); draft.put("channelName", channel.channelName());
@@ -342,15 +502,44 @@ public class MerchantBatchImportService {
return selected;
}
private static String label(String name, String code) { return name + "|" + code; }
private static void applyDestinationWarehouse(com.fasterxml.jackson.databind.node.ObjectNode draft,
MerchantWarehouseCatalogClient.Warehouse warehouse,
boolean fba) {
draft.put("destinationWarehouseId", warehouse.id());
draft.put("destinationWarehouseName", warehouse.warehouseName());
draft.put("destinationCountryCode", warehouse.countryCode());
if (fba) draft.put("amazonWarehouseCode", warehouse.warehouseCode());
}
private static void validateDestination(String destinationType, boolean fba, boolean overseas, boolean privateAddress) {
if ("FBA".equals(destinationType) && fba && !overseas && !privateAddress) return;
if ("OVERSEAS".equals(destinationType) && overseas && !fba && !privateAddress) return;
if ("PRIVATE".equals(destinationType) && privateAddress && !fba && !overseas) return;
if (!StringUtils.hasText(destinationType)) throw new BizException(ResultCode.VALIDATION_FAILED, "请选择目的类型");
throw new BizException(ResultCode.VALIDATION_FAILED, switch (destinationType) {
case "FBA" -> "亚马逊FBA订单必须且只能选择FBA目的仓";
case "OVERSEAS" -> "海外仓订单必须且只能选择海外仓目的仓";
case "PRIVATE" -> "私人地址订单必须且只能选择私人目的仓地址";
default -> "目的类型不正确";
});
}
private static String label(String name, String code) {
if (!StringUtils.hasText(name)) return code;
return name.equalsIgnoreCase(code) ? code : name + "|" + code;
}
private static String columnLetter(int column) { return String.valueOf((char) ('A' + column)); }
private record Column(String title, String field, String optionKey) { }
private record Column(String group, String title, String field, String optionKey, boolean required,
String instruction, int width) {
String displayTitle() { return required ? title + " *" : title; }
}
private record Catalog(
Map<String, CustomerAddressDtos.Response> pickupAddresses,
Map<String, CustomerAddressDtos.Response> privateAddresses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> receivingWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> destinationWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> fbaWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> overseasWarehouses,
Map<String, MerchantPricingCatalogClient.Channel> channels) { }
private Long upload(long tenantId, long userId, String batchNo, String type, MultipartFile file) {
@@ -373,11 +562,31 @@ public class MerchantBatchImportService {
private static String text(Cell cell) {
if (cell == null) return null;
cell.setCellType(org.apache.poi.ss.usermodel.CellType.STRING);
String value = cell.getStringCellValue();
String value = DATA_FORMATTER.get().formatCellValue(cell);
return value == null ? null : value.trim();
}
private static String value(Cell cell, String field) {
String formatted = text(cell);
if (!StringUtils.hasText(formatted)) return null;
boolean date = "expectedShipDate".equals(field);
boolean dateTime = "requestedPickupAt".equals(field);
if (!date && !dateTime) return formatted;
try {
if (cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) {
if (!DateUtil.isValidExcelDate(cell.getNumericCellValue())) throw new DateTimeParseException("invalid", formatted, 0);
LocalDateTime excelValue = DateUtil.getLocalDateTime(cell.getNumericCellValue());
return date ? DATE.format(excelValue.toLocalDate())
: DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(excelValue.withNano(0));
}
if (date) return DATE.format(LocalDate.parse(formatted, DATE));
return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.parse(formatted, DATE_TIME));
} catch (RuntimeException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED,
date ? "预计出运日期格式必须为yyyy-mm-dd" : "预约提货时间格式必须为yyyy-mm-dd hh:mm:ss");
}
}
private static String safeName(String name) { return StringUtils.hasText(name) ? name : "order-batch.xlsx"; }
private static String batchNo(long tenantId, long userId, String key) {
if (!StringUtils.hasText(key)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
@@ -71,8 +71,10 @@ paths:
get:
summary: 下载 Excel 批量下单模板
description: >-
返回仅含中文业务列名的 xlsx。静态业务枚举使用中文下拉;仓库、渠道和客户地址按当前登录客户及正式主数据实时生成,
工作表不暴露内部 ID。示例与填写规则位于独立说明页,不占用可导入数据行。导入时重新校验动态选项的状态、归属和组合有效性。
返回两级中文业务表头的 xlsx,按订单基础、目的地与仓库、国内提货与运输、报关与计价、FBA业务信息、货物信息分组。
静态业务枚举使用中文下拉;仓库按全部 ACTIVE 分页结果构建,FBA目的仓仅包含 FBA,海外仓目的仓仅包含 OVERSEAS,
客户地址按当前客户生成。预计出运日期和预约提货时间提供日期格式及校验。工作表不暴露内部 ID,权威仓库选择自动推导目的国、
目的仓 ID/名称及 FBA 仓库代码。示例与逐列填写规则位于独立说明页,不占用50行编辑区。
responses:
'200': {description: xlsx 模板}
'403': {description: 需要 merchant:order:create 权限}
@@ -83,6 +85,7 @@ paths:
ATOMIC 在任一行失败时整批回滚;PARTIAL 每行独立事务,错误行不影响成功行。
同一操作者重试必须复用 Idempotency-Key,每行再以 clientRowId 派生稳定幂等键。
仅接受当前中文模板,中文下拉值由服务端映射为权威代码,不兼容历史英文技术表头。
导入支持 Excel 原生日期值,并重新校验动态选项;FBA、OVERSEAS、PRIVATE 必须分别且只能填写对应目的仓字段。
原文件和错误报告分别通过 File 的 ORDER_BATCH_IMPORT_SOURCE、ORDER_BATCH_IMPORT_ERROR_REPORT 契约保存。
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
@@ -13,6 +13,8 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -34,13 +36,14 @@ class MerchantBatchImportServiceTest {
ReflectionTestUtils.setField(service, "addresses", addresses);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "pricing", pricing);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
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")));
var domestic = new MerchantWarehouseCatalogClient.Warehouse(21L, "CN-YW", "义乌收货仓", "DOMESTIC", "CN", "ACTIVE");
var overseas = new MerchantWarehouseCatalogClient.Warehouse(22L, "US-LAX", "洛杉矶海外仓", "OVERSEAS", "US", "ACTIVE");
var fba = new MerchantWarehouseCatalogClient.Warehouse(22L, "ATL6", "ATL6", "FBA", "US", "ACTIVE");
var overseas = new MerchantWarehouseCatalogClient.Warehouse(23L, "US-LAX", "洛杉矶海外仓", "OVERSEAS", "US", "ACTIVE");
when(warehouses.page("Bearer test", 1, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, overseas), 1, 200, 2, 1)));
.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", "美森海运"))));
}
@@ -50,14 +53,27 @@ class MerchantBatchImportServiceTest {
byte[] content = service.template(88L, "Bearer test");
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(10).getStringCellValue()).isEqualTo("取货地址");
assertThat(sheet.getRow(0).getCell(11).getStringCellValue()).isEqualTo("国内收货仓");
assertThat(sheet.getRow(0).getCell(29).getStringCellValue()).isEqualTo("是否木制品");
assertThat((Object) sheet.getRow(1)).isNull();
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(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.getPaneInformation().isFreezePane()).isTrue();
assertThat(sheet.getMergedRegions()).hasSize(6);
assertThat(sheet.getCTWorksheet().isSetAutoFilter()).isTrue();
assertThat(sheet.getRow(1).getCell(0).getCellComment()).isNotNull();
assertThat(sheet.getDataValidations()).hasSizeGreaterThanOrEqualTo(10);
assertThat(workbook.getSheet("填写说明")).isNotNull();
assertThat(workbook.isSheetHidden(workbook.getSheetIndex("选项数据"))).isTrue();
assertThat(hasCellValue(workbook.getSheet("选项数据"), "ATL6")).isTrue();
assertThat(hasCellValue(workbook.getSheet("选项数据"), "洛杉矶海外仓|US-LAX")).isTrue();
}
}
@@ -65,16 +81,18 @@ class MerchantBatchImportServiceTest {
void importMapsChineseAndBusinessLabelsToCodesAndAuthoritativeIds() throws Exception {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").createRow(1);
var row = workbook.getSheet("订单导入").getRow(2);
row.createCell(0).setCellValue("第1行"); row.createCell(1).setCellValue("测试客户");
row.createCell(2).setCellValue("海运"); row.createCell(8).setCellValue("平台上门提货");
row.createCell(10).setCellValue("工厂|张三|深圳|科技园1号");
row.createCell(11).setCellValue("义乌收货仓|CN-YW"); row.createCell(12).setCellValue("美森海运|US-SEA");
row.createCell(13).setCellValue("买单报关"); row.createCell(14).setCellValue("不包税");
row.createCell(17).setCellValue("私人地址"); row.createCell(18).setCellValue("美国门店|张三|深圳|科技园1号");
row.createCell(23).setCellValue("蓝牙音箱"); row.createCell(24).setCellValue("纸箱");
row.createCell(25).setCellValue("1"); row.createCell(26).setCellValue("1.2"); row.createCell(27).setCellValue("0.1");
row.createCell(29).setCellValue("否"); row.createCell(30).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("是");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.parseError()).isNull();
@@ -84,11 +102,31 @@ class MerchantBatchImportServiceTest {
assertThat(item.draft().receivingWarehouseId()).isEqualTo(21L);
assertThat(item.draft().channelCode()).isEqualTo("US-SEA");
assertThat(item.draft().taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(item.draft().destinationWarehouseId()).isEqualTo(22L);
assertThat(item.draft().destinationWarehouseName()).isEqualTo("ATL6");
assertThat(item.draft().destinationCountryCode()).isEqualTo("US");
assertThat(item.draft().amazonWarehouseCode()).isEqualTo("ATL6");
assertThat(item.draft().expectedShipDate()).isEqualTo(LocalDate.of(2026, 9, 20));
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();
}
}
@Test
void importRejectsOverseasWarehouseWhenDestinationTypeIsFba() throws Exception {
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");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.draft()).isNull();
assertThat(item.parseError()).isEqualTo("亚马逊FBA订单必须且只能选择FBA目的仓");
}
}
@Test
void templateReadsEveryActiveWarehousePageWithoutApplyingBusinessLimit() throws Exception {
List<MerchantWarehouseCatalogClient.Warehouse> firstPage = new ArrayList<>();
@@ -135,6 +173,12 @@ class MerchantBatchImportServiceTest {
return false;
}
private static List<String> rowValues(org.apache.poi.ss.usermodel.Row row) {
List<String> values = new ArrayList<>();
for (var cell : row) values.add(cell.getStringCellValue());
return values;
}
private static CustomerAddressDtos.Response address(long id, String name, String type) {
return new CustomerAddressDtos.Response(id, type, name, "测试公司", "张三", "13800000000", null,
"CN", "广东", "深圳", "南山", "科技园1号", "518000", true);