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