From 61adfc7818bb44e5a1154e8050a30b98b87863b3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 18:03:42 +0800 Subject: [PATCH] feat(order): refine batch import workbook --- .../service/MerchantBatchImportService.java | 309 +++++++++++++++--- .../src/main/resources/openapi/order-v1.yaml | 7 +- .../MerchantBatchImportServiceTest.java | 78 ++++- 3 files changed, 325 insertions(+), 69 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java index 473289d63..418c575de 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java @@ -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 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 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> 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> 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 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 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 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 pickupAddresses, Map privateAddresses, Map receivingWarehouses, - Map destinationWarehouses, + Map fbaWarehouses, + Map overseasWarehouses, Map 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"); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 1bdf069fc..c6207f044 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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' diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java index e7d236377..64eba1022 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java @@ -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 firstPage = new ArrayList<>(); @@ -135,6 +173,12 @@ class MerchantBatchImportServiceTest { return false; } + private static List rowValues(org.apache.poi.ss.usermodel.Row row) { + List 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);