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