This commit is contained in:
2026-11-05 14:53:54 +08:00
19 changed files with 462 additions and 41 deletions
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码', seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
remark varchar(1000) DEFAULT NULL COMMENT '证据备注', remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实', active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键', operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因', void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_container (container_booking_id,status), KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag), KEY idx_cabinet_evidence_seal (seal_no,active_flag),
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
@@ -174,7 +174,8 @@ public class GenericXlsxExportRunner {
case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns)); case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns));
case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact()); case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact());
case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue()); 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 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); } case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); }
} }
@@ -48,7 +48,7 @@ class GenericXlsxExportRunnerTest {
} }
@Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception { @Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception {
List<ExportColumn> 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<ExportColumn> 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<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>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<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>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 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); 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)); 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()); assertEquals(101,result.rows());
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){ 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()); 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()); assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue());
assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建"); assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建");
} }
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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);
}
}
@@ -339,35 +339,39 @@ public class FinanceExportProviderService {
} catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); } } catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); }
} }
/** 导出账户摘要,状态使用中文展示。 */
private Map<String, Object> accountRow(AccountSummary value) { private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(), "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<String, Object> ledgerRow(LedgerEntry value) { private Map<String, Object> 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(), "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
} }
/** 费用方向、来源类型及审核和结算状态统一展示中文。 */
private Map<String, Object> feeRow(FeeSourceSummary source) { private Map<String, Object> 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(), "sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(), "containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(), "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<String, Object> statementRow(ReconciliationDetail value) { private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
"periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(), "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()); "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
} }
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */ /** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
private Map<String, Object> settlementRow(SettlementSummary value) { private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), "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<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) { private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
@@ -375,18 +379,7 @@ public class FinanceExportProviderService {
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status())); "outstandingAmount", summary.outstandingAmount(), "status", FinanceExportLabels.status(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;
};
} }
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) { private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(), return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
@@ -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("部分结清");
}
}
@@ -45,6 +45,19 @@ class FinanceExportProviderServiceTest {
service.initializeSigningKey(); 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 @Test
/** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */ /** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */
void exportsSettlementStatusesInChinese() { void exportsSettlementStatusesInChinese() {
@@ -98,7 +111,7 @@ class FinanceExportProviderServiceTest {
assertThat(first.columns()).isNotEmpty(); assertThat(first.columns()).isNotEmpty();
assertThat(first.rows()).hasSize(1); 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(first.totalRows()).isEqualTo(1L);
assertThat(continued.columns()).isEmpty(); assertThat(continued.columns()).isEmpty();
assertThat(continued.rows()).isEmpty(); assertThat(continued.rows()).isEmpty();
@@ -119,7 +132,7 @@ class FinanceExportProviderServiceTest {
var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST, var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST,
request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null)); request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> { 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")); assertThat(row.get("amount")).isEqualTo(new BigDecimal("300"));
}); });
org.mockito.Mockito.verifyNoInteractions(queryService); org.mockito.Mockito.verifyNoInteractions(queryService);
@@ -254,6 +254,9 @@ public class ContainerCustomsService {
throw conflict("当前柜尚未完成首次发送报关行资料"); throw conflict("当前柜尚未完成首次发送报关行资料");
} }
CompletionRow completion = mapper.lockCompletion(documentId); CompletionRow completion = mapper.lockCompletion(documentId);
if (completion != null && "CONFIRMED".equals(completion.customerConfirmStatus())) {
throw conflict("客户已确认预录单,不可再次上传");
}
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId; long completionId;
int version; int version;
@@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingE
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id") @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId); List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE") @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 ORDER BY id DESC LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId, CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type); @Param("type") String type);
@@ -74,8 +74,6 @@ public class CabinetLoadingEvidenceService {
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试"); if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId()) if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope); && Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId); value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName()); value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
@@ -84,8 +82,7 @@ public class CabinetLoadingEvidenceService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) { try { mapper.insert(value); } catch (DuplicateKeyException exception) {
// A replacement may already have voided the old row in this transaction. // The task-scoped operation key protects concurrent duplicate uploads.
// Roll it back; a subsequent same-key request can read the committed winner.
throw conflict("装柜证据发生并发变更,请刷新后重试"); throw conflict("装柜证据发生并发变更,请刷新后重试");
} }
return view(scope); return view(scope);
@@ -0,0 +1,16 @@
-- Existing databases: allow multiple active photos per evidence type. No photo rows are changed.
USE baiyue_firstleg;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='uk_cabinet_evidence_active'
), 'ALTER TABLE fl_loading_cabinet_evidence DROP INDEX uk_cabinet_evidence_active', 'SELECT 1');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='idx_cabinet_evidence_active'
), 'SELECT 1', 'ALTER TABLE fl_loading_cabinet_evidence ADD INDEX idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码', seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码',
remark VARCHAR(1000) NULL COMMENT '证据备注', remark VARCHAR(1000) NULL COMMENT '证据备注',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效唯一标识;作废后置NULL', active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效标识;同类型允许多张,作废后置NULL',
operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键', operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason VARCHAR(500) NULL COMMENT '作废原因', void_reason VARCHAR(500) NULL COMMENT '作废原因',
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
KEY idx_cabinet_evidence_container (container_booking_id,status), KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag), KEY idx_cabinet_evidence_seal (seal_no,active_flag),
@@ -140,6 +140,7 @@ paths:
responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}} responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}}
post: post:
summary: 通过Firstleg代理上传并登记当前柜次装柜证据 summary: 通过Firstleg代理上传并登记当前柜次装柜证据
description: 同类型照片追加保留,不替换已有有效照片。expectedVersion取当前同类型最新有效照片版本,无照片时为0;重复幂等键不追加。
parameters: parameters:
- {$ref: '#/components/parameters/TaskId'} - {$ref: '#/components/parameters/TaskId'}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
@@ -304,6 +304,19 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString()); verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString());
} }
@Test void receiptUploadRejectsAfterCustomerConfirmationBeforeBrokerExecution(){
var source=document(1L);var declaring=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("confirmed"),anyString())).thenReturn(1);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(declaring);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission());
when(mapper.lockCompletion(1L)).thenReturn(new ContainerCustomsMapper.CompletionRow(21L,1L,99L,
"receipt.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1,"CONFIRMED",null));
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","confirmed",1,
new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1})))
.isInstanceOf(BizException.class).hasMessageContaining("客户已确认预录单");
verifyNoInteractions(files);
}
@Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){ @Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1); when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1);
@@ -58,7 +58,7 @@ class CabinetLoadingEvidenceTransactionTest {
}).collect(Collectors.joining(",")); }).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")"); jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)"); jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_seal UNIQUE(seal_no,active_flag)"); jdbc.execute("CREATE INDEX idx_active ON fl_loading_cabinet_evidence(container_booking_id,evidence_type,active_flag)");
String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields()) String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> .filter(field -> !Modifier.isStatic(field.getModifiers())).map(field ->
field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " " field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " "
@@ -98,7 +98,7 @@ class CabinetLoadingEvidenceTransactionTest {
} }
@Test @Test
void replacementInsertFailureRollsBackOldActiveRowAndSameRequestCanRetry() { void appendInsertFailureRetainsOldActiveRowAndSameRequestCanRetry() {
nextFile = 301; upload("original"); nextFile = 301; upload("original");
var before = snapshot(); var before = snapshot();
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)"); jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)");
@@ -108,11 +108,11 @@ class CabinetLoadingEvidenceTransactionTest {
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file"); jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file");
upload("replacement"); upload("replacement");
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus())) assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getFileId()).isEqualTo(302L)); .extracting(CabinetLoadingEvidenceEntity::getFileId).containsExactly(301L, 302L);
} }
@Test @Test
void duplicateKeyAfterStaleReplayReadsMustRollBackPriorVoid() { void duplicateKeyAfterStaleReplayReadsRetainsAllPhotos() {
nextFile = 301; upload("original"); nextFile = 301; upload("original");
nextFile = 302; upload("winner"); nextFile = 302; upload("winner");
var before = snapshot(); var before = snapshot();
@@ -167,7 +167,7 @@ class CabinetLoadingEvidenceTransactionTest {
boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS); boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS);
assertThat(firstPassed || secondPassed).isTrue(); assertThat(firstPassed || secondPassed).isTrue();
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus())) assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getOperationKey()).isEqualTo("concurrent")); .extracting(CabinetLoadingEvidenceEntity::getOperationKey).containsExactly("original", "concurrent");
var committed = snapshot(); var committed = snapshot();
upload("concurrent"); upload("concurrent");
assertThat(snapshot()).isEqualTo(committed); assertThat(snapshot()).isEqualTo(committed);
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> labels, String fallback) {
return labels.getOrDefault(value.trim().toUpperCase(Locale.ROOT), value.matches(".*[一-鿿].*") ? value : fallback);
}
}
@@ -47,6 +47,7 @@ public class LastlegExportProviderService {
@Resource private Validator validator; @Resource private Validator validator;
@Value("${baiyue.security.internal-service-token}") private String signingKey; @Value("${baiyue.security.internal-service-token}") private String signingKey;
/** 校验平台导出快照,复用列表筛选并生成中文业务数据批次。 */
public ExportBatchResponse batch(String type, ExportBatchRequest request) { public ExportBatchResponse batch(String type, ExportBatchRequest request) {
if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) { if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出"); 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(); case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page();
default -> throw invalid("不支持该导出类型"); default -> throw invalid("不支持该导出类型");
}; };
List<Map<String, Object>> rows = page.records().stream().map(value -> row(value, columns)).toList(); List<Map<String, Object>> rows = page.records().stream().map(value -> row(type, value, columns)).toList();
int next = offset + rows.size(); int next = offset + rows.size();
if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进"); if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进");
return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows, return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows,
@@ -98,7 +99,8 @@ public class LastlegExportProviderService {
} catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); } } catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); }
} }
private Map<String, Object> row(Object value, List<ExportColumn> columns) { /** 按显式列投影业务数据,仅对指定枚举字段进行中文转换。 */
private Map<String, Object> row(String type, Object value, List<ExportColumn> columns) {
JsonNode source = json.valueToTree(value); JsonNode source = json.valueToTree(value);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
for (ExportColumn column : columns) { for (ExportColumn column : columns) {
@@ -106,12 +108,11 @@ public class LastlegExportProviderService {
for (String part : column.key().split("\\.")) node = node.path(part); for (String part : column.key().split("\\.")) node = node.path(part);
Object cell = node.isMissingNode() || node.isNull() ? null Object cell = node.isMissingNode() || node.isNull() ? null
: node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText(); : 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) { if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) {
result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否"); result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否");
result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否"); 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) { if (value instanceof TripOperationsModels.Row trip) {
result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / "))); result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / ")));
@@ -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"));
}
}
@@ -46,6 +46,17 @@ class LastlegExportProviderServiceTest {
} }
@AfterEach void close() { validators.close(); } @AfterEach void close() { validators.close(); }
@Test void rowProjectionTranslatesOnlyBusinessEnumsBeforeWritingBatches() {
Map<String, Object> 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() { @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); 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 -> { when(rates.page(any())).thenAnswer(call -> {