Compare commits

...

2 Commits

Author SHA1 Message Date
mac
8bd40de41d Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-02 18:30:24 +08:00
mac
caebf90438 重构:flask_app.py 拆分为 db/helpers/routes/seed_data + Blueprint
- flask_app.py 1166行→33行纯入口
- 新建 db.py(配置+连接+SQL工具)
- 新建 helpers.py(attach_common/monthly_finance/add_file_index)
- 新建 routes.py(全路由 Blueprint + 装饰器 + TABLES)
- 新建 migrations/seed_data.py(seed_db 搬迁)
- migrations/{tables,columns,data_fixes,seed}.py 改 import 为 from db
- 删除死代码 init_db(228行)+ latest_followup(10行)
- 反向依赖消除:migrations 不再 import flask_app
- 前端零改动,URL 不变
2026-07-02 18:30:24 +08:00
10 changed files with 940 additions and 1151 deletions

76
backend/db.py Normal file
View File

@@ -0,0 +1,76 @@
# db.py — 基础层:配置常量 + 数据库连接 + SQL 工具 + logger
# 被 helpers.py, routes.py, migrations/*, flask_app.py 共同依赖
import os
import sys
import json
import logging
from pathlib import Path
from datetime import datetime
import mysql.connector
# 确保 backend 目录在 sys.path 中(兼容 gunicorn --preload 模式)
_backend_dir = os.path.dirname(os.path.abspath(__file__))
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
# ---------- 路径常量 ----------
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "opc.sqlite"
# ---------- 环境变量 ----------
try:
from dotenv import load_dotenv
load_dotenv(ROOT / ".env")
except ImportError:
pass
WEIXIN_BASE = Path(os.environ.get("WEIXIN_BASE", "/Users/mac/天机阁/地阁/慰心斋"))
# 建目录
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# ---------- 数据库连接 ----------
def db():
return mysql.connector.connect(
host=os.environ.get("DB_HOST", "127.0.0.1"),
port=int(os.environ.get("DB_PORT", "3306")),
user=os.environ.get("DB_USER", "opc"),
password=os.environ.get("DB_PASSWORD", "opc123456"),
database=os.environ.get("DB_NAME", "opc"),
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
)
def now():
return datetime.utcnow().isoformat()
def _exec(conn, sql, args=()):
"""执行 SQL自动将 ? 转为 MySQL 的 %s"""
cur = conn.cursor(dictionary=True)
cur.execute(sql.replace("?", "%s"), args)
return cur
def rows(conn, sql, args=()):
cur = _exec(conn, sql, args)
rows = cur.fetchall()
cur.close()
return rows
def one(conn, sql, args=()):
cur = _exec(conn, sql, args)
row = cur.fetchone()
cur.close()
return row

File diff suppressed because it is too large Load Diff

105
backend/helpers.py Normal file
View File

@@ -0,0 +1,105 @@
# helpers.py — 业务查询辅助函数
# 依赖db.py被 routes.py 和 migrations/seed_data.py 调用
import json
from pathlib import Path
from db import _exec, rows, one
def add_file_index(conn, module, owner_id, owner_version, category, path, external=True):
path = Path(path)
if not path.exists():
return
_exec(conn,
"""INSERT INTO file_assets
(module,owner_id,owner_version,file_category,file_name,file_type,file_size,file_path,is_external)
VALUES (?,?,?,?,?,?,?,?,?)""",
(module, owner_id, owner_version, category, path.name, path.suffix.lower().lstrip("."), path.stat().st_size, str(path), 1 if external else 0),
)
def attach_common(conn, resource, items):
"""批量加载 followups 和 files避免 N+1 查询"""
if not items:
return items
target_map = {"sales": "sales", "proposals": "proposal", "operations": "operation", "products": "product"}
target_type = target_map.get(resource)
ids = [item["id"] for item in items]
# 批量查 followups一次性 IN 查询)
if target_type:
placeholders = ",".join(["?"] * len(ids))
all_followups = rows(
conn,
f"SELECT * FROM follow_up_records WHERE target_type=? AND target_id IN ({placeholders}) ORDER BY followed_at DESC, id DESC",
[target_type] + ids,
)
followups_by_id = {}
for fu in all_followups:
followups_by_id.setdefault(fu["target_id"], []).append(fu)
for item in items:
item["followups"] = followups_by_id.get(item["id"], [])
item["latest_follow_up_record"] = item["followups"][0]["content"] if item["followups"] else ""
# 批量查 filesproposals + operations
file_modules = {"proposals": "proposal", "operations": "operation"}
if resource in file_modules:
module = file_modules[resource]
placeholders = ",".join(["?"] * len(ids))
all_files = rows(
conn,
f"SELECT * FROM file_assets WHERE module=? AND owner_id IN ({placeholders}) ORDER BY id DESC",
[module] + ids,
)
files_by_id = {}
for f in all_files:
files_by_id.setdefault(f["owner_id"], []).append(f)
for item in items:
item["files"] = files_by_id.get(item["id"], [])
return items
def monthly_finance(conn, tenant="科普·无界"):
months = [f"2026-{m:02d}" for m in range(1, 13)]
pfs = rows(conn,
"SELECT sign_amount, sign_month, status, budget_data FROM project_finances WHERE tenant=? AND status='已签约'",
[tenant])
parsed_budgets = []
for pf in pfs:
try:
budget = json.loads(pf.get("budget_data") or "[]")
except (json.JSONDecodeError, TypeError):
budget = []
budget_map = {}
for b in budget:
key = (b.get("month") or "").replace("-", "_")
budget_map[key] = {
"rev": float(b.get("rev") or 0),
"gross": float(b.get("gross") or 0),
"payment": float(b.get("payment") or 0),
"cost": float(b.get("cost") or 0),
}
parsed_budgets.append((pf, budget_map))
data = []
for month in months:
key = month.replace("-", "_")
revenue = gross = payment = cost = sign = 0
for pf, budget_map in parsed_budgets:
if pf["status"] == "已签约" and (pf.get("sign_month") or "") == month:
sign += float(pf["sign_amount"] or 0)
b = budget_map.get(key)
if b:
revenue += b["rev"]
gross += b["gross"]
payment += b["payment"]
cost += b["cost"]
data.append({
"month": month, "revenue": revenue,
"labor": 0, "expense": 0, "purchase": 0,
"gross": gross,
"sign": sign, "payment": payment, "cost": cost,
})
return data

View File

@@ -3,7 +3,7 @@
def _add_column_if_missing(conn, table, column, ddl):
"""检查列是否存在,不存在才加(幂等)"""
from flask_app import _exec, mysql, logger
from db import _exec, mysql, logger
cur = conn.cursor(dictionary=True)
cur.execute(f"SHOW COLUMNS FROM {table} LIKE %s", (column,))
@@ -19,7 +19,7 @@ def _add_column_if_missing(conn, table, column, ddl):
def migrate_add_columns():
"""为老表补齐后续新增的字段"""
from flask_app import db
from db import db
conn = db()
try:

View File

@@ -3,7 +3,7 @@
def migrate_fix_task_status():
"""修正 project_tasks 中非法的 status 值"""
from flask_app import db, _exec, mysql, logger
from db import db, _exec, mysql, logger
conn = db()
try:
@@ -28,7 +28,7 @@ def migrate_fix_task_status():
def migrate_rename_tenant():
"""工作台重命名:无界·无界 → 学会·无界"""
from flask_app import db, _exec, mysql
from db import db, _exec, mysql
conn = db()
try:
@@ -51,7 +51,7 @@ def migrate_rename_tenant():
def migrate_drop_product_fields():
"""删除 product_versions 表的 owner / platform / feature_list 字段"""
from flask_app import db, mysql
from db import db, mysql
conn = db()
try:

View File

@@ -5,7 +5,8 @@ from datetime import date
def migrate_seed_users():
"""初始化默认用户和工作台权限(仅空库时执行)"""
from flask_app import db, _exec, one, generate_password_hash
from db import db, _exec, one
from werkzeug.security import generate_password_hash
conn = db()
try:
@@ -42,7 +43,8 @@ def migrate_seed_users():
def migrate_seed_demo_data():
"""填充初始示例数据(仅在空库时执行)"""
from flask_app import db, one, seed_db
from db import db, one
from migrations.seed_data import seed_db
conn = db()
try:

View File

@@ -0,0 +1,127 @@
# seed_data.py — 初始示例数据填充(仅在空库时执行一次)
# 从 flask_app.py 搬迁,被 migrations/seed.py 调用
from datetime import date
from pathlib import Path
from db import db, _exec, one, WEIXIN_BASE
from helpers import add_file_index
def seed_db():
"""填充初始示例数据(仅在空库时执行一次)"""
conn = db()
try:
if one(conn, "SELECT id FROM sales_leads LIMIT 1"):
return # 已有数据,跳过
sales = [
("齐鲁制药", "P0", "跟进中", "多产品线科普年度框架,需推进高层沟通。"),
("百利天恒", "P0", "方案中", "BL-B01D1 上市前医生教育机会,准备方案。"),
("信达生物", "P0", "已签约", "现有科普项目升级/续约,重点保障执行。"),
("三生制药", "P1", "待跟进", "多科室医生教育+患者科普机会。"),
("天广实生物", "P1", "待跟进", "血液肿瘤医生教育机会。"),
]
for customer, priority, status, note in sales:
cur = _exec(conn,
"INSERT INTO sales_leads (target_customer, priority, status) VALUES (?,?,?)",
(customer, priority, status),
)
_exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("sales", cur.lastrowid, date.today().isoformat(), note, "明确下一次沟通人和时间"),
)
cur = _exec(conn,
"INSERT INTO business_proposals (customer_or_project_name,version,description,status,created_date) VALUES (?,?,?,?,?)",
("信达生物", "v1.5", "信达科普项目续约与报价方案", "已提交客户", "2026-05-28"),
)
proposal_id = cur.lastrowid
proposal_dir = WEIXIN_BASE / "2、业务方案/信达/v1.5"
for category, names in {
"方案": ["整体方案.pptx", "整体方案.pdf"],
"成本": ["业务报价-2亿方案.xlsx", "业务报价-5250万方案.xlsx", "5、最新报价.xlsx"],
"SOP": ["SOP.docx"],
"财务流程": ["财务流程.docx"],
}.items():
for name in names:
add_file_index(conn, "proposal", proposal_id, "v1.5", category, proposal_dir / name, external=True)
projects = [
("圆心科技 科普文章项目", "v2026-文章", "execution", "SOP 执行中", "内容生产", 55, "文章内容生产与审核执行中"),
("圆心科技 科普视频项目", "v2026-视频", "execution", "SOP 执行中", "内容生产", 45, "视频脚本、拍摄与审核推进"),
("圆心科技 科普专访项目", "v2026-专访", "opportunity", "方案已提交", "商务推进", 0, "专访项目推动签约"),
]
op_dir = WEIXIN_BASE / "3、运营方案"
for name, version, kind, status, stage, progress, note in projects:
cur = _exec(conn,
"""INSERT INTO operation_projects
(project_name,project_version,project_type,project_status,current_stage,target_customer,customer_need,
expected_contract_amount,expected_sign_date,sign_probability,next_action,sop_stage,execution_progress,current_deliverable)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(name, version, kind, status, stage, "圆心科技", "科普内容项目执行与管理", 0 if kind == "execution" else 200, "2026-06", 100 if kind == "execution" else 70, "补齐版本要求文件并更新下一节点", stage, progress, note),
)
_exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("operation", cur.lastrowid, date.today().isoformat(), note, "补齐版本要求文件并更新下一节点"),
)
file_map = [
(1, "v2026-文章", "项目方案", "圆心科技--科普文章项目(1).pptx"),
(2, "v2026-视频", "项目方案", "圆心科技-科普视频项目(1).pptx"),
(3, "v2026-专访", "项目方案", "圆心科技-科普专访项目-2026年(1).pdf"),
(1, "v2026-文章", "项目管理手册", "圆心科技《项目管理手册》-2026年.pdf"),
(2, "v2026-视频", "审核标准", "科普项目-审核标准(文章-视频-音频).pdf"),
]
for project_id, version, category, filename in file_map:
add_file_index(conn, "operation", project_id, version, category, op_dir / filename, external=True)
products = [
("妙手医生服务小程序", "v1.1", "视频任务增强 + 积分商城", "草稿箱、批量上传、积分商城、消息通知", "2026-Q3", "规划中", "科普平台"),
("数字化营销后台管理系统", "v1.2", "运营数据看板 + 智能审核", "医生活跃、任务完成率、AI 预审、渠道数据上报", "2026-Q3", "设计中", "真研平台"),
("妙手患者服务", "v0.5", "科普浏览 + 医生主页 MVP", "科普文章/视频浏览、医生主页、搜索", "2026-Q3", "规划中", "科普平台"),
("数字人内容平台", "v0.1", "基础数字人视频生成 MVP", "预设形象、AI 配音、脚本驱动、简单模板", "2026-Q3", "规划中", "科普平台"),
("渠道分发引擎", "v1.0", "六渠道统一分发", "分发 API、内容适配、分发排期、效果追踪", "2027-Q1", "规划中", "科普平台"),
]
for product in products:
cur = _exec(conn,
"INSERT INTO product_versions (product_name,version,version_goal,feature_list,launch_date,status,platform) VALUES (?,?,?,?,?,?,?)",
product,
)
_exec(conn,
"INSERT INTO follow_up_records (target_type,target_id,followed_at,content,next_action) VALUES (?,?,?,?,?)",
("product", cur.lastrowid, date.today().isoformat(), f"{product[0]} {product[1]}{product[2]}", "按路线图推进"),
)
for month, record_type, category, amount, notes in [
("2026-05", "revenue", "信达生物续约确认收入", 120, "信达项目阶段确收"),
("2026-06", "revenue", "信达生物续约确认收入", 80, "信达项目尾款预估"),
("2026-05", "cost_expense", "内容生产", 32, "医生劳务与内容制作"),
("2026-05", "cost_expense", "运营管理", 16, "项目管理与渠道协同"),
("2026-06", "cost_expense", "渠道分发", 24, "投放与分发费用"),
]:
_exec(conn,
"INSERT INTO finance_records (month,record_type,category,amount,occurred_date,notes) VALUES (?,?,?,?,?,?)",
(month, record_type, category, amount, f"{month}-01", notes),
)
tasks_seed = [
("阶段1渠道与商务确认", "商务对接", "合同签订", "Anna", "2026-06-30", "法务审核中", "合同签订后开始执行"),
("阶段1渠道与商务确认", "官媒渠道确认", "沟通官媒确定", "段丽华", "2026-06-30", "官媒尽力推,以先达成合作为准", "集团支持"),
("阶段1渠道与商务确认", "官媒渠道确认", "官媒合作签约", "段丽华", "2026-06-18", "", "官媒确认细节"),
("阶段2系统与标准搭建", "系统开发上线", "音频专访系统开发上线", "戴敏/梁军营", "2026-06-18", "客户比较着急执行,需要技术的资源", ""),
("阶段2系统与标准搭建", "系统开发上线", "精品视频系统开发上线", "戴敏/梁军营", "2026-06-25", "", ""),
("阶段2系统与标准搭建", "标准与培训", "业务执行手册SOP", "胡龙飞", "2026-06-12", "", "系统开发上线"),
("阶段3人员与审核入驻", "团队组建", "医学审核人员到位", "胡龙飞", "2026-06-15", "", "审核人员招聘"),
("阶段3人员与审核入驻", "团队组建", "视频制作人员到位", "胡龙飞", "2026-06-18", "", "项目经理招聘"),
("阶段4供应链与制作", "供应商准入", "准入拍摄/剪辑/主持人", "胡龙飞/侯亚凤", "2026-06-18", "", ""),
("阶段2系统与标准搭建", "脚本生产及审核", "生产脚本", "军营", "2026-06-12", "脚本目前生产比较机械,需要提前准备", "细分标签领域完成"),
]
for phase, milestone, task, owner, due_date, blockers, notes in tasks_seed:
_exec(conn,
"INSERT INTO project_tasks (project_id,phase,milestone,task,owner,due_date,blockers,notes) VALUES (?,?,?,?,?,?,?,?)",
(1, phase, milestone, task, owner, due_date, blockers, notes),
)
conn.commit()
finally:
conn.close()

View File

@@ -3,7 +3,7 @@
def migrate_create_tables():
"""确保所有业务表存在(幂等)"""
from flask_app import db, _exec, mysql, logger
from db import db, _exec, mysql, logger
conn = db()
try:

612
backend/routes.py Normal file
View File

@@ -0,0 +1,612 @@
# 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", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data"]),
}
# ---------- 鉴权装饰器 ----------
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]
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/me")
def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
tenants = session.get("tenants", [])
if "总工作台" not in tenants:
tenants = ["总工作台"] + 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():
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
allowed = session.get("tenants", [])
if "总工作台" not in allowed:
allowed = ["总工作台"] + allowed
if tenant not in allowed:
tenant = allowed[0]
conn = db()
try:
# 总工作台:聚合所有工作台的首页数据
if tenant == "总工作台":
real_tenants = [t for t in allowed if t != "总工作台"]
all_metrics = []
all_monthly = []
all_recent = []
for t in real_tenants:
t_pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [t])
t_ops = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [t]))
t_sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [t]))
t_products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [t]))
t_proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [t]))
t_signed_pfs = [x for x in t_pfs if x["status"] == "已签约"]
def t_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}
t_bm = {pf["id"]: t_parse_budget(pf) for pf in t_pfs}
def t_sum_budget(field, months_range):
total = 0
for pf in t_pfs:
bm = t_bm.get(pf["id"], {})
for m in months_range:
b = bm.get(f"2026_{m:02d}")
if b:
total += float(b.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)
_q_months = [f"2026-{m:02d}" for m in _q_range]
all_metrics.append({
"total_projects": len(t_signed_pfs),
"total_proposals": len(t_ops),
"total_products": len(t_proposals),
"upcoming_products": len(t_products),
"signed_amount": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
"signed_annual": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
"signed_q2": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months),
"signed_month": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_now_month:02d}"),
"revenue_annual": t_sum_budget("rev", range(1, 13)),
"revenue_q2": t_sum_budget("rev", _q_range),
"monthly_revenue": t_sum_budget("rev", [_now_month]),
"gross_annual": t_sum_budget("gross", range(1, 13)),
"gross_q2": t_sum_budget("gross", _q_range),
"monthly_net_profit": t_sum_budget("gross", [_now_month]),
"payment_annual": t_sum_budget("payment", range(1, 13)),
"payment_q2": t_sum_budget("payment", _q_range),
"payment_month": t_sum_budget("payment", [_now_month]),
"cost_annual": t_sum_budget("cost", range(1, 13)),
"cost_q2": t_sum_budget("cost", _q_range),
"cost_month": t_sum_budget("cost", [_now_month]),
})
all_monthly.append(monthly_finance(conn, t))
all_recent.extend(rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 4", [t]))
agg = {}
for key in ["total_projects","total_proposals","total_products","upcoming_products","signed_amount","signed_annual","signed_q2","signed_month","revenue_annual","revenue_q2","monthly_revenue","gross_annual","gross_q2","monthly_net_profit","payment_annual","payment_q2","payment_month","cost_annual","cost_q2","cost_month"]:
agg[key] = sum(m.get(key, 0) for m in all_metrics)
merged_monthly = []
for i in range(12):
m = {"month": all_monthly[0][i]["month"] if all_monthly and len(all_monthly[0]) > i else f"2026-{i+1:02d}"}
for field in ["revenue","gross","payment","cost","sign"]:
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
merged_monthly.append(m)
summary = {
"project_name": "总工作台",
"metrics": agg,
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
"risks": [],
}
return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "tenant": tenant, "tenants": allowed})
def q(sql, *args):
return rows(conn, sql, args)
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", tenant))
products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant))
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
signed_pfs = [x for x in pfs if x["status"] == "已签约"]
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 sum_budget(field, months_range):
total = 0
for pf, bm in budget_maps:
for m in months_range:
b = bm.get(f"2026_{m:02d}")
if b:
total += float(b.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)
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])
payment_annual = sum_budget("payment", range(1, 13))
cost_annual = sum_budget("cost", range(1, 13))
payment_q2 = sum_budget("payment", _q_range)
cost_q2 = sum_budget("cost", _q_range)
payment_month = sum_budget("payment", [_now_month])
cost_month = sum_budget("cost", [_now_month])
def pf_status_sum(status):
return sum(x["sign_amount"] or 0 for x in pfs if x["status"] == status)
signed_amount = pf_status_sum("已签约")
signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约")
_q_months = [f"2026-{m:02d}" for m in _q_range]
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months)
signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_now_month:02d}")
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": "科普(慰心斋)",
"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,
"pipeline_amount": pipeline_amount,
"revenue_annual": rev_annual,
"revenue_q2": rev_q2,
"gross_annual": gross_annual,
"gross_q2": gross_q2,
"payment_annual": payment_annual,
"payment_q2": payment_q2,
"payment_month": payment_month,
"cost_annual": cost_annual,
"cost_q2": cost_q2,
"cost_month": cost_month,
"signed_not_executed": signed_not_executed,
},
"recent": q("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, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "tenant": tenant, "tenants": allowed})
finally:
conn.close()
# ---------- 通用 CRUD ----------
@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>", 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()
# ---------- health ----------
@bp.route("/api/health")
def health():
return jsonify({"ok": True, "service": "opc-manager"})

View File

@@ -201,7 +201,7 @@ function renderFinance() {
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid;
rows.push(`<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center text-slate-500">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === '已签约' ? badge('已签约') : pf.status === '流程中' ? badge('流程中','blue') : badge('待签约','amber')}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
});
return card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">月度视图 <span class="text-slate-400 font-normal">(${allPfs.length} 项目)</span></h3></div><div class="flex items-center justify-between mb-3"><div class="flex gap-2">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><label class="inline-flex items-center gap-1 text-sm text-slate-500 cursor-pointer relative"><i data-lucide="calendar" style="width:14px;height:14px"></i><select onchange="state.finMonth=this.value;renderFinance()" class="bg-transparent border-0 text-sm text-slate-600 font-medium cursor-pointer pr-5" style="appearance:none;-webkit-appearance:none;-moz-appearance:none;outline:none">${monthOpts}</select><i data-lucide="chevron-down" style="width:14px;height:14px;position:absolute;right:0;top:50%;transform:translateY(-50%);pointer-events:none"></i></label></div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">类型</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="10" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="3">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
return card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">月度视图 <span class="text-slate-400 font-normal">(${allPfs.length} 项目)</span></h3></div><div class="flex items-center justify-between mb-3"><div class="flex gap-2">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><label class="inline-flex items-center gap-1 text-sm text-slate-500 cursor-pointer relative"><i data-lucide="calendar" style="width:14px;height:14px"></i><select onchange="state.finMonth=this.value;renderFinance()" class="bg-transparent border-0 text-sm text-slate-600 font-medium cursor-pointer" style="appearance:none;-webkit-appearance:none;-moz-appearance:none;outline:none;background-image:none;padding-right:4px">${monthOpts}</select></label></div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">类型</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="10" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="3">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
})() : (() => {
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}