Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import gc | |
| import os | |
| import traceback | |
| from contextlib import contextmanager | |
| from datetime import datetime | |
| from pathlib import Path | |
| from threading import Thread | |
| from typing import Any | |
| from uuid import uuid4 | |
| import pandas as pd | |
| from werkzeug.datastructures import FileStorage | |
| from werkzeug.utils import secure_filename | |
| ALLOWED_EXTENSIONS = {"xlsx", "xls", "xlsm", "csv"} | |
| analysis_tasks: dict[str, dict[str, Any]] = {} | |
| CHANNEL_INDEX = 1 | |
| SGUID_INDEX = 3 | |
| AVAILABLE_INDEX = 18 | |
| INV_0_90_INDEX = 19 | |
| INV_91_180_INDEX = 20 | |
| INV_181_270_INDEX = 21 | |
| INV_271_365_INDEX = 22 | |
| INV_365_PLUS_INDEX = 23 | |
| TRACE_CN_MAN_INDEX = 24 | |
| SOLD_30_DAYS_INDEX = 34 | |
| OVERAGE_STORAGE_INDEX = 36 | |
| MONTHLY_STORAGE_INDEX = 37 | |
| INCLUDE_RAW_SHEET = os.environ.get("MRO_INCLUDE_RAW_SHEET", "1") != "0" | |
| class AgingSummaryError(Exception): | |
| pass | |
| class AnalysisTaskNotFound(Exception): | |
| pass | |
| def allowed_file(filename: str) -> bool: | |
| return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS | |
| def safe_table_reader(file_path: Path): | |
| dataframe: pd.DataFrame | None = None | |
| suffix = file_path.suffix.lower() | |
| try: | |
| if suffix == ".csv": | |
| dataframe = None | |
| last_error = None | |
| for encoding in ("utf-8-sig", "gb18030", "gbk"): | |
| try: | |
| dataframe = pd.read_csv(file_path, encoding=encoding) | |
| break | |
| except Exception as exc: | |
| last_error = exc | |
| if dataframe is None: | |
| raise AgingSummaryError(f"CSV 读取失败: {last_error}") | |
| else: | |
| dataframe = read_excel_file(file_path, sheet_name=0) | |
| yield dataframe | |
| finally: | |
| if dataframe is not None: | |
| del dataframe | |
| gc.collect() | |
| def read_excel_file(file_path: Path, **kwargs) -> pd.DataFrame: | |
| try: | |
| return pd.read_excel(file_path, engine="calamine", **kwargs) | |
| except ImportError: | |
| return pd.read_excel(file_path, **kwargs) | |
| def start_analysis_task(uploaded_file: FileStorage, upload_dir: Path, output_dir: Path) -> str: | |
| filename = uploaded_file.filename or "" | |
| if not allowed_file(filename): | |
| raise AgingSummaryError("仅支持上传 .xlsx、.xls、.xlsm 或 .csv 文件。") | |
| timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f") | |
| safe_name = secure_filename(filename) or f"aging_{timestamp}.xlsx" | |
| upload_path = upload_dir / f"{timestamp}_{safe_name}" | |
| uploaded_file.save(upload_path) | |
| task_id = uuid4().hex | |
| analysis_tasks[task_id] = { | |
| "status": "processing", | |
| "result": None, | |
| "filename": upload_path.name, | |
| "start_time": datetime.now().isoformat(), | |
| } | |
| Thread( | |
| target=_process_analysis, | |
| kwargs={"task_id": task_id, "file_path": upload_path, "output_dir": output_dir}, | |
| daemon=True, | |
| ).start() | |
| return task_id | |
| def _process_analysis(task_id: str, file_path: Path, output_dir: Path) -> None: | |
| try: | |
| result = analyze_aging_stock_data(file_path=file_path, output_dir=output_dir) | |
| analysis_tasks[task_id]["status"] = "completed" | |
| analysis_tasks[task_id]["result"] = result | |
| analysis_tasks[task_id]["end_time"] = datetime.now().isoformat() | |
| except Exception as exc: | |
| traceback.print_exc() | |
| analysis_tasks[task_id]["status"] = "error" | |
| analysis_tasks[task_id]["result"] = {"success": False, "error": f"分析过程中出错: {exc}"} | |
| analysis_tasks[task_id]["end_time"] = datetime.now().isoformat() | |
| finally: | |
| file_path.unlink(missing_ok=True) | |
| def _collapse_people(series: pd.Series) -> str: | |
| values = [str(value).strip() for value in series.fillna("") if str(value).strip()] | |
| unique_values = [] | |
| for value in values: | |
| if value not in unique_values: | |
| unique_values.append(value) | |
| return " / ".join(unique_values) | |
| def analyze_aging_stock_data(file_path: Path, output_dir: Path) -> dict[str, Any]: | |
| with safe_table_reader(file_path) as dataframe: | |
| if dataframe.shape[1] <= MONTHLY_STORAGE_INDEX: | |
| return {"success": False, "error": "源文件列数不足,至少需要包含到 AL 列。"} | |
| working = dataframe.copy() | |
| extracted = pd.DataFrame( | |
| { | |
| "channel": working.iloc[:, CHANNEL_INDEX], | |
| "SGU": working.iloc[:, SGUID_INDEX], | |
| "available": working.iloc[:, AVAILABLE_INDEX], | |
| "inv_age_0_to_90_days": working.iloc[:, INV_0_90_INDEX], | |
| "inv_age_91_to_180_days": working.iloc[:, INV_91_180_INDEX], | |
| "inv_age_181_to_270_days": working.iloc[:, INV_181_270_INDEX], | |
| "inv_age_271_to_365_days": working.iloc[:, INV_271_365_INDEX], | |
| "inv_age_365_plus_days": working.iloc[:, INV_365_PLUS_INDEX], | |
| "trace_cn_man": working.iloc[:, TRACE_CN_MAN_INDEX], | |
| "sold_30days": working.iloc[:, SOLD_30_DAYS_INDEX], | |
| "超龄仓储": working.iloc[:, OVERAGE_STORAGE_INDEX], | |
| "月度仓储": working.iloc[:, MONTHLY_STORAGE_INDEX], | |
| } | |
| ) | |
| extracted["channel"] = extracted["channel"].fillna("").astype(str).str.strip() | |
| extracted["SGU"] = extracted["SGU"].fillna("").astype(str).str.strip() | |
| extracted["trace_cn_man"] = extracted["trace_cn_man"].fillna("").astype(str).str.strip() | |
| numeric_columns = [ | |
| "available", | |
| "inv_age_0_to_90_days", | |
| "inv_age_91_to_180_days", | |
| "inv_age_181_to_270_days", | |
| "inv_age_271_to_365_days", | |
| "inv_age_365_plus_days", | |
| "sold_30days", | |
| "超龄仓储", | |
| "月度仓储", | |
| ] | |
| for column in numeric_columns: | |
| extracted[column] = pd.to_numeric(extracted[column], errors="coerce").fillna(0) | |
| extracted = extracted[(extracted["SGU"] != "") & (extracted["channel"] != "")].copy() | |
| if extracted.empty: | |
| return {"success": False, "error": "未识别到有效的 SGU 和 channel 数据。"} | |
| extracted["inv_age_271_plus_days"] = ( | |
| extracted["inv_age_271_to_365_days"] + extracted["inv_age_365_plus_days"] | |
| ) | |
| summary = ( | |
| extracted.groupby(["SGU", "channel"], as_index=False, dropna=False) | |
| .agg( | |
| trace_cn_man=("trace_cn_man", _collapse_people), | |
| available=("available", "sum"), | |
| inv_age_0_to_90_days=("inv_age_0_to_90_days", "sum"), | |
| inv_age_91_to_180_days=("inv_age_91_to_180_days", "sum"), | |
| inv_age_181_to_270_days=("inv_age_181_to_270_days", "sum"), | |
| inv_age_271_plus_days=("inv_age_271_plus_days", "sum"), | |
| sold_30days=("sold_30days", "sum"), | |
| 超龄仓储=("超龄仓储", "sum"), | |
| 月度仓储=("月度仓储", "sum"), | |
| ) | |
| .sort_values(["超龄仓储", "available"], ascending=[True, False]) | |
| .reset_index(drop=True) | |
| ) | |
| summary.insert(0, "排名", range(1, len(summary) + 1)) | |
| output_filename = f"超龄库存汇总_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" | |
| output_path = output_dir / output_filename | |
| summary_stats = pd.DataFrame( | |
| { | |
| "指标": [ | |
| "有效原始行数", | |
| "SGU+channel 组合数", | |
| "总available", | |
| "总271天以上库存", | |
| "总sold_30days", | |
| "总超龄仓储", | |
| "总月度仓储", | |
| ], | |
| "数值": [ | |
| f"{len(extracted):,}", | |
| f"{len(summary):,}", | |
| f"{summary['available'].sum():,.2f}", | |
| f"{summary['inv_age_271_plus_days'].sum():,.2f}", | |
| f"{summary['sold_30days'].sum():,.2f}", | |
| f"{summary['超龄仓储'].sum():,.2f}", | |
| f"{summary['月度仓储'].sum():,.2f}", | |
| ], | |
| } | |
| ) | |
| with pd.ExcelWriter(output_path, engine="openpyxl") as writer: | |
| if INCLUDE_RAW_SHEET: | |
| dataframe.to_excel(writer, sheet_name="原始数据", index=False) | |
| summary.to_excel(writer, sheet_name="超龄库存汇总", index=False) | |
| summary_stats.to_excel(writer, sheet_name="数据摘要", index=False) | |
| return { | |
| "success": True, | |
| "output_file": str(output_path), | |
| "output_filename": output_filename, | |
| "stats": { | |
| "total_rows": int(len(extracted)), | |
| "total_groups": int(len(summary)), | |
| "total_available": float(summary["available"].sum()), | |
| "total_271_plus": float(summary["inv_age_271_plus_days"].sum()), | |
| "total_overage_storage": float(summary["超龄仓储"].sum()), | |
| "total_monthly_storage": float(summary["月度仓储"].sum()), | |
| }, | |
| "top_groups": summary.head(10).to_dict("records"), | |
| } | |
| def get_task_status(task_id: str) -> dict[str, Any]: | |
| if task_id not in analysis_tasks: | |
| raise AnalysisTaskNotFound("任务不存在。") | |
| task = analysis_tasks[task_id] | |
| return {"status": task["status"], "result": task["result"]} | |