系统概述
1.1 系统定位
FMS(Finance Management System)财务结算系统 + BI(Business Intelligence)数据分析系统是跨境电商供应链 SaaS 平台的决策支撑模块,负责把钱算清楚、把数据变成决策。
FMS 核心价值:
- 平台账单对账: 导入 Amazon、TikTok Shop、Shopee 等平台结算报告,自动匹配订单,识别差异
- SKU 级利润核算: 整合销售收入、采购成本、物流费、平台费、广告费、退款、VAT,计算真实利润
- 多货币管理: 支持 USD、EUR、GBP、JPY 等多币种,汇率快照,汇兑损益
- 现金流预测: 预测未来 30 天资金流入流出,提前发现资金缺口
BI 核心价值:
- 经营看板: GMV、订单量、净利润率、库存周转率等核心 KPI
- KPI 预警: 缺货率、低利润、物流异常、采购延迟等指标超阈值自动告警
- 销售预测: 基于历史销量预测未来日均销量
- 智能补货: 结合销售预测、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。
第八步:生成差异清单
匹配不上的明细进入差异清单。差异分三类:
- 账单有系统无: 平台有这笔费用,但系统没有对应订单(可能是订单同步失败、平台调整项、广告费、仓储费)
- 系统有账单无: 系统有订单,但账单没有记录(可能是跨结算周期、订单未到结算期、平台冻结款项)
- 金额不符: 订单金额和账单金额不一致(可能是平台费率变化、退款调整、汇率差异)
第九步:财务确认差异
财务专员查看差异清单,分析原因,标注处理结果。系统提供可能原因提示,但最终需要人工确认。
第十步:对账完成,触发利润核算
对账完成后,账单状态更新为”对账完成”。系统触发 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 通信。
核心模块:
- 账单管理模块: 账单导入、解析、对账、差异处理
- 利润核算模块: SKU 利润计算、成本分摊、利润快照
- 汇率管理模块: 汇率同步、汇率快照、汇兑损益
- 现金流模块: 应收应付、现金流预测、资金预警
- VAT 管理模块: VAT 申报数据生成、导出、缴纳记录
- BI 数据模块: ETL 聚合、KPI 计算、报表缓存
- 预警模块: KPI 阈值判断、预警通知
- 补货模块: 销售预测、补货建议
技术架构图:
┌─────────────────────────────────────────────────────────┐
│ 前端层 │
│ 商家后台 / 财务后台 / BI Dashboard │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ API 网关层 │
│ Spring Cloud Gateway + Sentinel │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ FMS + BI 业务层 │
│ ┌──────────┬──────────┬──────────┬──────────┐ │
│ │账单管理 │利润核算 │BI 数据 │预警补货 │ │
│ └──────────┴──────────┴──────────┴──────────┘ │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 基础设施层 │
│ MySQL + Redis + RocketMQ + XXL-Job + OSS + ECharts │
└─────────────────────────────────────────────────────────┘
3.2 数据流转
账单对账数据流:
- 财务上传账单文件 → OSS 存储 → 创建主账单记录
- 异步解析任务 → 流式读取 → 批量写入明细表
- 汇总主账单 → 自动匹配订单 → 生成差异清单
- 财务确认差异 → 对账完成 → 触发利润核算
利润核算数据流:
- 获取销售收入(平台账单/OMS) → 统一币种
- 获取采购成本(WMS) → 获取物流费(TMS)
- 获取平台费(FMS) → 分摊广告费和仓储费
- 计算毛利润和净利润 → 写入利润快照
BI 数据流:
- ETL 定时聚合 → 从各模块抽取数据
- 清洗转换 → 计算 KPI → 写入汇总表
- 写入 Redis 缓存 → Dashboard 展示
- KPI 阈值判断 → 生成预警通知
- 销售预测 → 生成补货建议
核心技术亮点
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("账单解析失败");
}
}
实施效果:
- 上传接口从分钟级等待变成秒级返回
- 支持 100MB 级账单文件解析
- 解析失败可重试,原始文件可审计
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 费、退款。有些费用不能直接归属,比如月度广告费、仓储费,需要按规则分摊。
常见分摊规则:
- 按销量分摊: 某 SKU 销量占比 × 总费用
- 按销售额分摊: 某 SKU 销售额占比 × 总费用
- 按库存占用分摊: 某 SKU 库存占用天数和体积占比 × 总费用
- 按毛利贡献分摊: 某 SKU 毛利占比 × 总费用
分摊规则必须固定,并且要在系统里留痕,否则财务报表前后口径不一致。
实施效果:
- 支持 SKU、平台、店铺、日期多维度利润分析
- 自动识别亏损 SKU 和低利润 SKU
- 帮助运营从看销量转向看利润
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 元汇兑损失。
这笔损失一般记录到财务损益里,不会修改原订单金额。
实施效果:
- 历史财务记录不会因汇率变化被污染
- 汇兑损益可单独统计
- 多平台多币种报表能统一折算成人民币口径
4.4 BI ETL 聚合与 Redis 报表缓存
问题背景:
BI 看板需要跨订单、库存、采购、物流、财务多模块统计。如果每次打开看板都实时查询这些业务表,SQL 会很复杂,响应会很慢,还会影响业务库性能。
技术方案:
采用定时 ETL 聚合 + BI 汇总表 + Redis 热点报表缓存。
ETL 聚合流程:
- 抽取(Extract): 从 OMS、WMS、PMS、TMS、FMS 抽取数据
- 转换(Transform): 清洗时间、币种、租户等口径,计算 KPI
- 加载(Load): 写入 BI 汇总表
KPI 指标体系:
- 销售类: GMV、订单量、客单价、退款率
- 库存类: 库存周转率、库存天数、缺货率、积压率
- 采购类: 准时到货率、质量合格率
- 物流类: 正常签收率、物流异常率、平均配送时效
- 财务类: 毛利润率、净利润率、广告 ACOS
Redis 缓存策略:
适合缓存的数据:
- 首页经营总览
- 近 30 天销售趋势
- SKU 利润排行榜
- 库存健康统计
- KPI 看板
缓存更新策略:
- 实时看板设置短 TTL,比如 5 分钟
- 日报表设置 1 小时 TTL
- 月报表设置 24 小时 TTL
- 订单完成、账单对账完成、库存变动后主动删除相关缓存
实施效果:
- 首页看板响应从秒级降低到毫秒级
- 指标口径统一,避免各模块重复计算
- 报表查询压力从业务表转移到汇总表和缓存
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)
);
预警流程:
- 定时任务计算 KPI
- 读取
bi_kpi_threshold阈值配置 - 判断是否超过阈值
- 超过阈值生成预警记录
- 查询通知角色
- 发送站内信或企业微信通知
预警示例:
- 缺货率 > 2% 黄色预警,> 5% 红色预警
- 净利润率 < 10% 预警,< 0 亏损预警
- 物流异常率 > 3% 预警
- 采购准时率 < 90% 预警
实施效果:
- 缺货、亏损、物流异常可以提前暴露
- 不同租户可以配置自己的经营阈值
- 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 类商品贡献低,安全库存可以低一些,避免资金占用。
实施效果:
- 提前发现断货风险
- 减少经验式补货带来的库存积压
- 采购计划和销售预测形成闭环
数据库设计
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 模式:先更新数据库,再删除缓存。
具体流程:
- 更新数据库(在事务中执行)
- 删除缓存(在事务提交后执行)
为什么要在事务提交后删除缓存?如果在事务提交前删除缓存,可能出现:线程 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。
任务执行流程:
- 从 OMS、WMS、PMS、TMS、FMS 抽取数据
- 清洗转换(统一时间、币种、租户)
- 计算 KPI
- 写入汇总表
- 写入 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 核心要点总结
业务价值:
- FMS 解决”钱算清楚”的问题:平台账单对账、SKU 利润核算、多货币管理、VAT 申报
- BI 解决”数据变决策”的问题:经营看板、KPI 预警、销售预测、智能补货
技术亮点:
- 大文件账单解析:异步 + 流式 + 批量,支持 100MB 级文件
- SKU 级利润核算:整合 7 大成本项,支持费用分摊
- 多货币管理:双字段存储 + 汇率快照 + 汇兑损益
- BI ETL 聚合:定时聚合 + 汇总表 + Redis 缓存
- KPI 预警:阈值配置 + 自动判断 + 多渠道通知
- 智能补货:加权移动平均 + ABC 分类联动
面试重点:
- 能讲清楚平台账单对账的 10 步完整流程
- 能讲清楚 SKU 利润核算的 8 步完整流程和 7 大成本项
- 能讲清楚为什么要异步解析、流式读取、批量插入
- 能讲清楚多货币如何设计、汇兑损益如何记录
- 能讲清楚 BI 为什么要做 ETL、Redis 缓存策略
- 能讲清楚销售预测算法和补货建议计算公式
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% 准确。可以通过以下方式提高准确率:
- 排除促销、节假日等异常数据
- 根据历史预测准确率动态调整权重
- 结合季节性、趋势性因素
- 人工审核和调整预测结果
Q5: 如果 KPI 预警太频繁怎么办?
A: 设计预警降噪机制:
- 同一 KPI 短时间内只发一次预警
- 预警恢复后才能再次预警
- 支持预警静默期配置
- 支持预警级别升级(连续黄色预警升级为红色)