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() {
填写项目财务信息与月度预算
总确收
-¥0
+¥0
总毛利
-¥0
+¥0
总回款
-¥0
+¥0
| 月份 | 确收 | 毛利 | 回款 | 备注 |
|---|
总应付
-¥0
+¥0
总已付
-¥0
+¥0
| 月份 | 确收 | 毛利 | 回款 | 应付 | 已付 |
|---|
| 月份 | 费用类型 | 应付 | 已付 | 备注 |
|---|
| - | - | - | - | - | - | `; + row.innerHTML = ` | + | + | + | + | + | `; tbody.appendChild(row); if (window.lucide) window.lucide.createIcons(); }; -window.updateBudgetSummary = () => { - const revEl = document.querySelector("#budgetTotalRev"); - const grossEl = document.querySelector("#budgetTotalGross"); - const paymentEl = document.querySelector("#budgetTotalPayment"); - const costEl = document.querySelector("#budgetTotalCost"); - const paidEl = document.querySelector("#budgetTotalPaid"); - if (!revEl || !grossEl) return; +window.addCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => { + const tbody = document.querySelector("#costTbody"); + if (!tbody) return; + const row = document.createElement("tr"); + row.innerHTML = ` | + | + | + | + | + | `; + tbody.appendChild(row); + if (window.lucide) window.lucide.createIcons(); +}; + +window.updateRevpaySummary = () => { + const revEl = document.querySelector("#revpayTotalRev"); + const grossEl = document.querySelector("#revpayTotalGross"); + const paymentEl = document.querySelector("#revpayTotalPayment"); + if (!revEl || !paymentEl) return; const revInputs = document.querySelectorAll('[name="budget_rev[]"]'); const grossInputs = document.querySelectorAll('[name="budget_gross[]"]'); const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]'); - const costInputs = document.querySelectorAll('[name="budget_cost[]"]'); - const paidInputs = document.querySelectorAll('[name="budget_paid[]"]'); - let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0, totalPaid = 0; + let totalRev = 0, totalGross = 0, totalPayment = 0; revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; }); grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; }); paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; }); - costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; }); - paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; }); revEl.textContent = money(totalRev); - grossEl.textContent = money(totalGross); - if (paymentEl) paymentEl.textContent = money(totalPayment); - if (costEl) costEl.textContent = money(totalCost); - if (paidEl) paidEl.textContent = money(totalPaid); + if (grossEl) grossEl.textContent = money(totalGross); + paymentEl.textContent = money(totalPayment); }; -window.initBudgetTable = (budgetData) => { - const tbody = document.querySelector("#budgetTbody"); +window.updateCostSummary = () => { + const costEl = document.querySelector("#costTotalCost"); + const paidEl = document.querySelector("#costTotalPaid"); + if (!costEl || !paidEl) return; + const costInputs = document.querySelectorAll('[name="expense_cost[]"]'); + const paidInputs = document.querySelectorAll('[name="expense_paid[]"]'); + let totalCost = 0, totalPaid = 0; + costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; }); + paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; }); + costEl.textContent = money(totalCost); + paidEl.textContent = money(totalPaid); +}; + +window.initRevpayTable = (budgetData) => { + const tbody = document.querySelector("#revpayTbody"); if (!tbody) return; tbody.innerHTML = ""; const rows = budgetData || []; - rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '', r.paid || '')); - setTimeout(() => updateBudgetSummary(), 50); + rows.forEach(r => addRevpayRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.rev_note || '')); + setTimeout(() => updateRevpaySummary(), 50); +}; + +window.initCostTable = (expenseData) => { + const tbody = document.querySelector("#costTbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const rows = expenseData || []; + rows.forEach(r => addCostRow(r.month || '', r.expense_type || '', r.cost || '', r.paid || '', r.exp_note || '')); + setTimeout(() => updateCostSummary(), 50); }; // ---------- 任务管理 tab ---------- @@ -557,7 +604,8 @@ window.editPfSignMonth = (event, pfId) => { window.switchFinanceTab = (tab) => { document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab)); document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info"); - document.querySelector("#financeTabBudget").classList.toggle("hidden", tab !== "budget"); + document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay"); + document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost"); document.querySelector("#financeTabExec").classList.toggle("hidden", tab !== "exec"); document.querySelector("#financeTabTasks").classList.toggle("hidden", tab !== "tasks"); document.querySelector("#financeTabActivity").classList.toggle("hidden", tab !== "activity"); @@ -662,7 +710,10 @@ window.openPfEditModal = (pfId) => { setVal("other_info", pf.other_info); let budgetData = []; try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; } - initBudgetTable(budgetData.length ? budgetData : null); + initRevpayTable(budgetData.length ? budgetData : null); + let expenseData = []; + try { expenseData = JSON.parse(pf.expense_data || "[]"); } catch (e) { expenseData = []; } + initCostTable(expenseData.length ? expenseData : null); let taskData = []; try { taskData = JSON.parse(pf.task_data || "[]"); } catch (e) { taskData = []; } initTaskTable(taskData.length ? taskData : null); @@ -690,31 +741,50 @@ window.createFinance = async (event) => { const revs = form.querySelectorAll('[name="budget_rev[]"]'); const grosses = form.querySelectorAll('[name="budget_gross[]"]'); const payments = form.querySelectorAll('[name="budget_payment[]"]'); - const costs = form.querySelectorAll('[name="budget_cost[]"]'); - const paids = form.querySelectorAll('[name="budget_paid[]"]'); + const revNotes = form.querySelectorAll('[name="budget_rev_note[]"]'); const budgetRows = []; - let totalRev = 0, totalGross = 0, totalPaidFromBudget = 0; + let totalRev = 0, totalGross = 0, totalPayment = 0; for (let i = 0; i < months.length; i++) { const m = months[i].value.trim(); if (!m) continue; const rev = parseFloat(revs[i].value) || 0; const gross = parseFloat(grosses[i].value) || 0; const payment = parseFloat(payments[i].value) || 0; - const cost = parseFloat(costs[i].value) || 0; - const paid = parseFloat(paids[i].value) || 0; - budgetRows.push({ month: m, rev, gross, payment, cost, paid }); + const rev_note = (revNotes[i] ? revNotes[i].value : '') || ''; + budgetRows.push({ month: m, rev, gross, payment, rev_note }); totalRev += rev; totalGross += gross; - totalPaidFromBudget += paid; + totalPayment += payment; } data.budget_data = JSON.stringify(budgetRows); data.total_rev = totalRev; data.total_gross = totalGross; - data.total_paid = totalPaidFromBudget; - let totalPayment = 0, totalCost = 0; - for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; } data.total_payment = totalPayment; + + // 费用明细数据 + const expMonths = form.querySelectorAll('[name="expense_month[]"]'); + const expTypes = form.querySelectorAll('[name="expense_type[]"]'); + const expCosts = form.querySelectorAll('[name="expense_cost[]"]'); + const expPaids = form.querySelectorAll('[name="expense_paid[]"]'); + const expNotes = form.querySelectorAll('[name="expense_note[]"]'); + const expenseRows = []; + let totalCost = 0, totalPaid = 0; + for (let i = 0; i < expMonths.length; i++) { + const m = expMonths[i].value.trim(); + if (!m) continue; + const expense_type = (expTypes[i] ? expTypes[i].value : '') || ''; + const cost = parseFloat(expCosts[i].value) || 0; + const paid = parseFloat(expPaids[i].value) || 0; + const exp_note = (expNotes[i] ? expNotes[i].value : '') || ''; + expenseRows.push({ month: m, expense_type, cost, paid, exp_note }); + totalCost += cost; + totalPaid += paid; + } + data.expense_data = JSON.stringify(expenseRows); + data.total_paid = totalPaid; data.total_cost = totalCost; + for (const r of budgetRows) { totalPayment += r.payment; } + data.total_payment = totalPayment; // 收集任务管理数据 const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]'); const taskCountInputs = form.querySelectorAll('[name="task_count[]"]'); |