This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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]
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)})"
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
+42
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user