feat: 项目流水拆分为确收与回款+费用明细两Tab + 数据迁移

后端:
- 新增 expense_data TEXT 列(columns.py迁移)
- 新增 data_split 迁移:旧 budget_data cost/paid → expense_data(幂等)
- sum_budget cost/paid → sum_expense cost/paid(routes.py/helpers.py)
- TABLES 配置加入 expense_data

前端:
- 项目详情弹窗Tab:基本信息→确收与回款→费用明细→执行信息→任务管理→活动
- 确收与回款Tab:月份/确收/毛利/回款/备注
- 费用明细Tab:月份/费用类型/应付/已付/备注
- 项目表格三视图(月/季/总)cost/paid来源切换为expense_data
- 保存逻辑分两路: budget_data(rev/gross/payment)+expense_data(cost/paid)
This commit is contained in:
mac
2026-07-06 21:34:48 +08:00
parent dbc98e4693
commit f44f7eab9e
6 changed files with 275 additions and 89 deletions

View File

@@ -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),

View File

@@ -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()

View File

@@ -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:

View File

@@ -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()

View File

@@ -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])

View File

@@ -121,7 +121,8 @@ function renderFinance() {
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>项目流水</button>
<button class="finance-tab" data-tab="revpay" onclick="switchFinanceTab('revpay')"><i data-lucide="dollar-sign" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>确收与回款</button>
<button class="finance-tab" data-tab="cost" onclick="switchFinanceTab('cost')"><i data-lucide="receipt" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>费用明细</button>
<button class="finance-tab" data-tab="exec" onclick="switchFinanceTab('exec')"><i data-lucide="play-circle" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>执行信息</button>
<button class="finance-tab" data-tab="tasks" onclick="switchFinanceTab('tasks')"><i data-lucide="list-checks" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>任务管理</button>
<button class="finance-tab" data-tab="activity" onclick="switchFinanceTab('activity')"><i data-lucide="message-square" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>活动与跟进</button>
@@ -163,34 +164,43 @@ function renderFinance() {
</div>
</div>
</div>
<div id="financeTabBudget" class="hidden">
<div class="grid grid-cols-6 gap-3 mb-4" id="budgetSummary">
<div id="financeTabRevpay" class="hidden">
<div class="grid grid-cols-3 gap-3 mb-4" id="revpaySummary">
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
<p class="text-xs text-blue-600 font-medium">总确收</p>
<p class="text-lg font-bold text-blue-700" id="budgetTotalRev">¥0</p>
<p class="text-lg font-bold text-blue-700" id="revpayTotalRev">¥0</p>
</div>
<div class="bg-green-50 rounded-lg p-3 text-center border border-green-100">
<p class="text-xs text-green-600 font-medium">总毛利</p>
<p class="text-lg font-bold text-green-700" id="budgetTotalGross">¥0</p>
<p class="text-lg font-bold text-green-700" id="revpayTotalGross">¥0</p>
</div>
<div class="bg-amber-50 rounded-lg p-3 text-center border border-amber-100" id="budgetTotalPaymentCard">
<div class="bg-amber-50 rounded-lg p-3 text-center border border-amber-100">
<p class="text-xs text-amber-600 font-medium">总回款</p>
<p class="text-lg font-bold text-amber-700" id="budgetTotalPayment">¥0</p>
<p class="text-lg font-bold text-amber-700" id="revpayTotalPayment">¥0</p>
</div>
<div class="bg-rose-50 rounded-lg p-3 text-center border border-rose-100" id="budgetTotalCostCard">
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="revpayTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addRevpayRow()"><i data-lucide="plus"></i></button>
</div>
<div id="financeTabCost" class="hidden">
<div class="grid grid-cols-2 gap-3 mb-4" id="costSummary">
<div class="bg-rose-50 rounded-lg p-3 text-center border border-rose-100">
<p class="text-xs text-rose-600 font-medium">总应付</p>
<p class="text-lg font-bold text-rose-700" id="budgetTotalCost">¥0</p>
<p class="text-lg font-bold text-rose-700" id="costTotalCost">¥0</p>
</div>
<div class="bg-purple-50 rounded-lg p-3 text-center border border-purple-100">
<p class="text-xs text-purple-600 font-medium">总已付</p>
<p class="text-lg font-bold text-purple-700" id="budgetTotalPaid">¥0</p>
<p class="text-lg font-bold text-purple-700" id="costTotalPaid">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="budgetTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="budgetTbody"></tbody>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="costTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="costTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i></button>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i></button>
</div>
<div id="financeTabTasks" class="hidden">
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
@@ -293,12 +303,14 @@ function renderFinance() {
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
const b = bd.find(x => (x.month || "") === selMonth) || {};
const e = ed.find(x => (x.month || "") === selMonth) || {};
const rev = Math.round(parseFloat(b.rev || 0) || 0);
const payment = Math.round(parseFloat(b.payment || 0) || 0);
const cost = Math.round(parseFloat(b.cost || 0) || 0);
const paid = Math.round(parseFloat(b.paid || 0) || 0);
const gross = Math.round(parseFloat(b.gross || 0) || 0);
const cost = Math.round(parseFloat(e.cost || 0) || 0);
const paid = Math.round(parseFloat(e.paid || 0) || 0);
if (!rev && !payment && !cost && !paid && !gross) return;
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
rows.push(tdRow(pf, rev, payment, cost, paid, gross));
@@ -323,14 +335,19 @@ function renderFinance() {
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total;
};
const sumExpense = (pf, field) => {
let total = 0;
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total;
};
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
const cost = sumBudget(pf, "cost");
const paid = sumBudget(pf, "paid");
const gross = sumBudget(pf, "gross");
const cost = sumExpense(pf, "cost");
const paid = sumExpense(pf, "paid");
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
rows.push(tdRow(pf, rev, payment, cost, paid, gross));
});
@@ -342,9 +359,11 @@ function renderFinance() {
// 总视图
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; cost += parseFloat(b.cost || 0) || 0; });
const paid = parseFloat(pf.total_paid) || 0;
let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; });
let cost = 0, paid = 0;
expense.forEach(e => { cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 0; });
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
@@ -371,60 +390,88 @@ window.openFinanceModal = () => {
if (dept) dept.value = state.tenant;
const pfIdInput = form.querySelector('[name="pf_id"]');
if (!pfIdInput || !pfIdInput.value) {
initBudgetTable(null);
initRevpayTable(null);
initCostTable(null);
initTaskTable(null);
document.querySelector("#financeDeleteBtn").classList.add("hidden");
}
modal.classList.remove("hidden");
};
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '', paid = '') => {
const tbody = document.querySelector("#budgetTbody");
window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `<td><select name="budget_month[]" class="form-ctrl form-ctrl-sm w-full" style="min-width:140px" onchange="updateBudgetSummary()">${monthOptions(month)}</select></td>
<td><input name="budget_rev[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${rev}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_gross[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${gross}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="updateBudgetSummary()"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();updateBudgetSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
row.innerHTML = `<td><select name="budget_month[]" class="form-ctrl form-ctrl-sm w-full" style="min-width:140px" onchange="updateRevpaySummary()">${monthOptions(month)}</select></td>
<td><input name="budget_rev[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${rev}" oninput="updateRevpaySummary()"></td>
<td><input name="budget_gross[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${gross}" oninput="updateRevpaySummary()"></td>
<td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateRevpaySummary()"></td>
<td><input name="budget_rev_note[]" class="form-ctrl form-ctrl-sm w-full" placeholder="备注" value="${rev_note}"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();updateRevpaySummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
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 = `<td><select name="expense_month[]" class="form-ctrl form-ctrl-sm w-full" style="min-width:140px" onchange="updateCostSummary()">${monthOptions(month)}</select></td>
<td><input name="expense_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="费用类型" value="${expense_type}"></td>
<td><input name="expense_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateCostSummary()"></td>
<td><input name="expense_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="updateCostSummary()"></td>
<td><input name="expense_note[]" class="form-ctrl form-ctrl-sm w-full" placeholder="备注" value="${exp_note}"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();updateCostSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
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[]"]');