This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import warnings
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell import WriteOnlyCell
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
from openpyxl.worksheet.table import Table, TableColumn, TableStyleInfo
|
||||
|
||||
from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts
|
||||
from .formula_columns import (
|
||||
RESERVED_DATA_HEADERS,
|
||||
ensure_available_metrics,
|
||||
metrics_used,
|
||||
to_excel,
|
||||
validate_column,
|
||||
)
|
||||
from .models import FormulaExport, Membership
|
||||
|
||||
FORMULA_VERSION = "3"
|
||||
MAX_ROWS = 100_000
|
||||
MAX_GROUPS = 200
|
||||
|
||||
|
||||
def _excel_number(value):
|
||||
if value is None:
|
||||
return None
|
||||
number = float(value)
|
||||
if not Decimal(str(number)).is_finite():
|
||||
raise ValueError("金额超出 Excel 数值范围,公式工作簿已拒绝生成")
|
||||
return number
|
||||
|
||||
|
||||
def _text_cell(sheet, value):
|
||||
cell = WriteOnlyCell(sheet, value="" if value is None else str(value))
|
||||
cell.data_type = "s"
|
||||
return cell
|
||||
|
||||
|
||||
def _styled_row(sheet, values, fill, font):
|
||||
cells = []
|
||||
for value in values:
|
||||
cell = value if hasattr(value, "data_type") else WriteOnlyCell(sheet, value=value)
|
||||
cell.fill, cell.font = fill, font
|
||||
cells.append(cell)
|
||||
return cells
|
||||
|
||||
|
||||
def _table(name, ref, headers, style):
|
||||
table = Table(displayName=name, ref=ref)
|
||||
table.tableColumns = [TableColumn(id=index, name=header) for index, header in enumerate(headers, 1)]
|
||||
table.tableStyleInfo = TableStyleInfo(name=style, showRowStripes=True, showColumnStripes=False)
|
||||
return table
|
||||
|
||||
|
||||
def _add_table(sheet, table):
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message="In write-only mode you must add table columns manually")
|
||||
sheet.add_table(table)
|
||||
|
||||
|
||||
def _quote(value):
|
||||
escaped = str(value).replace("~", "~~").replace("*", "~*").replace("?", "~?").replace('"', '""')
|
||||
return '"' + escaped + '"'
|
||||
|
||||
|
||||
def _text_criterion(value):
|
||||
criterion = "=" + str(value)
|
||||
escaped = criterion.replace("~", "~~").replace("*", "~*").replace("?", "~?")
|
||||
if len(escaped) > 255:
|
||||
raise ValueError("分组或筛选值超过 Excel criteria 的 255 字符上限,请缩短标签")
|
||||
return _quote(criterion)
|
||||
|
||||
|
||||
def _checked_formula(value):
|
||||
if len(value) > 8192:
|
||||
raise ValueError("生成公式超过 Excel 8,192 字符上限,请减少筛选条件或缩短标签")
|
||||
return value
|
||||
|
||||
|
||||
def _criteria(filters, group_key, label, periods):
|
||||
terms = []
|
||||
for key, value in sorted(filters.items()):
|
||||
terms.extend((f"Data[{key}]", _text_criterion(value)))
|
||||
terms.extend((f"Data[{group_key}]", _text_criterion(label)))
|
||||
terms.extend(("Data[Period]", _quote(">=" + periods[0]), "Data[Period]", _quote("<=" + periods[-1])))
|
||||
return ",".join(terms)
|
||||
|
||||
|
||||
def _sum(column, criteria):
|
||||
return f"SUMIFS(Data[{column}],{criteria})"
|
||||
|
||||
|
||||
def _column_name(index):
|
||||
name = ""
|
||||
while index:
|
||||
index, remainder = divmod(index - 1, 26)
|
||||
name = chr(65 + remainder) + name
|
||||
return name
|
||||
|
||||
|
||||
def _metric_formula(key, label, periods, side_col, metric_rows, filters, group_key, products, roi_formula):
|
||||
criteria = _criteria(filters, group_key, label, periods)
|
||||
rows = f"COUNTIFS({criteria})"
|
||||
revenue, cost = _sum("Revenue", criteria), _sum("Cost", criteria)
|
||||
gross, list_sum = _sum("GrossCalc", criteria), _sum("ListAmount", criteria)
|
||||
cost_n = f'COUNTIFS({criteria},Data[Cost],"<>")'
|
||||
gross_n = f'COUNTIFS({criteria},Data[GrossCalc],"<>")'
|
||||
list_n = f'COUNTIFS({criteria},Data[ListAmount],"<>")'
|
||||
customer_period = "Current" if side_col == "C" else "Baseline"
|
||||
customer_upgrade = f"{customer_period}Upgraded"
|
||||
customers = (
|
||||
f"COUNTIFS(CustomerRoster[Group],{_text_criterion(label)},CustomerRoster[{customer_period}],1)"
|
||||
)
|
||||
if key == "revenue":
|
||||
return f"=IF({rows}=0,0,{revenue})"
|
||||
if key == "cost":
|
||||
return f"=IF({rows}=0,0,IF({cost_n}={rows},{cost},NA()))"
|
||||
if key == "gross":
|
||||
return f"=IF({rows}=0,0,IF({gross_n}={rows},{gross},NA()))"
|
||||
if key == "customers":
|
||||
return f"=IF({rows}=0,NA(),IF(COUNTIFS({criteria},Data[CustomerPresent],0)>0,NA(),{customers}))"
|
||||
if key == "basket":
|
||||
ref = f"{side_col}{metric_rows[(label, 'customers')]}"
|
||||
return f"=IF(OR({rows}=0,ISNA({ref}),{ref}=0),NA(),{revenue}/{ref})"
|
||||
if key == "margin":
|
||||
return f"=IF(OR({rows}=0,{revenue}<=0,{gross_n}<>{rows}),NA(),{gross}/{revenue})"
|
||||
if key == "discount_rate":
|
||||
return f"=IF(OR({rows}=0,{list_n}<>{rows},{list_sum}<=0),NA(),{revenue}/{list_sum})"
|
||||
if key == "self_revenue":
|
||||
return (
|
||||
"=NA()"
|
||||
if not products
|
||||
else f"=IF({rows}=0,0,SUMIFS(Data[Revenue],{criteria},Data[SelfOperated],1))"
|
||||
)
|
||||
if key == "upgrade_rate":
|
||||
if not products:
|
||||
return "=NA()"
|
||||
ref = f"{side_col}{metric_rows[(label, 'customers')]}"
|
||||
upgraded = (
|
||||
f"COUNTIFS(CustomerRoster[Group],{_text_criterion(label)},CustomerRoster[{customer_upgrade}],1)"
|
||||
)
|
||||
return f"=IF(OR(ISNA({ref}),{ref}<=0),NA(),{upgraded}/{ref})"
|
||||
if key == "list_basket":
|
||||
ref = f"{side_col}{metric_rows[(label, 'customers')]}"
|
||||
return f"=IF(OR({rows}=0,{list_n}<>{rows},ISNA({ref}),{ref}<=0),NA(),{list_sum}/{ref})"
|
||||
numerator = revenue if roi_formula == "profit_over_cost" else gross
|
||||
complete = "FALSE" if roi_formula == "profit_over_cost" else f"{gross_n}<>{rows}"
|
||||
return f"=IF(OR({rows}=0,{cost_n}<>{rows},{cost}<=0,{complete}),NA(),({numerator}-{cost})/{cost})"
|
||||
|
||||
|
||||
def build_formula_workbook(ds, member, export_context, output):
|
||||
custom_columns = [validate_column(item) for item in export_context.get("custom_columns", [])]
|
||||
for item in custom_columns:
|
||||
ensure_available_metrics(ds, item)
|
||||
if len(custom_columns) > 10:
|
||||
raise ValueError("每次最多添加 10 个自定义指标列")
|
||||
ctx = context(ds, export_context)
|
||||
qs = (
|
||||
scoped_facts(ds, member, ctx["filters"])
|
||||
.filter(period__in=ctx["current"] + ctx["baseline"])
|
||||
.order_by("source_row")
|
||||
)
|
||||
count = qs.count()
|
||||
if count > MAX_ROWS:
|
||||
raise ValueError(f"公式工作簿最多支持 {MAX_ROWS:,} 行,请缩小范围")
|
||||
group_key = ctx["group"]
|
||||
groups = list(
|
||||
qs.order_by().values_list(f"dimensions__{group_key}", flat=True).distinct()[: MAX_GROUPS + 1]
|
||||
)
|
||||
group_values = {}
|
||||
excel_labels = {}
|
||||
for value in groups:
|
||||
label = str(value or "(未分类)")
|
||||
if label in group_values and group_values[label] != value:
|
||||
raise ValueError("分组存在与系统缺失值标签冲突的真实值,公式工作簿已拒绝生成")
|
||||
folded = label.casefold()
|
||||
if folded in excel_labels and excel_labels[folded] != label:
|
||||
raise ValueError("分组名称仅大小写不同,Excel 无法可靠区分,公式工作簿已拒绝生成")
|
||||
group_values[label] = value
|
||||
excel_labels[folded] = label
|
||||
if len(groups) > MAX_GROUPS:
|
||||
raise ValueError(f"所选维度超过 {MAX_GROUPS} 个分组,请缩小范围")
|
||||
labels = sorted(group_values)
|
||||
mapping = ds.mapping
|
||||
products, rates = mapping.get("self_operated_products", []), mapping.get("margin_rates", {})
|
||||
if len(products) > 100 or len(rates) > 2000:
|
||||
raise ValueError("产品口径超过公式工作簿的支持上限")
|
||||
dims = dimensions(ds)
|
||||
dim_headers = [d["key"] for d in dims]
|
||||
dim_names = {d["key"]: d["name"] for d in dims}
|
||||
if RESERVED_DATA_HEADERS.intersection(key.casefold() for key in dim_headers):
|
||||
raise ValueError("维度标识与公式工作簿保留列名冲突")
|
||||
wb = Workbook(write_only=True)
|
||||
summary = wb.create_sheet("分析汇总")
|
||||
instructions = wb.create_sheet("使用说明")
|
||||
parameters = wb.create_sheet("参数与口径")
|
||||
data = wb.create_sheet("计算明细")
|
||||
customers_sheet = wb.create_sheet("人数去重")
|
||||
evidence = wb.create_sheet("原始证据")
|
||||
reconcile = wb.create_sheet("对账检查")
|
||||
custom_sheet = wb.create_sheet("自定义指标") if custom_columns else None
|
||||
green, white = PatternFill("solid", fgColor="17664F"), Font(color="FFFFFF", bold=True)
|
||||
summary.freeze_panes = "A2"
|
||||
summary.sheet_view.showGridLines = False
|
||||
for sheet in (instructions, parameters, reconcile):
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.sheet_view.showGridLines = False
|
||||
data.freeze_panes = "A2"
|
||||
customers_sheet.freeze_panes = "A2"
|
||||
customers_sheet.sheet_view.showGridLines = False
|
||||
if custom_sheet:
|
||||
custom_sheet.freeze_panes = "B2"
|
||||
custom_sheet.sheet_view.showGridLines = False
|
||||
custom_sheet.auto_filter.ref = f"A1:{_column_name(1 + 4 * len(custom_columns))}{len(labels) + 1}"
|
||||
summary.append(_styled_row(summary, ["公式分析工作簿", "本地可重算副本", "修改不回写系统"], green, white))
|
||||
summary.append(["数据版本", ds.sha256, "公式版本", FORMULA_VERSION])
|
||||
summary.append(["筛选范围", str(ctx["filters"]), "分组维度", _text_cell(summary, dim_names[group_key])])
|
||||
summary.append(
|
||||
[
|
||||
"本期",
|
||||
f"{ctx['current'][0]} 至 {ctx['current'][-1]}",
|
||||
"基期",
|
||||
f"{ctx['baseline'][0]} 至 {ctx['baseline'][-1]}",
|
||||
]
|
||||
)
|
||||
summary.append(
|
||||
_styled_row(summary, ["指标", "分组", "本期", "基期", "差额", "变化率", "状态"], green, white)
|
||||
)
|
||||
all_metrics = {**METRICS, **{f"custom_{i}": item["name"] for i, item in enumerate(custom_columns)}}
|
||||
metric_rows = {}
|
||||
row = 6
|
||||
for label in labels:
|
||||
for key in all_metrics:
|
||||
metric_rows[(label, key)] = row
|
||||
row += 1
|
||||
|
||||
headers = [
|
||||
"Period",
|
||||
*dim_headers,
|
||||
"CustomerToken",
|
||||
"Revenue",
|
||||
"Cost",
|
||||
"GrossInput",
|
||||
"MarginRate",
|
||||
"GrossSource",
|
||||
"ListAmount",
|
||||
"SelfOperated",
|
||||
"CustomerPresent",
|
||||
"RowCount",
|
||||
"GrossCalc",
|
||||
]
|
||||
data.freeze_panes = "A2"
|
||||
if count:
|
||||
_add_table(
|
||||
data,
|
||||
_table(
|
||||
"Data",
|
||||
f"A1:{_column_name(len(headers))}{count + 1}",
|
||||
headers,
|
||||
"TableStyleMedium2",
|
||||
),
|
||||
)
|
||||
data.append(headers)
|
||||
token_secret = secrets.token_bytes(32)
|
||||
customer_roster = {}
|
||||
gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None)
|
||||
for row_number, fact in enumerate(qs.iterator(chunk_size=2000), start=2):
|
||||
product = fact.dimensions.get("product", "")
|
||||
direct = None
|
||||
if (
|
||||
gross_index is not None
|
||||
and gross_index < len(fact.raw)
|
||||
and fact.raw[gross_index] not in (None, "")
|
||||
):
|
||||
try:
|
||||
direct = _excel_number(Decimal(str(fact.raw[gross_index]).replace(",", "")))
|
||||
except (InvalidOperation, ValueError):
|
||||
pass
|
||||
rate = _excel_number(Decimal(str(rates[product]))) if product in rates else None
|
||||
customer = fact.customer or ""
|
||||
token = hmac.new(token_secret, customer.encode(), hashlib.sha256).hexdigest()[:24] if customer else ""
|
||||
if customer:
|
||||
group_label = str(fact.dimensions.get(group_key) or "(未分类)")
|
||||
record = customer_roster.setdefault(
|
||||
(group_label, token),
|
||||
{"current": False, "baseline": False, "current_upgraded": False, "baseline_upgraded": False},
|
||||
)
|
||||
if fact.period in ctx["current"]:
|
||||
record["current"] = True
|
||||
record["current_upgraded"] |= product in products
|
||||
if fact.period in ctx["baseline"]:
|
||||
record["baseline"] = True
|
||||
record["baseline_upgraded"] |= product in products
|
||||
dim_values = [fact.dimensions.get(d["key"], "(未分类)") or "(未分类)" for d in dims]
|
||||
values = [
|
||||
fact.period,
|
||||
*dim_values,
|
||||
token,
|
||||
_excel_number(fact.revenue),
|
||||
_excel_number(fact.cost),
|
||||
direct,
|
||||
rate,
|
||||
"direct" if direct is not None else ("lookup" if rate is not None else "missing"),
|
||||
_excel_number(fact.list_amount),
|
||||
int(product in products),
|
||||
int(bool(customer)),
|
||||
1,
|
||||
None,
|
||||
]
|
||||
row = [
|
||||
_text_cell(data, value) if i <= len(dims) + 1 or i == len(dims) + 6 else value
|
||||
for i, value in enumerate(values)
|
||||
]
|
||||
row[-1] = WriteOnlyCell(
|
||||
data,
|
||||
value=(
|
||||
f'=IF({_column_name(len(dims) + 7)}{row_number}="direct",'
|
||||
f"{_column_name(len(dims) + 5)}{row_number},"
|
||||
f'IF({_column_name(len(dims) + 7)}{row_number}="lookup",'
|
||||
f"{_column_name(len(dims) + 3)}{row_number}*"
|
||||
f'{_column_name(len(dims) + 6)}{row_number},""))'
|
||||
),
|
||||
)
|
||||
data.append(row)
|
||||
customer_headers = [
|
||||
"Group",
|
||||
"CustomerToken",
|
||||
"Current",
|
||||
"Baseline",
|
||||
"CurrentUpgraded",
|
||||
"BaselineUpgraded",
|
||||
]
|
||||
_add_table(
|
||||
customers_sheet,
|
||||
_table(
|
||||
"CustomerRoster",
|
||||
f"A1:F{len(customer_roster) + 1 if customer_roster else 2}",
|
||||
customer_headers,
|
||||
"TableStyleMedium4",
|
||||
),
|
||||
)
|
||||
customers_sheet.append(customer_headers)
|
||||
for (group_label, token), flags in customer_roster.items():
|
||||
customers_sheet.append(
|
||||
[
|
||||
_text_cell(customers_sheet, group_label),
|
||||
_text_cell(customers_sheet, token),
|
||||
int(flags["current"]),
|
||||
int(flags["baseline"]),
|
||||
int(flags["current_upgraded"]),
|
||||
int(flags["baseline_upgraded"]),
|
||||
]
|
||||
)
|
||||
if not customer_roster:
|
||||
customers_sheet.append(["", "", 0, 0, 0, 0])
|
||||
evidence.append(
|
||||
[
|
||||
"来源工作表",
|
||||
"来源行号",
|
||||
*[_text_cell(evidence, d["name"]) for d in dims],
|
||||
"期间",
|
||||
"客户 Token",
|
||||
"收入原值",
|
||||
"成本原值",
|
||||
"毛利原值",
|
||||
"原价原值",
|
||||
]
|
||||
)
|
||||
for source_row, period, values, customer, revenue, cost, gross, list_amount in qs.values_list(
|
||||
"source_row", "period", "dimensions", "customer", "revenue", "cost", "gross", "list_amount"
|
||||
).iterator(chunk_size=2000):
|
||||
token = (
|
||||
hmac.new(token_secret, (customer or "").encode(), hashlib.sha256).hexdigest()[:24]
|
||||
if customer
|
||||
else ""
|
||||
)
|
||||
values = [
|
||||
mapping["sheet"],
|
||||
source_row,
|
||||
*[values.get(d["key"], "(未分类)") for d in dims],
|
||||
period,
|
||||
token,
|
||||
str(revenue),
|
||||
"" if cost is None else str(cost),
|
||||
"" if gross is None else str(gross),
|
||||
"" if list_amount is None else str(list_amount),
|
||||
]
|
||||
evidence.append([_text_cell(evidence, value) for value in values])
|
||||
|
||||
parameters.append(_styled_row(parameters, ["参数", "值", "说明", "可编辑"], green, white))
|
||||
for row in [
|
||||
[
|
||||
"收入字段",
|
||||
_text_cell(
|
||||
parameters,
|
||||
next((c["name"] for c in mapping["columns"] if c["role"] == "revenue"), "未命名"),
|
||||
),
|
||||
"使用导入时确认的收入字段",
|
||||
"否",
|
||||
],
|
||||
["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"],
|
||||
["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"],
|
||||
["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"],
|
||||
[
|
||||
"Excel 数值精度",
|
||||
"IEEE-754 双精度",
|
||||
"精确 Decimal 在证据页按文本保存;公式不保证 15 位以上有效数字",
|
||||
"否",
|
||||
],
|
||||
["重算范围", f"最多 {MAX_ROWS:,} 行、{MAX_GROUPS} 组", "超限需缩小导出范围", "否"],
|
||||
["兼容性", "SUMIFS/COUNTIFS", "移除动态数组人数公式;仍需目标 Excel 桌面版本验收", "否"],
|
||||
]:
|
||||
parameters.append(row)
|
||||
for definition in custom_columns:
|
||||
parameters.append(
|
||||
[
|
||||
"自定义列",
|
||||
_text_cell(parameters, definition["name"]),
|
||||
_text_cell(parameters, definition["meaning"]),
|
||||
"公式由服务端生成",
|
||||
]
|
||||
)
|
||||
instructions.append(_styled_row(instructions, ["主题", "说明"], green, white))
|
||||
for row in [
|
||||
["用途", "公式分析工作簿;本地修改不回写系统。"],
|
||||
["行粒度", "计算明细每行对应一个 Fact,不等同患者就诊数。"],
|
||||
["维度列", "列名为数据导入映射中的维度键;显示名称见本页分组维度。"],
|
||||
["毛利", "直接毛利优先,否则收入×产品毛利率;无法计算则不可用。毛利贡献不代表净利润。"],
|
||||
["ROI", "按选定口径;缺成本、成本非正或所需指标缺失时不可用。"],
|
||||
["人数", "客户 token 跨行去重;缺客户编码时不可用。"],
|
||||
[
|
||||
"人数去重",
|
||||
"人数去重页按分组保存随机 token 及本期/基期/升单标记;身份、分组和期间标记属于固定快照,不可修改。",
|
||||
],
|
||||
["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"],
|
||||
["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"],
|
||||
["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"],
|
||||
]:
|
||||
instructions.append(row)
|
||||
for definition in custom_columns:
|
||||
instructions.append(
|
||||
[
|
||||
_text_cell(instructions, definition["name"]),
|
||||
_text_cell(
|
||||
instructions,
|
||||
f"含义:{definition['meaning']};范围:{definition['scope']};缺失/零分母:{definition['missing']}。",
|
||||
),
|
||||
]
|
||||
)
|
||||
if custom_sheet:
|
||||
custom_sheet.append(
|
||||
[
|
||||
"分组",
|
||||
*[
|
||||
_text_cell(custom_sheet, f"{item['name']}({suffix})")
|
||||
for item in custom_columns
|
||||
for suffix in ("本期", "基期", "差额", "变化率")
|
||||
],
|
||||
]
|
||||
)
|
||||
for label in labels:
|
||||
values = [_text_cell(custom_sheet, label)]
|
||||
for index, _definition in enumerate(custom_columns):
|
||||
formula_row = metric_rows[(label, f"custom_{index}")]
|
||||
values.extend(
|
||||
[
|
||||
f"='分析汇总'!C{formula_row}",
|
||||
f"='分析汇总'!D{formula_row}",
|
||||
f"='分析汇总'!E{formula_row}",
|
||||
f"='分析汇总'!F{formula_row}",
|
||||
]
|
||||
)
|
||||
custom_sheet.append(values)
|
||||
for label in labels:
|
||||
for key in METRICS:
|
||||
row = metric_rows[(label, key)]
|
||||
formulas = []
|
||||
for side, periods in enumerate((ctx["current"], ctx["baseline"])):
|
||||
if set(periods) & set(ctx["missing"]):
|
||||
formulas.append("=NA()")
|
||||
continue
|
||||
col = "C" if side == 0 else "D"
|
||||
formulas.append(
|
||||
_checked_formula(
|
||||
_metric_formula(
|
||||
key,
|
||||
label,
|
||||
periods,
|
||||
col,
|
||||
metric_rows,
|
||||
ctx["filters"],
|
||||
group_key,
|
||||
products,
|
||||
mapping["roi_formula"],
|
||||
)
|
||||
)
|
||||
)
|
||||
summary.append(
|
||||
[
|
||||
METRICS[key],
|
||||
_text_cell(summary, label),
|
||||
formulas[0],
|
||||
formulas[1],
|
||||
f"=IFERROR(C{row}-D{row},NA())",
|
||||
f"=IFERROR(IF(D{row}=0,NA(),E{row}/ABS(D{row})),NA())",
|
||||
f'=IF(ISNA(C{row}),"本期不可用",IF(ISNA(D{row}),"基期不可用","可用"))',
|
||||
]
|
||||
)
|
||||
for index, definition in enumerate(custom_columns):
|
||||
key, row = f"custom_{index}", metric_rows[(label, f"custom_{index}")]
|
||||
formulas = []
|
||||
for side in range(2):
|
||||
periods = ctx["current"] if side == 0 else ctx["baseline"]
|
||||
if set(periods) & set(ctx["missing"]):
|
||||
formulas.append("=NA()")
|
||||
continue
|
||||
col = "C" if side == 0 else "D"
|
||||
refs = {metric: f"{col}{metric_rows[(label, metric)]}" for metric in METRICS}
|
||||
expr = to_excel(definition["expression"], refs)
|
||||
used = metrics_used(definition["expression"])
|
||||
invalid = ",".join(f"ISNA({refs[metric]})" for metric in sorted(used))
|
||||
formula = (
|
||||
f"IF(OR({invalid}),NA(),IFERROR({expr},NA()))" if invalid else f"IFERROR({expr},NA())"
|
||||
)
|
||||
formulas.append(_checked_formula(f"={formula}"))
|
||||
summary.append(
|
||||
[
|
||||
_text_cell(summary, definition["name"]),
|
||||
_text_cell(summary, label),
|
||||
formulas[0],
|
||||
formulas[1],
|
||||
f"=IFERROR(C{row}-D{row},NA())",
|
||||
f"=IFERROR(IF(D{row}=0,NA(),E{row}/ABS(D{row})),NA())",
|
||||
f'=IF(ISNA(C{row}),"本期不可用",IF(ISNA(D{row}),"基期不可用","可用"))',
|
||||
]
|
||||
)
|
||||
|
||||
reconcile.append(
|
||||
_styled_row(
|
||||
reconcile,
|
||||
["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "状态"],
|
||||
green,
|
||||
white,
|
||||
)
|
||||
)
|
||||
reconcile_row = 2
|
||||
for label in labels:
|
||||
for side_name, periods, col in (("本期", ctx["current"], "C"), ("基期", ctx["baseline"], "D")):
|
||||
side_qs = qs.filter(period__in=periods, **{f"dimensions__{group_key}": group_values[label]})
|
||||
reference = metrics(side_qs.aggregate(**aggregations(products)), mapping["roi_formula"], products)
|
||||
for key, name in METRICS.items():
|
||||
formula_row = metric_rows[(label, key)]
|
||||
value = reference[key]
|
||||
reconcile.append(
|
||||
[
|
||||
name,
|
||||
_text_cell(reconcile, label),
|
||||
side_name,
|
||||
"" if value is None else str(value),
|
||||
f"='分析汇总'!{col}{formula_row}",
|
||||
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<0.01,"通过","待复核"),"待复核"))',
|
||||
]
|
||||
)
|
||||
reconcile_row += 1
|
||||
for index, definition in enumerate(custom_columns):
|
||||
formula_row = metric_rows[(label, f"custom_{index}")]
|
||||
reconcile.append(
|
||||
[
|
||||
_text_cell(reconcile, definition["name"]),
|
||||
_text_cell(reconcile, label),
|
||||
side_name,
|
||||
"自定义公式无服务器参考",
|
||||
f"='分析汇总'!{col}{formula_row}",
|
||||
"",
|
||||
"需业务验收",
|
||||
]
|
||||
)
|
||||
reconcile_row += 1
|
||||
wb.calculation.fullCalcOnLoad = True
|
||||
wb.calculation.forceFullCalc = True
|
||||
wb.calculation.calcMode = "auto"
|
||||
wb.save(output)
|
||||
return {"rows": count, "groups": len(labels), "formula_version": FORMULA_VERSION}
|
||||
|
||||
|
||||
def process_formula_export(export_id):
|
||||
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
|
||||
temporary = None
|
||||
target = None
|
||||
try:
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
if root.is_symlink():
|
||||
raise ValueError("公式导出目录不能是符号链接")
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
root.chmod(0o700)
|
||||
target = root / str(job.user_id) / f"{job.pk}.xlsx"
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if target.parent.is_symlink() or target.is_symlink():
|
||||
raise ValueError("公式导出路径不能是符号链接")
|
||||
target.parent.chmod(0o700)
|
||||
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
||||
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
||||
if set(member.departments) != set(job.departments):
|
||||
raise PermissionError("科室授权已变更,请重新提交导出")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as file:
|
||||
stats = build_formula_workbook(job.dataset, member, job.context, file)
|
||||
temporary.replace(target)
|
||||
job.path, job.status, job.error = str(target), "succeeded", ""
|
||||
job.finished_at = timezone.now()
|
||||
job.expires_at = timezone.now() + timedelta(hours=24)
|
||||
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
|
||||
return stats
|
||||
except Exception as exc:
|
||||
for path in (temporary, target):
|
||||
try:
|
||||
if path is not None and not path.is_symlink():
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
job.status, job.error, job.path = "failed", str(exc)[:500], ""
|
||||
job.finished_at, job.expires_at = timezone.now(), None
|
||||
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
||||
raise
|
||||
|
||||
|
||||
def csv_safe(value):
|
||||
text = str(value) if value is not None else ""
|
||||
if not isinstance(value, str):
|
||||
return text
|
||||
return (
|
||||
"'" + text
|
||||
if text.startswith(("\t", "\r", "\n")) or text.lstrip().startswith(("=", "+", "-", "@"))
|
||||
else text
|
||||
)
|
||||
Reference in New Issue
Block a user