From 5a8918e2e4020298a9701afb46f0e32d45648c0a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 5 Nov 2026 11:55:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/GenericXlsxExportRunner.java | 3 +- .../service/GenericXlsxExportRunnerTest.java | 4 +- .../finance/service/FinanceExportLabels.java | 107 +++++++++++ .../service/FinanceExportProviderService.java | 29 ++- .../service/FinanceExportLabelsTest.java | 34 ++++ .../FinanceExportProviderServiceTest.java | 17 +- .../lastleg/export/LastlegExportLabels.java | 169 ++++++++++++++++++ .../export/LastlegExportProviderService.java | 9 +- .../export/LastlegExportLabelsTest.java | 62 +++++++ .../LastlegExportProviderServiceTest.java | 11 ++ 10 files changed, 418 insertions(+), 27 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceExportLabels.java create mode 100644 finance/src/test/java/com/baiyue/finance/service/FinanceExportLabelsTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportLabels.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportLabelsTest.java diff --git a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java index 8be548608..72102eb77 100644 --- a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java +++ b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java @@ -174,7 +174,8 @@ public class GenericXlsxExportRunner { case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns)); case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact()); case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue()); - case BOOLEAN -> cell.setCellValue(value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))); + // 面向业务人员的是否字段统一显示中文,Provider 仍传输布尔类型。 + case BOOLEAN -> cell.setCellValue((value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))) ? "是" : "否"); case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); } case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); } } diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index 815b76abe..cd2c491b5 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -48,7 +48,7 @@ class GenericXlsxExportRunnerTest { } @Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception { List columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20)); - List> firstRows=IntStream.range(0,100).mapToObj(index -> Map.of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList(); + List> firstRows=IntStream.range(0,100).mapToObj(index -> Map.of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",index % 2 == 0,"date","2026-08-03","time","2026-08-03T21:00:00")).toList(); var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L); var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L); when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second)); @@ -58,7 +58,7 @@ class GenericXlsxExportRunnerTest { assertEquals(101,result.rows()); try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){ var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue()); - assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType()); + assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.STRING,row.getCell(3).getCellType());assertEquals("是",row.getCell(3).getStringCellValue());assertEquals("否",workbook.getSheetAt(0).getRow(2).getCell(3).getStringCellValue());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType()); assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue()); assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建"); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportLabels.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportLabels.java new file mode 100644 index 000000000..25fa9f1fd --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportLabels.java @@ -0,0 +1,107 @@ +package com.baiyue.finance.service; + +import java.util.Locale; +import java.util.Map; + +/** 导出业务枚举沿用财务页面中文口径,不改动筛选值、币种及业务编号。 */ +final class FinanceExportLabels { + /** 中文导出映射仅提供静态转换。 */ + private FinanceExportLabels() { } + private static final Map STATUSES = Map.ofEntries( + Map.entry("ACTIVE", "正常"), + Map.entry("DISABLED", "停用"), + Map.entry("INACTIVE", "停用"), + Map.entry("DRAFT", "草稿"), + Map.entry("PENDING", "待审核"), + Map.entry("PENDING_REVIEW", "待审核"), + Map.entry("APPROVED", "已审核"), + Map.entry("ISSUED", "已出账"), + Map.entry("REJECTED", "已驳回"), + Map.entry("UNPAID", "未支付"), + Map.entry("PARTIALLY_PAID", "部分结清"), + Map.entry("PAID", "已结清"), + Map.entry("VOIDED", "已作废"), + Map.entry("AVAILABLE", "待结算"), + Map.entry("LOCKED", "结算锁定"), + Map.entry("BILLED", "已入账"), + Map.entry("SETTLED", "已结算"), + Map.entry("RECORDED", "已登记"), + Map.entry("CONFIRMED", "已确认"), + Map.entry("PENDING_CONFIRMATION", "待客户确认"), + Map.entry("NOT_REQUIRED", "无需确认")); + private static final Map SOURCES = Map.ofEntries( + Map.entry("INBOUND_FEE", "入库费用"), + Map.entry("ORDER_FEE", "订单费用"), + Map.entry("CONTAINER_FEE", "装柜费用"), + Map.entry("CONTAINER_COST", "整柜成本"), + Map.entry("MANUAL_COST", "人工成本"), + Map.entry("STORAGE_FEE", "仓储费用"), + Map.entry("OVERSEAS_INBOUND_FEE", "海外入仓费用"), + Map.entry("OVERSEAS_PROCUREMENT_FEE", "海外采购费用"), + Map.entry("OVERSEAS_TRUCK_FEE", "本土卡车费用"), + Map.entry("OVERSEAS_LOOSEBOARD_FEE", "本土散板费用"), + Map.entry("OVERSEAS_WAREHOUSE_FEE", "海外仓服务费"), + Map.entry("OVERSEAS_CONTAINER_FEE", "海外柜子费用"), + Map.entry("OVERSEAS_ORDER_FEE", "尾程订单费用"), + Map.entry("OVERSEAS_DELIVERY_FEE", "尾程派送费用"), + Map.entry("OVERSEAS_RELABEL_FEE", "换标费用")); + private static final Map BUSINESSES = Map.ofEntries( + Map.entry("RECHARGE", "充值"), + Map.entry("DEDUCTION", "扣款"), + Map.entry("CREDIT_LIMIT", "授信额度调整"), + Map.entry("FREEZE", "冻结"), + Map.entry("UNFREEZE", "解冻"), + Map.entry("CONSUMPTION", "消费扣款"), + Map.entry("REFUND", "退款"), + Map.entry("RECONCILIATION_CREATED", "生成对账单"), + Map.entry("SETTLEMENT_ISSUED", "结算单生效"), + Map.entry("RECEIPT", "收款"), + Map.entry("PAYMENT", "付款"), + Map.entry("SUPPLIER_DIRECT_PAID", "供应商线下直付"), + Map.entry("SUPPLIER_ON_ACCOUNT_PAYMENT", "供应商账期付款"), + Map.entry("ADJUSTMENT", "账务调整"), + Map.entry("VOID", "作废"), + Map.entry("BILL", "对账单"), + Map.entry("COST", "成本"), + Map.entry("SETTLEMENT", "结算"), + Map.entry("CREATE", "创建"), + Map.entry("APPROVE", "审核通过"), + Map.entry("REJECT", "审核驳回"), + Map.entry("UPDATE", "更新"), + Map.entry("FEE_SOURCE_EDIT", "编辑费用"), + Map.entry("FEE_SOURCE_SUBMIT", "提交审核"), + Map.entry("FEE_SOURCE_REVIEW_APPROVE", "审核通过"), + Map.entry("FEE_SOURCE_REVIEW_REJECT", "审核驳回"), + Map.entry("FEE_SOURCE_ADJUST", "调整费用"), + Map.entry("FEE_SOURCE_VOID", "作废费用"), + Map.entry("FEE_SOURCE_PUBLISHED", "费用同步"), + Map.entry("FEE_SOURCE_VERSION_ADJUST", "费用调整同步"), + Map.entry("RECONCILIATION_CREATE", "生成对账单"), + Map.entry("RECONCILIATION_ADJUST", "调整对账单"), + Map.entry("BILL_CREATE", "创建账单"), + Map.entry("BILL_ISSUE", "出账"), + Map.entry("BILL_ADJUST", "调整账单"), + Map.entry("BILL_VOID", "作废账单"), + Map.entry("SETTLEMENT_AR_CREATE", "生成应收结算单"), + Map.entry("SETTLEMENT_AP_CREATE", "生成应付结算单"), + Map.entry("SETTLEMENT_PAYMENT_CREATE", "登记收付款"), + Map.entry("RECEIPT_CREATE", "登记收款"), + Map.entry("COST_CREATE", "登记成本"), + Map.entry("REOPEN", "重开")); + + /** 转换账户、审核及结算状态。 */ + static String status(String value) { return label(value, STATUSES, "未知状态"); } + /** 转换费用来源类型。 */ + static String source(String value) { return label(value, SOURCES, "未知来源类型"); } + /** 转换账务及资金流水业务类型。 */ + static String business(String value) { return label(value, BUSINESSES, "未知业务类型"); } + /** 转换应收应付方向。 */ + static String direction(String value) { return label(value, Map.of("RECEIVABLE", "应收", "PAYABLE", "应付"), "未知方向"); } + + /** 空值保持空白,已是中文的历史快照保持原文;新增英文枚举不直接泄露到业务报表。 */ + private static String label(String value, Map labels, String fallback) { + if (value == null || value.isBlank()) return value; + String code = value.trim().toUpperCase(Locale.ROOT); + return labels.getOrDefault(code, value.matches(".*[一-鿿].*") ? value : fallback); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index a1290218f..eb299e566 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -339,35 +339,39 @@ public class FinanceExportProviderService { } catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); } } + /** 导出账户摘要,状态使用中文展示。 */ private Map accountRow(AccountSummary value) { return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), "receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(), - "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); + "outstandingPayable", value.outstandingPayable(), "status", FinanceExportLabels.status(value.status()), "lastChangedAt", value.lastChangedAt()); } + /** 导出账户流水,转换业务类型并保留金额和编号。 */ private Map ledgerRow(LedgerEntry value) { - return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(), + return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", FinanceExportLabels.business(value.businessType()), "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(), "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); } + /** 费用方向、来源类型及审核和结算状态统一展示中文。 */ private Map feeRow(FeeSourceSummary source) { - return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(), + return row("customerName", source.customerName(), "direction", FinanceExportLabels.direction(source.direction()), "sourceType", FinanceExportLabels.source(source.sourceType()), "sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(), "containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(), "quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(), - "reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated()); + "reviewStatus", FinanceExportLabels.status(source.reviewStatus()), "sourceStatus", FinanceExportLabels.status(source.sourceStatus()), "statementGenerated", source.statementGenerated()); } + /** 对账单导出沿用页面状态口径,是否结清仍传递布尔值。 */ private Map statementRow(ReconciliationDetail value) { return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), "periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(), - "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(), + "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", FinanceExportLabels.status(value.status()), "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt()); } /** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */ private Map settlementRow(SettlementSummary value) { return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), "currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), - "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", settlementStatusLabel(value.status())); + "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", FinanceExportLabels.status(value.status())); } /** 明细导出与列表保持相同的中文结算状态。 */ private Map settlementDetailRow(SettlementSummary summary, SettlementLine line) { @@ -375,18 +379,7 @@ public class FinanceExportProviderService { "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), - "outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status())); - } - /** 转换正式结算状态,未知值保留原值以便排查。 */ - private String settlementStatusLabel(String status) { - if (status == null) return null; - return switch (status) { - case "ISSUED" -> "已出账"; - case "PARTIALLY_PAID" -> "部分结清"; - case "PAID" -> "已结清"; - case "VOIDED" -> "已作废"; - default -> status; - }; + "outstandingAmount", summary.outstandingAmount(), "status", FinanceExportLabels.status(summary.status())); } private Map receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) { return row("statisticsMonth", month.toString(), "customerName", value.customerName(), diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportLabelsTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportLabelsTest.java new file mode 100644 index 000000000..ca71ca2e5 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportLabelsTest.java @@ -0,0 +1,34 @@ +package com.baiyue.finance.service; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceExportLabelsTest { + @Test void translatesStatusSourceAndLedgerFamiliesWithoutLeakingCodes() { + assertThat(FinanceExportLabels.status("PARTIALLY_PAID")).isEqualTo("部分结清"); + assertThat(FinanceExportLabels.status("SETTLED")).isEqualTo("已结算"); + for (String code : List.of("ACTIVE", "DISABLED", "DRAFT", "PENDING", "PENDING_REVIEW", "APPROVED", "REJECTED", "ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED", "AVAILABLE", "LOCKED", "BILLED")) { + assertThat(FinanceExportLabels.status(code)).matches(".*[一-鿿].*").doesNotContain("未知"); + } + for (String code : List.of("INBOUND_FEE", "ORDER_FEE", "CONTAINER_FEE", "CONTAINER_COST", "MANUAL_COST", "STORAGE_FEE", "OVERSEAS_INBOUND_FEE", "OVERSEAS_PROCUREMENT_FEE", "OVERSEAS_TRUCK_FEE", "OVERSEAS_LOOSEBOARD_FEE", "OVERSEAS_WAREHOUSE_FEE", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_ORDER_FEE", "OVERSEAS_DELIVERY_FEE", "OVERSEAS_RELABEL_FEE")) { + assertThat(FinanceExportLabels.source(code)).matches(".*[一-鿿].*").doesNotContain("未知"); + } + for (String code : List.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID")) { + assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知"); + } + assertThat(FinanceExportLabels.direction("RECEIVABLE")).isEqualTo("应收"); + for (String code : List.of("RECHARGE", "DEDUCTION", "CREDIT_LIMIT", "FREEZE", "UNFREEZE", "CONSUMPTION", "REFUND")) { + assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知"); + } + assertThat(FinanceExportLabels.direction("PAYABLE")).isEqualTo("应付"); + } + + @Test void handlesEmptyChineseSnapshotsAndUnknownValues() { + assertThat(FinanceExportLabels.status(null)).isNull(); + assertThat(FinanceExportLabels.status("")).isEmpty(); + assertThat(FinanceExportLabels.status("已结清")).isEqualTo("已结清"); + assertThat(FinanceExportLabels.status("NEW_INTERNAL_STATUS")).isEqualTo("未知状态"); + assertThat(FinanceExportLabels.status(" partially_paid ")).isEqualTo("部分结清"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java index 8ed3e61d7..5e51da4d1 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -45,6 +45,19 @@ class FinanceExportProviderServiceTest { service.initializeSigningKey(); } + @Test + void exportsAccountAndLedgerEnumsAsChineseWithoutChangingFiltersOrReferences() throws Exception { + var mapper = new ObjectMapper().findAndRegisterModules(); + var account = mapper.convertValue(Map.of("customerId", 10001L, "customerCode", "C-10001", "status", "ACTIVE"), com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary.class); + var ledger = mapper.convertValue(Map.of("ledgerId", 1L, "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT", "documentNo", "AP-1"), com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry.class); + when(accountService.list(1, 100, null, "ACTIVE", 10001L)).thenReturn(PageEnvelope.success(List.of(account), 1, 100, 1)); + when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, "SUPPLIER_ON_ACCOUNT_PAYMENT", null, null)).thenReturn(PageEnvelope.success(List.of(ledger), 1, 100, 1)); + var accounts = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request(Map.of("customerId", "10001", "status", "ACTIVE"), null, null)); + var ledgers = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, request(Map.of("customerId", "10001", "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT"), null, null)); + assertThat(accounts.rows().get(0)).containsEntry("status", "正常").containsEntry("customerCode", "C-10001"); + assertThat(ledgers.rows().get(0)).containsEntry("businessType", "供应商账期付款").containsEntry("documentNo", "AP-1"); + } + @Test /** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */ void exportsSettlementStatusesInChinese() { @@ -98,7 +111,7 @@ class FinanceExportProviderServiceTest { assertThat(first.columns()).isNotEmpty(); assertThat(first.rows()).hasSize(1); - assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A"); + assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A").containsEntry("status", "已出账"); assertThat(first.totalRows()).isEqualTo(1L); assertThat(continued.columns()).isEmpty(); assertThat(continued.rows()).isEmpty(); @@ -119,7 +132,7 @@ class FinanceExportProviderServiceTest { var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST, request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null)); assertThat(result.rows()).singleElement().satisfies(row -> { - assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001"); + assertThat(row).containsEntry("sourceStatus", "已结清").containsEntry("reviewStatus", "已审核").containsEntry("direction", "应付").containsEntry("sourceType", "装柜费用").containsEntry("sourceFeeId", "9001"); assertThat(row.get("amount")).isEqualTo(new BigDecimal("300")); }); org.mockito.Mockito.verifyNoInteractions(queryService); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportLabels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportLabels.java new file mode 100644 index 000000000..3e0c54332 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportLabels.java @@ -0,0 +1,169 @@ +package com.baiyue.lastleg.export; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +/** 导出按任务类型及字段转换中文;仓码、SKU、币种与自由文本原样保留。 */ +final class LastlegExportLabels { + /** 中文导出映射仅提供静态转换。 */ + private LastlegExportLabels() { } + private static final Map WORKFLOW = Map.ofEntries( + Map.entry("PENDING_INBOUND", "待入库"), + Map.entry("PENDING_REVIEW", "待审核"), + Map.entry("PENDING_RECEIPT", "待签收入库"), + Map.entry("PARTIALLY_RECEIVED", "部分入库"), + Map.entry("INBOUND_COMPLETED", "全部入库"), + Map.entry("WAIT_PDA_RECEIVING", "入库测量中"), + Map.entry("INBOUND_MEASURING", "入库测量中"), + Map.entry("WAIT_CUSTOMER_CONFIRMATION", "待客户确认实测"), + Map.entry("APPROVED", "审核通过"), + Map.entry("PENDING_ASSIGNMENT", "待分配"), + Map.entry("WAIT_RELABEL", "待换标"), + Map.entry("WAIT_FBA_DESTINATION", "待确认目的仓"), + Map.entry("READY_FOR_DISPATCH", "待创建出库单"), + Map.entry("WAIT_OUTBOUND", "待出库"), + Map.entry("ALLOCATED", "已分配库存"), + Map.entry("OUTBOUNDED", "已出库"), + Map.entry("RECEIVED", "已收货"), + Map.entry("IN_TRANSIT", "运输中"), + Map.entry("DESTINATION_ASSIGNED", "已分配目的仓"), + Map.entry("INBOUND_PLANNED", "已生成入库计划"), + Map.entry("PENDING_PROCESSING", "待处理"), + Map.entry("PROCESSING", "处理中"), + Map.entry("COMPLETED", "已完成"), + Map.entry("REJECTED", "已驳回"), + Map.entry("CANCELLED", "已取消"), + Map.entry("DRAFT", "草稿"), + Map.entry("PARTIAL_INBOUND", "部分入库"), + Map.entry("SIGNED", "已签收"), + Map.entry("PICKING", "拣货中"), + Map.entry("PICKED", "已拣货"), + Map.entry("SHIPPED", "已出库"), + Map.entry("WAIT_DELIVERY", "待派送"), + Map.entry("PENDING_DELIVERY", "待派送"), + Map.entry("DELIVERING", "派送中"), + Map.entry("DELIVERED", "派送完成"), + Map.entry("RETURNED", "已退回"), + Map.entry("CUSTOMER_CONFIRM_PENDING", "待客户确认"), + Map.entry("READY_FOR_OUTBOUND", "待出库"), + Map.entry("NOT_REQUIRED", "无需换标")); + private static final Map INVENTORY_BUSINESS = Map.ofEntries( + Map.entry("IN_TRANSIT", "在途入账"), + Map.entry("STOCK_IN", "入库"), + Map.entry("STOCK_IN_TRANSIT_RELEASE", "在途库存转入"), + Map.entry("HOLD", "库存暂扣"), + Map.entry("RELEASE", "解除暂扣"), + Map.entry("ALLOCATE", "库存分配"), + Map.entry("UNALLOCATE", "取消分配"), + Map.entry("PICK", "拣货"), + Map.entry("SHIP", "出库"), + Map.entry("ADJUST", "库存调整"), + Map.entry("STOCKTAKE_ADJUST", "盘点调整"), + Map.entry("RECEIVING_WAREHOUSE_CHANGED", "变更接仓点"), + Map.entry("INTERNAL_MOVE", "库内移库"), + Map.entry("RELABEL_PALLET_DISASSEMBLE", "换标拆托"), + Map.entry("PALLET_ASSEMBLE", "组托"), + Map.entry("PALLET_DISASSEMBLE", "拆托"), + Map.entry("PALLET_SWAP", "换托"), + Map.entry("PALLET_UNBIND", "解托"), + Map.entry("RETURN_STOCK_IN", "退回入库"), + Map.entry("DISCARD", "货物废弃")); + private static final Map ORDER_TYPES = Map.ofEntries( + Map.entry("ONE_CLICK_DROPSHIP", "一件代发"), + Map.entry("FIRST_LEG", "头程订单"), + Map.entry("RELABEL_APPLICATION", "换标下单"), + Map.entry("WAREHOUSE_TRANSFER", "仓储转运"), + Map.entry("RETURN_RELABEL", "退货换标"), + Map.entry("DELIVERY_RETURN", "派送异常退回"), + Map.entry("LOCAL_LOOSEBOARD", "本土散板"), + Map.entry("CARGO_DISCARD", "货物废弃订单"), + Map.entry("REJECTED", "拒收订单"), + Map.entry("EXPRESS", "快递订单")); + private static final Map NODES = Map.ofEntries( + Map.entry("WAIT_LOADING", "待装柜"), + Map.entry("LOADING", "装柜中"), + Map.entry("WAIT_CUSTOMS", "待出口报关"), + Map.entry("WAIT_OUTBOUND", "待国内出仓"), + Map.entry("DEPARTED_WAREHOUSE", "已离开国内仓库"), + Map.entry("IN_TRANSIT", "海运途中"), + Map.entry("ARRIVED_DESTINATION_PORT", "船舶已到目的港"), + Map.entry("WAITING_FOR_ARRIVAL", "待到港"), + Map.entry("ARRIVED", "已到港"), + Map.entry("PICKUP_READY", "已清关、可提柜"), + Map.entry("PICKUP_SCHEDULED", "已预约提柜"), + Map.entry("PICKED_UP", "已提柜"), + Map.entry("UNLOADED", "待还柜"), + Map.entry("EMPTY_RETURNED", "已还柜"), + Map.entry("AT_ORIGIN_PORT", "重箱已进入起运港"), + Map.entry("LOADED_ON_VESSEL", "已装船"), + Map.entry("DEPARTED_ORIGIN_PORT", "船舶已离开起运港"), + Map.entry("DISCHARGED_FROM_VESSEL", "已卸船、未清关")); + private static final Map INVENTORY_STATUS = Map.ofEntries( + Map.entry("OUTSIDE", "仓外"), + Map.entry("UNBOUND", "未绑托"), + Map.entry("IN_TRANSIT", "在途"), + Map.entry("RECEIVING", "收货中"), + Map.entry("AVAILABLE", "可用"), + Map.entry("HOLD", "暂扣"), + Map.entry("HELD", "暂扣"), + Map.entry("ALLOCATED", "已分配"), + Map.entry("PICKED", "已拣货"), + Map.entry("SHIPPED", "已出库"), + Map.entry("DISCARDED", "已废弃"), + Map.entry("STOCK_IN", "已入库"), + Map.entry("EMPTY", "空置"), + Map.entry("OCCUPIED", "占用")); + + /** 只转换明确的业务枚举列,避免将编号、币种和备注误当作状态。 */ + static Object value(String type, String key, Object value) { + if (!(value instanceof String text) || text.isBlank()) return value; + return switch (type) { + case "LAST_MILE_ORDER_LIST" -> switch (key) { + case "currentStatus" -> label(text, WORKFLOW, "未知状态"); + case "orderTypeName", "businessSource.label" -> label(text, ORDER_TYPES, "未知业务类型"); + case "containerNode" -> label(text, NODES, "未知货柜节点"); + case "relabelStatus" -> label(text, WORKFLOW, "未知换标状态"); + case "palletMergeFlag" -> label(text, Map.of("REQUIRED", "待合托", "MERGED", "已合托"), "未标记"); + case "subOrderStatus" -> subOrders(text); + default -> value; + }; + case "OVERSEAS_INVENTORY_LEDGER" -> switch (key) { + case "businessType" -> label(text, INVENTORY_BUSINESS, "未知业务类型"); + case "statusFrom", "statusTo" -> label(text, INVENTORY_STATUS, "未知库存状态"); + default -> value; + }; + case "OVERSEAS_QUOTE_LIST", "MONTHLY_EXCHANGE_RATE_LIST", "FLEET_QUOTE_LIST" -> switch (key) { + case "status" -> label(text, Map.of("ACTIVE", "启用", "INACTIVE", "停用", "DELETED", "已删除"), "未知状态"); + case "details.kind" -> label(text, Map.of("PURCHASE", "车队采购报价", "SALE", "外接销售报价"), "未知用途"); + default -> value; + }; + case "VEHICLE_LIST" -> switch (key) { + case "status" -> label(text, Map.of("IN_SERVICE", "在役", "DISPATCHED", "外派中", "MAINTENANCE", "维修中", "INACTIVE", "停用", "RETURNED", "已归还"), "未知状态"); + case "ownership" -> label(text, Map.of("OWNED", "自有", "DISPATCHED", "外派"), "未知车辆归属"); + case "region" -> label(text, Map.of("DOMESTIC", "国内", "OVERSEAS", "海外"), "未知区域"); + default -> value; + }; + case "TRIP_OPERATIONS_LIST" -> "status".equals(key) + ? label(text, Map.of("PLANNED", "计划中", "IN_TRANSIT", "运输中", "COMPLETED", "货物已结案", "CANCELLED", "已取消"), "未知状态") : value; + default -> value; + }; + } + + /** 子单状态可能由多个“业务类型:状态”组成,分别翻译而不破坏分组。 */ + private static String subOrders(String value) { + Map kinds = Map.of("INBOUND_PLAN", "入库单", "OUTBOUND", "出库单", "DELIVERY", "派送单", "RELABEL", "换标单", "WAREHOUSE_TRANSFER", "仓储转运", "RETURN_RELABEL", "退货换标"); + return Arrays.stream(value.split("[;;]")) + .map(part -> { + String[] pair = part.split(":", 2); + return pair.length == 2 ? label(pair[0], kinds, "子单") + ":" + label(pair[1], WORKFLOW, "未知状态") + : label(part, WORKFLOW, "未知状态"); + }).collect(Collectors.joining(";")); + } + + /** 中文历史快照保持原文,未知英文枚举使用中文占位。 */ + private static String label(String value, Map labels, String fallback) { + return labels.getOrDefault(value.trim().toUpperCase(Locale.ROOT), value.matches(".*[一-鿿].*") ? value : fallback); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java index 3d3544477..393168a58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java @@ -47,6 +47,7 @@ public class LastlegExportProviderService { @Resource private Validator validator; @Value("${baiyue.security.internal-service-token}") private String signingKey; + /** 校验平台导出快照,复用列表筛选并生成中文业务数据批次。 */ public ExportBatchResponse batch(String type, ExportBatchRequest request) { if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出"); @@ -81,7 +82,7 @@ public class LastlegExportProviderService { case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page(); default -> throw invalid("不支持该导出类型"); }; - List> rows = page.records().stream().map(value -> row(value, columns)).toList(); + List> rows = page.records().stream().map(value -> row(type, value, columns)).toList(); int next = offset + rows.size(); if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进"); return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows, @@ -98,7 +99,8 @@ public class LastlegExportProviderService { } catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); } } - private Map row(Object value, List columns) { + /** 按显式列投影业务数据,仅对指定枚举字段进行中文转换。 */ + private Map row(String type, Object value, List columns) { JsonNode source = json.valueToTree(value); Map result = new LinkedHashMap<>(); for (ExportColumn column : columns) { @@ -106,12 +108,11 @@ public class LastlegExportProviderService { for (String part : column.key().split("\\.")) node = node.path(part); Object cell = node.isMissingNode() || node.isNull() ? null : node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText(); - result.put(column.key(), cell); + result.put(column.key(), LastlegExportLabels.value(type, column.key(), cell)); } if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) { result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否"); result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否"); - result.put("currentStatus", com.baiyue.lastleg.model.LastlegOrderStatus.labelOf(order.currentStatus())); } if (value instanceof TripOperationsModels.Row trip) { result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / "))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportLabelsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportLabelsTest.java new file mode 100644 index 000000000..f18de2756 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportLabelsTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.export; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class LastlegExportLabelsTest { + @Test void translatesEveryEnumColumnOfAllLastlegTaskTypes() { + Object[][] examples = { + {"LAST_MILE_ORDER_LIST", "currentStatus", "WAIT_PDA_RECEIVING", "入库测量中"}, + {"LAST_MILE_ORDER_LIST", "orderTypeName", "FIRST_LEG", "头程订单"}, + {"LAST_MILE_ORDER_LIST", "businessSource.label", "LOCAL_LOOSEBOARD", "本土散板"}, + {"LAST_MILE_ORDER_LIST", "containerNode", "PICKUP_READY", "已清关、可提柜"}, + {"LAST_MILE_ORDER_LIST", "relabelStatus", "CUSTOMER_CONFIRM_PENDING", "待客户确认"}, + {"LAST_MILE_ORDER_LIST", "palletMergeFlag", "REQUIRED", "待合托"}, + {"OVERSEAS_INVENTORY_LEDGER", "businessType", "RETURN_STOCK_IN", "退回入库"}, + {"OVERSEAS_INVENTORY_LEDGER", "statusFrom", "PICKED", "已拣货"}, + {"OVERSEAS_INVENTORY_LEDGER", "statusTo", "SHIPPED", "已出库"}, + {"OVERSEAS_QUOTE_LIST", "status", "ACTIVE", "启用"}, + {"MONTHLY_EXCHANGE_RATE_LIST", "status", "INACTIVE", "停用"}, + {"FLEET_QUOTE_LIST", "status", "ACTIVE", "启用"}, + {"FLEET_QUOTE_LIST", "details.kind", "PURCHASE", "车队采购报价"}, + {"FLEET_QUOTE_LIST", "details.kind", "SALE", "外接销售报价"}, + {"VEHICLE_LIST", "status", "DISPATCHED", "外派中"}, + {"VEHICLE_LIST", "ownership", "DISPATCHED", "外派"}, + {"VEHICLE_LIST", "region", "OVERSEAS", "海外"}, + {"TRIP_OPERATIONS_LIST", "status", "COMPLETED", "货物已结案"} + }; + for (Object[] example : examples) { + assertEquals(example[3], LastlegExportLabels.value((String) example[0], (String) example[1], example[2]), example[0] + ":" + example[1]); + assertTrue(LastlegExportColumns.columns((String) example[0]).stream().anyMatch(c -> c.key().equals(example[1])), "映射必须对应实际导出列"); + } + } + + @Test void translatesCompositeSubOrdersAndAllVehicleStatuses() { + assertEquals("出库单:已出库;派送单:待派送", LastlegExportLabels.value("LAST_MILE_ORDER_LIST", "subOrderStatus", "OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY")); + assertEquals("入库单:部分入库", LastlegExportLabels.value("LAST_MILE_ORDER_LIST", "subOrderStatus", "INBOUND_PLAN:PARTIAL_INBOUND")); + for (String code : List.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED")) { + String label = (String) LastlegExportLabels.value("VEHICLE_LIST", "status", code); + assertTrue(label.matches(".*[一-鿿].*")); assertFalse(label.contains("未知")); + } + for (String code : List.of("OUTSIDE", "UNBOUND", "IN_TRANSIT", "RECEIVING", "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "SHIPPED")) { + assertFalse(LastlegExportLabels.value("OVERSEAS_INVENTORY_LEDGER", "statusTo", code).toString().contains("未知")); + } + for (String code : List.of("RECEIVING_WAREHOUSE_CHANGED", "INTERNAL_MOVE", "RELABEL_PALLET_DISASSEMBLE")) { + assertFalse(LastlegExportLabels.value("OVERSEAS_INVENTORY_LEDGER", "businessType", code).toString().contains("未知")); + } + } + + @Test void preservesBusinessIdentifiersAmountsDatesCurrenciesAndFreeText() { + for (String type : List.of("LAST_MILE_ORDER_LIST", "OVERSEAS_INVENTORY_BALANCE", "OVERSEAS_INVENTORY_LEDGER", "OVERSEAS_QUOTE_LIST", "MONTHLY_EXCHANGE_RATE_LIST", "VEHICLE_LIST", "FLEET_QUOTE_LIST", "TRIP_OPERATIONS_LIST", "SELFOP_INVENTORY_TURNOVER")) { + assertEquals("ACTIVE", LastlegExportLabels.value(type, "remark", "ACTIVE")); + assertEquals("ONT8", LastlegExportLabels.value(type, "warehouseCode", "ONT8")); + assertEquals("USD", LastlegExportLabels.value(type, "currency", "USD")); + assertEquals(BigDecimal.TEN, LastlegExportLabels.value(type, "amount", BigDecimal.TEN)); + assertNull(LastlegExportLabels.value(type, "status", null)); + } + assertEquals("已完成", LastlegExportLabels.value("LAST_MILE_ORDER_LIST", "currentStatus", "已完成")); + assertEquals("未知状态", LastlegExportLabels.value("TRIP_OPERATIONS_LIST", "status", "NEW_INTERNAL_STATE")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java index 2ab08ed38..9fe24da9a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java @@ -46,6 +46,17 @@ class LastlegExportProviderServiceTest { } @AfterEach void close() { validators.close(); } + @Test void rowProjectionTranslatesOnlyBusinessEnumsBeforeWritingBatches() { + Map row = ReflectionTestUtils.invokeMethod(service, "row", "FLEET_QUOTE_LIST", + Map.of("status", "ACTIVE", "quoteNo", "ACTIVE", "details", Map.of("kind", "SALE", "currency", "USD", "remark", "SALE")), + LastlegExportColumns.columns("FLEET_QUOTE_LIST")); + assertEquals("启用", row.get("status")); + assertEquals("外接销售报价", row.get("details.kind")); + assertEquals("ACTIVE", row.get("quoteNo")); + assertEquals("USD", row.get("details.currency")); + assertEquals("SALE", row.get("details.remark")); + } + @Test void batchesAllRowsAndBindsSnapshotToFiltersRequesterAndAttempt() { var row = new MonthlyExchangeRateDtos.Response("2026-10", "USD", "CNY", new BigDecimal("7.12"), "ACTIVE", 1, "operator", LocalDateTime.of(2026,10,8,12,0), null); when(rates.page(any())).thenAnswer(call -> {