财年配置方案
业务背景
许多企业的财务年度并非从1月开始,而是根据业务特点选择不同的起始月份。例如:
- 4月制财年:4月1日 - 次年3月31日(常见于日本企业)
- 7月制财年:7月1日 - 次年6月30日(常见于澳洲企业)
- 10月制财年:10月1日 - 次年9月30日
当用户问"今年的销售额"时,系统默认按自然年(1月-12月)计算。但对于使用财年的企业,需要将"今年"解析为当前财年的时间范围。
解决方案概述
ChatBI 通过内置的日历表来实现财年支持,目前提供以下方案:
| 方案 | 适用场景 | 复杂度 |
|---|---|---|
| 方案一:时间偏移 | 财年仅有月份偏移,无复杂规则 | 简单 |
| 方案二:自定义日历表 | 有特殊财年规则(如445周制) | 中等 |
方案一:时间偏移
原理说明
通过 alias.enrich 函数,判断 logicform 中是否有 calendar 字段。当 calendar 为 fiscal 时,自动将查询条件中的日期($gte 和 $lte)进行对应的时间偏移。
例如,对于4月制财年:
- 用户问"2024年销售额"
- 原始日期范围:
2024-01-01至2024-12-31 - 偏移后日期范围:
2024-04-01至2025-03-31
配置步骤
1. 进入 enrich 配置
在专家模式中,点击右上角进入 alias.enrich 配置。
2. 编写 enrich 函数
以下是4月制财年的示例代码:
(logicform, normedLogicform, helperFunctions) => {
const { moment } = helperFunctions;
// 判断是否使用财年模式
if (logicform.calendar !== 'fiscal') {
return logicform;
}
// 财年起始月份(4月制财年 = 4)
const fiscalYearStartMonth = 4;
const monthOffset = fiscalYearStartMonth - 1; // 偏移3个月
// 获取日期字段名(根据你的 schema 修改)
const dateField = '日期';
// 检查是否有日期查询条件
if (logicform.query && logicform.query[dateField]) {
const dateQuery = logicform.query[dateField];
// 处理 $gte(起始日期)
if (dateQuery.$gte) {
const newGte = moment(dateQuery.$gte).add(monthOffset, 'months').format('YYYY-MM-DD');
logicform.query[dateField].$gte = newGte;
}
// 处理 $lte(结束日期)
if (dateQuery.$lte) {
const newLte = moment(dateQuery.$lte).add(monthOffset, 'months').format('YYYY-MM-DD');
logicform.query[dateField].$lte = newLte;
}
}
return logicform;
}
3. 配置同义词触发
为了让用户能够触发财年模式,需要配置相关同义词。在同义词管理中添加:
| 词汇 | 映射 |
|---|---|
| 财年 | calendar: fiscal |
| FY | calendar: fiscal |
这样用户问"财年销售额"或"FY2024销售额"时,系统就会自动应用财年时间偏移。
使用示例
配置完成后的效果:
| 用户提问 | 实际查询时间范围 |
|---|---|
| 2024年销售额 | 2024-01-01 ~ 2024-12-31(自然年) |
| 2024财年销售额 | 2024-04-01 ~ 2025-03-31(财年) |
| 上个财年销售额 | 2023-04-01 ~ 2024-03-31(上一财年) |
进阶配置:支持财年季度
如果需要支持"财年Q1"这样的表达,可以扩展 enrich 函数:
(logicform, normedLogicform, helperFunctions) => {
const { moment } = helperFunctions;
if (logicform.calendar !== 'fiscal') {
return logicform;
}
const fiscalYearStartMonth = 4;
const monthOffset = fiscalYearStartMonth - 1;
const dateField = '日期';
if (logicform.query && logicform.query[dateField]) {
const dateQuery = logicform.query[dateField];
// 处理年度范围
if (dateQuery.$gte) {
logicform.query[dateField].$gte = moment(dateQuery.$gte)
.add(monthOffset, 'months')
.format('YYYY-MM-DD');
}
if (dateQuery.$lte) {
logicform.query[dateField].$lte = moment(dateQuery.$lte)
.add(monthOffset, 'months')
.format('YYYY-MM-DD');
}
// 处理季度(如果有)
if (dateQuery.quarter) {
// 财年季度映射:Q1=4-6月, Q2=7-9月, Q3=10-12月, Q4=1-3月
const quarterStartMonth = ((dateQuery.quarter - 1) * 3 + fiscalYearStartMonth - 1) % 12 + 1;
// 根据季度重新计算日期范围...
}
}
return logicform;
}
方案二:日历维度表
原理说明
通过创建一张日历维度表,预先计算好每一天所属的财年、财季、财月、财周等信息。其他事件表(如销售订单)通过日期字段关联到日历表,从而实现按财年维度进行筛选和分组。
这种方案的优势:
- 支持任意复杂的财年规则(如445周制)
- 财年、财季等信息预先计算,查询性能好
- 可以灵活扩展其他时间维度
配置步骤
1. 准备日历表数据
日历表的结构如下:
| 字段名 | 类型 | 说明 | 示例 |
|---|---|---|---|
| id | String | 自然日,格式 yyyyMMdd | 20240401 |
| 财年 | String | 所属财年 | FY2024 |
| 财季 | String | 所属财季 | FY2024-Q1 |
| 财月 | String | 所属财月 | FY2024-M01 |
| 财周 | String | 所属财周 | FY2024-W01 |
示例数据(4月制财年):
id,财年,财季,财月,财周
20240401,FY2024,FY2024-Q1,FY2024-M01,FY2024-W01
20240402,FY2024,FY2024-Q1,FY2024-M01,FY2024-W01
...
20240701,FY2024,FY2024-Q2,FY2024-M04,FY2024-W14
...
20250331,FY2024,FY2024-Q4,FY2024-M12,FY2024-W52
20250401,FY2025,FY2025-Q1,FY2025-M01,FY2025-W01
2. 创建日历实体 Schema
在 ChatBI 中创建日历实体:
{
"name": "日历",
"properties": [
{
"name": "id",
"type": "ID",
"primeKey": true,
"comment": "自然日 yyyyMMdd"
},
{
"name": "财年",
"type": "category"
},
{
"name": "财季",
"type": "category"
},
{
"name": "财月",
"type": "category"
},
{
"name": "财周",
"type": "category"
}
]
}
3. 事件表关联日历表
在事件表(如销售订单)中,添加对日历表的引用:
{
"name": "销售订单",
"properties": [
{
"name": "日期",
"type": "timestamp"
},
{
"name": "日历",
"type": "object",
"ref": "日历",
"comment": "关联日历维度表"
}
]
}
注意:日历字段的值需要与日历表的 id 对应。例如订单日期为
2024-04-01,则日历字段值为20240401。
4. 配置 enrich 函数
在 alias.enrich 中,将用户的日期筛选条件转换为日历维度的筛选条件:
(logicform, normedLogicform, helperFunctions) => {
const { moment } = helperFunctions;
// 判断是否使用财年模式
if (logicform.calendar !== 'fiscal') {
return logicform;
}
const dateField = '日期';
// 检查是否有日期查询条件
if (logicform.query && logicform.query[dateField]) {
const dateQuery = logicform.query[dateField];
// 将日期范围转换为日历维度筛选
// 例如:用户问 "FY2024的销售额"
// 原始:query: { 日期: { year: 2024 } }
// 转换:query: { "日历.财年": "FY2024" }
// 处理年度查询 -> 转为财年筛选
if (dateQuery.year) {
delete logicform.query[dateField];
logicform.query['日历.财年'] = `FY${dateQuery.year}`;
}
// 处理季度查询 -> 转为财季筛选
if (dateQuery.quarter) {
const year = dateQuery.year || moment().year();
delete logicform.query[dateField];
logicform.query['日历.财季'] = `FY${year}-Q${dateQuery.quarter}`;
}
// 处理月份查询 -> 转为财月筛选
if (dateQuery.month) {
const year = dateQuery.year || moment().year();
const monthStr = String(dateQuery.month).padStart(2, '0');
delete logicform.query[dateField];
logicform.query['日历.财月'] = `FY${year}-M${monthStr}`;
}
}
// 同时处理 groupby 中的日期维度
if (logicform.groupby) {
logicform.groupby = logicform.groupby.map(item => {
// 将按年分组改为按财年分组
if (item === `${dateField}.year` || item._id === `${dateField}.year`) {
return '日历.财年';
}
// 将按季度分组改为按财季分组
if (item === `${dateField}.quarter` || item._id === `${dateField}.quarter`) {
return '日历.财季';
}
// 将按月分组改为按财月分组
if (item === `${dateField}.month` || item._id === `${dateField}.month`) {
return '日历.财月';
}
// 将按周分组改为按财周分组
if (item === `${dateField}.week` || item._id === `${dateField}.week`) {
return '日历.财周';
}
return item;
});
}
return logicform;
}
使用示例
配置完成后的效果:
| 用户提问 | 转换后的查询条件 |
|---|---|
| FY2024销售额 | query: { "日历.财年": "FY2024" } |
| FY2024 Q1 销售额 | query: { "日历.财季": "FY2024-Q1" } |
| 按财年统计销售额 | groupby: ["日历.财年"] |
| 按财月统计销售额 | groupby: ["日历.财月"] |
配置财年同环比
使用日历维度表后,系统默认的同环比计算是基于自然年的,无法正确计算财年的同环比。例如:
- 用户问"FY2024 Q1 销售额同比"
- 期望对比的是 FY2023 Q1,而不是自然年的 2023 Q1
需要通过覆盖系统内置算子来实现财年同环比。
1. 创建算子覆盖文件
在项目文件夹中创建 funcdefinitions 目录,并创建以下文件:
funcdefinitions/fiscalYoy.js(财年同比)
export default {
useCustomFunc: (lf, { moment }) => {
// 仅当使用财年模式时,才启用自定义同比
return lf.calendar === 'fiscal';
},
run: async (lf, _self, config, { execute, commonLib, moment }) => {
// 获取当前查询的财年维度条件
const fiscalYear = lf.query?.['日历.财年'];
const fiscalQuarter = lf.query?.['日历.财季'];
const fiscalMonth = lf.query?.['日历.财月'];
// 构建去年同期的查询条件
const lastYearLf = JSON.parse(JSON.stringify(lf));
// 移除同比算子,避免递归
lastYearLf.preds = lastYearLf.preds.map(pred => {
if (pred.operator === '$yoy') {
return { ...pred, operator: '$sum' };
}
return pred;
});
// 计算去年同期的财年维度
if (fiscalYear) {
// FY2024 -> FY2023
const year = parseInt(fiscalYear.replace('FY', ''));
lastYearLf.query['日历.财年'] = `FY${year - 1}`;
}
if (fiscalQuarter) {
// FY2024-Q1 -> FY2023-Q1
const match = fiscalQuarter.match(/FY(\d+)-Q(\d)/);
if (match) {
lastYearLf.query['日历.财季'] = `FY${parseInt(match[1]) - 1}-Q${match[2]}`;
}
}
if (fiscalMonth) {
// FY2024-M01 -> FY2023-M01
const match = fiscalMonth.match(/FY(\d+)-M(\d+)/);
if (match) {
lastYearLf.query['日历.财月'] = `FY${parseInt(match[1]) - 1}-M${match[2]}`;
}
}
// 执行当期和去年同期的查询
const [currentResult, lastYearResult] = await Promise.all([
execute({ ...lf, preds: lastYearLf.preds }),
execute(lastYearLf)
]);
// 计算同比
const currentValue = currentResult[0]?.[lf.preds[0].name] || 0;
const lastYearValue = lastYearResult[0]?.[lf.preds[0].name] || 0;
const yoyValue = lastYearValue !== 0
? (currentValue - lastYearValue) / lastYearValue
: null;
return [{ '_id': '0', [lf.preds[0].name]: yoyValue }];
},
setColumnProperty: async (property, { moment }) => {
property.description = '财年同比';
}
};
funcdefinitions/fiscalMom.js(财年环比)
export default {
useCustomFunc: (lf, { moment }) => {
return lf.calendar === 'fiscal';
},
run: async (lf, _self, config, { execute, commonLib, moment }) => {
const fiscalQuarter = lf.query?.['日历.财季'];
const fiscalMonth = lf.query?.['日历.财月'];
const fiscalWeek = lf.query?.['日历.财周'];
const lastPeriodLf = JSON.parse(JSON.stringify(lf));
lastPeriodLf.preds = lastPeriodLf.preds.map(pred => {
if (pred.operator === '$mom') {
return { ...pred, operator: '$sum' };
}
return pred;
});
// 计算上期的财年维度
if (fiscalMonth) {
// FY2024-M01 -> FY2023-M12, FY2024-M02 -> FY2024-M01
const match = fiscalMonth.match(/FY(\d+)-M(\d+)/);
if (match) {
let year = parseInt(match[1]);
let month = parseInt(match[2]);
if (month === 1) {
year -= 1;
month = 12;
} else {
month -= 1;
}
lastPeriodLf.query['日历.财月'] = `FY${year}-M${String(month).padStart(2, '0')}`;
}
}
if (fiscalQuarter) {
// FY2024-Q1 -> FY2023-Q4, FY2024-Q2 -> FY2024-Q1
const match = fiscalQuarter.match(/FY(\d+)-Q(\d)/);
if (match) {
let year = parseInt(match[1]);
let quarter = parseInt(match[2]);
if (quarter === 1) {
year -= 1;
quarter = 4;
} else {
quarter -= 1;
}
lastPeriodLf.query['日历.财季'] = `FY${year}-Q${quarter}`;
}
}
if (fiscalWeek) {
// FY2024-W01 -> FY2023-W52, FY2024-W02 -> FY2024-W01
const match = fiscalWeek.match(/FY(\d+)-W(\d+)/);
if (match) {
let year = parseInt(match[1]);
let week = parseInt(match[2]);
if (week === 1) {
year -= 1;
week = 52; // 或根据实际财年周数调整
} else {
week -= 1;
}
lastPeriodLf.query['日历.财周'] = `FY${year}-W${String(week).padStart(2, '0')}`;
}
}
const [currentResult, lastPeriodResult] = await Promise.all([
execute({ ...lf, preds: lastPeriodLf.preds }),
execute(lastPeriodLf)
]);
const currentValue = currentResult[0]?.[lf.preds[0].name] || 0;
const lastPeriodValue = lastPeriodResult[0]?.[lf.preds[0].name] || 0;
const momValue = lastPeriodValue !== 0
? (currentValue - lastPeriodValue) / lastPeriodValue
: null;
return [{ '_id': '0', [lf.preds[0].name]: momValue }];
},
setColumnProperty: async (property, { moment }) => {
property.description = '财年环比';
}
};
2. 注册算子覆盖
funcdefinitions/index.js
import fiscalYoy from './fiscalYoy.js';
import fiscalMom from './fiscalMom.js';
export default {
'$yoy': fiscalYoy,
'$mom': fiscalMom,
};
3. 重启系统
配置完成后,重启系统使算子覆盖生效。
使用效果
| 用户提问 | 计算逻辑 |
|---|---|
| FY2024 销售额同比 | FY2024 vs FY2023 |
| FY2024-Q1 销售额同比 | FY2024-Q1 vs FY2023-Q1 |
| FY2024-M03 销售额环比 | FY2024-M03 vs FY2024-M02 |
| FY2024-M01 销售额环比 | FY2024-M01 vs FY2023-M12(跨财年) |
进阶:445周制日历
445周制是零售业常用的财年划分方式,每个季度分为4周+4周+5周。日历表数据示例:
id,财年,财季,财月,财周
20240204,FY2024,FY2024-Q1,FY2024-M01,FY2024-W01
20240205,FY2024,FY2024-Q1,FY2024-M01,FY2024-W01
...
20240302,FY2024,FY2024-Q1,FY2024-M01,FY2024-W04
20240303,FY2024,FY2024-Q1,FY2024-M02,FY2024-W05
...
只需要调整日历表的数据生成逻辑,Schema 和 enrich 配置保持不变。
方案对比
| 对比项 | 方案一:时间偏移 | 方案二:日历维度表 |
|---|---|---|
| 实现复杂度 | 简单 | 中等 |
| 支持规则 | 仅支持月份偏移 | 支持任意规则 |
| 数据准备 | 无需额外数据 | 需要准备日历表 |
| 查询性能 | 一般 | 较好(预计算) |
| 维护成本 | 低 | 需要定期更新日历表 |
| 适用场景 | 标准财年 | 复杂财年(445周制等) |
常见问题
Q1: 如何同时支持自然年和财年?
通过 calendar 字段区分。不带"财年"关键词的问题默认按自然年处理,带"财年"关键词的问题会触发财年逻辑。
Q2: 财年跨年时如何处理?
在 enrich 函数中已自动处理。例如4月制财年的2024财年实际跨越2024年和2025年两个自然年,日期偏移会正确计算到 2025-03-31。
Q3: 如何处理不同实体使用不同财年?
可以在 enrich 函数中根据 logicform.schema(实体名)判断,为不同实体应用不同的偏移量:
(logicform, normedLogicform, helperFunctions) => {
const { moment } = helperFunctions;
if (logicform.calendar !== 'fiscal') {
return logicform;
}
// 根据不同实体设置不同的财年起始月
const fiscalYearConfig = {
'销售订单': 4, // 4月制
'采购订单': 7, // 7月制
};
const fiscalYearStartMonth = fiscalYearConfig[logicform.schema] || 4;
const monthOffset = fiscalYearStartMonth - 1;
// ... 后续处理逻辑
return logicform;
}
Q4: 如何在前端显示"财年"而不是自然年?
这需要配合前端的显示格式化。目前时间偏移方案仅处理查询逻辑,显示层面的"FY2024"格式化需要另行配置。