- 后端新增sign_monthly[12]按月份拆分签单数据 - 月报/季报API均导出sign_monthly,季度转换包含sign_monthly - 前端projVals首项改用sign_monthly替代硬编码0 - 版本号 v1.2.0.32
1266 lines
54 KiB
Python
1266 lines
54 KiB
Python
# routes.py — 全部 HTTP 路由(Blueprint)
|
||
# 依赖:flask, db.py, helpers.py, werkzeug
|
||
|
||
import os
|
||
import json
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
from flask import Blueprint, jsonify, render_template, request, send_file, session, redirect
|
||
from werkzeug.security import generate_password_hash, check_password_hash
|
||
|
||
from db import db, now, _exec, rows, one, UPLOAD_DIR
|
||
from helpers import add_file_index, attach_common, monthly_finance
|
||
|
||
bp = Blueprint("api", __name__)
|
||
|
||
ALL_TENANTS = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
|
||
|
||
TABLES = {
|
||
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
|
||
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]),
|
||
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "tenant"]),
|
||
"products": ("product_versions", ["product_name", "version", "version_goal", "priority", "start_date", "plan_date", "dev_done_date", "test_date", "launch_date", "status", "notes", "tenant"]),
|
||
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
|
||
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
|
||
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
|
||
"expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
|
||
"planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info", "project_type", "weight"]),
|
||
"planExpense": ("plan_expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
|
||
}
|
||
|
||
# ---------- 鉴权装饰器 ----------
|
||
|
||
def login_required(f):
|
||
from functools import wraps
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
if "user_id" not in session:
|
||
return jsonify({"error": "未登录"}), 401
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
|
||
def admin_required(f):
|
||
from functools import wraps
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
if "user_id" not in session:
|
||
return jsonify({"error": "未登录"}), 401
|
||
if session.get("role") != "admin":
|
||
return jsonify({"error": "无权限"}), 403
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
|
||
# ---------- 认证路由 ----------
|
||
|
||
@bp.route("/login")
|
||
def login_page():
|
||
return render_template("login.html")
|
||
|
||
|
||
@bp.route("/api/auth/login", methods=["POST"])
|
||
def auth_login():
|
||
data = request.get_json(force=True) or {}
|
||
username = data.get("username", "").strip()
|
||
password = data.get("password", "")
|
||
conn = db()
|
||
try:
|
||
user = one(conn, "SELECT * FROM users WHERE username=?", (username,))
|
||
if not user or not check_password_hash(user["password_hash"], password):
|
||
return jsonify({"error": "用户名或密码错误"}), 401
|
||
session["user_id"] = user["id"]
|
||
session["username"] = user["username"]
|
||
session["display_name"] = user["display_name"]
|
||
session["role"] = user["role"]
|
||
if user["role"] == "admin":
|
||
session["tenants"] = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
|
||
else:
|
||
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
|
||
session["tenants"] = [x["tenant"] for x in ut]
|
||
# 清理旧名称
|
||
session["tenants"] = [t if t != "总工作台" else "总览" for t in session["tenants"]]
|
||
session["tenants"] = [t if t != "学会·无界" else "学术·无界" for t in session["tenants"]]
|
||
return jsonify({
|
||
"ok": True,
|
||
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
|
||
"tenants": session["tenants"],
|
||
})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/auth/logout", methods=["POST"])
|
||
def auth_logout():
|
||
session.clear()
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@bp.route("/api/auth/change-password", methods=["POST"])
|
||
@login_required
|
||
def auth_change_password():
|
||
data = request.get_json(force=True) or {}
|
||
old_password = data.get("old_password", "")
|
||
new_password = data.get("new_password", "")
|
||
if not old_password or not new_password:
|
||
return jsonify({"error": "旧密码和新密码不能为空"}), 400
|
||
if len(new_password) < 6:
|
||
return jsonify({"error": "新密码至少需要6位"}), 400
|
||
user_id = session.get("user_id")
|
||
conn = db()
|
||
try:
|
||
user = one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
|
||
if not user or not check_password_hash(user["password_hash"], old_password):
|
||
return jsonify({"error": "旧密码错误"}), 401
|
||
new_hash = generate_password_hash(new_password)
|
||
_exec(conn, "UPDATE users SET password_hash=? WHERE id=?", (new_hash, user_id))
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/auth/me")
|
||
def auth_me():
|
||
if "user_id" not in session:
|
||
return jsonify({"logged_in": False})
|
||
tenants = session.get("tenants", [])
|
||
tenants = [t if t != "总工作台" else "科普·无界" for t in tenants]
|
||
tenants = [t if t != "学会·无界" else "学术·无界" for t in tenants]
|
||
return jsonify({
|
||
"logged_in": True,
|
||
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
|
||
"tenants": tenants,
|
||
})
|
||
|
||
|
||
# ---------- 账号管理 ----------
|
||
|
||
@bp.route("/api/users")
|
||
@admin_required
|
||
def list_users():
|
||
conn = db()
|
||
try:
|
||
users = rows(conn, "SELECT id, username, display_name, role, created_at FROM users ORDER BY id")
|
||
ut_rows = rows(conn, "SELECT user_id, tenant FROM user_tenants")
|
||
tenant_map = {}
|
||
for r in ut_rows:
|
||
tenant_map.setdefault(r["user_id"], []).append(r["tenant"])
|
||
for u in users:
|
||
u["tenants"] = tenant_map.get(u["id"], [])
|
||
return jsonify(users)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/users", methods=["POST"])
|
||
@admin_required
|
||
def create_user():
|
||
data = request.get_json(force=True)
|
||
username = (data.get("username") or "").strip()
|
||
display_name = (data.get("display_name") or "").strip()
|
||
password = data.get("password") or ""
|
||
role = data.get("role") or "opc_owner"
|
||
tenants = data.get("tenants") or []
|
||
if not username or not password or not display_name:
|
||
return jsonify({"error": "用户名/密码/显示名不能为空"}), 400
|
||
if role not in ("admin", "opc_owner"):
|
||
return jsonify({"error": "角色非法"}), 400
|
||
conn = db()
|
||
try:
|
||
if one(conn, "SELECT id FROM users WHERE username=?", (username,)):
|
||
return jsonify({"error": "用户名已存在"}), 400
|
||
_exec(conn, "INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)",
|
||
(username, generate_password_hash(password, "pbkdf2:sha256"), display_name, role, date.today().isoformat()))
|
||
u = one(conn, "SELECT id FROM users WHERE username=?", (username,))
|
||
for t in tenants:
|
||
if t in ALL_TENANTS:
|
||
_exec(conn, "INSERT IGNORE INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], t))
|
||
conn.commit()
|
||
return jsonify({"ok": True, "id": u["id"]})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/users/<int:uid>", methods=["PUT"])
|
||
@admin_required
|
||
def update_user(uid):
|
||
data = request.get_json(force=True)
|
||
conn = db()
|
||
try:
|
||
u = one(conn, "SELECT * FROM users WHERE id=?", (uid,))
|
||
if not u:
|
||
return jsonify({"error": "用户不存在"}), 404
|
||
display_name = (data.get("display_name") or "").strip() or u["display_name"]
|
||
role = data.get("role") or u["role"]
|
||
if role not in ("admin", "opc_owner"):
|
||
return jsonify({"error": "角色非法"}), 400
|
||
password = data.get("password") or ""
|
||
if password:
|
||
_exec(conn, "UPDATE users SET display_name=?, role=?, password_hash=? WHERE id=?",
|
||
(display_name, role, generate_password_hash(password, "pbkdf2:sha256"), uid))
|
||
else:
|
||
_exec(conn, "UPDATE users SET display_name=?, role=? WHERE id=?", (display_name, role, uid))
|
||
if "tenants" in data:
|
||
_exec(conn, "DELETE FROM user_tenants WHERE user_id=?", (uid,))
|
||
for t in data["tenants"]:
|
||
if t in ALL_TENANTS:
|
||
_exec(conn, "INSERT IGNORE INTO user_tenants (user_id, tenant) VALUES (?,?)", (uid, t))
|
||
if role != "admin":
|
||
admin_count = one(conn, "SELECT COUNT(*) AS c FROM users WHERE role='admin'")["c"]
|
||
if admin_count == 0:
|
||
return jsonify({"error": "至少保留一个管理员"}), 400
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/users/<int:uid>", methods=["DELETE"])
|
||
@admin_required
|
||
def delete_user(uid):
|
||
if uid == session.get("user_id"):
|
||
return jsonify({"error": "不能删除当前登录账号"}), 400
|
||
conn = db()
|
||
try:
|
||
u = one(conn, "SELECT * FROM users WHERE id=?", (uid,))
|
||
if not u:
|
||
return jsonify({"error": "用户不存在"}), 404
|
||
if u["role"] == "admin":
|
||
admin_count = one(conn, "SELECT COUNT(*) AS c FROM users WHERE role='admin'")["c"]
|
||
if admin_count <= 1:
|
||
return jsonify({"error": "至少保留一个管理员"}), 400
|
||
_exec(conn, "DELETE FROM user_tenants WHERE user_id=?", (uid,))
|
||
_exec(conn, "DELETE FROM users WHERE id=?", (uid,))
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/tenants")
|
||
def list_tenants():
|
||
return jsonify(ALL_TENANTS)
|
||
|
||
|
||
# ---------- 首页 + bootstrap ----------
|
||
|
||
@bp.route("/")
|
||
def index():
|
||
if "user_id" not in session:
|
||
return redirect("/login")
|
||
return render_template("index.html")
|
||
|
||
|
||
@bp.route("/api/bootstrap")
|
||
def bootstrap():
|
||
"""轻量级 bootstrap:只返回 summary + financeMonthly(首页用),其他模块按需加载"""
|
||
if "user_id" not in session:
|
||
return jsonify({"error": "未登录"}), 401
|
||
_year = date.today().year
|
||
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
|
||
allowed = session.get("tenants", [])
|
||
allowed = [t if t != "总工作台" else "科普·无界" for t in allowed]
|
||
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
|
||
if tenant not in allowed:
|
||
tenant = allowed[0] if allowed else ""
|
||
conn = db()
|
||
try:
|
||
# 普通工作台:只查 summary 所需的数据(project_finances + operations + sales + products + proposals + expense)
|
||
pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [tenant])
|
||
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [tenant]))
|
||
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [tenant]))
|
||
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [tenant]))
|
||
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [tenant]))
|
||
expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", [tenant])
|
||
signed_pfs = pfs
|
||
|
||
def parse_budget(pf):
|
||
try:
|
||
budget = json.loads(pf.get("budget_data") or "[]")
|
||
except (json.JSONDecodeError, TypeError):
|
||
budget = []
|
||
return {(b.get("month") or "").replace("-", "_"): b for b in budget}
|
||
|
||
budget_maps = [(pf, parse_budget(pf)) for pf in signed_pfs]
|
||
|
||
def parse_expense(pf):
|
||
try:
|
||
ed = json.loads(pf.get("expense_data") or "[]")
|
||
except (json.JSONDecodeError, TypeError):
|
||
ed = []
|
||
return {(e.get("month") or "").replace("-", "_"): e for e in ed}
|
||
|
||
expense_maps = [(pf, parse_expense(pf)) for pf in signed_pfs]
|
||
|
||
def sum_budget(field, months_range):
|
||
total = 0
|
||
for pf, bm in budget_maps:
|
||
for m in months_range:
|
||
b = bm.get(f"{_year}_{m:02d}")
|
||
if b:
|
||
total += float(b.get(field) or 0)
|
||
return total
|
||
|
||
def sum_expense(field, months_range):
|
||
total = 0
|
||
for pf, em in expense_maps:
|
||
for m in months_range:
|
||
e = em.get(f"{_year}_{m:02d}")
|
||
if e:
|
||
total += float(e.get(field) or 0)
|
||
return total
|
||
|
||
_now_month = date.today().month
|
||
_q_start = ((_now_month - 1) // 3) * 3 + 1
|
||
_q_range = range(_q_start, _q_start + 3)
|
||
_prev_q_start = ((_now_month - 4) // 3) * 3 + 1
|
||
_prev_q_range = range(max(_prev_q_start, 1), _prev_q_start + 3)
|
||
_prev_month = _now_month - 1 if _now_month > 1 else None
|
||
rev_annual = sum_budget("rev", range(1, 13))
|
||
gross_annual = sum_budget("gross", range(1, 13))
|
||
rev_q2 = sum_budget("rev", _q_range)
|
||
gross_q2 = sum_budget("gross", _q_range)
|
||
rev_month = sum_budget("rev", [_now_month])
|
||
gross_month = sum_budget("gross", [_now_month])
|
||
rev_prev_q = sum_budget("rev", _prev_q_range)
|
||
gross_prev_q = sum_budget("gross", _prev_q_range)
|
||
rev_prev_month = sum_budget("rev", [_prev_month]) if _prev_month else 0
|
||
gross_prev_month = sum_budget("gross", [_prev_month]) if _prev_month else 0
|
||
payment_annual = sum_budget("payment", range(1, 13))
|
||
cost_annual = sum_expense("cost", range(1, 13))
|
||
payment_q2 = sum_budget("payment", _q_range)
|
||
cost_q2 = sum_expense("cost", _q_range)
|
||
payment_month = sum_budget("payment", [_now_month])
|
||
cost_month = sum_expense("cost", [_now_month])
|
||
payment_prev_q = sum_budget("payment", _prev_q_range)
|
||
cost_prev_q = sum_expense("cost", _prev_q_range)
|
||
payment_prev_month = sum_budget("payment", [_prev_month]) if _prev_month else 0
|
||
cost_prev_month = sum_expense("cost", [_prev_month]) if _prev_month else 0
|
||
def expense_sum(month_range):
|
||
total = 0
|
||
for r in expense:
|
||
if (r.get("status") or "") == "暂不计入": continue
|
||
m = (r.get("expense_month") or "").strip()
|
||
if not m or "-" not in m: continue
|
||
try:
|
||
if int(m.split("-")[1]) in month_range:
|
||
total += float(r.get("amount") or 0)
|
||
except: pass
|
||
return total
|
||
expense_annual = expense_sum(range(1, 13))
|
||
expense_q2 = expense_sum(_q_range)
|
||
expense_month_val = expense_sum([_now_month])
|
||
expense_prev_q = expense_sum(_prev_q_range)
|
||
expense_prev_month = expense_sum([_prev_month]) if _prev_month else 0
|
||
def expense_paid_sum(month_range):
|
||
total = 0
|
||
for r in expense:
|
||
if (r.get("status") or "") == "暂不计入": continue
|
||
m = (r.get("expense_month") or "").strip()
|
||
if not m or "-" not in m: continue
|
||
try:
|
||
if int(m.split("-")[1]) in month_range:
|
||
total += float(r.get("incurred_amount") or 0)
|
||
except: pass
|
||
return total
|
||
expense_paid_annual = expense_paid_sum(range(1, 13))
|
||
expense_paid_q2 = expense_paid_sum(_q_range)
|
||
expense_paid_month = expense_paid_sum([_now_month])
|
||
expense_paid_prev_q = expense_paid_sum(_prev_q_range)
|
||
expense_paid_prev_month = expense_paid_sum([_prev_month]) if _prev_month else 0
|
||
paid_annual = sum_expense("paid", range(1, 13))
|
||
paid_q2 = sum_expense("paid", _q_range)
|
||
paid_month = sum_expense("paid", [_now_month])
|
||
paid_prev_q = sum_expense("paid", _prev_q_range)
|
||
paid_prev_month = sum_expense("paid", [_prev_month]) if _prev_month else 0
|
||
proj_expense_annual = sum_budget("expense", range(1, 13))
|
||
proj_expense_q2 = sum_budget("expense", _q_range)
|
||
proj_expense_month = sum_budget("expense", [_now_month])
|
||
proj_expense_prev_q = sum_budget("expense", _prev_q_range)
|
||
proj_expense_prev_month = sum_budget("expense", [_prev_month]) if _prev_month else 0
|
||
cashflow_annual = payment_annual - paid_annual
|
||
cashflow_q2 = payment_q2 - paid_q2
|
||
cashflow_month = payment_month - paid_month
|
||
cashflow_prev_q = payment_prev_q - paid_prev_q
|
||
cashflow_prev_month = payment_prev_month - paid_prev_month
|
||
dept_cf_annual = cashflow_annual - expense_paid_annual
|
||
dept_cf_q2 = cashflow_q2 - expense_paid_q2
|
||
dept_cf_month = cashflow_month - expense_paid_month
|
||
dept_cf_prev_q = cashflow_prev_q - expense_paid_prev_q
|
||
dept_cf_prev_month = cashflow_prev_month - expense_paid_prev_month
|
||
profit_annual = gross_annual - proj_expense_annual
|
||
profit_q2 = gross_q2 - proj_expense_q2
|
||
profit_month = gross_month - proj_expense_month
|
||
profit_prev_q = gross_prev_q - proj_expense_prev_q
|
||
profit_prev_month = gross_prev_month - proj_expense_prev_month
|
||
signed_amount = sum(x["sign_amount"] or 0 for x in pfs)
|
||
signed_annual = sum(x["sign_amount"] or 0 for x in pfs)
|
||
_q_months = [f"{_year}-{m:02d}" for m in _q_range]
|
||
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] in _q_months)
|
||
signed_month = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] == f"{_year}-{_now_month:02d}")
|
||
_prev_q_months = [f"{_year}-{m:02d}" for m in _prev_q_range]
|
||
signed_prev_q = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] in _prev_q_months)
|
||
signed_prev_month = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] == f"{_year}-{_prev_month:02d}") if _prev_month else 0
|
||
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"])
|
||
signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100)
|
||
summary = {
|
||
"project_name": tenant,
|
||
"metrics": {
|
||
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
|
||
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
|
||
"execution_projects": len([x for x in operations if x["project_type"] == "execution"]),
|
||
"risk_projects": len([x for x in operations if x["project_status"] == "有风险" or x["risks"]]),
|
||
"monthly_revenue": rev_month,
|
||
"monthly_net_profit": gross_month,
|
||
"monthly_gross": gross_month,
|
||
"upcoming_products": len(products),
|
||
"total_projects": len(signed_pfs),
|
||
"total_proposals": len(operations),
|
||
"total_products": len(proposals),
|
||
"signed_amount": signed_amount,
|
||
"signed_annual": signed_annual,
|
||
"signed_q2": signed_q2,
|
||
"signed_month": signed_month,
|
||
"signed_prev_q": signed_prev_q,
|
||
"signed_prev_month": signed_prev_month,
|
||
"pipeline_amount": pipeline_amount,
|
||
"revenue_annual": rev_annual,
|
||
"revenue_q2": rev_q2,
|
||
"revenue_prev_q": rev_prev_q,
|
||
"revenue_prev_month": rev_prev_month,
|
||
"gross_annual": gross_annual,
|
||
"gross_q2": gross_q2,
|
||
"gross_prev_q": gross_prev_q,
|
||
"gross_prev_month": gross_prev_month,
|
||
"payment_annual": payment_annual,
|
||
"payment_q2": payment_q2,
|
||
"payment_month": payment_month,
|
||
"payment_prev_q": payment_prev_q,
|
||
"payment_prev_month": payment_prev_month,
|
||
"cost_annual": cost_annual,
|
||
"cost_q2": cost_q2,
|
||
"cost_month": cost_month,
|
||
"cost_prev_q": cost_prev_q,
|
||
"cost_prev_month": cost_prev_month,
|
||
"expense_annual": expense_annual,
|
||
"expense_q2": expense_q2,
|
||
"expense_month": expense_month_val,
|
||
"expense_prev_q": expense_prev_q,
|
||
"expense_prev_month": expense_prev_month,
|
||
"paid_annual": paid_annual,
|
||
"paid_q2": paid_q2,
|
||
"paid_month": paid_month,
|
||
"paid_prev_q": paid_prev_q,
|
||
"paid_prev_month": paid_prev_month,
|
||
"cashflow_annual": cashflow_annual,
|
||
"cashflow_q2": cashflow_q2,
|
||
"cashflow_month": cashflow_month,
|
||
"cashflow_prev_q": cashflow_prev_q,
|
||
"cashflow_prev_month": cashflow_prev_month,
|
||
"expense_paid_annual": expense_paid_annual,
|
||
"expense_paid_q2": expense_paid_q2,
|
||
"expense_paid_month": expense_paid_month,
|
||
"expense_paid_prev_q": expense_paid_prev_q,
|
||
"expense_paid_prev_month": expense_paid_prev_month,
|
||
"dept_cf_annual": dept_cf_annual,
|
||
"dept_cf_q2": dept_cf_q2,
|
||
"dept_cf_month": dept_cf_month,
|
||
"dept_cf_prev_q": dept_cf_prev_q,
|
||
"dept_cf_prev_month": dept_cf_prev_month,
|
||
"profit_annual": profit_annual,
|
||
"profit_q2": profit_q2,
|
||
"profit_month": profit_month,
|
||
"profit_prev_q": profit_prev_q,
|
||
"profit_prev_month": profit_prev_month,
|
||
"proj_expense_annual": proj_expense_annual,
|
||
"proj_expense_q2": proj_expense_q2,
|
||
"proj_expense_month": proj_expense_month,
|
||
"proj_expense_prev_q": proj_expense_prev_q,
|
||
"proj_expense_prev_month": proj_expense_prev_month,
|
||
"signed_not_executed": signed_not_executed,
|
||
},
|
||
"recent": rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", [tenant]),
|
||
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
|
||
}
|
||
return jsonify({"summary": summary, "financeMonthly": monthly_finance(conn, tenant), "tenant": tenant, "tenants": allowed})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ---------- 通用 CRUD ----------
|
||
|
||
@bp.route("/api/<resource>/list", methods=["GET"])
|
||
@login_required
|
||
def list_resource(resource):
|
||
"""轻量级列表接口,只返回指定 tenant 的记录"""
|
||
if resource not in TABLES:
|
||
return jsonify({"error": "unknown resource"}), 404
|
||
table, _ = TABLES[resource]
|
||
tenant = request.args.get("tenant", session.get("tenants", [""])[0])
|
||
allowed = session.get("tenants", [])
|
||
allowed = [t if t != "总工作台" else "总览" for t in allowed]
|
||
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
|
||
if tenant not in allowed:
|
||
tenant = allowed[0] if allowed else ""
|
||
conn = db()
|
||
try:
|
||
result = rows(conn, f"SELECT * FROM {table} WHERE tenant=? ORDER BY id DESC", [tenant])
|
||
return jsonify(result)
|
||
finally:
|
||
conn.close()
|
||
|
||
@bp.route("/api/<resource>", methods=["POST"])
|
||
@login_required
|
||
def create_resource(resource):
|
||
if resource not in TABLES:
|
||
return jsonify({"error": "unknown resource"}), 404
|
||
table, cols = TABLES[resource]
|
||
payload = request.get_json(force=True).get("data", {})
|
||
if resource == "tasks":
|
||
valid_statuses = ["未开始", "进行中", "已结束"]
|
||
if not payload.get("status") or payload["status"] not in valid_statuses:
|
||
payload["status"] = "未开始"
|
||
conn = db()
|
||
try:
|
||
type_cur = conn.cursor()
|
||
type_cur.execute(f"DESCRIBE {table}")
|
||
col_types = {r[0]: r[1].upper() for r in type_cur.fetchall()}
|
||
type_cur.close()
|
||
values = []
|
||
for col in cols:
|
||
val = payload.get(col, "")
|
||
if val == "" and ("DOUBLE" in col_types.get(col, "") or "INT" in col_types.get(col, "")):
|
||
val = 0
|
||
values.append(val)
|
||
cur = _exec(conn, f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})", values)
|
||
conn.commit()
|
||
return jsonify({"id": cur.lastrowid})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/<resource>/<int:item_id>", methods=["PUT", "DELETE"])
|
||
@login_required
|
||
def update_resource(resource, item_id):
|
||
if resource not in TABLES:
|
||
return jsonify({"error": "unknown resource"}), 404
|
||
table, cols = TABLES[resource]
|
||
conn = db()
|
||
try:
|
||
if request.method == "DELETE":
|
||
_exec(conn, f"DELETE FROM {table} WHERE id=?", (item_id,))
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
payload = request.get_json(force=True).get("data", {})
|
||
if resource == "tasks" and "status" in payload:
|
||
valid_statuses = ["未开始", "进行中", "已结束"]
|
||
if not payload["status"] or payload["status"] not in valid_statuses:
|
||
payload["status"] = "未开始"
|
||
if resource == "products":
|
||
cur = _exec(conn, f"SELECT start_date FROM {table} WHERE id=?", (item_id,))
|
||
row = cur.fetchone()
|
||
cur.close()
|
||
current_start = (row or {}).get("start_date", "") or ""
|
||
new_start = payload.get("start_date", current_start)
|
||
if "start_date" in payload and not new_start:
|
||
return jsonify({"error": "启动时间为必填项"}), 400
|
||
date_fields = ["plan_date", "dev_done_date", "test_date", "launch_date"]
|
||
for f in date_fields:
|
||
if f in payload and payload[f] and new_start and payload[f] < new_start:
|
||
labels = {"plan_date": "产品方案", "dev_done_date": "研发完成", "test_date": "测试完成", "launch_date": "上线时间"}
|
||
return jsonify({"error": f"{labels[f]}不能早于启动时间({new_start})"}), 400
|
||
update_cols = [col for col in cols if col in payload]
|
||
if update_cols:
|
||
_exec(conn,
|
||
f"UPDATE {table} SET {','.join([col + '=?' for col in update_cols])}, updated_at=? WHERE id=?",
|
||
[payload[col] for col in update_cols] + [now(), item_id],
|
||
)
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ---------- followups ----------
|
||
|
||
@bp.route("/api/followups/<target_type>/<int:target_id>")
|
||
def list_followups(target_type, target_id):
|
||
conn = db()
|
||
try:
|
||
fups = rows(conn,
|
||
"SELECT * FROM follow_up_records WHERE target_type=? AND target_id=? ORDER BY followed_at DESC, id DESC",
|
||
(target_type, target_id))
|
||
return jsonify(fups)
|
||
finally:
|
||
conn.close()
|
||
|
||
@bp.route("/api/followups/<target_type>/<int:target_id>", methods=["POST"])
|
||
@login_required
|
||
def add_followup(target_type, target_id):
|
||
payload = request.get_json(force=True).get("data", {})
|
||
conn = db()
|
||
try:
|
||
_exec(conn,
|
||
"""INSERT INTO follow_up_records
|
||
(target_type,target_id,followed_at,follower,follow_up_method,content,next_action,next_follow_up_at,tenant)
|
||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
target_type,
|
||
target_id,
|
||
payload.get("followed_at") or date.today().isoformat(),
|
||
payload.get("follower") or "慰心",
|
||
payload.get("follow_up_method") or "记录",
|
||
payload.get("content") or "",
|
||
payload.get("next_action") or "",
|
||
payload.get("next_follow_up_at") or "",
|
||
payload.get("tenant") or "科普·无界",
|
||
),
|
||
)
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/followups/<int:followup_id>", methods=["DELETE"])
|
||
@login_required
|
||
def delete_followup(followup_id):
|
||
conn = db()
|
||
try:
|
||
cur = _exec(conn, "DELETE FROM follow_up_records WHERE id=?", (followup_id,))
|
||
conn.commit()
|
||
if cur.rowcount == 0:
|
||
return jsonify({"error": "not found"}), 404
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ---------- batch sort ----------
|
||
|
||
@bp.route("/api/tasks/batch-sort", methods=["POST"])
|
||
@login_required
|
||
def batch_sort_tasks():
|
||
conn = db()
|
||
try:
|
||
items = request.get_json(force=True).get("items", [])
|
||
for item in items:
|
||
_exec(conn, "UPDATE project_tasks SET sort_order=? WHERE id=?", (item["sort_order"], item["id"]))
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/operations/batch-sort", methods=["POST"])
|
||
@login_required
|
||
def batch_sort_operations():
|
||
conn = db()
|
||
try:
|
||
items = request.get_json(force=True).get("items", [])
|
||
for item in items:
|
||
_exec(conn, "UPDATE operation_projects SET sort_order=? WHERE id=?", (item["sort_order"], item["id"]))
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ---------- files ----------
|
||
|
||
@bp.route("/api/files/upload", methods=["POST"])
|
||
@login_required
|
||
def upload_file():
|
||
file = request.files["file"]
|
||
module = request.form["module"]
|
||
owner_id = int(request.form["owner_id"])
|
||
owner_version = request.form.get("owner_version", "")
|
||
category = request.form.get("file_category", "")
|
||
folder = UPLOAD_DIR / module / str(owner_id)
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
target = folder / file.filename
|
||
file.save(target)
|
||
conn = db()
|
||
try:
|
||
add_file_index(conn, module, owner_id, owner_version, category, target, external=False)
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/files/<int:file_id>/content")
|
||
def file_content(file_id):
|
||
conn = db()
|
||
try:
|
||
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
|
||
if not asset:
|
||
return jsonify({"error": "not found"}), 404
|
||
path = Path(asset["file_path"])
|
||
if not path.exists():
|
||
return jsonify({"error": "missing"}), 404
|
||
return send_file(path, as_attachment=request.args.get("inline") == "false", download_name=asset["file_name"])
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@bp.route("/api/files/<int:file_id>", methods=["DELETE"])
|
||
@login_required
|
||
def delete_file(file_id):
|
||
conn = db()
|
||
try:
|
||
asset = one(conn, "SELECT * FROM file_assets WHERE id=?", (file_id,))
|
||
if not asset:
|
||
return jsonify({"error": "not found"}), 404
|
||
path = Path(asset["file_path"])
|
||
if path.exists() and str(UPLOAD_DIR) in str(path.resolve()):
|
||
path.unlink(missing_ok=True)
|
||
_exec(conn, "DELETE FROM file_assets WHERE id=?", (file_id,))
|
||
conn.commit()
|
||
return jsonify({"ok": True})
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
|
||
# ---------- 总览部门损益矩阵 API ----------
|
||
|
||
@bp.route("/api/overview/dept-matrix")
|
||
@login_required
|
||
def overview_dept_matrix():
|
||
"""返回各工作台的损益数据矩阵(按年/季/月筛选)"""
|
||
conn = db()
|
||
_year = int(request.args.get("year", date.today().year))
|
||
view = request.args.get("view", "monthly")
|
||
|
||
# 12个月数组
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
|
||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_gross": [0]*12, "dept_expense": [0]*12, "profit": [0]*12}}
|
||
|
||
for tenant in tenants:
|
||
# 项目毛利(从 budget_data JSON)
|
||
pfs = rows(conn, "SELECT budget_data FROM plan_finances WHERE tenant=?", [tenant])
|
||
gross = [0]*12
|
||
for pf in pfs:
|
||
try:
|
||
bd = json.loads(pf.get("budget_data") or "[]")
|
||
except:
|
||
bd = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
gross[i] += float(b.get("gross") or 0)
|
||
|
||
# 部门费用(从 plan_expense_records)
|
||
expense = [0]*12
|
||
exps = rows(conn, "SELECT expense_month, amount FROM plan_expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||
for e in exps:
|
||
m = (e.get("expense_month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
expense[i] += float(e.get("amount") or 0)
|
||
|
||
profit = [gross[i] - expense[i] for i in range(12)]
|
||
|
||
result["data"][tenant] = {
|
||
"project_gross": gross,
|
||
"dept_expense": expense,
|
||
"profit": profit
|
||
}
|
||
|
||
for i in range(12):
|
||
result["total"]["project_gross"][i] += gross[i]
|
||
result["total"]["dept_expense"][i] += expense[i]
|
||
result["total"]["profit"][i] += profit[i]
|
||
|
||
# 按视图聚合
|
||
if view == "quarterly":
|
||
def to_quarterly(arr12):
|
||
qr = [(0,1,2),(3,4,5),(6,7,8),(9,10,11)]
|
||
return [arr12[q[0]]+arr12[q[1]]+arr12[q[2]] for q in qr]
|
||
result["months"] = ["Q1","Q2","Q3","Q4"]
|
||
for t in tenants:
|
||
for key in ["project_gross","dept_expense","profit"]:
|
||
result["data"][t][key] = to_quarterly(result["data"][t][key])
|
||
for key in ["project_gross","dept_expense","profit"]:
|
||
result["total"][key] = to_quarterly(result["total"][key])
|
||
elif view == "monthly":
|
||
sel_month = request.args.get("month", "")
|
||
if sel_month and sel_month in months:
|
||
mi = months.index(sel_month)
|
||
result["months"] = [sel_month]
|
||
for t in tenants:
|
||
for key in ["project_gross","dept_expense","profit"]:
|
||
result["data"][t][key] = [result["data"][t][key][mi]]
|
||
for key in ["project_gross","dept_expense","profit"]:
|
||
result["total"][key] = [result["total"][key][mi]]
|
||
# else: yearly (default) — keep all 12 months
|
||
|
||
# Compute annual totals
|
||
result["annual"] = {}
|
||
for t in tenants:
|
||
d = result["data"][t]
|
||
result["annual"][t] = {
|
||
"project_gross": sum(d["project_gross"]),
|
||
"dept_expense": sum(d["dept_expense"]),
|
||
"profit": sum(d["profit"])
|
||
}
|
||
result["annual"]["total"] = {
|
||
"project_gross": sum(result["total"]["project_gross"]),
|
||
"dept_expense": sum(result["total"]["dept_expense"]),
|
||
"profit": sum(result["total"]["profit"])
|
||
}
|
||
|
||
conn.close()
|
||
return jsonify(result)
|
||
|
||
@bp.route("/api/overview/finance-dept-matrix")
|
||
@login_required
|
||
def overview_finance_dept_matrix():
|
||
"""返回各工作台已发生损益数据矩阵"""
|
||
conn = db()
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_gross": [0]*12, "dept_expense": [0]*12, "profit": [0]*12}}
|
||
|
||
for tenant in tenants:
|
||
pfs = rows(conn, "SELECT budget_data FROM project_finances WHERE tenant=?", [tenant])
|
||
gross = [0]*12
|
||
for pf in pfs:
|
||
try:
|
||
bd = json.loads(pf.get("budget_data") or "[]")
|
||
except:
|
||
bd = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
gross[i] += float(b.get("gross") or 0)
|
||
|
||
expense = [0]*12
|
||
exps = rows(conn, "SELECT expense_month, amount FROM expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||
for e in exps:
|
||
m = (e.get("expense_month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
expense[i] += float(e.get("amount") or 0)
|
||
|
||
profit = [gross[i] - expense[i] for i in range(12)]
|
||
result["data"][tenant] = {"project_gross": gross, "dept_expense": expense, "profit": profit}
|
||
for i in range(12):
|
||
result["total"]["project_gross"][i] += gross[i]
|
||
result["total"]["dept_expense"][i] += expense[i]
|
||
result["total"]["profit"][i] += profit[i]
|
||
|
||
result["annual"] = {}
|
||
for t in tenants:
|
||
d = result["data"][t]
|
||
result["annual"][t] = {"project_gross": sum(d["project_gross"]), "dept_expense": sum(d["dept_expense"]), "profit": sum(d["profit"])}
|
||
result["annual"]["total"] = {"project_gross": sum(result["total"]["project_gross"]), "dept_expense": sum(result["total"]["dept_expense"]), "profit": sum(result["total"]["profit"])}
|
||
|
||
conn.close()
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/overview/dept-cf-matrix")
|
||
@login_required
|
||
def overview_dept_cf_matrix():
|
||
"""返回各工作台预算现金流数据矩阵"""
|
||
conn = db()
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_cf": [0]*12, "dept_cf": [0]*12, "bl_cf": [0]*12}}
|
||
|
||
for tenant in tenants:
|
||
pfs = rows(conn, "SELECT budget_data, expense_data FROM plan_finances WHERE tenant=?", [tenant])
|
||
project_cf = [0]*12
|
||
for pf in pfs:
|
||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||
except: bd = []
|
||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||
except: ed = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
project_cf[i] += float(b.get("payment") or 0)
|
||
for e in ed:
|
||
m = (e.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
project_cf[i] -= float(e.get("paid") or 0)
|
||
|
||
dept_cf = [0]*12
|
||
exps = rows(conn, "SELECT expense_month, incurred_amount FROM plan_expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||
for e in exps:
|
||
m = (e.get("expense_month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
dept_cf[i] += float(e.get("incurred_amount") or 0)
|
||
|
||
bl_cf = [project_cf[i] - dept_cf[i] for i in range(12)]
|
||
result["data"][tenant] = {"project_cf": project_cf, "dept_cf": dept_cf, "bl_cf": bl_cf}
|
||
for i in range(12):
|
||
result["total"]["project_cf"][i] += project_cf[i]
|
||
result["total"]["dept_cf"][i] += dept_cf[i]
|
||
result["total"]["bl_cf"][i] += bl_cf[i]
|
||
|
||
result["annual"] = {}
|
||
for t in tenants:
|
||
d = result["data"][t]
|
||
result["annual"][t] = {"project_cf": sum(d["project_cf"]), "dept_cf": sum(d["dept_cf"]), "bl_cf": sum(d["bl_cf"])}
|
||
result["annual"]["total"] = {"project_cf": sum(result["total"]["project_cf"]), "dept_cf": sum(result["total"]["dept_cf"]), "bl_cf": sum(result["total"]["bl_cf"])}
|
||
|
||
conn.close()
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/overview/finance-dept-cf-matrix")
|
||
@login_required
|
||
def overview_finance_dept_cf_matrix():
|
||
"""返回各工作台已发生现金流数据矩阵"""
|
||
conn = db()
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_cf": [0]*12, "dept_cf": [0]*12, "bl_cf": [0]*12}}
|
||
|
||
for tenant in tenants:
|
||
pfs = rows(conn, "SELECT budget_data, expense_data FROM project_finances WHERE tenant=?", [tenant])
|
||
project_cf = [0]*12
|
||
for pf in pfs:
|
||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||
except: bd = []
|
||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||
except: ed = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
project_cf[i] += float(b.get("payment") or 0)
|
||
for e in ed:
|
||
m = (e.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
project_cf[i] -= float(e.get("paid") or 0)
|
||
|
||
dept_cf = [0]*12
|
||
exps = rows(conn, "SELECT expense_month, incurred_amount FROM expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||
for e in exps:
|
||
m = (e.get("expense_month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
dept_cf[i] += float(e.get("incurred_amount") or 0)
|
||
|
||
bl_cf = [project_cf[i] - dept_cf[i] for i in range(12)]
|
||
result["data"][tenant] = {"project_cf": project_cf, "dept_cf": dept_cf, "bl_cf": bl_cf}
|
||
for i in range(12):
|
||
result["total"]["project_cf"][i] += project_cf[i]
|
||
result["total"]["dept_cf"][i] += dept_cf[i]
|
||
result["total"]["bl_cf"][i] += bl_cf[i]
|
||
|
||
result["annual"] = {}
|
||
for t in tenants:
|
||
d = result["data"][t]
|
||
result["annual"][t] = {"project_cf": sum(d["project_cf"]), "dept_cf": sum(d["dept_cf"]), "bl_cf": sum(d["bl_cf"])}
|
||
result["annual"]["total"] = {"project_cf": sum(result["total"]["project_cf"]), "dept_cf": sum(result["total"]["dept_cf"]), "bl_cf": sum(result["total"]["bl_cf"])}
|
||
|
||
conn.close()
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/overview/dept-project-matrix")
|
||
@login_required
|
||
def overview_dept_project_matrix():
|
||
"""返回各工作台预算项目经营详情矩阵"""
|
||
conn = db()
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
keys = ["sign","revenue","gross","cost","paid","payment","cashflow"]
|
||
result = {"tenants": tenants, "months": months, "data": {}, "total": {k: [0]*12 for k in keys}}
|
||
|
||
for tenant in tenants:
|
||
tdata = {k: [0]*12 for k in keys}
|
||
pfs = rows(conn, "SELECT sign_amount, sign_month, budget_data, expense_data FROM plan_finances WHERE tenant=?", [tenant])
|
||
for pf in pfs:
|
||
sm = (pf.get("sign_month") or "")[:7]
|
||
if sm in months:
|
||
i = months.index(sm)
|
||
tdata["sign"][i] += float(pf.get("sign_amount") or 0)
|
||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||
except: bd = []
|
||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||
except: ed = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
tdata["revenue"][i] += float(b.get("rev") or 0)
|
||
tdata["gross"][i] += float(b.get("gross") or 0)
|
||
tdata["payment"][i] += float(b.get("payment") or 0)
|
||
for e in ed:
|
||
m = (e.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
tdata["cost"][i] += float(e.get("cost") or 0)
|
||
tdata["paid"][i] += float(e.get("paid") or 0)
|
||
for i in range(12):
|
||
tdata["cashflow"][i] = tdata["payment"][i] - tdata["paid"][i]
|
||
result["data"][tenant] = tdata
|
||
for k in keys:
|
||
for i in range(12):
|
||
result["total"][k][i] += tdata[k][i]
|
||
|
||
result["annual"] = {}
|
||
for t in tenants:
|
||
result["annual"][t] = {k: sum(result["data"][t][k]) for k in keys}
|
||
result["annual"]["total"] = {k: sum(result["total"][k]) for k in keys}
|
||
conn.close()
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/overview/finance-dept-project-matrix")
|
||
@login_required
|
||
def overview_finance_dept_project_matrix():
|
||
"""返回各工作台已发生项目经营详情矩阵"""
|
||
conn = db()
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
keys = ["sign","revenue","gross","cost","paid","payment","cashflow"]
|
||
result = {"tenants": tenants, "months": months, "data": {}, "total": {k: [0]*12 for k in keys}}
|
||
|
||
for tenant in tenants:
|
||
tdata = {k: [0]*12 for k in keys}
|
||
pfs = rows(conn, "SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?", [tenant])
|
||
for pf in pfs:
|
||
sm = (pf.get("sign_month") or "")[:7]
|
||
if sm in months:
|
||
i = months.index(sm)
|
||
tdata["sign"][i] += float(pf.get("sign_amount") or 0)
|
||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||
except: bd = []
|
||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||
except: ed = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
tdata["revenue"][i] += float(b.get("rev") or 0)
|
||
tdata["gross"][i] += float(b.get("gross") or 0)
|
||
tdata["payment"][i] += float(b.get("payment") or 0)
|
||
for e in ed:
|
||
m = (e.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
tdata["cost"][i] += float(e.get("cost") or 0)
|
||
tdata["paid"][i] += float(e.get("paid") or 0)
|
||
for i in range(12):
|
||
tdata["cashflow"][i] = tdata["payment"][i] - tdata["paid"][i]
|
||
result["data"][tenant] = tdata
|
||
for k in keys:
|
||
for i in range(12):
|
||
result["total"][k][i] += tdata[k][i]
|
||
|
||
result["annual"] = {}
|
||
for t in tenants:
|
||
result["annual"][t] = {k: sum(result["data"][t][k]) for k in keys}
|
||
result["annual"]["total"] = {k: sum(result["total"][k]) for k in keys}
|
||
conn.close()
|
||
return jsonify(result)
|
||
|
||
# ---------- health ----------
|
||
|
||
@bp.route("/api/health")
|
||
def health():
|
||
return jsonify({"ok": True, "service": "opc-manager"})
|
||
|
||
# ---------- 总览聚合 API ----------
|
||
|
||
OVERVIEW_TENANTS = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||
|
||
|
||
def _aggregate_monthly_from_json(conn, table, tenants):
|
||
"""聚合多个 tenant 的表 (含 budget_data/expense_data JSON) 到 12 个月数组"""
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
rev, gross, payment, cost, paid = [0]*12, [0]*12, [0]*12, [0]*12, [0]*12
|
||
sign_monthly = [0]*12
|
||
sign_total = 0
|
||
sign_cnt = 0
|
||
|
||
for tenant in tenants:
|
||
pfs = rows(conn, f"SELECT sign_amount, sign_month, budget_data, expense_data FROM {table} WHERE tenant=?", [tenant])
|
||
for pf in pfs:
|
||
sm = pf.get("sign_month") or ""
|
||
if sm.startswith(str(_year)):
|
||
sign_total += float(pf.get("sign_amount") or 0)
|
||
sign_cnt += 1
|
||
# 签单按月拆分
|
||
sm7 = sm[:7]
|
||
if sm7 in months:
|
||
sign_monthly[months.index(sm7)] += float(pf.get("sign_amount") or 0)
|
||
try:
|
||
bd = json.loads(pf.get("budget_data") or "[]")
|
||
except:
|
||
bd = []
|
||
try:
|
||
ed = json.loads(pf.get("expense_data") or "[]")
|
||
except:
|
||
ed = []
|
||
for b in bd:
|
||
m = (b.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
rev[i] += float(b.get("rev") or 0)
|
||
gross[i] += float(b.get("gross") or 0)
|
||
payment[i] += float(b.get("payment") or 0)
|
||
for e in ed:
|
||
m = (e.get("month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
cost[i] += float(e.get("cost") or 0)
|
||
paid[i] += float(e.get("paid") or 0)
|
||
|
||
return {
|
||
"months": months,
|
||
"rev": rev, "gross": gross, "payment": payment, "cost": cost, "paid": paid,
|
||
"cashflow": [payment[i] - paid[i] for i in range(12)],
|
||
"sign_total": sign_total, "sign_cnt": sign_cnt,
|
||
"sign_monthly": sign_monthly,
|
||
}
|
||
|
||
|
||
def _aggregate_expenses(conn, table, tenants):
|
||
"""聚合多个 tenant 的平台费用到 12 个月"""
|
||
_year = date.today().year
|
||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||
amount = [0]*12
|
||
incurred = [0]*12
|
||
for tenant in tenants:
|
||
exps = rows(conn, f"SELECT expense_month, amount, incurred_amount FROM {table} WHERE tenant=? AND expense_month LIKE ?", [tenant, f"{_year}-%"])
|
||
for e in exps:
|
||
m = (e.get("expense_month") or "")[:7]
|
||
if m in months:
|
||
i = months.index(m)
|
||
amount[i] += float(e.get("amount") or 0)
|
||
incurred[i] += float(e.get("incurred_amount") or 0)
|
||
return amount, incurred
|
||
|
||
|
||
def _build_overview_result(agg, exp_amount, exp_incurred):
|
||
"""根据聚合数据构建前端使用的 overview JSON"""
|
||
months = agg["months"]
|
||
rev = agg["rev"]
|
||
gross = agg["gross"]
|
||
payment = agg["payment"]
|
||
cost = agg["cost"]
|
||
paid = agg["paid"]
|
||
cf = agg["cashflow"]
|
||
|
||
# 部门损益情况
|
||
dept_profit = [gross[i] - exp_amount[i] for i in range(12)]
|
||
dept = {
|
||
"project_gross": gross,
|
||
"dept_expense": exp_amount,
|
||
"profit": dept_profit,
|
||
"annual": [sum(gross), sum(exp_amount), sum(dept_profit)],
|
||
}
|
||
|
||
# 部门现金流
|
||
bl_cf = [cf[i] - exp_incurred[i] for i in range(12)]
|
||
dept_cf = {
|
||
"project_cf": cf,
|
||
"dept_cf_item": exp_incurred,
|
||
"bl_cf": bl_cf,
|
||
"annual": [sum(cf), sum(exp_incurred), sum(bl_cf)],
|
||
}
|
||
|
||
# 项目经营详情
|
||
project = {
|
||
"sign": agg["sign_total"],
|
||
"sign_monthly": agg["sign_monthly"],
|
||
"revenue": rev,
|
||
"gross": gross,
|
||
"cost": cost,
|
||
"paid": paid,
|
||
"payment": payment,
|
||
"cashflow": cf,
|
||
"annual": [agg["sign_total"], sum(rev), sum(gross), sum(cost), sum(paid), sum(payment), sum(cf)],
|
||
}
|
||
|
||
return {"dept": dept, "dept_cf": dept_cf, "project": project, "months": months}
|
||
|
||
|
||
def _quarterly_from_monthly(arr12):
|
||
"""将 12 个月数组转换为 4 个季度"""
|
||
q_ranges = [(0,1,2),(3,4,5),(6,7,8),(9,10,11)]
|
||
return [arr12[q[0]]+arr12[q[1]]+arr12[q[2]] for q in q_ranges]
|
||
|
||
|
||
@bp.route("/api/overview/plan-monthly")
|
||
@login_required
|
||
def overview_plan_monthly():
|
||
conn = db()
|
||
agg = _aggregate_monthly_from_json(conn, "plan_finances", OVERVIEW_TENANTS)
|
||
exp_amount, exp_incurred = _aggregate_expenses(conn, "plan_expense_records", OVERVIEW_TENANTS)
|
||
conn.close()
|
||
return jsonify(_build_overview_result(agg, exp_amount, exp_incurred))
|
||
|
||
|
||
@bp.route("/api/overview/plan-quarterly")
|
||
@login_required
|
||
def overview_plan_quarterly():
|
||
conn = db()
|
||
agg = _aggregate_monthly_from_json(conn, "plan_finances", OVERVIEW_TENANTS)
|
||
exp_amount, exp_incurred = _aggregate_expenses(conn, "plan_expense_records", OVERVIEW_TENANTS)
|
||
conn.close()
|
||
# Convert monthly to quarterly
|
||
result = _build_overview_result(agg, exp_amount, exp_incurred)
|
||
for key in ["sign_monthly","revenue","gross","payment","cost","paid","cashflow"]:
|
||
result["project"][key] = _quarterly_from_monthly(result["project"][key])
|
||
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
|
||
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
|
||
result["dept"]["profit"] = _quarterly_from_monthly(result["dept"]["profit"])
|
||
result["dept_cf"]["project_cf"] = _quarterly_from_monthly(result["dept_cf"]["project_cf"])
|
||
result["dept_cf"]["dept_cf_item"] = _quarterly_from_monthly(result["dept_cf"]["dept_cf_item"])
|
||
result["dept_cf"]["bl_cf"] = _quarterly_from_monthly(result["dept_cf"]["bl_cf"])
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/overview/finance-monthly")
|
||
@login_required
|
||
def overview_finance_monthly():
|
||
conn = db()
|
||
agg = _aggregate_monthly_from_json(conn, "project_finances", OVERVIEW_TENANTS)
|
||
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
|
||
conn.close()
|
||
return jsonify(_build_overview_result(agg, exp_amount, exp_incurred))
|
||
|
||
|
||
@bp.route("/api/overview/finance-quarterly")
|
||
@login_required
|
||
def overview_finance_quarterly():
|
||
conn = db()
|
||
agg = _aggregate_monthly_from_json(conn, "project_finances", OVERVIEW_TENANTS)
|
||
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
|
||
conn.close()
|
||
result = _build_overview_result(agg, exp_amount, exp_incurred)
|
||
for key in ["sign_monthly","revenue","gross","payment","cost","paid","cashflow"]:
|
||
result["project"][key] = _quarterly_from_monthly(result["project"][key])
|
||
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
|
||
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
|
||
result["dept"]["profit"] = _quarterly_from_monthly(result["dept"]["profit"])
|
||
result["dept_cf"]["project_cf"] = _quarterly_from_monthly(result["dept_cf"]["project_cf"])
|
||
result["dept_cf"]["dept_cf_item"] = _quarterly_from_monthly(result["dept_cf"]["dept_cf_item"])
|
||
result["dept_cf"]["bl_cf"] = _quarterly_from_monthly(result["dept_cf"]["bl_cf"])
|
||
return jsonify(result)
|