diff --git a/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java b/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java index 48061a6dd..04666f955 100644 --- a/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java +++ b/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java @@ -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> 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) { } } 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 418c575de..53b30e8fb 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java @@ -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 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 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 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 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 addressOptions(List values) { return optionMap(values, value -> String.join("|", value.addressName(), value.contactName(), value.city(), value.addressLine())); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c6207f044..74bd4dfcb 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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 契约保存。 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 64eba1022..4080c502b 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java @@ -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 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);