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:
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
56
backend/migrations/data_split.py
Normal file
56
backend/migrations/data_split.py
Normal 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()
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user