chore: import existing codebase as baseline
Verify / verify (push) Failing after 1m20s

This commit is contained in:
Jayway Liu
2026-09-26 01:12:10 +08:00
commit 0ac0c4eb56
77 changed files with 14155 additions and 0 deletions
View File
+275
View File
@@ -0,0 +1,275 @@
import re
from decimal import Decimal
from django.db.models import Count, Q, Sum
from .models import Fact
METRICS = {
"revenue": "实际收入",
"cost": "成本",
"gross": "毛利贡献",
"roi": "ROI",
"customers": "消费人数",
"basket": "客单价",
"margin": "毛利率",
"discount_rate": "产品扣率",
"self_revenue": "自营收入",
"upgrade_rate": "升单率",
"list_basket": "原价客单价",
}
def dimensions(ds):
return [
{"key": c["key"], "name": c["name"], "description": c.get("description", "")}
for c in ds.mapping["columns"]
if c["role"] == "dimension"
]
def default_preferences(ds):
dims = [d["key"] for d in dimensions(ds)]
preferred = [
d
for d in ["department", "subject", "doctor_level", "doctor", "product", "product_subcategory"]
if d in dims
]
return {
"entry": "overview",
"comparison": "yoy",
"hierarchy": preferred or dims[:5],
"metric": "revenue",
"threshold": 0.15,
"min_impact": 1000,
"min_samples": 5,
"show_heatmap": True,
"show_queue": True,
"show_copilot": True,
"alerts": True,
}
def month_index(s):
if not isinstance(s, str) or not re.fullmatch(r"\d{4}-\d{2}", s):
raise ValueError("期间格式必须为 YYYY-MM")
year, month = map(int, s.split("-"))
if not 1900 <= year <= 2200 or not 1 <= month <= 12:
raise ValueError("期间无效")
return year * 12 + month - 1
def month_text(index):
year, month = divmod(index, 12)
return f"{year:04d}-{month + 1:02d}"
def context(ds, body):
if not isinstance(body, dict):
raise ValueError("查询格式错误")
end = body.get("end", ds.periods[-1])
start = body.get("start", end)
a, b = month_index(start), month_index(end)
if b < a or b - a > 59:
raise ValueError("期间范围必须为 1 至 60 个月")
mode = body.get("comparison", "yoy")
if mode not in ("yoy", "mom", "custom"):
raise ValueError("比较口径无效")
length = b - a + 1
base_end = (
month_index(body.get("baseline_end", ""))
if mode == "custom"
else b - (12 if mode == "yoy" else length)
)
current = [month_text(i) for i in range(a, b + 1)]
baseline = [month_text(i) for i in range(base_end - length + 1, base_end + 1)]
if set(current) & set(baseline):
raise ValueError("本期与基期不能重叠")
filters = body.get("filters", {})
allowed = {d["key"] for d in dimensions(ds)}
if not isinstance(filters, dict) or any(
k not in allowed or not isinstance(v, str) or len(v) > 256 for k, v in filters.items()
):
raise ValueError("筛选维度无效")
group = body.get("group", next(iter(dimensions(ds)))["key"])
if not isinstance(group, str) or group not in allowed:
raise ValueError("分组维度不存在")
metric = body.get("metric", "revenue")
if not isinstance(metric, str) or metric not in METRICS:
raise ValueError("指标不存在")
missing = sorted(set(current + baseline) - set(ds.periods))
return {
"start": start,
"end": end,
"comparison": mode,
"baseline_end": baseline[-1],
"current": current,
"baseline": baseline,
"missing": missing,
"filters": filters,
"group": group,
"metric": metric,
}
def scoped_facts(ds, member, filters=None):
qs = Fact.objects.filter(dataset=ds)
if member.departments:
qs = qs.filter(dimensions__department__in=member.departments)
for key, value in (filters or {}).items():
qs = qs.filter(**{f"dimensions__{key}": value})
return qs
def aggregations(products=None):
return {
"rows": Count("id"),
"revenue_sum": Sum("revenue"),
"cost_sum": Sum("cost"),
"gross_sum": Sum("gross"),
"cost_n": Count("cost"),
"gross_n": Count("gross"),
"customer_n": Count("customer"),
"customers": Count("customer", distinct=True),
"list_sum": Sum("list_amount"),
"list_n": Count("list_amount"),
"self_sum": Sum("revenue", filter=Q(dimensions__product__in=products or [])),
"upgraded": Count("customer", distinct=True, filter=Q(dimensions__product__in=products or [])),
}
def ratio(a, b):
return a / b if a is not None and b is not None and b > 0 else None
def metrics(agg, formula, products=None):
n = agg["rows"]
revenue = agg["revenue_sum"] or Decimal(0)
cost = (agg["cost_sum"] or Decimal(0)) if n and agg["cost_n"] == n else None
gross = (agg["gross_sum"] or Decimal(0)) if n and agg["gross_n"] == n else None
customers = agg["customers"] if n and agg["customer_n"] == n else None
profit = revenue - cost if cost is not None else None
if formula == "gross_less_cost_over_cost":
profit = gross - cost if gross is not None and cost is not None else None
return {
"rows": n,
"revenue": revenue,
"cost": cost,
"gross": gross,
"customers": customers,
"roi": ratio(profit, cost),
"basket": ratio(revenue, customers),
"margin": ratio(gross, revenue),
"discount_rate": ratio(revenue, agg["list_sum"]) if n and agg["list_n"] == n else None,
"self_revenue": (agg["self_sum"] or Decimal(0)) if products else None,
"upgrade_rate": ratio(Decimal(agg["upgraded"]), customers) if products else None,
"list_basket": ratio(agg["list_sum"], customers) if n and agg["list_n"] == n else None,
}
def comparison(current, baseline, comparable):
result = {}
for key in METRICS:
c, b = current.get(key), baseline.get(key)
delta = c - b if comparable and c is not None and b is not None else None
change = Decimal(delta) / abs(Decimal(b)) if delta is not None and b != 0 else None
result[key] = {"current": c, "baseline": b if comparable else None, "delta": delta, "change": change}
return result
def analyze(ds, member, body, preferences):
ctx = context(ds, body)
qs = scoped_facts(ds, member, ctx["filters"])
formula = ds.mapping["roi_formula"]
products = ds.mapping.get("self_operated_products", [])
total_c = metrics(
qs.filter(period__in=ctx["current"]).aggregate(**aggregations(products)), formula, products
)
total_b = metrics(
qs.filter(period__in=ctx["baseline"]).aggregate(**aggregations(products)), formula, products
)
comparable = not ctx["missing"]
if set(ctx["current"]) - set(ds.periods):
total_c = {**total_c, **{key: None for key in METRICS}}
summary = comparison(total_c, total_b, comparable)
group = "dimensions__" + ctx["group"]
buckets = {}
for side in ("current", "baseline"):
for row in qs.filter(period__in=ctx[side]).values(group).annotate(**aggregations(products)):
buckets.setdefault(row[group], {})[side] = metrics(row, formula, products)
def empty_side(total):
return {
"rows": 0,
**{
k: Decimal(0)
if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None
else None
for k in METRICS
},
}
rows = []
metric = ctx["metric"]
parent_delta = summary[metric]["delta"]
threshold = Decimal(str(preferences["threshold"]))
impact_floor = Decimal(str(preferences["min_impact"]))
for label, sides in buckets.items():
c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b))
if set(ctx["current"]) - set(ds.periods):
c = {**c, **{key: None for key in METRICS}}
values = comparison(c, b, comparable)
v = values[metric]
change, delta = v["change"], v["delta"]
impact = (
abs(delta)
if metric in ("revenue", "gross", "cost", "self_revenue") and delta is not None
else abs(values["revenue"]["delta"] or 0)
)
adverse = change is not None and (change >= threshold if metric == "cost" else change <= -threshold)
anomaly = bool(
adverse and impact >= impact_floor and min(c["rows"], b["rows"]) >= preferences["min_samples"]
)
contribution = (
delta / parent_delta
if delta is not None and parent_delta and metric in ("revenue", "cost", "gross", "self_revenue")
else None
)
rows.append(
{
"label": label or "(未分类)",
"values": values,
"rows": c["rows"],
"baseline_rows": b["rows"],
"anomaly": anomaly,
"impact": impact,
"contribution": contribution,
"filters": {**ctx["filters"], ctx["group"]: label or "(未分类)"},
}
)
rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"]))
observed = {
r["period"]: metrics(r, formula, products)
for r in qs.values("period").annotate(**aggregations(products))
}
trend = []
for index in range(month_index(ds.periods[0]), month_index(ds.periods[-1]) + 1):
period = month_text(index)
values = observed.get(period)
if values is None:
values = empty_side(total_c) if period in ds.periods else {key: None for key in METRICS}
trend.append({"period": period, **values})
return {
"dataset_id": str(ds.pk),
"version": ds.sha256,
"context": ctx,
"summary": summary,
"rows": rows,
"trend": trend,
"row_count": total_c["rows"],
"anomaly_count": sum(r["anomaly"] for r in rows),
"comparable": comparable,
"rules": preferences,
"notice": "贡献分解为统计关联,不代表业务因果;退款和冲销保留在净额中。",
"roi_formula": formula,
}
+163
View File
@@ -0,0 +1,163 @@
import json
import httpx
from django.conf import settings
from django.http import JsonResponse
from .analysis import METRICS, analyze, context, dimensions
from .views import APIError, audit, body, dataset, endpoint, limit, preferences
def model_intent(question, ds, ctx):
keys = [d["key"] for d in dimensions(ds)]
schema = {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["overview", "anomalies", "drill", "evidence"]},
"metric": {"type": "string", "enum": list(METRICS)},
"group": {"type": "string", "enum": keys},
"filters": {
"type": "array",
"items": {
"type": "object",
"properties": {"key": {"type": "string", "enum": keys}, "value": {"type": "string"}},
"required": ["key", "value"],
"additionalProperties": False,
},
},
},
"required": ["action", "metric", "group", "filters"],
"additionalProperties": False,
}
# Only schema, selected context and the user's question leave the server; never raw rows.
payload = {
"model": settings.OPENAI_MODEL,
"store": False,
"max_output_tokens": 1200,
"input": [
{
"role": "system",
"content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。保留当前筛选,除非用户明确改变。"
+ json.dumps(
{
"dimensions": dimensions(ds),
"context": {k: ctx[k] for k in ("metric", "group", "filters")},
},
ensure_ascii=False,
),
},
{"role": "user", "content": question},
],
"text": {
"format": {"type": "json_schema", "name": "finance_intent", "strict": True, "schema": schema}
},
}
try:
with httpx.Client(timeout=30) as client:
response = client.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
json=payload,
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict) or result.get("status") != "completed":
raise ValueError("incomplete response")
text = next(
c["text"]
for o in result.get("output", [])
for c in o.get("content", [])
if c.get("type") == "output_text"
)
intent = json.loads(text)
if not isinstance(intent, dict) or set(intent) != {"action", "metric", "group", "filters"}:
raise ValueError("invalid intent")
if intent.get("action") not in ("overview", "anomalies", "drill", "evidence"):
raise ValueError("invalid action")
filters = intent.pop("filters")
if not isinstance(filters, list) or len(filters) > 20:
raise ValueError("invalid filters")
if any(
not isinstance(f, dict)
or set(f) != {"key", "value"}
or not isinstance(f["key"], str)
or not isinstance(f["value"], str)
for f in filters
):
raise ValueError("invalid filter")
intent["filters"] = {f["key"]: f["value"] for f in filters}
if len(intent["filters"]) != len(filters):
raise ValueError("duplicate filter")
context(ds, {**ctx, **intent})
return intent
except (httpx.HTTPError, ValueError, KeyError, TypeError, AttributeError, StopIteration) as exc:
raise APIError("模型暂时不可用或返回了无效意图;未执行任何操作", 502) from exc
@endpoint(("POST",))
def assistant(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
question = data.get("question", "")
if not isinstance(question, str) or not 1 <= len(question.strip()) <= 2000:
raise APIError("问题需为 1 至 2,000 字")
limit(f"assistant:{request.user.pk}", 20, 60)
ctx = context(ds, data.get("context", {}))
pref, _ = preferences(request, ds)
use_model = data.get("use_model", False)
if type(use_model) is not bool:
raise APIError("AI 模式必须为布尔值")
if use_model:
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
if data.get("consent") is not True:
raise APIError("请先同意发送问题与当前筛选上下文", 400)
intent = model_intent(question, ds, ctx)
else:
action = (
"evidence"
if "明细" in question or "证据" in question
else "anomalies"
if "异常" in question
else "drill"
if "下钻" in question
else "overview"
)
intent = {"action": action, "metric": ctx["metric"], "group": ctx["group"], "filters": ctx["filters"]}
for k, label in METRICS.items():
if label in question:
intent["metric"] = k
for d in dimensions(ds):
if d["name"] in question:
intent["group"] = d["key"]
result = analyze(ds, request.member, {**ctx, **intent}, pref)
metric = intent["metric"]
value = result["summary"][metric]
if not result["comparable"]:
answer = "缺少期间 " + "、".join(result["context"]["missing"]) + ",不能给出比较或异常结论。"
elif value["current"] is None:
answer = f"当前数据缺少计算{METRICS[metric]}所需的完整字段。"
else:
change = value["change"]
change_text = f"{change:+.1%}" if change is not None else "不可计算(基期为零或数据缺失)"
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.4f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
candidates = [r for r in result["rows"] if r["anomaly"]][:3]
if candidates:
answer += " 优先核查:" + "、".join(r["label"] for r in candidates) + "。"
answer += " 这是数据贡献分解,不是因果结论。"
audit(
request, "assistant.query", ds.pk, {"provider": "openai" if use_model else "local", "intent": intent}
)
return JsonResponse(
{
"answer": answer,
"intent": intent,
"context": result["context"],
"version": ds.sha256,
"provider": "openai" if use_model else "local",
"evidence": [
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
for r in result["rows"][:5]
],
}
)
+644
View File
@@ -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
)
+109
View File
@@ -0,0 +1,109 @@
import math
from .analysis import METRICS
RESERVED_DATA_HEADERS = {
"period",
"customertoken",
"revenue",
"cost",
"grossinput",
"marginrate",
"grosssource",
"listamount",
"selfoperated",
"customerpresent",
"rowcount",
"grosscalc",
}
def available_metrics(ds):
roles = {column["role"] for column in ds.mapping["columns"]}
dimensions = {column.get("key") for column in ds.mapping["columns"] if column["role"] == "dimension"}
products = bool(ds.mapping.get("self_operated_products")) and "product" in dimensions
gross = "gross" in roles or (bool(ds.mapping.get("margin_rates")) and "product" in dimensions)
customer, list_amount, cost = "customer" in roles, "list_amount" in roles, "cost" in roles
available = {"revenue"}
if cost:
available.add("cost")
if gross:
available.update(("gross", "margin"))
if customer:
available.update(("customers", "basket"))
if list_amount:
available.add("discount_rate")
if customer and list_amount:
available.add("list_basket")
if cost and (ds.mapping.get("roi_formula") == "profit_over_cost" or gross):
available.add("roi")
if products:
available.add("self_revenue")
if customer:
available.add("upgrade_rate")
return {key: value for key, value in METRICS.items() if key in available}
OPERATORS = {"add": "+", "subtract": "-", "multiply": "*", "divide": "/"}
def validate_column(value):
if not isinstance(value, dict) or set(value) != {"name", "meaning", "scope", "missing", "expression"}:
raise ValueError("自定义列定义字段无效")
for key, limit in (("name", 80), ("meaning", 500), ("scope", 200), ("missing", 300)):
text = value[key]
if not isinstance(text, str) or not text.strip() or len(text) > limit:
raise ValueError(f"自定义列{key}说明不完整")
if value["missing"] != "结果不可用(#N/A);零分母不可用":
raise ValueError("暂不支持自定义缺失值策略;请选择结果不可用(#N/A)及零分母不可用")
_validate_expression(value["expression"])
return value
def _validate_expression(node, depth=0):
if depth > 8 or not isinstance(node, dict):
raise ValueError("自定义计算公式结构无效")
if set(node) == {"metric"}:
if not isinstance(node["metric"], str) or node["metric"] not in METRICS:
raise ValueError("公式引用了不支持的指标")
return
if set(node) == {"number"}:
if (
type(node["number"]) not in (int, float)
or not math.isfinite(node["number"])
or abs(node["number"]) > 1e12
):
raise ValueError("公式常数无效")
return
if (
set(node) == {"operator", "left", "right"}
and isinstance(node["operator"], str)
and node["operator"] in OPERATORS
):
_validate_expression(node["left"], depth + 1)
_validate_expression(node["right"], depth + 1)
return
raise ValueError("公式包含不支持的字段或运算")
def metrics_used(node):
if "metric" in node:
return {node["metric"]}
if "number" in node:
return set()
return metrics_used(node["left"]) | metrics_used(node["right"])
def ensure_available_metrics(ds, column):
unavailable = metrics_used(column["expression"]) - available_metrics(ds).keys()
if unavailable:
names = "、".join(METRICS[key] for key in sorted(unavailable))
raise ValueError(f"当前数据集未映射计算“{names}”所需字段,不能生成此自定义列")
def to_excel(node, references):
if "metric" in node:
return references[node["metric"]]
if "number" in node:
return str(node["number"])
return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})"
+328
View File
@@ -0,0 +1,328 @@
import json
from pathlib import Path
import httpx
from django.conf import settings
from django.http import FileResponse, JsonResponse
from django.utils import timezone
from .analysis import METRICS, context, dimensions
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
from .models import FormulaDefinition, FormulaExport
from .views import APIError, audit, body, dataset, endpoint, limit
def design_column(messages, ds, ctx):
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
if not isinstance(messages, list) or not 1 <= len(messages) <= 12:
raise APIError("对话消息格式无效")
if any(
not isinstance(item, dict)
or set(item) != {"role", "content"}
or item["role"] not in ("user", "assistant")
or not isinstance(item["content"], str)
or len(item["content"]) > 1000
for item in messages
):
raise APIError("对话消息内容无效")
if (
messages[0]["role"] != "user"
or messages[-1]["role"] != "user"
or any(left["role"] == right["role"] for left, right in zip(messages, messages[1:]))
):
raise APIError("对话消息必须按用户描述、助手追问、用户补充的顺序提交")
available = available_metrics(ds)
def expression_schema(depth=0):
leaf = [
{
"type": "object",
"properties": {"metric": {"type": "string", "enum": list(available)}},
"required": ["metric"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"number": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000}
},
"required": ["number"],
"additionalProperties": False,
},
]
if depth >= 8:
return {"anyOf": leaf}
return {
"anyOf": [
*leaf,
{
"type": "object",
"properties": {
"operator": {"type": "string", "enum": ["add", "subtract", "multiply", "divide"]},
"left": expression_schema(depth + 1),
"right": expression_schema(depth + 1),
},
"required": ["operator", "left", "right"],
"additionalProperties": False,
},
]
}
schema = {
"type": "object",
"properties": {
"complete": {"type": "boolean"},
"questions": {"type": "array", "items": {"type": "string"}},
"column": {
"anyOf": [
{"type": "null"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"meaning": {"type": "string"},
"scope": {"type": "string"},
"missing": {"type": "string", "enum": ["结果不可用(#N/A);零分母不可用"]},
"expression": expression_schema(),
},
"required": ["name", "meaning", "scope", "missing", "expression"],
"additionalProperties": False,
},
]
},
},
"required": ["complete", "questions", "column"],
"additionalProperties": False,
}
system = (
"你是财务计算定义助手。只可用给定指标生成表达式树,不可输出公式文本、SQL或新字段。"
"available_metrics 是当前数据集可计算指标;其他指标依赖字段未映射,必须说明并追问,不可猜测或替代。"
"必须确认列名、含义、适用范围和缺失值/零分母处理。缺少任何信息时 complete=false、column=null,提出具体中文问题。"
"指标节点{metric:指标key},常数{number:数值},运算{operator:add|subtract|multiply|divide,left:...,right:...}。"
"本系统当前仅支持结果不可用(Excel #N/A)且零分母不可用,不支持按零替代或跳过缺失项。"
"用户补充信息后结合整段对话重新判断,只有公式含义无歧义且能表达时 complete=true。"
+ json.dumps(
{
"metrics": METRICS,
"available_metrics": available,
"unavailable_metrics": {key: value for key, value in METRICS.items() if key not in available},
"dimensions": dimensions(ds),
"context": {k: ctx[k] for k in ("current", "baseline", "filters", "group")},
},
ensure_ascii=False,
)
)
payload = {
"model": settings.OPENAI_MODEL,
"store": False,
"max_output_tokens": 1400,
"input": [{"role": "system", "content": system}, *messages],
"text": {
"format": {
"type": "json_schema",
"name": "formula_column_design",
"strict": True,
"schema": schema,
}
},
}
try:
with httpx.Client(timeout=30) as client:
response = client.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
json=payload,
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict) or result.get("status") != "completed":
raise ValueError("incomplete response")
text = next(c["text"] for o in result["output"] for c in o["content"] if c["type"] == "output_text")
design = json.loads(text)
if (
set(design) != {"complete", "questions", "column"}
or type(design["complete"]) is not bool
or not isinstance(design["questions"], list)
or len(design["questions"]) > 8
):
raise ValueError("invalid response")
if design["complete"]:
if design["column"] is None:
raise ValueError("complete response has no column")
design["column"] = validate_column(design["column"])
try:
ensure_available_metrics(ds, design["column"])
except ValueError as exc:
return {"complete": False, "questions": [str(exc)], "column": None}
if design["questions"]:
raise ValueError("complete response contains questions")
elif (
design["column"] is not None
or not design["questions"]
or any(not isinstance(q, str) or not q.strip() or len(q) > 500 for q in design["questions"])
):
raise ValueError("incomplete response malformed")
return design
except (httpx.HTTPError, ValueError, KeyError, TypeError, StopIteration) as exc:
raise APIError("模型暂时不可用或返回了不安全的计算定义;未创建工作簿", 502) from exc
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_design(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
if request.member.departments:
raise APIError("科室授权账号不可定义跨范围公式导出", 403)
ctx = context(ds, data.get("context", {}))
if data.get("consent") is not True:
raise APIError("请先同意发送列描述和筛选范围到外部 AI", 400)
limit(f"formula-design:{request.user.pk}", 20, 60)
result = design_column(data.get("messages"), ds, ctx)
audit(request, "formula_export.design", ds.pk, {"complete": result["complete"]})
return JsonResponse(result)
@endpoint(("GET", "POST"), roles=("admin", "analyst"))
def formula_definitions(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
if request.method == "GET":
items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name")
return JsonResponse(
{
"items": [
{
"id": str(item.pk),
"definition": item.definition,
"updated_at": item.updated_at.isoformat(),
}
for item in items
]
}
)
definition = validate_column(body(request).get("definition"))
try:
ensure_available_metrics(ds, definition)
except ValueError as exc:
raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold()
existing = FormulaDefinition.objects.filter(
dataset=ds, user=request.user, normalized_name=normalized_name
).first()
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
raise APIError("每个数据集最多保存 50 个公式定义")
item, created = FormulaDefinition.objects.update_or_create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
defaults={"definition": definition},
)
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"updated_at": item.updated_at.isoformat(),
},
status=201 if created else 200,
)
@endpoint(("DELETE",), roles=("admin", "analyst"))
def formula_definition_delete(request, dataset_id, definition_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
item = FormulaDefinition.objects.filter(pk=definition_id, dataset=ds, user=request.user).first()
if not item:
raise APIError("公式定义不存在", 404)
name = item.definition.get("name", "")
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
item.delete()
return JsonResponse({"deleted": True})
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_export_create(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可创建公式导出", 403)
data = body(request)
ctx = context(ds, data)
definitions = data.get("custom_columns", [])
if not isinstance(definitions, list) or len(definitions) > 10:
raise APIError("自定义列最多 10 个")
for item in definitions:
validate_column(item)
try:
ensure_available_metrics(ds, item)
except ValueError as exc:
raise APIError(str(exc)) from exc
limit(f"formula-export:{request.user.pk}", 5, 3600)
job = FormulaExport.objects.create(
dataset=ds,
user=request.user,
context={**ctx, "custom_columns": definitions},
departments=list(request.member.departments),
)
audit(
request,
"formula_export.create",
job.pk,
{"dataset": str(ds.pk), "columns": [x["name"] for x in definitions]},
)
return JsonResponse({"id": str(job.pk), "status": job.status}, status=202)
def _owned_job(request, dataset_id, export_id):
job = FormulaExport.objects.filter(
pk=export_id,
dataset_id=dataset_id,
dataset__organization=request.member.organization,
user=request.user,
).first()
if not job:
raise APIError("导出任务不存在", 404)
if set(request.member.departments) != set(job.departments):
raise APIError("授权范围已变化,不能访问此导出", 403)
return job
@endpoint(("GET",))
def formula_export_status(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
return JsonResponse(
{
"id": str(job.pk),
"status": job.status,
"error": "生成失败,请联系管理员并提供任务编号" if job.status == "failed" else "",
"expires_at": job.expires_at.isoformat() if job.expires_at else None,
}
)
@endpoint(("GET",))
def formula_export_download(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
if job.status != "succeeded" or not job.path or not job.expires_at or job.expires_at <= timezone.now():
raise APIError("文件尚未就绪或已过期", 410 if job.status == "succeeded" else 409)
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
candidate = Path(job.path)
expected = export_root / str(job.user_id) / f"{job.pk}.xlsx"
root = export_root.resolve()
path = candidate.resolve()
if (
export_root.is_symlink()
or candidate.is_symlink()
or candidate.parent.is_symlink()
or path != expected.resolve()
or not path.is_relative_to(root)
or not path.is_file()
):
raise APIError("导出文件不存在", 410)
audit(request, "formula_export.download", job.pk)
return FileResponse(path.open("rb"), as_attachment=True, filename="traceiq-formula-analysis.xlsx")
+378
View File
@@ -0,0 +1,378 @@
import csv
import hashlib
import re
import zipfile
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, getcontext
from itertools import islice
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from openpyxl import load_workbook
from .formula_columns import RESERVED_DATA_HEADERS
from .models import Dataset, Fact
getcontext().prec = 60
ROLES = {
"period",
"year",
"month",
"revenue",
"cost",
"gross",
"quantity",
"customer",
"list_amount",
"discount",
"dimension",
"ignore",
}
ALIASES = {
"period": ["期间", "日期", "period", "date"],
"year": ["年份", "年", "year"],
"month": ["月份", "月", "month"],
"revenue": ["实际收入", "收入", "revenue", "actual_revenue"],
"cost": ["成本", "cost"],
"gross": ["毛利", "毛利贡献", "gross_profit"],
"quantity": ["数量", "quantity"],
"customer": ["客户姓名", "客户编码", "customer_id"],
"list_amount": ["应收金额", "list_amount"],
"discount": ["折扣金额(去税)", "discount"],
}
DIMENSIONS = {
"科室(重分类)": "department",
"科室": "department",
"department": "department",
"学科分类": "subject",
"学科": "subject",
"医生等级": "doctor_level",
"医生": "doctor",
"医生ID": "doctor",
"产品一级分类": "product",
"产品二级分类": "product_subcategory",
"区域": "region",
"顾客来源": "source",
}
def safe_text(value):
if value is None:
return ""
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)[:4000]
def validate_archive(file):
file.seek(0)
with zipfile.ZipFile(file) as archive:
infos = archive.infolist()
if len(infos) > 5000 or sum(i.file_size for i in infos) > 1024 * 1024 * 1024:
raise ValueError("工作簿解压体积超过安全限制")
if any(i.file_size / max(i.compress_size, 1) > 1000 for i in infos):
raise ValueError("工作簿压缩比异常")
if any("vbaProject" in i.filename or "externalLinks/" in i.filename for i in infos):
raise ValueError("不接受宏或外部链接工作簿")
file.seek(0)
def rows_for(path, sheet=None, formulas=False):
if str(path).lower().endswith(".csv"):
with open(path, encoding="utf-8-sig", newline="") as file:
yield from csv.reader(file)
else:
wb = load_workbook(path, read_only=True, data_only=not formulas, keep_links=False)
try:
yield from wb[sheet].iter_rows(values_only=True)
finally:
wb.close()
def profile_file(path):
if str(path).lower().endswith(".csv"):
sheets = [{"name": "CSV", "rows": None, "columns": None}]
else:
with open(path, "rb") as file:
validate_archive(file)
wb = load_workbook(path, read_only=True, data_only=True, keep_links=False)
sheets = [{"name": s.title, "rows": s.max_row, "columns": s.max_column} for s in wb]
wb.close()
previews = []
for sheet in sheets:
preview_count = 600 if sheet["name"] == "产品毛利率" else 8
rows = [list(map(safe_text, r)) for r in islice(rows_for(path, sheet["name"]), preview_count)]
if any(len(r) > 200 for r in rows):
raise ValueError("最多支持 200 列")
previews.append({**sheet, "preview": rows})
return {"sheets": previews}
def suggest_mapping(profile, sheet=None, header=None):
s = next((s for s in profile["sheets"] if s["name"] == sheet), None)
s = s or next((s for s in profile["sheets"] if s["name"] == "明细"), profile["sheets"][0])
rows = s["preview"]
if not rows:
raise ValueError("工作表为空")
if header is None:
def score(row):
names = set(row)
return sum(any(a in names for a in aliases) for aliases in ALIASES.values())
header = max(range(min(5, len(rows))), key=lambda i: score(rows[i])) + 1
if not isinstance(header, int) or not 1 <= header <= len(rows):
raise ValueError("表头行超出预览范围")
columns, used = [], set()
for i, name in enumerate(rows[header - 1]):
role = next(
(r for r, names in ALIASES.items() if name in names),
"ignore" if name in {"单价", "折扣金额", "实收金额"} else "dimension",
)
if role != "dimension" and role in used:
role = "ignore"
# The reference workbook's L column is the agreed list-amount source.
if name == "应收金额" and i == 11:
for c in columns:
if c["role"] == "list_amount":
c["role"] = "ignore"
role = "list_amount"
used.add(role)
columns.append(
{
"index": i,
"name": name or f"列{i + 1}",
"description": rows[header - 2][i] if header > 1 and i < len(rows[header - 2]) else "",
"role": role,
"key": DIMENSIONS.get(name, f"field_{i}"),
"confidence": 1 if name in DIMENSIONS or role not in ("dimension", "ignore") else 0.5,
}
)
rates = {}
for lookup in profile["sheets"]:
if lookup["name"] != "产品毛利率":
continue
name_index = rate_index = None
for row in lookup["preview"]:
if "产品分类" in row and "毛利率" in row:
name_index, rate_index = row.index("产品分类"), row.index("毛利率")
elif name_index is not None and len(row) > max(name_index, rate_index) and row[name_index]:
try:
rate = decimal(row[rate_index])
if rate is not None and 0 <= rate <= 1:
rates[row[name_index]] = str(rate)
except ValueError:
pass
return {
"sheet": s["name"],
"header": header,
"columns": columns,
"margin_rates": rates,
"self_operated_products": [p for p in ("保健品", "检验检查", "医疗服务", "中药") if p in rates],
"roi_formula": "profit_over_cost",
"description": "",
}
def validate_mapping(mapping, profile):
if not isinstance(mapping, dict):
raise ValueError("映射格式错误")
sheets = {s["name"]: s for s in profile["sheets"]}
if (
not isinstance(mapping.get("sheet"), str)
or mapping["sheet"] not in sheets
or type(mapping.get("header")) is not int
or not 1 <= mapping["header"] <= 8
):
raise ValueError("工作表或表头无效")
columns = mapping.get("columns", [])
if not isinstance(columns, list) or not 1 <= len(columns) <= 200:
raise ValueError("映射列数无效")
roles, keys, indexes = [], [], []
for c in columns:
if (
not isinstance(c, dict)
or not isinstance(c.get("role"), str)
or c.get("role") not in ROLES
or type(c.get("index")) is not int
or not 0 <= c["index"] < 200
or not isinstance(c.get("name"), str)
or not 1 <= len(c["name"]) <= 256
or not isinstance(c.get("description", ""), str)
or len(c.get("description", "")) > 4000
):
raise ValueError("字段映射无效")
indexes.append(c["index"])
if c["role"] not in ("ignore", "dimension"):
roles.append(c["role"])
if c["role"] == "dimension":
key = c.get("key", "")
if not isinstance(key, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,49}", key) or "__" in key:
raise ValueError("维度标识只允许小写字母、数字和单下划线")
keys.append(key)
if len(set(roles)) != len(roles) or len(set(keys)) != len(keys) or len(set(indexes)) != len(indexes):
raise ValueError("指标、维度标识或列索引重复")
if RESERVED_DATA_HEADERS.intersection(key.casefold() for key in keys):
raise ValueError("维度标识与公式工作簿保留列名冲突")
if "revenue" not in roles or not ("period" in roles or {"year", "month"}.issubset(roles)):
raise ValueError("必须映射实际收入以及期间(或年份和月份)")
if not keys:
raise ValueError("至少保留一个分析维度")
rates = mapping.get("margin_rates", {})
if not isinstance(rates, dict) or len(rates) > 500:
raise ValueError("毛利率映射无效")
for key, value in rates.items():
if not isinstance(key, str) or not 1 <= len(key) <= 256:
raise ValueError("产品分类名称无效")
rate = decimal(value)
if rate is None or not Decimal(0) <= rate <= Decimal(1):
raise ValueError("毛利率必须在 0 到 1 之间")
if rates and "product" not in keys:
raise ValueError("毛利率映射需要 product 维度")
if mapping.get("roi_formula") not in ("profit_over_cost", "gross_less_cost_over_cost"):
raise ValueError("请选择 ROI 口径")
products = mapping.get("self_operated_products", [])
if (
not isinstance(products, list)
or len(products) > 500
or any(not isinstance(p, str) or not 1 <= len(p) <= 256 for p in products)
):
raise ValueError("自营产品范围无效")
if products and "product" not in keys:
raise ValueError("自营产品范围需要 product 维度")
return mapping
def decimal(value, required=False):
if value is None or str(value).strip() == "":
if required:
raise ValueError("必填金额为空")
return None
try:
number = Decimal(str(value).replace(",", "").strip())
if not number.is_finite() or abs(number) >= Decimal("1e16"):
raise ValueError("金额超出范围")
if number != number.quantize(Decimal("1e-24")):
raise ValueError("金额精度超过二十四位小数")
return number
except InvalidOperation as exc:
raise ValueError("不是有效金额") from exc
def period_value(values):
if values.get("period"):
value = values["period"]
if isinstance(value, (date, datetime)):
return value.strftime("%Y-%m")
text = str(value).strip().replace("/", "-")
match = re.fullmatch(r"(\d{4})-(\d{1,2})(?:-\d{1,2}(?:T.*)?)?", text)
if not match:
raise ValueError("期间必须是 YYYY-MM 或日期")
year, month = map(int, match.groups())
else:
year = int(str(values.get("year", "")).removesuffix("年"))
month = int(str(values.get("month", "")).removesuffix("月"))
if not 1900 <= year <= 2200 or not 1 <= month <= 12:
raise ValueError("期间超出有效范围")
return f"{year:04d}-{month:02d}"
def process_dataset(dataset_id):
ds = Dataset.objects.get(pk=dataset_id)
mapping = ds.mapping
quality = {
"negative_revenue": 0,
"missing_cost": 0,
"missing_gross": 0,
"missing_customer": 0,
"blank_rows": 0,
"g_l_mismatch": 0,
"i_n_mismatch": 0,
}
names = {c["index"]: c["name"] for c in mapping["columns"]}
reference_layout = names.get(6) == names.get(11) == "应收金额" and names.get(13) == "实际收入"
periods, batch, count = set(), [], 0
try:
with transaction.atomic():
if ds.facts.exists():
raise ValueError("数据版本已包含明细,禁止覆盖")
for row_no, row in enumerate(rows_for(ds.file.path, mapping["sheet"]), 1):
if row_no <= mapping["header"]:
continue
if not any(v is not None and str(v).strip() for v in row):
quality["blank_rows"] += 1
continue
count += 1
if count > settings.MAX_IMPORT_ROWS:
raise ValueError("数据行超过配置上限")
values, dims = {}, {}
for col in mapping["columns"]:
v = row[col["index"]] if col["index"] < len(row) else None
if col["role"] == "dimension":
dims[col["key"]] = safe_text(v)[:256] or "(未分类)"
elif col["role"] != "ignore":
values[col["role"]] = v
try:
period = period_value(values)
revenue = decimal(values.get("revenue"), required=True)
nums = {
k: decimal(values.get(k))
for k in ("cost", "gross", "quantity", "list_amount", "discount")
}
if nums["gross"] is None and dims.get("product") in mapping.get("margin_rates", {}):
nums["gross"] = (
revenue * Decimal(str(mapping["margin_rates"][dims["product"]]))
).quantize(Decimal("1e-24"))
customer = safe_text(values.get("customer"))[:256] or None
except (ValueError, TypeError, OverflowError) as exc:
raise ValueError(f"第 {row_no} 行:{exc}") from exc
quality["negative_revenue"] += revenue < 0
if reference_layout and len(row) >= 14:
quality["g_l_mismatch"] += row[6] != row[11]
quality["i_n_mismatch"] += row[8] != row[13]
quality["missing_cost"] += nums["cost"] is None
quality["missing_gross"] += nums["gross"] is None
quality["missing_customer"] += customer is None
periods.add(period)
batch.append(
Fact(
dataset=ds,
period=period,
revenue=revenue,
dimensions=dims,
customer=customer,
source_row=row_no,
raw=list(map(safe_text, row)),
**nums,
)
)
if len(batch) >= 2000:
Fact.objects.bulk_create(batch, batch_size=500)
batch.clear()
if not count:
raise ValueError("工作表没有数据行")
Fact.objects.bulk_create(batch, batch_size=500)
ds.status, ds.row_count, ds.periods, ds.quality = "ready", count, sorted(periods), quality
ds.error, ds.finished_at = "", timezone.now()
ds.save()
except Exception as exc:
Dataset.objects.filter(pk=ds.pk).update(
status="failed",
error=str(exc)[:500] if isinstance(exc, ValueError) else "导入失败,请查看服务端日志",
finished_at=timezone.now(),
)
if not isinstance(exc, ValueError):
import logging
logging.getLogger(__name__).exception("Import failed for %s", ds.pk)
return Dataset.objects.get(pk=ds.pk)
def file_digest(file):
digest = hashlib.sha256()
for chunk in file.chunks():
digest.update(chunk)
file.seek(0)
return digest.hexdigest()
View File
+29
View File
@@ -0,0 +1,29 @@
import getpass
import os
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from finance.models import Membership, Organization
class Command(BaseCommand):
help = "Create the initial organization administrator without a default password"
def add_arguments(self, parser):
parser.add_argument("--username", required=True)
parser.add_argument("--organization", required=True)
def handle(self, *args, **options):
username = options["username"]
if User.objects.filter(username=username).exists():
raise CommandError("Username already exists; no changes made")
password = os.environ.get("BOOTSTRAP_PASSWORD") or getpass.getpass("Administrator password: ")
validate_password(password, User(username=username))
with transaction.atomic():
org = Organization.objects.create(name=options["organization"])
user = User.objects.create_user(username=username, password=password)
Membership.objects.create(user=user, organization=org, role="admin")
self.stdout.write("Administrator created")
@@ -0,0 +1,65 @@
import time
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from finance.models import FormulaExport
class Command(BaseCommand):
help = "Remove expired formula workbooks and stale temporary files"
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true")
parser.add_argument("--interval", type=int, default=3600)
def handle(self, *args, **options):
while True:
jobs, files = self.clean_expired()
self.stdout.write(f"Expired {jobs} jobs; removed {files} files")
if not options["loop"]:
return
time.sleep(max(60, options["interval"]))
def clean_expired(self):
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
if export_root.is_symlink():
raise ValueError("公式导出目录不能是符号链接")
root = export_root.resolve()
now = timezone.now()
jobs = files = 0
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
path = Path(job.path) if job.path else None
resolved = path.resolve() if path and path.is_absolute() and not path.is_symlink() else None
in_root = bool(resolved and resolved.is_relative_to(root))
try:
with transaction.atomic():
if in_root and resolved.is_file():
resolved.unlink()
files += 1
job.status, job.path = "expired", ""
job.save(update_fields=["status", "path"])
jobs += 1
except OSError as exc:
self.stderr.write(f"Could not remove expired export {job.pk}: {exc}")
cutoff = (now - timedelta(hours=24)).timestamp()
if root.is_dir() and not root.is_symlink():
for user_dir in root.iterdir():
if user_dir.is_symlink() or not user_dir.is_dir():
continue
for path in user_dir.glob("*.tmp"):
try:
if path.is_symlink() or not path.is_file() or path.stat().st_mtime > cutoff:
continue
path.unlink()
files += 1
except OSError as exc:
self.stderr.write(f"Could not remove stale temporary export {path.name}: {exc}")
return jobs, files
@@ -0,0 +1,54 @@
from pathlib import Path
from django.contrib.auth.models import User
from django.core.files import File
from django.core.management.base import BaseCommand, CommandError
from finance.importing import file_digest, profile_file, suggest_mapping, validate_mapping
from finance.models import Audit, Dataset
class Command(BaseCommand):
help = "Import a local source using reviewed field aliases and lookup rates"
def add_arguments(self, parser):
parser.add_argument("path")
parser.add_argument("--username", required=True)
parser.add_argument("--confirm-mapping", action="store_true")
def handle(self, *args, **options):
user = User.objects.get(username=options["username"])
if user.membership.role != "admin" or user.membership.departments:
raise CommandError("Full administrator required")
path = Path(options["path"])
profile = profile_file(path)
mapping = validate_mapping(suggest_mapping(profile), profile)
if not options["confirm_mapping"]:
self.stdout.write(str(mapping))
self.stdout.write("Review mapping, then rerun with --confirm-mapping")
return
with path.open("rb") as source:
file = File(source, name=path.name)
digest = file_digest(file)
ds = Dataset.objects.filter(organization=user.membership.organization, sha256=digest).first()
if ds:
self.stdout.write(f"Existing version: {ds.pk} {ds.status}")
return
ds = Dataset.objects.create(
organization=user.membership.organization,
created_by=user,
name=path.name,
file=file,
sha256=digest,
mapping=mapping,
profile=profile,
status="queued",
)
Audit.objects.create(
organization=user.membership.organization,
user=user,
action="dataset.cli_import",
resource=str(ds.pk),
details={"mapping": mapping},
)
self.stdout.write(f"{ds.pk}: queued; import_worker will process this version")
@@ -0,0 +1,74 @@
import time
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from django.utils import timezone
from finance.exporting import process_formula_export
from finance.importing import process_dataset
from finance.models import Dataset, FormulaExport
class Command(BaseCommand):
help = "Process durable import and formula-export jobs"
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true")
def handle(self, *args, **options):
postgres = connection.vendor == "postgresql"
if postgres:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_try_advisory_lock(782146291)")
if not cursor.fetchone()[0]:
self.stdout.write("Another worker owns the database lock")
return
try:
self.run_jobs(options, postgres)
finally:
if postgres:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(782146291)")
def run_jobs(self, options, recover):
while True:
processed = self._next_import(recover)
if not processed:
processed = self._next_export(recover)
if options["once"]:
return
if not processed:
time.sleep(2)
def _claim(self, model, states):
with transaction.atomic():
qs = model.objects.filter(status__in=states).order_by("created_at")
if connection.features.has_select_for_update_skip_locked:
qs = qs.select_for_update(skip_locked=True)
item = qs.first()
if not item:
return None
updated = model.objects.filter(pk=item.pk, status__in=states).update(status="processing")
return item if updated else None
def _next_import(self, recover):
states = ["queued", "processing"] if recover else ["queued"]
item = self._claim(Dataset, states)
if not item:
return False
Dataset.objects.filter(pk=item.pk).update(started_at=timezone.now())
result = process_dataset(item.pk)
self.stdout.write(f"{result.pk}: {result.status}, {result.row_count} rows")
return True
def _next_export(self, recover):
states = ["queued", "processing"] if recover else ["queued"]
item = self._claim(FormulaExport, states)
if not item:
return False
try:
stats = process_formula_export(item.pk)
self.stdout.write(f"{item.pk}: formula export ready, {stats['rows']} rows")
except Exception as exc:
self.stderr.write(f"Formula export {item.pk} failed: {exc}")
return True
+14
View File
@@ -0,0 +1,14 @@
class SecurityHeaders:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
)
response["Referrer-Policy"] = "same-origin"
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if request.path.startswith("/api/"):
response["Cache-Control"] = "no-store"
return response
+224
View File
@@ -0,0 +1,224 @@
# Generated by Django 5.2.17 on 2026-09-22 16:35
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Organization",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=160)),
],
),
migrations.CreateModel(
name="Throttle",
fields=[
("key", models.CharField(max_length=64, primary_key=True, serialize=False)),
("count", models.PositiveIntegerField(default=0)),
("expires_at", models.DateTimeField()),
],
),
migrations.CreateModel(
name="Dataset",
fields=[
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
("name", models.CharField(max_length=200)),
("file", models.FileField(upload_to="sources/%Y/%m")),
("sha256", models.CharField(max_length=64)),
("status", models.CharField(default="mapping", max_length=16)),
("profile", models.JSONField(default=dict)),
("mapping", models.JSONField(default=dict)),
("quality", models.JSONField(default=dict)),
("row_count", models.PositiveIntegerField(default=0)),
("periods", models.JSONField(default=list)),
("error", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("started_at", models.DateTimeField(null=True)),
("finished_at", models.DateTimeField(null=True)),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
(
"organization",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.organization"),
),
],
),
migrations.CreateModel(
name="Membership",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"role",
models.CharField(
choices=[("admin", "管理员"), ("analyst", "分析师"), ("viewer", "查看者")],
max_length=16,
),
),
("departments", models.JSONField(default=list)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
(
"organization",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.organization"),
),
],
),
migrations.CreateModel(
name="Audit",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("action", models.CharField(max_length=64)),
("resource", models.CharField(blank=True, max_length=100)),
("details", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
(
"organization",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.organization"),
),
],
),
migrations.CreateModel(
name="Preference",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("value", models.JSONField(default=dict)),
("revision", models.PositiveIntegerField(default=1)),
(
"dataset",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="finance.dataset"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.CreateModel(
name="Fact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("period", models.CharField(max_length=7)),
("dimensions", models.JSONField(default=dict)),
("customer", models.CharField(max_length=256, null=True)),
("revenue", models.DecimalField(decimal_places=4, max_digits=22)),
("cost", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("gross", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("quantity", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("list_amount", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("discount", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("source_row", models.PositiveIntegerField()),
("raw", models.JSONField(default=list)),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="facts",
to="finance.dataset",
),
),
],
options={
"indexes": [
models.Index(fields=["dataset", "period"], name="finance_fac_dataset_69c9ce_idx")
],
"constraints": [
models.UniqueConstraint(fields=("dataset", "source_row"), name="unique_source_row")
],
},
),
migrations.CreateModel(
name="Investigation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("key", models.CharField(max_length=64)),
("context", models.JSONField(default=dict)),
("status", models.CharField(default="open", max_length=16)),
("note", models.TextField(blank=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"dataset",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.dataset"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
],
options={
"constraints": [
models.UniqueConstraint(fields=("dataset", "user", "key"), name="unique_investigation")
],
},
),
migrations.AddConstraint(
model_name="dataset",
constraint=models.UniqueConstraint(fields=("organization", "sha256"), name="unique_org_source"),
),
migrations.AddConstraint(
model_name="preference",
constraint=models.UniqueConstraint(fields=("dataset", "user"), name="unique_preference"),
),
]
@@ -0,0 +1,42 @@
# Generated by Django 5.2.17 on 2026-09-22 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="fact",
name="cost",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="discount",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="gross",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="list_amount",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="quantity",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="revenue",
field=models.DecimalField(decimal_places=24, max_digits=44),
),
]
+50
View File
@@ -0,0 +1,50 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0002_alter_fact_cost_alter_fact_discount_alter_fact_gross_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FormulaExport",
fields=[
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
("context", models.JSONField(default=dict)),
("departments", models.JSONField(default=list)),
("status", models.CharField(default="queued", max_length=16)),
("path", models.CharField(blank=True, max_length=512)),
("error", models.CharField(blank=True, max_length=500)),
("created_at", models.DateTimeField(auto_now_add=True)),
("finished_at", models.DateTimeField(null=True)),
("expires_at", models.DateTimeField(null=True)),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="formula_exports",
to="finance.dataset",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddIndex(
model_name="formulaexport",
index=models.Index(fields=["status", "created_at"], name="finance_for_status_fa1c07_idx"),
),
]
@@ -0,0 +1,50 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0003_formulaexport"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FormulaDefinition",
fields=[
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
("normalized_name", models.CharField(max_length=320)),
("definition", models.JSONField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="formula_definitions",
to="finance.dataset",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="formuladefinition",
constraint=models.UniqueConstraint(
fields=("dataset", "user", "normalized_name"),
name="unique_formula_definition_name",
),
),
]
View File
+132
View File
@@ -0,0 +1,132 @@
import uuid
from django.conf import settings
from django.db import models
class Organization(models.Model):
name = models.CharField(max_length=160)
class Membership(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
role = models.CharField(
max_length=16, choices=[("admin", "管理员"), ("analyst", "分析师"), ("viewer", "查看者")]
)
departments = models.JSONField(default=list)
class Dataset(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
name = models.CharField(max_length=200)
file = models.FileField(upload_to="sources/%Y/%m")
sha256 = models.CharField(max_length=64)
status = models.CharField(max_length=16, default="mapping")
profile = models.JSONField(default=dict)
mapping = models.JSONField(default=dict)
quality = models.JSONField(default=dict)
row_count = models.PositiveIntegerField(default=0)
periods = models.JSONField(default=list)
error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True)
finished_at = models.DateTimeField(null=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["organization", "sha256"], name="unique_org_source")]
class Fact(models.Model):
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="facts")
period = models.CharField(max_length=7)
dimensions = models.JSONField(default=dict)
customer = models.CharField(max_length=256, null=True)
revenue = models.DecimalField(max_digits=44, decimal_places=24)
cost = models.DecimalField(max_digits=44, decimal_places=24, null=True)
gross = models.DecimalField(max_digits=44, decimal_places=24, null=True)
quantity = models.DecimalField(max_digits=44, decimal_places=24, null=True)
list_amount = models.DecimalField(max_digits=44, decimal_places=24, null=True)
discount = models.DecimalField(max_digits=44, decimal_places=24, null=True)
source_row = models.PositiveIntegerField()
raw = models.JSONField(default=list)
class Meta:
indexes = [models.Index(fields=["dataset", "period"])]
constraints = [models.UniqueConstraint(fields=["dataset", "source_row"], name="unique_source_row")]
class Preference(models.Model):
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
value = models.JSONField(default=dict)
revision = models.PositiveIntegerField(default=1)
class Meta:
constraints = [models.UniqueConstraint(fields=["dataset", "user"], name="unique_preference")]
class Investigation(models.Model):
dataset = models.ForeignKey(Dataset, on_delete=models.PROTECT)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
key = models.CharField(max_length=64)
context = models.JSONField(default=dict)
status = models.CharField(max_length=16, default="open")
note = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["dataset", "user", "key"], name="unique_investigation")
]
class Audit(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
action = models.CharField(max_length=64)
resource = models.CharField(max_length=100, blank=True)
details = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Throttle(models.Model):
key = models.CharField(max_length=64, primary_key=True)
count = models.PositiveIntegerField(default=0)
expires_at = models.DateTimeField()
class FormulaExport(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="formula_exports")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
context = models.JSONField(default=dict)
departments = models.JSONField(default=list)
status = models.CharField(max_length=16, default="queued")
path = models.CharField(max_length=512, blank=True)
error = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True)
expires_at = models.DateTimeField(null=True)
class Meta:
indexes = [models.Index(fields=["status", "created_at"], name="finance_for_status_fa1c07_idx")]
class FormulaDefinition(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="formula_definitions")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
normalized_name = models.CharField(max_length=320)
definition = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["dataset", "user", "normalized_name"], name="unique_formula_definition_name"
)
]
+610
View File
@@ -0,0 +1,610 @@
import csv
import hashlib
import io
import ipaddress
import json
import math
import tempfile
import uuid
from datetime import timedelta
from functools import wraps
from pathlib import Path
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection, transaction
from django.http import FileResponse, HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.utils import timezone
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts
from .importing import file_digest, profile_file, suggest_mapping, validate_mapping
from .models import Audit, Dataset, Investigation, Membership, Preference, Throttle
class APIError(Exception):
def __init__(self, message, status=400):
self.message, self.status = message, status
def endpoint(methods=("GET",), roles=None, public=False):
def decorate(fn):
@wraps(fn)
def view(request, *args, **kwargs):
try:
if request.method not in methods:
raise APIError("请求方法不允许", 405)
if not public:
if not request.user.is_authenticated:
raise APIError("请先登录", 401)
try:
request.member = request.user.membership
except Membership.DoesNotExist:
raise APIError("账号尚未分配组织", 403)
if roles and request.member.role not in roles:
raise APIError("没有此操作的权限", 403)
return fn(request, *args, **kwargs)
except APIError as exc:
return JsonResponse({"error": exc.message}, status=exc.status)
except (ValueError, ValidationError, json.JSONDecodeError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return view
return decorate
def body(request):
if request.content_type != "application/json":
raise APIError("请求必须使用 application/json")
value = json.loads(request.body or b"{}")
if not isinstance(value, dict):
raise APIError("请求必须是 JSON 对象")
return value
def audit(request, action, resource="", details=None):
Audit.objects.create(
organization=request.member.organization,
user=request.user,
action=action,
resource=str(resource),
details=details or {},
)
def limit(key, count=10, seconds=900):
key = hashlib.sha256(key.encode()).hexdigest()
now = timezone.now()
with transaction.atomic():
item, _ = Throttle.objects.get_or_create(
key=key, defaults={"expires_at": now + timedelta(seconds=seconds)}
)
item = Throttle.objects.select_for_update().get(pk=key)
if item.expires_at <= now:
item.count, item.expires_at = 0, now + timedelta(seconds=seconds)
if item.count >= count:
raise APIError("请求过于频繁,请稍后重试", 429)
item.count += 1
item.save()
def dataset(request, dataset_id, ready=False):
ds = Dataset.objects.filter(pk=dataset_id, organization=request.member.organization).first()
if not ds:
raise APIError("数据集不存在", 404)
if ready and ds.status != "ready":
raise APIError("数据集尚未导入完成", 409)
return ds
def preferences(request, ds):
pref = Preference.objects.filter(dataset=ds, user=request.user).first()
return {**default_preferences(ds), **(pref.value if pref else {})}, pref.revision if pref else 0
def serialize_dataset(ds, detailed=False, restricted=False, raw_access=True):
out = {
"id": str(ds.pk),
"name": ds.name,
"status": ds.status,
"version": ds.sha256,
"row_count": None if restricted else ds.row_count,
"periods": ds.periods,
"created_at": ds.created_at.isoformat(),
"error": ds.error if not restricted else "",
"quality": ds.quality if not restricted else {},
"dimensions": dimensions(ds) if ds.mapping else [],
}
if detailed:
out.update(mapping=ds.mapping)
if not restricted and raw_access:
out["profile"] = ds.profile
return out
@endpoint(public=True)
def session(request):
data = {
"csrf": get_token(request),
"user": None,
"ai_enabled": settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL),
}
if request.user.is_authenticated:
try:
m = request.user.membership
data["user"] = {
"id": request.user.pk,
"name": request.user.username,
"role": m.role,
"organization": m.organization.name,
"departments": m.departments,
}
except Membership.DoesNotExist:
pass
return JsonResponse(data)
@endpoint(("POST",), public=True)
def sign_in(request):
data = body(request)
username, password = data.get("username", ""), data.get("password", "")
if (
not isinstance(username, str)
or not isinstance(password, str)
or len(username) > 150
or len(password) > 1024
):
raise APIError("登录参数无效")
address = request.META.get("REMOTE_ADDR", "unknown")
if settings.TRUST_PROXY and request.META.get("HTTP_X_REAL_IP"):
try:
address = str(ipaddress.ip_address(request.META["HTTP_X_REAL_IP"]))
except ValueError:
raise APIError("代理来源地址无效")
limit("login-ip:" + address, 30)
limit("login-user:" + username.casefold(), 10)
user = authenticate(request, username=username, password=password)
if not user or not Membership.objects.filter(user=user).exists():
raise APIError("用户名或密码错误", 401)
login(request, user)
request.member = user.membership
audit(request, "auth.login")
return JsonResponse({"ok": True, "csrf": get_token(request)})
@endpoint(("POST",))
def sign_out(request):
audit(request, "auth.logout")
logout(request)
return JsonResponse({"ok": True})
@endpoint(("GET", "POST"))
def datasets(request):
if request.method == "GET":
return JsonResponse(
{
"items": [
serialize_dataset(ds, restricted=bool(request.member.departments))
for ds in Dataset.objects.filter(organization=request.member.organization).order_by(
"-created_at"
)[:100]
]
}
)
if request.member.role not in ("admin", "analyst") or request.member.departments:
raise APIError("仅有完整数据权限的管理员或分析师可导入", 403)
limit(f"upload:{request.user.pk}", 20, 3600)
file = request.FILES.get("file")
if not file or not 0 < file.size <= settings.MAX_UPLOAD_BYTES:
raise APIError("文件为空或超过上传大小限制")
ext = Path(file.name).suffix.lower()
if ext not in (".xlsx", ".csv"):
raise APIError("仅接受 .xlsx 或 UTF-8 CSV")
digest = file_digest(file)
existing = Dataset.objects.filter(organization=request.member.organization, sha256=digest).first()
if existing:
return JsonResponse(
serialize_dataset(
existing,
True,
raw_access=request.member.role == "admin"
or (existing.created_by_id == request.user.pk and existing.status in ("mapping", "failed")),
),
status=200,
)
ds = Dataset(
organization=request.member.organization,
created_by=request.user,
name=Path(file.name).name[:200],
sha256=digest,
)
ds.file.save(str(uuid.uuid4()) + ext, file, save=False)
try:
ds.profile = profile_file(ds.file.path)
ds.mapping = suggest_mapping(ds.profile)
ds.save()
except IntegrityError:
ds.file.delete(save=False)
return JsonResponse(
serialize_dataset(
Dataset.objects.get(organization=request.member.organization, sha256=digest),
True,
raw_access=request.member.role == "admin",
)
)
except Exception as exc:
ds.file.delete(save=False)
raise APIError(
"无法读取文件,请检查格式或文件安全限制:"
+ (str(exc)[:160] if isinstance(exc, (ValueError, UnicodeError)) else "文件损坏")
) from exc
audit(request, "dataset.upload", ds.pk, {"sha256": digest})
return JsonResponse(serialize_dataset(ds, True), status=201)
@endpoint()
def dataset_detail(request, dataset_id):
ds = dataset(request, dataset_id)
raw_access = request.member.role == "admin" or (
ds.created_by_id == request.user.pk and ds.status in ("mapping", "failed")
)
result = serialize_dataset(ds, True, bool(request.member.departments), raw_access)
if ds.status == "ready":
result["preferences"], result["revision"] = preferences(request, ds)
return JsonResponse(result)
@endpoint(("POST",), roles=("admin", "analyst"))
def mapping(request, dataset_id):
ds = dataset(request, dataset_id)
if request.member.departments:
raise APIError("没有修改导入映射的权限", 403)
data = body(request)
if data.get("suggest"):
return JsonResponse(suggest_mapping(ds.profile, data.get("sheet"), data.get("header")))
value = validate_mapping(data, ds.profile)
updated = Dataset.objects.filter(pk=ds.pk, status__in=["mapping", "failed"]).update(
mapping=value, status="queued", error="", started_at=None, finished_at=None
)
if not updated:
raise APIError("数据版本已锁定,不能重复导入或修改", 409)
audit(request, "dataset.confirm_mapping", ds.pk, {"mapping": value})
return JsonResponse({"status": "queued"}, status=202)
@endpoint(("POST",))
def query(request, dataset_id):
ds = dataset(request, dataset_id, True)
pref, _ = preferences(request, ds)
limit(f"analysis:{request.user.pk}", 120, 60)
return JsonResponse(analyze(ds, request.member, body(request), pref))
@endpoint(("POST",))
def evidence(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
ctx = context(ds, data)
offset, size = data.get("offset", 0), data.get("limit", 50)
if type(offset) is not int or type(size) is not int or not 0 <= offset <= 1000000 or not 1 <= size <= 200:
raise APIError("分页参数无效")
side = data.get("side", "current")
if side not in ("current", "baseline"):
raise APIError("期间选择无效")
qs = scoped_facts(ds, request.member, ctx["filters"]).filter(period__in=ctx[side]).order_by("source_row")
rows = list(
qs[offset : offset + size].values(
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw"
)
)
# Raw patient/customer identifiers require an explicit administrator role.
if request.member.role != "admin":
for row in rows:
row.pop("raw")
audit(
request,
"evidence.read",
ds.pk,
{"context": ctx, "side": side, "offset": offset, "limit": size},
)
return JsonResponse(
{
"rows": rows,
"total": qs.count(),
"context": ctx,
"version": ds.sha256,
"sheet": ds.mapping["sheet"],
}
)
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
)
@endpoint(("POST",), roles=("admin", "analyst"))
def export(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
ctx = context(ds, data)
limit(f"export:{request.user.pk}", 10, 3600)
kind = data.get("format", "csv")
if kind not in ("csv", "xlsx"):
raise APIError("导出格式无效")
qs = (
scoped_facts(ds, request.member, ctx["filters"])
.filter(period__in=ctx["current"] + ctx["baseline"])
.order_by("source_row")
)
if qs.count() > 100000:
raise APIError("单次导出上限 100,000 行,请缩小期间或筛选范围")
output = tempfile.TemporaryFile(mode="w+b")
csv_stream = None
if kind == "csv":
output.write(b"\xef\xbb\xbf")
csv_stream = io.TextIOWrapper(output, encoding="utf-8", newline="", write_through=True)
csv_writer = csv.writer(csv_stream) if csv_stream else None
workbook = Workbook(write_only=True) if kind == "xlsx" else None
sheet = workbook.create_sheet("证据明细") if workbook else None
def write_row(values):
if sheet is None:
csv_writer.writerow(csv_safe(value) for value in values)
else:
cells = []
for value in values:
# Excel has only 15 significant digits; evidence amounts stay exact text.
cell = WriteOnlyCell(sheet, value=str(value) if value is not None else "")
cell.data_type = "s"
cells.append(cell)
sheet.append(cells)
try:
write_row(
[
"TraceIQ 数据版本",
ds.sha256,
]
)
write_row(["筛选上下文", json.dumps(ctx, ensure_ascii=False)])
write_row(["ROI 口径", ds.mapping["roi_formula"]])
write_row(["导出时间 UTC", timezone.now().isoformat()])
dims = dimensions(ds)
write_row(
[
"原始工作表",
"原始行号",
"期间",
*[d["name"] for d in dims],
"实际收入",
"成本",
"毛利贡献",
]
)
for row in qs.iterator(chunk_size=1000):
write_row(
[
ds.mapping["sheet"],
row.source_row,
row.period,
*[row.dimensions.get(d["key"], "") for d in dims],
row.revenue,
row.cost,
row.gross,
]
)
if workbook:
workbook.save(output)
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
csv_stream.flush()
csv_stream.detach()
csv_stream = None
content_type = "text/csv; charset=utf-8"
output.seek(0)
audit(request, "evidence.export", ds.pk, {"context": ctx, "format": kind})
return FileResponse(
output,
as_attachment=True,
filename=f"traceiq-evidence.{kind}",
content_type=content_type,
)
except Exception:
if csv_stream is not None:
csv_stream.close()
else:
output.close()
raise
@endpoint(("GET", "PUT"))
def settings_view(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.method == "GET":
value, revision = preferences(request, ds)
return JsonResponse({"value": value, "revision": revision})
data = body(request)
value = data.get("value")
defaults = default_preferences(ds)
if not isinstance(value, dict) or set(value) != set(defaults):
raise APIError("设置字段不完整")
allowed = {d["key"] for d in dimensions(ds)}
hierarchy = value["hierarchy"]
if (
not isinstance(hierarchy, list)
or not hierarchy
or any(not isinstance(k, str) or k not in allowed for k in hierarchy)
or len(set(hierarchy)) != len(hierarchy)
):
raise APIError("下钻层级无效或重复")
if (
value["entry"] not in ("overview", "anomalies")
or value["comparison"] not in ("yoy", "mom")
or not isinstance(value["metric"], str)
or value["metric"] not in METRICS
):
raise APIError("默认视图或指标无效")
for field, lo, hi in [("threshold", 0, 10), ("min_impact", 0, 1e15), ("min_samples", 1, 1000000)]:
v = value[field]
if type(v) not in (int, float) or not math.isfinite(v) or not lo <= v <= hi:
raise APIError("异常阈值无效")
if type(value["min_samples"]) is not int:
raise APIError("最小样本数必须为整数")
for field in ["show_heatmap", "show_queue", "show_copilot", "alerts"]:
if type(value[field]) is not bool:
raise APIError("显示设置必须为布尔值")
with transaction.atomic():
pref = Preference.objects.select_for_update().filter(dataset=ds, user=request.user).first()
if data.get("revision") != (pref.revision if pref else 0):
raise APIError("设置已在其他窗口更新,请重新打开", 409)
if pref:
pref.value, pref.revision = value, pref.revision + 1
pref.save()
else:
try:
pref = Preference.objects.create(dataset=ds, user=request.user, value=value)
except IntegrityError as exc:
raise APIError("设置发生并发更新,请重新打开", 409) from exc
audit(request, "settings.update", ds.pk, {"revision": pref.revision, "value": value})
return JsonResponse({"value": pref.value, "revision": pref.revision})
@endpoint(("GET", "POST"))
def investigations(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.method == "GET":
return JsonResponse(
{
"items": list(
Investigation.objects.filter(dataset=ds, user=request.user).values(
"id", "key", "context", "status", "note", "updated_at"
)
)
}
)
if request.member.role == "viewer":
raise APIError("查看者不可修改调查状态", 403)
data = body(request)
ctx = context(ds, data.get("context", {}))
status, note = data.get("status"), data.get("note", "")
if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000:
raise APIError("调查状态或备注无效")
key = hashlib.sha256(json.dumps(ctx, sort_keys=True).encode()).hexdigest()
item, _ = Investigation.objects.update_or_create(
dataset=ds, user=request.user, key=key, defaults={"context": ctx, "status": status, "note": note}
)
audit(request, "investigation.update", ds.pk, {"key": key, "status": status})
return JsonResponse({"id": item.pk, "key": key, "status": status})
@endpoint(("GET", "POST", "PATCH"), roles=("admin",))
def users(request):
if request.member.departments:
raise APIError("限域管理员不能管理账号", 403)
if request.method == "GET":
return JsonResponse(
{
"items": [
{
"id": m.user_id,
"username": m.user.username,
"role": m.role,
"active": m.user.is_active,
"departments": m.departments,
}
for m in Membership.objects.filter(
organization=request.member.organization
).select_related("user")
]
}
)
data = body(request)
role, departments = data.get("role"), data.get("departments", [])
if (
role not in ("admin", "analyst", "viewer")
or not isinstance(departments, list)
or any(not isinstance(d, str) or not d or len(d) > 256 for d in departments)
or len(departments) > 100
):
raise APIError("角色或科室范围无效")
with transaction.atomic():
if request.method == "POST":
username, password = data.get("username"), data.get("password")
if (
not isinstance(username, str)
or not 1 <= len(username) <= 150
or not isinstance(password, str)
):
raise APIError("账号格式无效")
validate_password(password, User(username=username))
try:
user = User.objects.create_user(username=username, password=password)
except IntegrityError as exc:
raise APIError("用户名已存在", 409) from exc
Membership.objects.create(
user=user, organization=request.member.organization, role=role, departments=departments
)
else:
if type(data.get("id")) is not int or data["id"] <= 0:
raise APIError("账号 ID 无效")
membership = (
Membership.objects.filter(user_id=data.get("id"), organization=request.member.organization)
.select_related("user")
.first()
)
if not membership:
raise APIError("账号不存在", 404)
if membership.user_id == request.user.pk:
raise APIError("不可在此修改自己的权限")
if type(data.get("active")) is not bool:
raise APIError("账号状态无效")
user = membership.user
user.is_active = data["active"]
user.save(update_fields=["is_active"])
membership.role, membership.departments = role, departments
membership.save()
audit(request, "user.update", user.pk, {"role": role, "departments": departments})
return JsonResponse({"ok": True})
@endpoint(roles=("admin",))
def audit_log(request):
if request.member.departments:
raise APIError("没有组织审计权限", 403)
return JsonResponse(
{
"items": list(
Audit.objects.filter(organization=request.member.organization)
.order_by("-id")
.values("id", "user__username", "action", "resource", "details", "created_at")[:200]
)
}
)
@endpoint(public=True)
def health(request):
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return JsonResponse({"status": "ok"})
def index(request):
path = settings.BASE_DIR / "frontend/dist/index.html"
if not path.exists():
return HttpResponse("Frontend not built. Run npm run build.", status=503)
return FileResponse(path.open("rb"), content_type="text/html")