Files
fa-agent/finance/importing.py
T
2026-09-26 01:12:10 +08:00

379 lines
15 KiB
Python

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()