diff --git a/backend/helpers.py b/backend/helpers.py index a04335f..87fddaa 100644 --- a/backend/helpers.py +++ b/backend/helpers.py @@ -63,10 +63,11 @@ def attach_common(conn, resource, 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='已签约'", + "SELECT sign_amount, sign_month, status, budget_data, expense_data FROM project_finances WHERE tenant=? AND status='已签约'", [tenant]) parsed_budgets = [] + expense_maps = {} for pf in pfs: try: budget = json.loads(pf.get("budget_data") or "[]") @@ -79,11 +80,23 @@ def monthly_finance(conn, tenant="科普·无界"): "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), - "paid": float(b.get("paid") or 0), } parsed_budgets.append((pf, budget_map)) + # Parse expense_data for cost/paid + try: + ed = json.loads(pf.get("expense_data") or "[]") + except (json.JSONDecodeError, TypeError): + ed = [] + em = {} + for e in ed: + key = (e.get("month") or "").replace("-", "_") + em[key] = { + "cost": float(e.get("cost") or 0), + "paid": float(e.get("paid") or 0), + } + expense_maps[pf.get("id", id(pf))] = em + data = [] for month in months: key = month.replace("-", "_") @@ -96,8 +109,11 @@ def monthly_finance(conn, tenant="科普·无界"): revenue += b["rev"] gross += b["gross"] payment += b["payment"] - cost += b["cost"] - paid += b["paid"] + em = expense_maps.get(pf.get("id", id(pf)), {}) + e = em.get(key) + if e: + cost += e["cost"] + paid += e["paid"] data.append({ "month": month, "revenue": round(revenue, 2), diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py index b37f4f9..77cd8ae 100644 --- a/backend/migrations/__init__.py +++ b/backend/migrations/__init__.py @@ -18,6 +18,7 @@ def run_migrations(): from migrations.tables import migrate_create_tables from migrations.columns import migrate_add_columns from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard + from migrations.data_split import migrate_data_split from migrations.seed import migrate_seed_users, migrate_seed_demo_data migrate_create_tables() @@ -26,5 +27,6 @@ def run_migrations(): migrate_rename_tenant() migrate_drop_product_fields() migrate_fix_service_fee_standard() + migrate_data_split() migrate_seed_users() migrate_seed_demo_data() diff --git a/backend/migrations/columns.py b/backend/migrations/columns.py index 5600acf..a6946fc 100644 --- a/backend/migrations/columns.py +++ b/backend/migrations/columns.py @@ -106,6 +106,10 @@ def migrate_add_columns(): _add_column_if_missing(conn, "expense_records", "incurred_amount", "ALTER TABLE expense_records ADD COLUMN incurred_amount DOUBLE NOT NULL DEFAULT 0") + # project_finances 费用明细 JSON 数据 + _add_column_if_missing(conn, "project_finances", "expense_data", + "ALTER TABLE project_finances ADD COLUMN expense_data TEXT") + conn.commit() print("[migrate] 加列迁移完成") finally: diff --git a/backend/migrations/data_split.py b/backend/migrations/data_split.py new file mode 100644 index 0000000..06b0b42 --- /dev/null +++ b/backend/migrations/data_split.py @@ -0,0 +1,56 @@ +"""migrations/data_split.py — 预算数据拆分迁移:budget_data → expense_data + +旧结构:budget_data = [{month, rev, gross, payment, cost, paid}] +新结构:expense_data = [{month, expense_type, cost, paid, exp_note}] + +部署时自动:检测未拆分的旧数据,将 cost/paid 从 budget_data 迁移到 expense_data。 +幂等:只处理 expense_data 为空的记录。 +""" + + +def migrate_data_split(): + """一次性数据拆分迁移""" + from db import db, _exec + import json + + conn = db() + try: + # 查询未迁移的记录 + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT id, budget_data, expense_data FROM project_finances " + "WHERE (expense_data IS NULL OR expense_data = '' OR expense_data = '[]') " + "AND budget_data IS NOT NULL AND budget_data != '' AND budget_data != '[]'" + ) + rows = cur.fetchall() + cur.close() + + migrated = 0 + for row in rows: + try: + budget = json.loads(row.get("budget_data") or "[]") + except (json.JSONDecodeError, TypeError): + continue + + expense_rows = [] + for b in budget: + expense_rows.append({ + "month": b.get("month", ""), + "expense_type": "", + "cost": float(b.get("cost") or 0), + "paid": float(b.get("paid") or 0), + "exp_note": "", + }) + + if expense_rows: + _exec( + conn, + "UPDATE project_finances SET expense_data = ? WHERE id = ?", + (json.dumps(expense_rows, ensure_ascii=False), row["id"]), + ) + migrated += 1 + + conn.commit() + print(f"[migrate] 数据拆分迁移完成:{migrated} 条记录已从 budget_data 提取 cost/paid 到 expense_data") + finally: + conn.close() diff --git a/backend/routes.py b/backend/routes.py index e010991..bbd4b1f 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -23,7 +23,7 @@ TABLES = { "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", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]), + "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", "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", "tenant"]), } @@ -266,6 +266,26 @@ def bootstrap(): if b: total += float(b.get(field) or 0) return total + + def t_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} + + t_em = {pf["id"]: t_parse_expense(pf) for pf in t_pfs} + + def t_sum_expense(field, months_range): + total = 0 + for pf in t_pfs: + em = t_em.get(pf["id"], {}) + for m in months_range: + e = em.get(f"2026_{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) @@ -300,11 +320,11 @@ def bootstrap(): "payment_month": t_sum_budget("payment", [_now_month]), "payment_prev_q": t_sum_budget("payment", _prev_q_range), "payment_prev_month": t_sum_budget("payment", [_prev_month]) if _prev_month else 0, - "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]), - "cost_prev_q": t_sum_budget("cost", _prev_q_range), - "cost_prev_month": t_sum_budget("cost", [_prev_month]) if _prev_month else 0, + "cost_annual": t_sum_expense("cost", range(1, 13)), + "cost_q2": t_sum_expense("cost", _q_range), + "cost_month": t_sum_expense("cost", [_now_month]), + "cost_prev_q": t_sum_expense("cost", _prev_q_range), + "cost_prev_month": t_sum_expense("cost", [_prev_month]) if _prev_month else 0, }) # 费用汇总(从 expense_records 表计算) def t_expense_sum(m_range): @@ -322,11 +342,11 @@ def bootstrap(): all_metrics[-1]["expense_month"] = t_expense_sum([_now_month]) all_metrics[-1]["expense_prev_q"] = t_expense_sum(_prev_q_range) all_metrics[-1]["expense_prev_month"] = t_expense_sum([_prev_month]) if _prev_month else 0 - all_metrics[-1]["paid_annual"] = t_sum_budget("paid", range(1, 13)) - all_metrics[-1]["paid_q2"] = t_sum_budget("paid", _q_range) - all_metrics[-1]["paid_month"] = t_sum_budget("paid", [_now_month]) - all_metrics[-1]["paid_prev_q"] = t_sum_budget("paid", _prev_q_range) - all_metrics[-1]["paid_prev_month"] = t_sum_budget("paid", [_prev_month]) if _prev_month else 0 + all_metrics[-1]["paid_annual"] = t_sum_expense("paid", range(1, 13)) + all_metrics[-1]["paid_q2"] = t_sum_expense("paid", _q_range) + all_metrics[-1]["paid_month"] = t_sum_expense("paid", [_now_month]) + all_metrics[-1]["paid_prev_q"] = t_sum_expense("paid", _prev_q_range) + all_metrics[-1]["paid_prev_month"] = t_sum_expense("paid", [_prev_month]) if _prev_month else 0 all_metrics[-1]["proj_expense_annual"] = t_sum_budget("expense", range(1, 13)) all_metrics[-1]["proj_expense_q2"] = t_sum_budget("expense", _q_range) all_metrics[-1]["proj_expense_month"] = t_sum_budget("expense", [_now_month]) @@ -382,6 +402,15 @@ def bootstrap(): 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: @@ -391,6 +420,15 @@ def bootstrap(): 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"2026_{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) @@ -408,15 +446,15 @@ def bootstrap(): 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_budget("cost", range(1, 13)) + cost_annual = sum_expense("cost", range(1, 13)) payment_q2 = sum_budget("payment", _q_range) - cost_q2 = sum_budget("cost", _q_range) + cost_q2 = sum_expense("cost", _q_range) payment_month = sum_budget("payment", [_now_month]) - cost_month = sum_budget("cost", [_now_month]) + cost_month = sum_expense("cost", [_now_month]) payment_prev_q = sum_budget("payment", _prev_q_range) - cost_prev_q = sum_budget("cost", _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_budget("cost", [_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: @@ -432,11 +470,11 @@ def bootstrap(): 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 - paid_annual = sum_budget("paid", range(1, 13)) - paid_q2 = sum_budget("paid", _q_range) - paid_month = sum_budget("paid", [_now_month]) - paid_prev_q = sum_budget("paid", _prev_q_range) - paid_prev_month = sum_budget("paid", [_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]) diff --git a/static/modules/finance.js b/static/modules/finance.js index cdfa79a..54a2d4c 100644 --- a/static/modules/finance.js +++ b/static/modules/finance.js @@ -121,7 +121,8 @@ function renderFinance() { -