Series Article

Day07 · FMS + BI 财务结算与数据分析完整面试指南

系统概述

1.1 系统定位

FMS(Finance Management System)财务结算系统 + BI(Business Intelligence)数据分析系统是跨境电商供应链 SaaS 平台的决策支撑模块,负责把钱算清楚、把数据变成决策。

FMS 核心价值:

  1. 平台账单对账: 导入 Amazon、TikTok Shop、Shopee 等平台结算报告,自动匹配订单,识别差异
  2. SKU 级利润核算: 整合销售收入、采购成本、物流费、平台费、广告费、退款、VAT,计算真实利润
  3. 多货币管理: 支持 USD、EUR、GBP、JPY 等多币种,汇率快照,汇兑损益
  4. 现金流预测: 预测未来 30 天资金流入流出,提前发现资金缺口

BI 核心价值:

  1. 经营看板: GMV、订单量、净利润率、库存周转率等核心 KPI
  2. KPI 预警: 缺货率、低利润、物流异常、采购延迟等指标超阈值自动告警
  3. 销售预测: 基于历史销量预测未来日均销量
  4. 智能补货: 结合销售预测、Lead Time、安全库存计算补货建议

1.2 业务规模

  • 日均账单处理: 100+ 份平台账单,30 万+ 行明细
  • 利润核算: 5000+ SKU 的日利润快照和月利润快照
  • 支持平台: Amazon、TikTok Shop、Shopee、Temu、Shopify 等
  • 支持币种: USD、EUR、GBP、JPY、CNY 等 10+ 种货币
  • BI 指标: 50+ 个核心 KPI,20+ 个预警规则

1.3 技术栈

  • 后端框架: Spring Boot 3.2、MyBatis-Plus 3.5
  • 数据库: MySQL 8.0(主从架构)、分库分表(ShardingSphere)
  • 缓存: Redis 7.0(报表缓存、KPI 缓存)
  • 消息队列: RocketMQ 5.0(账单解析、利润核算通知)
  • 定时任务: XXL-Job 2.4(汇率同步、ETL 聚合、KPI 计算)
  • 文件处理: EasyExcel 3.3(账单导入、VAT 导出)
  • 对象存储: OSS(账单原始文件存储)
  • 前端图表: ECharts 5.4(Dashboard 可视化)

核心业务流程

2.1 平台账单对账完整流程

平台账单对账是 FMS 最核心的业务流程,解决平台结算金额与系统订单金额不一致的问题。

业务背景:

跨境卖家在 Amazon、TikTok Shop 等平台开店,订单在平台产生,但平台最终打给卖家的钱不等于订单销售额。比如买家支付 29.99 美元,平台会扣推荐费、FBA 配送费、广告费、仓储费,还可能有退款和调整项。如果只看 OMS 订单金额,会误以为赚了很多钱,但实际到账可能少很多。

完整流程(10 步):

第一步:获取平台账单

财务专员从平台后台下载结算报告(CSV 或 Excel),或系统通过平台 API 定时拉取账单数据。

技术上,账单来源有三种:平台 API 拉取(优先)、文件上传(常用)、模板导入(小平台)。

第二步:上传账单文件

财务专员上传账单文件到系统。系统先将文件上传到 OSS 对象存储,获取文件 URL。

技术上,不会在上传接口中同步解析文件,因为文件可能很大(几万行),会导致请求超时。正确做法是先上传到 OSS,再异步解析。

第三步:创建账单主表记录

系统创建 finance_platform_bill 主账单记录,保存平台、店铺、结算周期、文件 URL 等信息,状态为”待解析”。

第四步:异步解析账单

系统将解析任务提交给线程池或 MQ,异步执行。解析任务从 OSS 下载文件,流式读取每一行。

技术上,使用 EasyExcel 或 CSV Reader 流式读取,不一次性加载到内存。每读取一行,识别费用类型(商品收入、退款、推荐费、FBA 费、广告费、仓储费、调整项),转换为 finance_bill_item 明细记录。

第五步:批量写入明细表

解析出的明细记录不是单条插入,而是每 1000 条批量插入一次,减少数据库交互次数。

第六步:汇总主账单

明细解析完成后,按费用类型汇总:销售收入、退款金额、平台推荐费、FBA 配送费、广告费、仓储费,计算净到账金额,回写到主账单表。

第七步:自动匹配订单

系统根据平台订单号(platform_order_no)匹配 OMS 内部订单。匹配成功的明细记录内部订单 ID,标记为”已匹配”。

技术上,使用 SQL JOIN 或批量查询实现:UPDATE finance_bill_item bi JOIN order_main om ON bi.order_no = om.platform_order_no SET bi.is_matched = 1, bi.match_order_id = om.id WHERE bi.bill_id = ? AND bi.is_matched = 0

第八步:生成差异清单

匹配不上的明细进入差异清单。差异分三类:

  1. 账单有系统无: 平台有这笔费用,但系统没有对应订单(可能是订单同步失败、平台调整项、广告费、仓储费)
  2. 系统有账单无: 系统有订单,但账单没有记录(可能是跨结算周期、订单未到结算期、平台冻结款项)
  3. 金额不符: 订单金额和账单金额不一致(可能是平台费率变化、退款调整、汇率差异)

第九步:财务确认差异

财务专员查看差异清单,分析原因,标注处理结果。系统提供可能原因提示,但最终需要人工确认。

第十步:对账完成,触发利润核算

对账完成后,账单状态更新为”对账完成”。系统触发 SKU 利润核算,使用平台真实扣费重新计算利润快照。

2.2 SKU 级利润核算完整流程

SKU 级利润核算是 FMS 的第二核心流程,解决”哪些商品真正赚钱”的问题。

业务背景:

跨境卖家最常见的问题是:整体销售额看起来不错,但不知道哪些商品赚钱,哪些商品亏钱。一个 SKU 的真实利润不能只看售价和采购价,还要扣平台推荐费、FBA 配送费、头程物流费、广告费、仓储费、退款损失、VAT 税费。

利润核算数据来源:

数据来源模块说明
销售收入OMS / 平台账单优先使用对账后的平台账单口径
采购成本PMS / WMS采购入库后的加权平均成本
物流费TMS运单实际费用或头程费用分摊
平台费FMS账单明细中的推荐费、FBA 费
广告费FMS / 平台广告账单按 SKU 或销售额分摊
仓储费FMS / WMS按库存占用分摊
退款损失OMS / WMS退款金额减去退货残值
VAT 税费FMS按目的国税率计算

完整流程(8 步):

第一步:获取销售收入

从平台账单或 OMS 订单获取销售收入。最终确认利润时,优先使用平台账单口径,因为平台账单代表平台实际认可并结算的数据。

第二步:统一币种

Amazon 美国站是 USD,欧洲站是 EUR,日本站是 JPY。所有收入和费用都保存原币金额,同时按交易日或结算日汇率折算成人民币。

技术上,金额字段设计为:amount_origin(原币金额)、currency(币种)、amount_cny(人民币金额)、exchange_rate(汇率)、rate_date(汇率日期)。

第三步:获取采购成本

从 WMS 获取 SKU 的加权平均成本,乘以销售数量。

加权平均成本公式:avg_cost = (原库存 × 原成本 + 新入库 × 新成本) / 总库存

第四步:获取物流费

从 TMS 获取运单实际费用。如果是 FBA 订单,物流费已包含在 FBA 费中,不重复计算。

第五步:获取平台费

finance_bill_item 获取该订单的平台推荐费、FBA 配送费。

第六步:分摊广告费和仓储费

广告费和仓储费通常是周期性费用,不能直接归属到某笔订单,需要按规则分摊。

广告费分摊规则:按 SKU 销售额或销量分摊。比如某 SKU 本月销售额占店铺总销售额的 10%,就分摊 10% 的广告费。

仓储费分摊规则:按库存占用天数分摊。比如某 SKU 占用 FBA 仓库 30 天,占用体积 1 立方米,就按占用天数和体积分摊仓储费。

第七步:计算毛利润和净利润

毛利润公式:毛利润 = 销售收入 - 采购成本 - 平台手续费 - 物流费

净利润公式:净利润 = 毛利润 - 广告费 - 仓储费 - 退款损失 - VAT 税费 - 其他成本

净利润率公式:净利润率 = 净利润 / 销售收入 × 100%

第八步:写入利润快照

将计算结果写入 finance_profit_snapshot 表,按租户、SKU、平台、店铺、日期维度存储。支持日快照和月快照。

技术上,使用 INSERT ... ON DUPLICATE KEY UPDATE 保证幂等,定时任务重复执行也不会产生重复数据。

2.3 BI 指标看板与智能补货完整流程

BI 不是简单做图表,而是把 OMS、WMS、PMS、TMS、FMS 的数据汇总成经营指标和决策建议。

业务背景:

老板关心 GMV、净利润、现金流;运营关心 SKU 利润和广告 ACOS;采购关心补货建议和供应商准时率;仓库关心库存周转、缺货率和积压率;财务关心账单差异、应收应付和 VAT。

BI 数据处理流程(7 步):

第一步:ETL 定时聚合

每天凌晨或每小时,定时任务从 OMS、WMS、PMS、TMS、FMS 抽取数据。

第二步:数据清洗和转换

统一时间口径(订单时间、发货时间、签收时间)、统一币种(折算为人民币)、统一租户隔离。

第三步:计算 KPI 指标

计算 GMV、订单量、客单价、净利润率、库存周转率、缺货率、积压率、物流异常率、采购准时率等 50+ 个 KPI。

第四步:写入 BI 汇总表

将计算结果写入 bi_kpi_daily(日汇总)、bi_kpi_monthly(月汇总)等表。

第五步:写入 Redis 缓存

将高频查询的报表数据写入 Redis,比如首页经营总览、近 30 天销售趋势、SKU 利润排行榜、库存健康统计。

第六步:KPI 阈值判断

将计算出的 KPI 与 bi_kpi_threshold 阈值表对比。如果超过阈值,生成预警记录,并通知对应角色。

比如:缺货率 > 2% 黄色预警,> 5% 红色预警;净利润率 < 10% 预警,< 0 亏损预警。

第七步:生成补货建议

根据历史销量预测未来日均销量,结合当前可用库存、在途库存、供应商 Lead Time 和安全库存天数,计算建议补货量。

智能补货流程(5 步):

第一步:统计历史销量

统计 SKU 最近 7 天、8-14 天、15-30 天的日均销量。

第二步:加权移动平均预测

预测日均销量公式:预测日均销量 = 近 7 天日均 × 50% + 近 8-14 天日均 × 30% + 近 15-30 天日均 × 20%

这样既参考历史,又让最近的销量变化对预测更敏感。

第三步:计算目标库存

目标库存公式:目标库存 = 预测日均销量 × (Lead Time + 安全天数)

比如:预测每天卖 10 件,供应商 Lead Time 7 天,安全天数 15 天,目标库存 = 10 × (7 + 15) = 220 件。

第四步:计算建议补货量

建议补货量公式:建议补货量 = 目标库存 - (可用库存 + 在途库存)

比如:目标库存 220 件,可用库存 80 件,在途库存 30 件,建议补货量 = 220 - (80 + 30) = 110 件。

第五步:生成补货建议单

如果建议补货量 > 0,系统生成补货建议单,推送给采购专员。采购专员确认后,可以一键转成 PMS 采购申请。


技术架构设计

3.1 整体架构

FMS + BI 采用微服务架构,与 OMS、WMS、PMS、TMS 等模块通过 RPC 和 MQ 通信。

核心模块:

  1. 账单管理模块: 账单导入、解析、对账、差异处理
  2. 利润核算模块: SKU 利润计算、成本分摊、利润快照
  3. 汇率管理模块: 汇率同步、汇率快照、汇兑损益
  4. 现金流模块: 应收应付、现金流预测、资金预警
  5. VAT 管理模块: VAT 申报数据生成、导出、缴纳记录
  6. BI 数据模块: ETL 聚合、KPI 计算、报表缓存
  7. 预警模块: KPI 阈值判断、预警通知
  8. 补货模块: 销售预测、补货建议

技术架构图:

┌─────────────────────────────────────────────────────────┐
│                      前端层                              │
│         商家后台 / 财务后台 / BI Dashboard               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    API 网关层                            │
│          Spring Cloud Gateway + Sentinel                │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                 FMS + BI 业务层                          │
│  ┌──────────┬──────────┬──────────┬──────────┐         │
│  │账单管理  │利润核算  │BI 数据   │预警补货  │         │
│  └──────────┴──────────┴──────────┴──────────┘         │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                  基础设施层                              │
│  MySQL + Redis + RocketMQ + XXL-Job + OSS + ECharts   │
└─────────────────────────────────────────────────────────┘

3.2 数据流转

账单对账数据流:

  1. 财务上传账单文件 → OSS 存储 → 创建主账单记录
  2. 异步解析任务 → 流式读取 → 批量写入明细表
  3. 汇总主账单 → 自动匹配订单 → 生成差异清单
  4. 财务确认差异 → 对账完成 → 触发利润核算

利润核算数据流:

  1. 获取销售收入(平台账单/OMS) → 统一币种
  2. 获取采购成本(WMS) → 获取物流费(TMS)
  3. 获取平台费(FMS) → 分摊广告费和仓储费
  4. 计算毛利润和净利润 → 写入利润快照

BI 数据流:

  1. ETL 定时聚合 → 从各模块抽取数据
  2. 清洗转换 → 计算 KPI → 写入汇总表
  3. 写入 Redis 缓存 → Dashboard 展示
  4. KPI 阈值判断 → 生成预警通知
  5. 销售预测 → 生成补货建议

核心技术亮点

4.1 大文件账单解析与自动对账

问题背景:

平台账单文件可能有几万行,甚至几十万行。如果用户上传后在 HTTP 请求线程里同步解析,会出现三个问题:请求超时、文件一次性读入内存容易 OOM、单条插入数据库性能很差。

技术方案:

核心方案是:OSS 存储原始文件 + 账单状态机 + 线程池或 MQ 异步解析 + EasyExcel 流式读取 + 批量插入 + 自动匹配。

账单状态设计:

  • 待解析(0)
  • 解析中(1)
  • 已解析(2)
  • 对账完成(3)
  • 有差异(4)
  • 解析失败(5)

具体实现:

@Transactional(rollbackFor = Exception.class)
public Long uploadBill(BillUploadCommand command) {
    String fileUrl = command.getFileUrl();
    
    PlatformBill bill = new PlatformBill();
    bill.setTenantId(command.getTenantId());
    bill.setPlatform(command.getPlatform());
    bill.setStoreId(command.getStoreId());
    bill.setSourceFileUrl(fileUrl);
    bill.setStatus(BillStatus.PENDING_PARSE.getCode());
    platformBillMapper.insert(bill);
    
    // 提交异步解析任务
    billParseExecutor.execute(() -> billParseTaskService.parseBill(bill.getId()));
    return bill.getId();
}

解析任务流式读取,每 1000 条批量插入:

public void parseBill(Long billId) {
    updateStatus(billId, BillStatus.PARSING);
    
    try (InputStream inputStream = downloadBillFile(billId)) {
        List<FinanceBillItem> batch = new ArrayList<>(BATCH_SIZE);
        CsvReader reader = CsvReader.builder().build(inputStream);
        
        for (CsvRow row : reader) {
            FinanceBillItem item = convertToBillItem(billId, row);
            batch.add(item);
            
            if (batch.size() >= BATCH_SIZE) {
                billItemMapper.insertBatch(batch);
                batch.clear();
            }
        }
        
        if (!batch.isEmpty()) {
            billItemMapper.insertBatch(batch);
        }
        
        calculateSummary(billId);
        matchOrders(billId);
        updateStatus(billId, BillStatus.PARSED);
    } catch (Exception e) {
        updateStatus(billId, BillStatus.PARSE_FAILED);
        throw new BusinessException("账单解析失败");
    }
}

实施效果:

  1. 上传接口从分钟级等待变成秒级返回
  2. 支持 100MB 级账单文件解析
  3. 解析失败可重试,原始文件可审计

4.2 SKU 级利润快照与成本分摊

问题背景:

FMS 最能体现业务深度的地方就是利润核算。如果只做销售额统计,面试官很容易觉得这是普通报表。但如果能讲清楚 SKU 级全成本拆解、费用分摊、预估利润和确认利润,就能体现真实供应链财务系统的复杂度。

快照设计:

利润快照表按租户、SKU、平台、店铺、日期维度存储结果:

CREATE TABLE finance_profit_snapshot (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    snapshot_type TINYINT NOT NULL COMMENT '1日快照 2月快照',
    snapshot_date DATE NOT NULL,
    sku_id BIGINT NOT NULL,
    platform VARCHAR(32),
    store_id BIGINT,
    
    gross_revenue_cny DECIMAL(12,2) COMMENT '销售收入',
    purchase_cost DECIMAL(12,2) COMMENT '采购成本',
    logistics_fee DECIMAL(12,2) COMMENT '物流费用',
    platform_fee DECIMAL(12,2) COMMENT '平台手续费',
    advertising_fee DECIMAL(12,2) COMMENT '广告费',
    storage_fee DECIMAL(12,2) COMMENT '仓储费',
    refund_loss DECIMAL(12,2) COMMENT '退款损失',
    vat_fee DECIMAL(12,2) COMMENT 'VAT税费',
    
    gross_profit DECIMAL(12,2) COMMENT '毛利润',
    net_profit DECIMAL(12,2) COMMENT '净利润',
    net_margin DECIMAL(8,4) COMMENT '净利润率',
    
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_snapshot (tenant_id, snapshot_type, snapshot_date, sku_id, platform, store_id)
);

成本分摊原则:

有些费用可以直接归属到订单,比如平台推荐费、FBA 费、退款。有些费用不能直接归属,比如月度广告费、仓储费,需要按规则分摊。

常见分摊规则:

  1. 按销量分摊: 某 SKU 销量占比 × 总费用
  2. 按销售额分摊: 某 SKU 销售额占比 × 总费用
  3. 按库存占用分摊: 某 SKU 库存占用天数和体积占比 × 总费用
  4. 按毛利贡献分摊: 某 SKU 毛利占比 × 总费用

分摊规则必须固定,并且要在系统里留痕,否则财务报表前后口径不一致。

实施效果:

  1. 支持 SKU、平台、店铺、日期多维度利润分析
  2. 自动识别亏损 SKU 和低利润 SKU
  3. 帮助运营从看销量转向看利润

4.3 多货币汇率快照与汇兑损益

问题背景:

跨境卖家会同时面对 USD、EUR、GBP、JPY 等货币。订单发生时一个汇率,平台结算时一个汇率,实际换汇时可能又是另一个汇率。如果系统只存人民币金额,后续无法解释差异;如果只存外币金额,报表又无法统一比较。

技术方案:

双字段存储 + 汇率快照 + 汇兑损益单独记录。

双字段存储:

财务金额同时保存:

  • 原币金额:amount_origin
  • 原币币种:currency
  • 折算人民币金额:amount_cny
  • 使用汇率:exchange_rate
  • 汇率日期:rate_date

这样既能保留原始业务事实,又能支撑人民币口径报表。

汇率同步任务:

@XxlJob("syncExchangeRateJob")
public void syncExchangeRateJob() {
    LocalDate rateDate = LocalDate.now();
    Map<String, BigDecimal> latestRates = exchangeRateClient.queryLatestRates();
    
    for (Map.Entry<String, BigDecimal> entry : latestRates.entrySet()) {
        ExchangeRate rate = new ExchangeRate();
        rate.setRateDate(rateDate);
        rate.setCurrency(entry.getKey());
        rate.setRateToCny(entry.getValue());
        rate.setRateSource("OpenExchange");
        exchangeRateMapper.upsert(rate);
    }
    
    exchangeRateAlertService.checkFluctuation(rateDate);
}

汇兑损益:

汇兑损益不是订单错误,而是汇率变化导致的财务差异。

比如 1 月 1 日平台有 10000 美元待结算,当天汇率 7.20,账面价值 72000 元。1 月 15 日平台打款时汇率变成 7.15,实际折算 71500 元,发生 500 元汇兑损失。

这笔损失一般记录到财务损益里,不会修改原订单金额。

实施效果:

  1. 历史财务记录不会因汇率变化被污染
  2. 汇兑损益可单独统计
  3. 多平台多币种报表能统一折算成人民币口径

4.4 BI ETL 聚合与 Redis 报表缓存

问题背景:

BI 看板需要跨订单、库存、采购、物流、财务多模块统计。如果每次打开看板都实时查询这些业务表,SQL 会很复杂,响应会很慢,还会影响业务库性能。

技术方案:

采用定时 ETL 聚合 + BI 汇总表 + Redis 热点报表缓存。

ETL 聚合流程:

  1. 抽取(Extract): 从 OMS、WMS、PMS、TMS、FMS 抽取数据
  2. 转换(Transform): 清洗时间、币种、租户等口径,计算 KPI
  3. 加载(Load): 写入 BI 汇总表

KPI 指标体系:

  • 销售类: GMV、订单量、客单价、退款率
  • 库存类: 库存周转率、库存天数、缺货率、积压率
  • 采购类: 准时到货率、质量合格率
  • 物流类: 正常签收率、物流异常率、平均配送时效
  • 财务类: 毛利润率、净利润率、广告 ACOS

Redis 缓存策略:

适合缓存的数据:

  • 首页经营总览
  • 近 30 天销售趋势
  • SKU 利润排行榜
  • 库存健康统计
  • KPI 看板

缓存更新策略:

  • 实时看板设置短 TTL,比如 5 分钟
  • 日报表设置 1 小时 TTL
  • 月报表设置 24 小时 TTL
  • 订单完成、账单对账完成、库存变动后主动删除相关缓存

实施效果:

  1. 首页看板响应从秒级降低到毫秒级
  2. 指标口径统一,避免各模块重复计算
  3. 报表查询压力从业务表转移到汇总表和缓存

4.5 KPI 阈值预警机制

问题背景:

报表只能告诉用户已经发生了什么,不能主动提醒业务风险。需要设计 KPI 预警机制,让系统从被动报表升级为主动预警。

技术方案:

设计 bi_kpi_threshold 阈值配置表,支持租户级 KPI 预警。

阈值配置表设计:

CREATE TABLE bi_kpi_threshold (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    kpi_code VARCHAR(64) NOT NULL COMMENT 'KPI编码',
    kpi_name VARCHAR(128) NOT NULL COMMENT 'KPI名称',
    
    yellow_threshold DECIMAL(12,4) COMMENT '黄色预警阈值',
    red_threshold DECIMAL(12,4) COMMENT '红色危险阈值',
    compare_type TINYINT NOT NULL COMMENT '1大于触发 2小于触发',
    
    notify_roles VARCHAR(256) COMMENT '通知角色',
    is_enabled TINYINT NOT NULL DEFAULT 1,
    
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_tenant_kpi (tenant_id, kpi_code)
);

预警流程:

  1. 定时任务计算 KPI
  2. 读取 bi_kpi_threshold 阈值配置
  3. 判断是否超过阈值
  4. 超过阈值生成预警记录
  5. 查询通知角色
  6. 发送站内信或企业微信通知

预警示例:

  • 缺货率 > 2% 黄色预警,> 5% 红色预警
  • 净利润率 < 10% 预警,< 0 亏损预警
  • 物流异常率 > 3% 预警
  • 采购准时率 < 90% 预警

实施效果:

  1. 缺货、亏损、物流异常可以提前暴露
  2. 不同租户可以配置自己的经营阈值
  3. BI 从被动报表升级为主动预警

4.6 销售预测与智能补货建议

问题背景:

补货太少会断货,补货太多会积压库存,占用现金流。跨境供应链还有 Lead Time,今天下采购单可能 7 天、15 天甚至 30 天后才到货,所以不能等库存快没了才补。

技术方案:

用加权移动平均预测日销量,再结合库存和供应商交期计算补货建议。

加权移动平均算法:

public BigDecimal calculateForecastDailySales(SalesWindow window) {
    BigDecimal avg7 = window.getAvgLast7Days();
    BigDecimal avg14 = window.getAvgLast8To14Days();
    BigDecimal avg30 = window.getAvgLast15To30Days();
    
    return avg7.multiply(new BigDecimal("0.50"))
        .add(avg14.multiply(new BigDecimal("0.30")))
        .add(avg30.multiply(new BigDecimal("0.20")))
        .setScale(2, RoundingMode.HALF_UP);
}

补货建议计算:

public ReplenishmentSuggestion calculateSuggestion(ReplenishmentContext context) {
    BigDecimal forecastDailySales = context.getForecastDailySales();
    int leadTime = context.getLeadTimeDays();
    int safetyDays = context.getSafetyStockDays();
    
    BigDecimal targetStock = forecastDailySales
        .multiply(BigDecimal.valueOf(leadTime + safetyDays));
    
    BigDecimal currentStock = BigDecimal.valueOf(
        context.getAvailableQty() + context.getInTransitQty());
    BigDecimal suggestionQty = targetStock.subtract(currentStock);
    
    if (suggestionQty.compareTo(BigDecimal.ZERO) <= 0) {
        return ReplenishmentSuggestion.noNeed(context.getSkuId());
    }
    
    return ReplenishmentSuggestion.needPurchase(
        context.getSkuId(),
        suggestionQty.setScale(0, RoundingMode.CEILING).intValue()
    );
}

ABC 分类联动:

A 类商品贡献高,断货影响大,安全天数可以高一些。B 类商品按正常安全库存管理。C 类商品贡献低,安全库存可以低一些,避免资金占用。

实施效果:

  1. 提前发现断货风险
  2. 减少经验式补货带来的库存积压
  3. 采购计划和销售预测形成闭环

数据库设计

5.1 核心表结构

平台账单主表(finance_platform_bill):

CREATE TABLE finance_platform_bill (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    bill_no VARCHAR(64) NOT NULL COMMENT '账单号',
    platform VARCHAR(32) NOT NULL COMMENT '平台',
    store_id BIGINT NOT NULL COMMENT '店铺ID',
    
    settlement_start_date DATE COMMENT '结算开始日期',
    settlement_end_date DATE COMMENT '结算结束日期',
    
    source_file_url VARCHAR(512) COMMENT '原始文件URL',
    status TINYINT NOT NULL COMMENT '0待解析 1解析中 2已解析 3对账完成 4有差异 5解析失败',
    
    total_sales DECIMAL(12,2) COMMENT '销售总额',
    total_refund DECIMAL(12,2) COMMENT '退款总额',
    total_platform_fee DECIMAL(12,2) COMMENT '平台费用',
    total_advertising_fee DECIMAL(12,2) COMMENT '广告费',
    total_storage_fee DECIMAL(12,2) COMMENT '仓储费',
    net_amount DECIMAL(12,2) COMMENT '净到账金额',
    
    currency VARCHAR(8) COMMENT '币种',
    
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_bill_no (bill_no),
    KEY idx_tenant_platform (tenant_id, platform),
    KEY idx_status (status)
);

账单明细表(finance_bill_item):

CREATE TABLE finance_bill_item (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    bill_id BIGINT NOT NULL COMMENT '账单主表ID',
    
    order_no VARCHAR(128) COMMENT '平台订单号',
    sku_code VARCHAR(128) COMMENT 'SKU编码',
    
    item_type VARCHAR(32) NOT NULL COMMENT '明细类型:销售/退款/推荐费/FBA费/广告费/仓储费/调整',
    amount_origin DECIMAL(12,2) NOT NULL COMMENT '原币金额',
    currency VARCHAR(8) NOT NULL COMMENT '币种',
    amount_cny DECIMAL(12,2) COMMENT '人民币金额',
    
    is_matched TINYINT DEFAULT 0 COMMENT '是否已匹配订单',
    match_order_id BIGINT COMMENT '匹配的内部订单ID',
    
    create_time DATETIME NOT NULL,
    
    KEY idx_bill_id (bill_id),
    KEY idx_order_no (order_no),
    KEY idx_matched (is_matched)
);

利润快照表(finance_profit_snapshot):

CREATE TABLE finance_profit_snapshot (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    snapshot_type TINYINT NOT NULL COMMENT '1日快照 2月快照',
    snapshot_date DATE NOT NULL,
    sku_id BIGINT NOT NULL,
    platform VARCHAR(32),
    store_id BIGINT,
    
    gross_revenue_cny DECIMAL(12,2) COMMENT '销售收入',
    purchase_cost DECIMAL(12,2) COMMENT '采购成本',
    logistics_fee DECIMAL(12,2) COMMENT '物流费用',
    platform_fee DECIMAL(12,2) COMMENT '平台手续费',
    advertising_fee DECIMAL(12,2) COMMENT '广告费',
    storage_fee DECIMAL(12,2) COMMENT '仓储费',
    refund_loss DECIMAL(12,2) COMMENT '退款损失',
    vat_fee DECIMAL(12,2) COMMENT 'VAT税费',
    
    gross_profit DECIMAL(12,2) COMMENT '毛利润',
    net_profit DECIMAL(12,2) COMMENT '净利润',
    net_margin DECIMAL(8,4) COMMENT '净利润率',
    
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_snapshot (tenant_id, snapshot_type, snapshot_date, sku_id, platform, store_id)
);

业务面试题

Q1: 平台账单主表和账单明细表是什么关系?

A1: 一对多关系。

finance_platform_bill 是账单主表,表示某个平台、某个店铺、某个结算周期的一份结算报告。它保存汇总信息,比如销售总额、退款总额、平台费用、净到账金额。

finance_bill_item 是账单明细表,保存这份账单里的每一行收入或扣费,比如某个订单的商品收入、推荐费、FBA 费、退款、广告费、仓储费。

主表像账单封面,明细表像账单清单。

Q2: 为什么账单匹配率不是百分之百?

A2: 因为平台账单里不是所有明细都能和系统订单一一对应。

比如广告费、仓储费、平台调整项可能没有订单号;跨周期退款可能发生在本期账单,但订单属于上期;有些平台订单可能因为同步失败没有进入 OMS;还有些费用是平台临时补扣或补发。

所以 95% 是自动匹配率阈值,不是财务准确率。系统自动匹配大部分订单类明细,剩余异常进入差异清单,由财务确认。

Q3: 如何解析几十万行账单文件?

A3: 核心是异步、流式、批量。

上传文件后先存 OSS,插入主账单记录,然后异步解析。解析时用流式读取,不一次性加载到内存;每行解析成账单明细;每 1000 条左右批量插入数据库。

同时用账单状态记录解析进度和结果,比如待解析、解析中、已解析、解析失败。失败后可以重新触发解析。

Q4: 为什么利润核算要做成 SKU 级?

A4: 因为店铺整体赚钱不代表每个 SKU 都赚钱。

有些 SKU 销量高,但广告费高、退款率高、FBA 费高,最后可能亏损。如果只看店铺总利润,很难发现这些利润黑洞。

SKU 级利润核算可以告诉运营:哪个 SKU 赚钱,哪个 SKU 亏钱,亏损原因是采购成本高、广告费高、退款高还是物流费高。

Q5: 广告费和仓储费如何分摊到 SKU?

A5: 要看费用类型和业务口径。

广告费如果平台能提供 SKU 级广告数据,就直接按 SKU 归集。如果只有店铺或广告活动级别,可以按 SKU 销售额、销量或广告归因销售额分摊。

仓储费通常按库存占用分摊,比如 SKU 库存体积、数量、占用天数。

关键是分摊规则要固定,并且要留痕。否则同一份利润报表今天按销量分摊,明天按销售额分摊,前后就不可比。

Q6: BI 为什么要做 ETL?

A6: 因为 BI 看板要跨多个模块聚合数据。

如果每次打开看板都实时 JOIN 订单表、库存表、采购表、物流表、财务表,SQL 会很复杂,响应会很慢,还会影响业务库性能。

ETL 的做法是定时抽取业务数据,清洗后提前计算指标,写入 BI 汇总表。前端看板查汇总表或 Redis 缓存,性能更稳定。

Q7: KPI 阈值预警怎么实现?

A7: 先定义 KPI 阈值配置表,比如 bi_kpi_threshold,里面有 KPI 编码、黄色预警值、红色危险值、比较方式、通知角色。

定时任务计算 KPI 后,和阈值表比较。如果超过阈值,就生成预警记录,并通知对应角色。

比如缺货率超过 2% 黄色预警,超过 5% 红色预警;净利润率低于 10% 预警,低于 0 说明亏损。

Q8: 智能补货建议怎么计算?

A8: 先预测日均销量,再结合库存和供应商交期。

预测日均销量可以用加权移动平均:近 7 天权重 50%,近 8 到 14 天权重 30%,近 15 到 30 天权重 20%。

建议补货量 = 预测日均销量 × (Lead Time + 安全天数) - 可用库存 - 在途库存。

如果结果大于 0,就生成补货建议;如果小于等于 0,说明当前库存足够。

Q9: VAT 申报数据是系统直接交税吗?

A9: 不是。

系统负责计算、整理、导出申报数据。比如按国家汇总应税销售额,扣除退款,查询 VAT 税率,生成 finance_vat_record 和 OSS 格式导出文件。

实际申报和缴税通常由财务专员或税务顾问登录官方系统完成。缴税完成后,系统更新 VAT 记录状态,并写入资金流水。

Q10: FMS 和其他模块如何交互?

A10: FMS 不是孤立模块。

它从 OMS 拿订单收入和退款,从 PMS 拿采购应付,从 WMS 拿库存成本和退货残值,从 TMS 拿物流费用,从平台账单拿真实扣费。

对外输出给 BI 利润快照、现金流、应收应付和 KPI 指标。

模块交互可以同步查询,也可以通过 MQ 解耦。比如订单完成后 OMS 发消息给 FMS,FMS 触发利润预估;物流费用确认后 TMS 发消息给 FMS 生成物流应付。


技术面试题

Q1: 如何保证账单解析的性能?

A1: 三个关键点:异步、流式、批量。

第一,异步解析。上传文件后不在 HTTP 请求线程里同步解析,而是先上传到 OSS,创建主账单记录,然后提交异步任务。

第二,流式读取。使用 EasyExcel 或 CSV Reader 流式读取,不一次性加载到内存。每读取一行,就转换成账单明细对象。

第三,批量插入。不是单条插入,而是每 1000 条批量插入一次,减少数据库交互次数。

这样即使几十万行的账单文件,也能稳定解析,不会 OOM,不会超时。

Q2: 利润快照表如何保证幂等?

A2: 使用 INSERT ... ON DUPLICATE KEY UPDATE

利润快照表有唯一索引:UNIQUE KEY uk_snapshot (tenant_id, snapshot_type, snapshot_date, sku_id, platform, store_id)

插入时使用:

INSERT INTO finance_profit_snapshot (...) VALUES (...)
ON DUPLICATE KEY UPDATE
    gross_revenue_cny = VALUES(gross_revenue_cny),
    purchase_cost = VALUES(purchase_cost),
    ...

这样定时任务重复执行也不会产生重复数据。如果数据不存在就插入,如果数据存在就更新。

Q3: 为什么要用 Redis 缓存报表?

A3: 因为 BI 报表查询频繁,但短时间内不要求秒级实时。

比如首页经营总览、销售趋势、利润排行,这些报表访问频繁,但数据可以接受几分钟的延迟。

使用 Redis 缓存后,查询时间从几百毫秒降到几毫秒,用户体验更好,也减少了数据库压力。

缓存策略用 Cache Aside:先查 Redis,没有再查汇总表,查询完成写入 Redis。数据变更时,比如账单对账完成、订单完成、库存变动,就删除相关缓存。

Q4: Redis 缓存如何保证一致性?

A4: 采用 Cache Aside 模式:先更新数据库,再删除缓存。

具体流程:

  1. 更新数据库(在事务中执行)
  2. 删除缓存(在事务提交后执行)

为什么要在事务提交后删除缓存?如果在事务提交前删除缓存,可能出现:线程 A 删除了缓存,线程 B 查询缓存未命中,从数据库加载旧数据到缓存,线程 A 事务提交,数据库更新。结果:缓存是旧数据,数据库是新数据,不一致了。

所以必须在事务提交后删除缓存。使用 Spring 的 TransactionSynchronizationManager 注册回调,在事务提交后执行删除缓存的操作。

Q5: 如何解决缓存穿透、击穿、雪崩?

A5: 三个问题分别解决:

缓存穿透: 查询不存在的数据,缓存没有,数据库也没有,每次都打到数据库。

解决方案:布隆过滤器 + 空值缓存。布隆过滤器快速判断 SKU 是否存在,如果判断不存在,直接返回,不查数据库。如果数据库也没有,缓存空值,TTL 5 分钟。

缓存击穿: 热点 Key 过期瞬间,大量请求同时打到数据库。

解决方案:分布式锁 + 双重检查。缓存未命中时,加分布式锁(Redis SETNX),只有一个线程去查数据库,其他线程等待。查到数据后写入缓存,释放锁。

缓存雪崩: 大量 Key 同时过期,数据库瞬间压力暴增。

解决方案:过期时间加随机值。基础 TTL 60 分钟,加上 0-5 分钟的随机值。这样即使批量加载缓存,过期时间也是分散的,不会同时失效。

Q6: ETL 定时任务如何设计?

A6: 使用 XXL-Job 定时任务,按不同频率执行。

日汇总任务:每天凌晨 2 点执行,统计昨天的 GMV、订单量、净利润等指标,写入 bi_kpi_daily

小时汇总任务:每小时执行,统计最近 1 小时的实时指标,写入 Redis 缓存。

月汇总任务:每月 1 号凌晨执行,统计上个月的月度指标,写入 bi_kpi_monthly

任务执行流程:

  1. 从 OMS、WMS、PMS、TMS、FMS 抽取数据
  2. 清洗转换(统一时间、币种、租户)
  3. 计算 KPI
  4. 写入汇总表
  5. 写入 Redis 缓存

Q7: KPI 预警如何通知用户?

A7: 支持站内信和企业微信两种方式。

站内信:生成预警记录后,写入 sys_message 表,用户登录后台时可以看到。

企业微信:调用企业微信 API,发送消息到对应角色的企业微信。

通知内容包括:KPI 名称、当前值、阈值、预警级别(黄色/红色)、建议处理措施。

Q8: 销售预测算法为什么用加权移动平均?

A8: 因为要平衡历史趋势和最近变化。

如果只用最近 7 天,容易受短期波动影响,预测不稳定。如果只用最近 30 天,对最近的销量变化不敏感,预测滞后。

加权移动平均给最近 7 天最高权重(50%),近 8-14 天次高权重(30%),近 15-30 天最低权重(20%)。这样既参考历史,又让最近的销量变化对预测更敏感。

Q9: 补货建议如何联动 ABC 分类?

A9: ABC 分类影响安全库存天数。

A 类 SKU 销售贡献高,断货影响大,安全天数设置更高,比如 20 天。

B 类 SKU 正常管理,安全天数 15 天。

C 类 SKU 贡献低,安全天数更低,比如 10 天,避免积压资金。

补货公式:建议补货量 = 预测日均销量 × (Lead Time + 安全天数) - 可用库存 - 在途库存

安全天数根据 ABC 分类动态调整。

Q10: 如何保证财务数据可追溯?

A10: 三个方面。

第一,保留原始账单文件地址,任何解析结果都能回到原始文件。

第二,账单主表和明细表分开保存,既有汇总,也有每一笔明细。

第三,利润快照、VAT 申报、资金流水都保存来源单据 ID,比如订单 ID、账单 ID、付款申请 ID。

财务系统不能只给结果,必须能解释结果从哪里来。


简历描述

8.1 项目描述

项目名称:跨境电商 SaaS 柔性供应链管理平台
负责模块:FMS 财务结算系统 + BI 数据分析系统
项目时间:2024.06 - 2024.12
技术栈:Spring Boot 3.2、MyBatis-Plus、MySQL 8.0、Redis 7.0、
       RocketMQ 5.0、XXL-Job 2.4、EasyExcel 3.3、ECharts 5.4

项目描述:
该项目面向跨境电商卖家,提供从供应商、采购、仓储、商品、订单、物流到
财务结算和数据分析的一体化管理能力。我主要负责 FMS 财务结算和 BI 数据
分析模块,支持多平台账单导入、自动对账、SKU 级利润核算、多货币汇率折算、
汇兑损益、VAT 申报数据导出、现金流预测、KPI 预警、销售预测和智能补货建议。

业务规模:
- 日均处理平台账单 100+ 份,账单明细 30 万+ 行
- 覆盖 5000+ SKU 的利润核算
- 支持 Amazon、TikTok Shop、Shopee、Temu、Shopify 等 5 个主流平台
- 支持 USD、EUR、GBP、JPY、CNY 等 10+ 种货币
- 50+ 个核心 KPI,20+ 个预警规则

核心难点:
1. 平台账单文件大,解析慢,且需要保留原始文件和解析状态
2. 平台账单和内部订单存在跨周期、缺失、金额不符等差异
3. SKU 利润核算需要整合订单、采购、库存、物流、平台费用和 VAT
4. 多平台多币种结算,需要处理汇率快照和汇兑损益
5. BI 看板要跨模块聚合数据,不能每次实时 JOIN 大表
6. KPI 预警和智能补货需要从事后报表升级为事前决策建议

8.2 核心亮点

亮点1:异步解析平台账单大文件

问题:Amazon、Shopee 等平台账单文件可能有几万行,直接同步解析会导致接口超时和内存压力。

方案:采用 OSS 原始文件存储 + 线程池异步解析 + 流式读取 + 批量入库。

具体实现:

  • 上传文件后先保存 OSS,并创建 finance_platform_bill
  • 使用账单状态机跟踪待解析、解析中、已解析、解析失败
  • 使用流式读取逐行解析 CSV 或 Excel,避免一次性加载到内存
  • 每 1000 条 finance_bill_item 批量插入一次
  • 解析完成后自动汇总账单主表并触发订单匹配

效果:上传接口从分钟级等待变成秒级返回,支持 100MB 级账单文件解析,解析失败可重试,原始文件可审计。

亮点2:平台账单自动对账与差异分析

问题:平台账单金额和系统订单金额经常不一致,人工逐条核对效率低。

方案:基于平台订单号、SKU、金额和结算周期做自动匹配,并生成差异清单。

具体实现:

  • 通过 platform_order_no 匹配 OMS 内部订单
  • 自动识别账单有系统无、系统有账单无、金额不符三类差异
  • 设置自动匹配率阈值,低于阈值进入人工处理
  • 对账完成后触发利润重算,保证最终利润使用真实平台扣费

效果:大部分订单类账单明细可自动匹配,财务只处理异常差异,减少人工核对工作量,对账过程可追溯,支撑后续利润核算和审计。

亮点3:SKU 级全成本利润核算

问题:卖家只看 GMV 无法判断哪些 SKU 真正赚钱。

方案:设计 SKU 级利润快照,整合销售收入、采购成本、物流费、平台费、广告费、仓储费、退款损失和 VAT。

具体实现:

  • 销售收入按平台账单或 OMS 订单获取
  • 采购成本使用 WMS 加权平均成本
  • 物流费来自 TMS 实际运费
  • 平台费、广告费来自 finance_bill_item
  • 广告费、仓储费按销量、销售额或库存占用进行分摊
  • 日快照和月快照写入 finance_profit_snapshot

效果:支持 SKU、平台、店铺、日期多维度利润分析,自动识别亏损 SKU 和低利润 SKU,帮助运营从看销量转向看利润。

亮点4:多货币汇率快照与汇兑损益

问题:跨境卖家同时面对 USD、EUR、GBP、JPY 等多币种,订单时汇率和结算时汇率不同,如何统一折算和记录汇兑损益?

方案:采用双字段存储(原币+人民币)+ 汇率快照 + 汇兑损益单独记录。

具体实现:

  • 所有金额字段保存 amount_origin(原币)、currency(币种)、amount_cny(人民币)、exchange_rate(汇率)、rate_date(汇率日期)
  • 定时任务每天从 OpenExchange API 同步最新汇率,写入 finance_exchange_rate
  • 汇率波动超过阈值自动预警
  • 汇兑损益单独记录到财务损益表,不污染原订单金额

效果:历史财务记录不会因汇率变化被污染,汇兑损益可单独统计,多平台多币种报表能统一折算成人民币口径。

亮点5:BI ETL 聚合与 Redis 报表缓存

问题:BI 看板需要跨 OMS、WMS、PMS、TMS、FMS 多模块统计,实时查询性能差且影响业务库。

方案:采用定时 ETL 聚合 + BI 汇总表 + Redis 热点报表缓存。

具体实现:

  • XXL-Job 定时任务从各模块抽取数据,清洗转换后计算 KPI
  • 写入 bi_kpi_daily(日汇总)、bi_kpi_monthly(月汇总)
  • 高频查询报表写入 Redis,设置分级 TTL(实时 5 分钟,日报 1 小时,月报 24 小时)
  • 数据变更时主动删除相关缓存,保证一致性
  • 使用 Cache Aside 模式:先查 Redis,未命中查汇总表,查询完成写入 Redis

效果:首页看板响应从秒级降低到毫秒级,指标口径统一,报表查询压力从业务表转移到汇总表和缓存。

亮点6:KPI 阈值预警机制

问题:报表只能告诉用户已经发生了什么,不能主动提醒业务风险。

方案:设计 bi_kpi_threshold 阈值配置表,支持租户级 KPI 预警。

具体实现:

  • 阈值表支持黄色预警值、红色危险值、比较方式(大于/小于触发)、通知角色
  • 定时任务计算 KPI 后与阈值对比,超过阈值生成预警记录
  • 支持站内信和企业微信两种通知方式
  • 常见预警:缺货率 > 2% 黄色预警,> 5% 红色预警;净利润率 < 10% 预警,< 0 亏损预警

效果:缺货、亏损、物流异常可以提前暴露,不同租户可以配置自己的经营阈值,BI 从被动报表升级为主动预警。

亮点7:销售预测与智能补货建议

问题:补货太少会断货,补货太多会积压库存,跨境供应链还有 Lead Time,不能等库存快没了才补。

方案:用加权移动平均预测日销量,再结合库存和供应商交期计算补货建议。

具体实现:

  • 统计 SKU 最近 7 天、8-14 天、15-30 天的日均销量
  • 预测日均销量 = 近 7 天日均 × 50% + 近 8-14 天日均 × 30% + 近 15-30 天日均 × 20%
  • 目标库存 = 预测日均销量 × (Lead Time + 安全天数)
  • 建议补货量 = 目标库存 - (可用库存 + 在途库存)
  • ABC 分类联动:A 类安全天数 20 天,B 类 15 天,C 类 10 天

效果:提前发现断货风险,减少经验式补货带来的库存积压,采购计划和销售预测形成闭环。

亮点8:VAT 申报数据生成与合规留痕

问题:跨境卖家需要按目的国申报 VAT,手工整理数据容易出错。

方案:系统自动按国家汇总应税销售额,扣除退款,查询 VAT 税率,生成申报数据和导出文件。

具体实现:

  • 按目的国、结算周期汇总销售额和退款
  • 查询 finance_vat_rate 获取各国 VAT 税率
  • 计算应缴 VAT,生成 finance_vat_record
  • 导出 Excel 格式申报文件到 OSS
  • 财务完成申报后更新状态,并写入资金流水

效果:VAT 申报数据自动生成,减少人工整理工作量,申报过程可追溯,支撑合规审计。


关键代码示例

9.1 异步账单上传与解析

账单上传接口:

@RestController
@RequestMapping("/finance/bill")
public class PlatformBillController {
    
    @Autowired
    private PlatformBillService platformBillService;
    
    @PostMapping("/upload")
    public Result<Long> uploadBill(@RequestBody BillUploadCommand command) {
        Long billId = platformBillService.uploadBill(command);
        return Result.success(billId);
    }
    
    @GetMapping("/status/{billId}")
    public Result<BillStatusVO> queryStatus(@PathVariable Long billId) {
        BillStatusVO status = platformBillService.queryBillStatus(billId);
        return Result.success(status);
    }
}

账单上传服务:

@Service
public class PlatformBillService {
    
    @Autowired
    private PlatformBillMapper platformBillMapper;
    
    @Autowired
    private BillParseTaskService billParseTaskService;
    
    @Autowired
    private ThreadPoolExecutor billParseExecutor;
    
    @Transactional(rollbackFor = Exception.class)
    public Long uploadBill(BillUploadCommand command) {
        // 文件已上传到 OSS,这里只保存 URL
        String fileUrl = command.getFileUrl();
        
        // 创建账单主表记录
        PlatformBill bill = new PlatformBill();
        bill.setTenantId(command.getTenantId());
        bill.setPlatform(command.getPlatform());
        bill.setStoreId(command.getStoreId());
        bill.setSettlementStartDate(command.getStartDate());
        bill.setSettlementEndDate(command.getEndDate());
        bill.setSourceFileUrl(fileUrl);
        bill.setStatus(BillStatus.PENDING_PARSE.getCode());
        bill.setCurrency(command.getCurrency());
        platformBillMapper.insert(bill);
        
        // 提交异步解析任务
        billParseExecutor.execute(() -> {
            billParseTaskService.parseBill(bill.getId());
        });
        
        return bill.getId();
    }
}

账单解析任务:

@Service
public class BillParseTaskService {
    
    private static final int BATCH_SIZE = 1000;
    
    @Autowired
    private PlatformBillMapper platformBillMapper;
    
    @Autowired
    private FinanceBillItemMapper billItemMapper;
    
    @Autowired
    private OssClient ossClient;
    
    @Autowired
    private OrderMainMapper orderMainMapper;
    
    public void parseBill(Long billId) {
        // 更新状态为解析中
        updateStatus(billId, BillStatus.PARSING);
        
        try {
            // 从 OSS 下载文件流
            InputStream inputStream = downloadBillFile(billId);
            
            // 流式读取并批量插入
            List<FinanceBillItem> batch = new ArrayList<>(BATCH_SIZE);
            CsvReader reader = CsvReader.builder().build(inputStream);
            
            for (CsvRow row : reader) {
                FinanceBillItem item = convertToBillItem(billId, row);
                batch.add(item);
                
                if (batch.size() >= BATCH_SIZE) {
                    billItemMapper.insertBatch(batch);
                    batch.clear();
                }
            }
            
            // 插入剩余数据
            if (!batch.isEmpty()) {
                billItemMapper.insertBatch(batch);
            }
            
            // 汇总主账单
            calculateSummary(billId);
            
            // 自动匹配订单
            matchOrders(billId);
            
            // 更新状态为已解析
            updateStatus(billId, BillStatus.PARSED);
            
        } catch (Exception e) {
            log.error("账单解析失败, billId={}", billId, e);
            updateStatus(billId, BillStatus.PARSE_FAILED);
            throw new BusinessException("账单解析失败");
        }
    }
    
    private FinanceBillItem convertToBillItem(Long billId, CsvRow row) {
        FinanceBillItem item = new FinanceBillItem();
        item.setBillId(billId);
        item.setOrderNo(row.getField("order_id"));
        item.setSkuCode(row.getField("sku"));
        item.setItemType(row.getField("type"));
        item.setAmountOrigin(new BigDecimal(row.getField("amount")));
        item.setCurrency(row.getField("currency"));
        // 汇率折算
        BigDecimal exchangeRate = getExchangeRate(item.getCurrency());
        item.setAmountCny(item.getAmountOrigin().multiply(exchangeRate));
        return item;
    }
    
    private void matchOrders(Long billId) {
        // 批量匹配订单
        billItemMapper.batchMatchOrders(billId);
    }
}

9.2 SKU 利润核算服务

利润核算服务:

@Service
public class ProfitCalculateService {
    
    @Autowired
    private FinanceProfitSnapshotMapper profitSnapshotMapper;
    
    @Autowired
    private OrderMainMapper orderMainMapper;
    
    @Autowired
    private WmsStockCostRpc wmsStockCostRpc;
    
    @Autowired
    private TmsWaybillRpc tmsWaybillRpc;
    
    @Autowired
    private FinanceBillItemMapper billItemMapper;
    
    public void calculateDailyProfit(Long tenantId, LocalDate snapshotDate) {
        // 查询当天所有已完成订单
        List<OrderMain> orders = orderMainMapper.selectCompletedOrders(tenantId, snapshotDate);
        
        for (OrderMain order : orders) {
            for (OrderItem item : order.getItems()) {
                ProfitSnapshot snapshot = buildProfitSnapshot(order, item, snapshotDate);
                
                // 幂等插入或更新
                profitSnapshotMapper.upsert(snapshot);
            }
        }
    }
    
    private ProfitSnapshot buildProfitSnapshot(OrderMain order, OrderItem item, LocalDate date) {
        ProfitSnapshot snapshot = new ProfitSnapshot();
        snapshot.setTenantId(order.getTenantId());
        snapshot.setSnapshotType(SnapshotType.DAILY.getCode());
        snapshot.setSnapshotDate(date);
        snapshot.setSkuId(item.getSkuId());
        snapshot.setPlatform(order.getPlatform());
        snapshot.setStoreId(order.getStoreId());
        
        // 1. 销售收入(优先使用平台账单口径)
        BigDecimal revenue = getBillRevenue(order.getId())
            .orElse(item.getPayAmount());
        snapshot.setGrossRevenueCny(revenue);
        
        // 2. 采购成本(WMS 加权平均成本)
        BigDecimal avgCost = wmsStockCostRpc.getAvgCost(item.getSkuId());
        BigDecimal purchaseCost = avgCost.multiply(BigDecimal.valueOf(item.getQty()));
        snapshot.setPurchaseCost(purchaseCost);
        
        // 3. 物流费用
        BigDecimal logisticsFee = tmsWaybillRpc.getWaybillFee(order.getWaybillNo())
            .orElse(BigDecimal.ZERO);
        snapshot.setLogisticsFee(logisticsFee);
        
        // 4. 平台费用(推荐费 + FBA 费)
        BigDecimal platformFee = getBillPlatformFee(order.getId());
        snapshot.setPlatformFee(platformFee);
        
        // 5. 广告费(按销售额分摊)
        BigDecimal advertisingFee = allocateAdvertisingFee(order, item);
        snapshot.setAdvertisingFee(advertisingFee);
        
        // 6. 仓储费(按库存占用分摊)
        BigDecimal storageFee = allocateStorageFee(item.getSkuId(), date);
        snapshot.setStorageFee(storageFee);
        
        // 7. 退款损失
        BigDecimal refundLoss = getRefundLoss(order.getId());
        snapshot.setRefundLoss(refundLoss);
        
        // 8. VAT 税费
        BigDecimal vatFee = calculateVat(revenue, order.getDestCountry());
        snapshot.setVatFee(vatFee);
        
        // 计算毛利润和净利润
        BigDecimal grossProfit = revenue
            .subtract(purchaseCost)
            .subtract(platformFee)
            .subtract(logisticsFee);
        snapshot.setGrossProfit(grossProfit);
        
        BigDecimal netProfit = grossProfit
            .subtract(advertisingFee)
            .subtract(storageFee)
            .subtract(refundLoss)
            .subtract(vatFee);
        snapshot.setNetProfit(netProfit);
        
        // 净利润率
        BigDecimal netMargin = netProfit.divide(revenue, 4, RoundingMode.HALF_UP);
        snapshot.setNetMargin(netMargin);
        
        return snapshot;
    }
}

9.3 汇率同步定时任务

@Component
public class ExchangeRateSyncJob {
    
    @Autowired
    private ExchangeRateMapper exchangeRateMapper;
    
    @Autowired
    private ExchangeRateClient exchangeRateClient;
    
    @Autowired
    private ExchangeRateAlertService exchangeRateAlertService;
    
    @XxlJob("syncExchangeRateJob")
    public void syncExchangeRateJob() {
        LocalDate rateDate = LocalDate.now();
        
        // 从第三方 API 获取最新汇率
        Map<String, BigDecimal> latestRates = exchangeRateClient.queryLatestRates();
        
        for (Map.Entry<String, BigDecimal> entry : latestRates.entrySet()) {
            ExchangeRate rate = new ExchangeRate();
            rate.setRateDate(rateDate);
            rate.setCurrency(entry.getKey());
            rate.setRateToCny(entry.getValue());
            rate.setRateSource("OpenExchange");
            
            // 幂等插入或更新
            exchangeRateMapper.upsert(rate);
        }
        
        // 检查汇率波动,超过阈值预警
        exchangeRateAlertService.checkFluctuation(rateDate);
    }
}

9.4 BI ETL 聚合任务

@Component
public class BiKpiAggregateJob {
    
    @Autowired
    private BiKpiDailyMapper biKpiDailyMapper;
    
    @Autowired
    private OrderMainMapper orderMainMapper;
    
    @Autowired
    private WmsStockMapper wmsStockMapper;
    
    @Autowired
    private FinanceProfitSnapshotMapper profitSnapshotMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @XxlJob("biDailyAggregateJob")
    public void biDailyAggregateJob() {
        LocalDate yesterday = LocalDate.now().minusDays(1);
        
        // 按租户聚合
        List<Long> tenantIds = orderMainMapper.selectAllTenantIds();
        
        for (Long tenantId : tenantIds) {
            BiKpiDaily kpi = aggregateDailyKpi(tenantId, yesterday);
            
            // 幂等插入或更新
            biKpiDailyMapper.upsert(kpi);
            
            // 写入 Redis 缓存
            String cacheKey = String.format("bi:kpi:daily:%d:%s", tenantId, yesterday);
            redisTemplate.opsForValue().set(cacheKey, kpi, 1, TimeUnit.HOURS);
        }
    }
    
    private BiKpiDaily aggregateDailyKpi(Long tenantId, LocalDate date) {
        BiKpiDaily kpi = new BiKpiDaily();
        kpi.setTenantId(tenantId);
        kpi.setStatDate(date);
        
        // 1. GMV 和订单量
        OrderStatDTO orderStat = orderMainMapper.statByDate(tenantId, date);
        kpi.setGmv(orderStat.getTotalAmount());
        kpi.setOrderCount(orderStat.getOrderCount());
        kpi.setAvgOrderAmount(orderStat.getAvgAmount());
        
        // 2. 净利润和净利润率
        ProfitStatDTO profitStat = profitSnapshotMapper.statByDate(tenantId, date);
        kpi.setNetProfit(profitStat.getTotalNetProfit());
        kpi.setNetMargin(profitStat.getAvgNetMargin());
        
        // 3. 库存周转率
        BigDecimal turnoverRate = calculateInventoryTurnover(tenantId, date);
        kpi.setInventoryTurnoverRate(turnoverRate);
        
        // 4. 缺货率
        BigDecimal stockoutRate = calculateStockoutRate(tenantId, date);
        kpi.setStockoutRate(stockoutRate);
        
        return kpi;
    }
}

9.5 销售预测与补货建议

@Service
public class ReplenishmentSuggestionService {
    
    @Autowired
    private OrderItemMapper orderItemMapper;
    
    @Autowired
    private WmsStockMapper wmsStockMapper;
    
    @Autowired
    private PmsSupplierSkuMapper supplierSkuMapper;
    
    public ReplenishmentSuggestion calculateSuggestion(Long skuId) {
        // 1. 统计历史销量
        SalesWindow window = orderItemMapper.statSalesWindow(skuId);
        
        // 2. 加权移动平均预测日均销量
        BigDecimal forecastDailySales = calculateForecastDailySales(window);
        
        // 3. 获取当前库存
        WmsStock stock = wmsStockMapper.selectBySkuId(skuId);
        int availableQty = stock.getAvailableQty();
        int inTransitQty = stock.getInTransitQty();
        
        // 4. 获取供应商 Lead Time
        SupplierSku supplierSku = supplierSkuMapper.selectBySkuId(skuId);
        int leadTimeDays = supplierSku.getLeadTimeDays();
        
        // 5. 获取安全库存天数(根据 ABC 分类)
        int safetyDays = getSafetyDaysByAbc(stock.getAbcCategory());
        
        // 6. 计算目标库存
        BigDecimal targetStock = forecastDailySales
            .multiply(BigDecimal.valueOf(leadTimeDays + safetyDays));
        
        // 7. 计算建议补货量
        BigDecimal currentStock = BigDecimal.valueOf(availableQty + inTransitQty);
        BigDecimal suggestionQty = targetStock.subtract(currentStock);
        
        if (suggestionQty.compareTo(BigDecimal.ZERO) <= 0) {
            return ReplenishmentSuggestion.noNeed(skuId);
        }
        
        return ReplenishmentSuggestion.needPurchase(
            skuId,
            suggestionQty.setScale(0, RoundingMode.CEILING).intValue(),
            forecastDailySales,
            targetStock.intValue()
        );
    }
    
    private BigDecimal calculateForecastDailySales(SalesWindow window) {
        BigDecimal avg7 = window.getAvgLast7Days();
        BigDecimal avg14 = window.getAvgLast8To14Days();
        BigDecimal avg30 = window.getAvgLast15To30Days();
        
        // 加权移动平均:7天50% + 8-14天30% + 15-30天20%
        return avg7.multiply(new BigDecimal("0.50"))
            .add(avg14.multiply(new BigDecimal("0.30")))
            .add(avg30.multiply(new BigDecimal("0.20")))
            .setScale(2, RoundingMode.HALF_UP);
    }
    
    private int getSafetyDaysByAbc(String abcCategory) {
        return switch (abcCategory) {
            case "A" -> 20;  // A类高价值,安全天数高
            case "B" -> 15;  // B类正常管理
            case "C" -> 10;  // C类低价值,安全天数低
            default -> 15;
        };
    }
}

9.6 KPI 阈值预警

@Service
public class KpiAlertService {
    
    @Autowired
    private BiKpiThresholdMapper thresholdMapper;
    
    @Autowired
    private BiKpiAlertRecordMapper alertRecordMapper;
    
    @Autowired
    private MessageNotifyService messageNotifyService;
    
    public void checkKpiAlert(Long tenantId, String kpiCode, BigDecimal kpiValue) {
        // 查询阈值配置
        BiKpiThreshold threshold = thresholdMapper.selectByTenantAndKpi(tenantId, kpiCode);
        if (threshold == null || !threshold.getIsEnabled()) {
            return;
        }
        
        // 判断是否触发预警
        AlertLevel alertLevel = judgeAlertLevel(kpiValue, threshold);
        if (alertLevel == AlertLevel.NONE) {
            return;
        }
        
        // 生成预警记录
        BiKpiAlertRecord record = new BiKpiAlertRecord();
        record.setTenantId(tenantId);
        record.setKpiCode(kpiCode);
        record.setKpiName(threshold.getKpiName());
        record.setKpiValue(kpiValue);
        record.setAlertLevel(alertLevel.getCode());
        record.setAlertTime(LocalDateTime.now());
        alertRecordMapper.insert(record);
        
        // 发送通知
        messageNotifyService.sendKpiAlert(threshold.getNotifyRoles(), record);
    }
    
    private AlertLevel judgeAlertLevel(BigDecimal value, BiKpiThreshold threshold) {
        int compareType = threshold.getCompareType();
        BigDecimal yellowThreshold = threshold.getYellowThreshold();
        BigDecimal redThreshold = threshold.getRedThreshold();
        
        if (compareType == 1) {  // 大于触发
            if (value.compareTo(redThreshold) >= 0) {
                return AlertLevel.RED;
            } else if (value.compareTo(yellowThreshold) >= 0) {
                return AlertLevel.YELLOW;
            }
        } else {  // 小于触发
            if (value.compareTo(redThreshold) <= 0) {
                return AlertLevel.RED;
            } else if (value.compareTo(yellowThreshold) <= 0) {
                return AlertLevel.YELLOW;
            }
        }
        
        return AlertLevel.NONE;
    }
}

9.7 Redis 报表缓存

@Service
public class BiReportCacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private BiKpiDailyMapper biKpiDailyMapper;
    
    public BiKpiDaily getDailyKpi(Long tenantId, LocalDate date) {
        String cacheKey = String.format("bi:kpi:daily:%d:%s", tenantId, date);
        
        // 先查 Redis
        BiKpiDaily cached = (BiKpiDaily) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        // 查数据库
        BiKpiDaily kpi = biKpiDailyMapper.selectByTenantAndDate(tenantId, date);
        if (kpi != null) {
            // 写入缓存,TTL 1小时
            redisTemplate.opsForValue().set(cacheKey, kpi, 1, TimeUnit.HOURS);
        }
        
        return kpi;
    }
    
    public void deleteDailyKpiCache(Long tenantId, LocalDate date) {
        String cacheKey = String.format("bi:kpi:daily:%d:%s", tenantId, date);
        redisTemplate.delete(cacheKey);
    }
}

总结与面试准备建议

10.1 核心要点总结

业务价值:

  1. FMS 解决”钱算清楚”的问题:平台账单对账、SKU 利润核算、多货币管理、VAT 申报
  2. BI 解决”数据变决策”的问题:经营看板、KPI 预警、销售预测、智能补货

技术亮点:

  1. 大文件账单解析:异步 + 流式 + 批量,支持 100MB 级文件
  2. SKU 级利润核算:整合 7 大成本项,支持费用分摊
  3. 多货币管理:双字段存储 + 汇率快照 + 汇兑损益
  4. BI ETL 聚合:定时聚合 + 汇总表 + Redis 缓存
  5. KPI 预警:阈值配置 + 自动判断 + 多渠道通知
  6. 智能补货:加权移动平均 + ABC 分类联动

面试重点:

  1. 能讲清楚平台账单对账的 10 步完整流程
  2. 能讲清楚 SKU 利润核算的 8 步完整流程和 7 大成本项
  3. 能讲清楚为什么要异步解析、流式读取、批量插入
  4. 能讲清楚多货币如何设计、汇兑损益如何记录
  5. 能讲清楚 BI 为什么要做 ETL、Redis 缓存策略
  6. 能讲清楚销售预测算法和补货建议计算公式

10.2 面试准备建议

第一步:熟悉业务流程

重点理解平台账单对账、SKU 利润核算、BI 数据处理三大核心流程。能用自己的话讲出每个流程的关键步骤和业务价值。

第二步:掌握技术方案

重点掌握 6 大技术亮点的实现方案。每个亮点都要能讲出:问题是什么、为什么这样设计、具体怎么实现、效果如何。

第三步:准备代码示例

重点准备异步账单解析、利润核算、销售预测三段核心代码。能讲出关键代码的设计思路和实现细节。

第四步:模拟面试问答

按照业务面试题和技术面试题,模拟回答。重点练习:

  • 为什么账单匹配率不是 100%?
  • 如何保证账单解析性能?
  • 为什么要做 SKU 级利润核算?
  • Redis 缓存如何保证一致性?
  • 销售预测算法为什么用加权移动平均?

第五步:准备简历话术

按照简历描述模板,准备 1 分钟项目介绍和 3 分钟核心亮点介绍。重点突出业务规模、技术难点、解决方案、实施效果。

10.3 常见追问准备

Q1: 如果平台账单文件格式变了怎么办?

A: 设计账单解析适配器模式。每个平台一个解析器,实现统一的 BillParser 接口。平台格式变化只需要修改对应平台的解析器,不影响其他平台。同时保留原始文件,解析失败可以重新解析。

Q2: 如果利润核算结果和财务对不上怎么办?

A: 利润核算的每一项成本都要能追溯到源头。比如销售收入追溯到平台账单或订单,采购成本追溯到入库单,物流费追溯到运单。如果对不上,可以逐项核对数据来源,找到差异原因。

Q3: 如果 Redis 缓存和数据库不一致怎么办?

A: 采用 Cache Aside 模式:先更新数据库,再删除缓存。删除缓存要在事务提交后执行,避免缓存和数据库不一致。如果删除缓存失败,可以通过 MQ 延迟删除或定时任务兜底。

Q4: 如果销售预测不准怎么办?

A: 销售预测本身就是估算,不可能 100% 准确。可以通过以下方式提高准确率:

  1. 排除促销、节假日等异常数据
  2. 根据历史预测准确率动态调整权重
  3. 结合季节性、趋势性因素
  4. 人工审核和调整预测结果

Q5: 如果 KPI 预警太频繁怎么办?

A: 设计预警降噪机制:

  1. 同一 KPI 短时间内只发一次预警
  2. 预警恢复后才能再次预警
  3. 支持预警静默期配置
  4. 支持预警级别升级(连续黄色预警升级为红色)