Compare commits

...

8 Commits

Author SHA1 Message Date
mac
356c129668 feat: 总览工作台新增损益/现金流/项目经营详细报表
- 预算总览和发生总览各新增3个tab: 损益详细报表、现金流详细报表、项目经营详细报表
- 12个月卡片布局, 按部门(学术/科普/科研/医患/MCN)展示矩阵
- 后端新增6个聚合API: dept-matrix/cf-matrix/project-matrix x 2
- tab改为finance-tab风格, 与经营月报一致
- 修复项目报表函数未注册导致的加载失败
- 版本号 v1.2.0.26
2026-07-11 14:36:00 +08:00
mac
94b9f25938 fix: 总览工作台tab显示问题
- 移除hidden class避免CSS覆盖style.display
- 总览模式下隐藏部门和预算/已发生tab
- 只显示预算总览和发生总览
- 版本号 v1.2.0.25
2026-07-11 12:31:50 +08:00
mac
536b165a9b feat: 新增总览工作台(仅管理员)
- 新增总览工作台,聚合学术/科普/科研/医患/MCN数据
- 4个聚合API: 预算/发生月报季报
- 新增预算总览/发生总览侧边栏tab
- 月报/季报视图切换
- 总览仅管理员可见
- 版本号 v1.2.0.24
2026-07-11 12:26:29 +08:00
mac
75227c51c3 fix: 已发生模块经营季报卡片顺序修正
- 修正为: 部门损益情况→部门现金流→项目经营详情
- 与预算模块保持一致的顺序
- 版本号 v1.2.0.23
2026-07-11 11:57:42 +08:00
mac
f5dd152ca4 feat: 季报环比+指标改名
- 部门损益/部门现金流季报Q2/Q3/Q4后增加环比列
- 部门损益卡片行改名: 毛利→项目毛利, 平台费用→部门费用
- 部门现金流卡片行改名: 平台费用→部门现金流, 部门现金流→经营现金流
- 版本号 v1.2.0.22
2026-07-11 11:50:18 +08:00
mac
2c20900e75 feat: 经营月报卡片改名+样式优化+切换工作台默认经营月报
- 业务线经营情况→部门损益情况,副标题改为部门利润公式
- 业务线现金流→部门现金流,副标题改为部门现金流公式
- 财务月度/季度概览→项目经营详情
- 3个卡片主副标题加大+间距对齐+颜色加深
- 签约月份/添加月份/平台费用月份默认当前月份
- 切换工作台默认显示经营月报
- 版本号 v1.2.0.21
2026-07-11 11:44:04 +08:00
mac
774cdec9e3 feat: 预算模块成本→当期成本,付款→当期流出,去除付款率
- 成本列/卡片改名为当期成本
- 付款列/卡片改名为当期流出
- 去除付款率列和卡片
- colspan和theadCols同步调整
- 展开明细表头同步改名
- 版本号 v1.2.0.20
2026-07-10 23:47:54 +08:00
mac
19fa00e220 feat: 季度/月度标签再次点击取消选中
- setPlanQuarter/setPlanMonth增加toggle逻辑
- setFinQuarter/setFinMonth增加toggle逻辑
- 再次点击已选中的季度/月份回到overview总视图
- 版本号 v1.2.0.19
2026-07-10 23:22:12 +08:00
10 changed files with 1174 additions and 69 deletions

View File

@@ -14,7 +14,7 @@ from helpers import add_file_index, attach_common, monthly_finance
bp = Blueprint("api", __name__)
ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
ALL_TENANTS = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
@@ -75,7 +75,7 @@ def auth_login():
session["display_name"] = user["display_name"]
session["role"] = user["role"]
if user["role"] == "admin":
session["tenants"] = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
session["tenants"] = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut]
@@ -128,7 +128,6 @@ def auth_me():
tenants = session.get("tenants", [])
tenants = [t if t != "总工作台" else "科普·无界" for t in tenants]
tenants = [t if t != "学会·无界" else "学术·无界" for t in tenants]
tenants = [t for t in tenants if t != "总览" and t != "总工作台"]
return jsonify({
"logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
@@ -264,7 +263,6 @@ def bootstrap():
allowed = session.get("tenants", [])
allowed = [t if t != "总工作台" else "科普·无界" for t in allowed]
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
allowed = [t for t in allowed if t != "总览" and t != "总工作台"]
if tenant not in allowed:
tenant = allowed[0] if allowed else ""
conn = db()
@@ -726,8 +724,535 @@ def delete_file(file_id):
conn.close()
# ---------- 总览部门损益矩阵 API ----------
@bp.route("/api/overview/dept-matrix")
@login_required
def overview_dept_matrix():
"""返回各工作台的损益数据矩阵(按年/季/月筛选)"""
conn = db()
_year = int(request.args.get("year", date.today().year))
view = request.args.get("view", "monthly")
# 12个月数组
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_gross": [0]*12, "dept_expense": [0]*12, "profit": [0]*12}}
for tenant in tenants:
# 项目毛利(从 budget_data JSON
pfs = rows(conn, "SELECT budget_data FROM plan_finances WHERE tenant=?", [tenant])
gross = [0]*12
for pf in pfs:
try:
bd = json.loads(pf.get("budget_data") or "[]")
except:
bd = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
gross[i] += float(b.get("gross") or 0)
# 部门费用(从 plan_expense_records
expense = [0]*12
exps = rows(conn, "SELECT expense_month, amount FROM plan_expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
for e in exps:
m = (e.get("expense_month") or "")[:7]
if m in months:
i = months.index(m)
expense[i] += float(e.get("amount") or 0)
profit = [gross[i] - expense[i] for i in range(12)]
result["data"][tenant] = {
"project_gross": gross,
"dept_expense": expense,
"profit": profit
}
for i in range(12):
result["total"]["project_gross"][i] += gross[i]
result["total"]["dept_expense"][i] += expense[i]
result["total"]["profit"][i] += profit[i]
# 按视图聚合
if view == "quarterly":
def to_quarterly(arr12):
qr = [(0,1,2),(3,4,5),(6,7,8),(9,10,11)]
return [arr12[q[0]]+arr12[q[1]]+arr12[q[2]] for q in qr]
result["months"] = ["Q1","Q2","Q3","Q4"]
for t in tenants:
for key in ["project_gross","dept_expense","profit"]:
result["data"][t][key] = to_quarterly(result["data"][t][key])
for key in ["project_gross","dept_expense","profit"]:
result["total"][key] = to_quarterly(result["total"][key])
elif view == "monthly":
sel_month = request.args.get("month", "")
if sel_month and sel_month in months:
mi = months.index(sel_month)
result["months"] = [sel_month]
for t in tenants:
for key in ["project_gross","dept_expense","profit"]:
result["data"][t][key] = [result["data"][t][key][mi]]
for key in ["project_gross","dept_expense","profit"]:
result["total"][key] = [result["total"][key][mi]]
# else: yearly (default) — keep all 12 months
# Compute annual totals
result["annual"] = {}
for t in tenants:
d = result["data"][t]
result["annual"][t] = {
"project_gross": sum(d["project_gross"]),
"dept_expense": sum(d["dept_expense"]),
"profit": sum(d["profit"])
}
result["annual"]["total"] = {
"project_gross": sum(result["total"]["project_gross"]),
"dept_expense": sum(result["total"]["dept_expense"]),
"profit": sum(result["total"]["profit"])
}
conn.close()
return jsonify(result)
@bp.route("/api/overview/finance-dept-matrix")
@login_required
def overview_finance_dept_matrix():
"""返回各工作台已发生损益数据矩阵"""
conn = db()
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_gross": [0]*12, "dept_expense": [0]*12, "profit": [0]*12}}
for tenant in tenants:
pfs = rows(conn, "SELECT budget_data FROM project_finances WHERE tenant=?", [tenant])
gross = [0]*12
for pf in pfs:
try:
bd = json.loads(pf.get("budget_data") or "[]")
except:
bd = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
gross[i] += float(b.get("gross") or 0)
expense = [0]*12
exps = rows(conn, "SELECT expense_month, amount FROM expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
for e in exps:
m = (e.get("expense_month") or "")[:7]
if m in months:
i = months.index(m)
expense[i] += float(e.get("amount") or 0)
profit = [gross[i] - expense[i] for i in range(12)]
result["data"][tenant] = {"project_gross": gross, "dept_expense": expense, "profit": profit}
for i in range(12):
result["total"]["project_gross"][i] += gross[i]
result["total"]["dept_expense"][i] += expense[i]
result["total"]["profit"][i] += profit[i]
result["annual"] = {}
for t in tenants:
d = result["data"][t]
result["annual"][t] = {"project_gross": sum(d["project_gross"]), "dept_expense": sum(d["dept_expense"]), "profit": sum(d["profit"])}
result["annual"]["total"] = {"project_gross": sum(result["total"]["project_gross"]), "dept_expense": sum(result["total"]["dept_expense"]), "profit": sum(result["total"]["profit"])}
conn.close()
return jsonify(result)
@bp.route("/api/overview/dept-cf-matrix")
@login_required
def overview_dept_cf_matrix():
"""返回各工作台预算现金流数据矩阵"""
conn = db()
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_cf": [0]*12, "dept_cf": [0]*12, "bl_cf": [0]*12}}
for tenant in tenants:
pfs = rows(conn, "SELECT budget_data, expense_data FROM plan_finances WHERE tenant=?", [tenant])
project_cf = [0]*12
for pf in pfs:
try: bd = json.loads(pf.get("budget_data") or "[]")
except: bd = []
try: ed = json.loads(pf.get("expense_data") or "[]")
except: ed = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
project_cf[i] += float(b.get("payment") or 0)
for e in ed:
m = (e.get("month") or "")[:7]
if m in months:
i = months.index(m)
project_cf[i] -= float(e.get("paid") or 0)
dept_cf = [0]*12
exps = rows(conn, "SELECT expense_month, incurred_amount FROM plan_expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
for e in exps:
m = (e.get("expense_month") or "")[:7]
if m in months:
i = months.index(m)
dept_cf[i] += float(e.get("incurred_amount") or 0)
bl_cf = [project_cf[i] - dept_cf[i] for i in range(12)]
result["data"][tenant] = {"project_cf": project_cf, "dept_cf": dept_cf, "bl_cf": bl_cf}
for i in range(12):
result["total"]["project_cf"][i] += project_cf[i]
result["total"]["dept_cf"][i] += dept_cf[i]
result["total"]["bl_cf"][i] += bl_cf[i]
result["annual"] = {}
for t in tenants:
d = result["data"][t]
result["annual"][t] = {"project_cf": sum(d["project_cf"]), "dept_cf": sum(d["dept_cf"]), "bl_cf": sum(d["bl_cf"])}
result["annual"]["total"] = {"project_cf": sum(result["total"]["project_cf"]), "dept_cf": sum(result["total"]["dept_cf"]), "bl_cf": sum(result["total"]["bl_cf"])}
conn.close()
return jsonify(result)
@bp.route("/api/overview/finance-dept-cf-matrix")
@login_required
def overview_finance_dept_cf_matrix():
"""返回各工作台已发生现金流数据矩阵"""
conn = db()
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_cf": [0]*12, "dept_cf": [0]*12, "bl_cf": [0]*12}}
for tenant in tenants:
pfs = rows(conn, "SELECT budget_data, expense_data FROM project_finances WHERE tenant=?", [tenant])
project_cf = [0]*12
for pf in pfs:
try: bd = json.loads(pf.get("budget_data") or "[]")
except: bd = []
try: ed = json.loads(pf.get("expense_data") or "[]")
except: ed = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
project_cf[i] += float(b.get("payment") or 0)
for e in ed:
m = (e.get("month") or "")[:7]
if m in months:
i = months.index(m)
project_cf[i] -= float(e.get("paid") or 0)
dept_cf = [0]*12
exps = rows(conn, "SELECT expense_month, incurred_amount FROM expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
for e in exps:
m = (e.get("expense_month") or "")[:7]
if m in months:
i = months.index(m)
dept_cf[i] += float(e.get("incurred_amount") or 0)
bl_cf = [project_cf[i] - dept_cf[i] for i in range(12)]
result["data"][tenant] = {"project_cf": project_cf, "dept_cf": dept_cf, "bl_cf": bl_cf}
for i in range(12):
result["total"]["project_cf"][i] += project_cf[i]
result["total"]["dept_cf"][i] += dept_cf[i]
result["total"]["bl_cf"][i] += bl_cf[i]
result["annual"] = {}
for t in tenants:
d = result["data"][t]
result["annual"][t] = {"project_cf": sum(d["project_cf"]), "dept_cf": sum(d["dept_cf"]), "bl_cf": sum(d["bl_cf"])}
result["annual"]["total"] = {"project_cf": sum(result["total"]["project_cf"]), "dept_cf": sum(result["total"]["dept_cf"]), "bl_cf": sum(result["total"]["bl_cf"])}
conn.close()
return jsonify(result)
@bp.route("/api/overview/dept-project-matrix")
@login_required
def overview_dept_project_matrix():
"""返回各工作台预算项目经营详情矩阵"""
conn = db()
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
keys = ["sign","revenue","gross","cost","paid","payment","cashflow"]
result = {"tenants": tenants, "months": months, "data": {}, "total": {k: [0]*12 for k in keys}}
for tenant in tenants:
tdata = {k: [0]*12 for k in keys}
pfs = rows(conn, "SELECT sign_amount, sign_month, budget_data, expense_data FROM plan_finances WHERE tenant=?", [tenant])
for pf in pfs:
sm = (pf.get("sign_month") or "")[:7]
if sm in months:
i = months.index(sm)
tdata["sign"][i] += float(pf.get("sign_amount") or 0)
try: bd = json.loads(pf.get("budget_data") or "[]")
except: bd = []
try: ed = json.loads(pf.get("expense_data") or "[]")
except: ed = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
tdata["revenue"][i] += float(b.get("rev") or 0)
tdata["gross"][i] += float(b.get("gross") or 0)
tdata["payment"][i] += float(b.get("payment") or 0)
for e in ed:
m = (e.get("month") or "")[:7]
if m in months:
i = months.index(m)
tdata["cost"][i] += float(e.get("cost") or 0)
tdata["paid"][i] += float(e.get("paid") or 0)
for i in range(12):
tdata["cashflow"][i] = tdata["payment"][i] - tdata["paid"][i]
result["data"][tenant] = tdata
for k in keys:
for i in range(12):
result["total"][k][i] += tdata[k][i]
result["annual"] = {}
for t in tenants:
result["annual"][t] = {k: sum(result["data"][t][k]) for k in keys}
result["annual"]["total"] = {k: sum(result["total"][k]) for k in keys}
conn.close()
return jsonify(result)
@bp.route("/api/overview/finance-dept-project-matrix")
@login_required
def overview_finance_dept_project_matrix():
"""返回各工作台已发生项目经营详情矩阵"""
conn = db()
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
keys = ["sign","revenue","gross","cost","paid","payment","cashflow"]
result = {"tenants": tenants, "months": months, "data": {}, "total": {k: [0]*12 for k in keys}}
for tenant in tenants:
tdata = {k: [0]*12 for k in keys}
pfs = rows(conn, "SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?", [tenant])
for pf in pfs:
sm = (pf.get("sign_month") or "")[:7]
if sm in months:
i = months.index(sm)
tdata["sign"][i] += float(pf.get("sign_amount") or 0)
try: bd = json.loads(pf.get("budget_data") or "[]")
except: bd = []
try: ed = json.loads(pf.get("expense_data") or "[]")
except: ed = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
tdata["revenue"][i] += float(b.get("rev") or 0)
tdata["gross"][i] += float(b.get("gross") or 0)
tdata["payment"][i] += float(b.get("payment") or 0)
for e in ed:
m = (e.get("month") or "")[:7]
if m in months:
i = months.index(m)
tdata["cost"][i] += float(e.get("cost") or 0)
tdata["paid"][i] += float(e.get("paid") or 0)
for i in range(12):
tdata["cashflow"][i] = tdata["payment"][i] - tdata["paid"][i]
result["data"][tenant] = tdata
for k in keys:
for i in range(12):
result["total"][k][i] += tdata[k][i]
result["annual"] = {}
for t in tenants:
result["annual"][t] = {k: sum(result["data"][t][k]) for k in keys}
result["annual"]["total"] = {k: sum(result["total"][k]) for k in keys}
conn.close()
return jsonify(result)
# ---------- health ----------
@bp.route("/api/health")
def health():
return jsonify({"ok": True, "service": "opc-manager"})
# ---------- 总览聚合 API ----------
OVERVIEW_TENANTS = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
def _aggregate_monthly_from_json(conn, table, tenants):
"""聚合多个 tenant 的表 (含 budget_data/expense_data JSON) 到 12 个月数组"""
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
rev, gross, payment, cost, paid = [0]*12, [0]*12, [0]*12, [0]*12, [0]*12
sign_total = 0
sign_cnt = 0
for tenant in tenants:
pfs = rows(conn, f"SELECT sign_amount, sign_month, budget_data, expense_data FROM {table} WHERE tenant=?", [tenant])
for pf in pfs:
sm = pf.get("sign_month") or ""
if sm.startswith(str(_year)):
sign_total += float(pf.get("sign_amount") or 0)
sign_cnt += 1
try:
bd = json.loads(pf.get("budget_data") or "[]")
except:
bd = []
try:
ed = json.loads(pf.get("expense_data") or "[]")
except:
ed = []
for b in bd:
m = (b.get("month") or "")[:7]
if m in months:
i = months.index(m)
rev[i] += float(b.get("rev") or 0)
gross[i] += float(b.get("gross") or 0)
payment[i] += float(b.get("payment") or 0)
for e in ed:
m = (e.get("month") or "")[:7]
if m in months:
i = months.index(m)
cost[i] += float(e.get("cost") or 0)
paid[i] += float(e.get("paid") or 0)
return {
"months": months,
"rev": rev, "gross": gross, "payment": payment, "cost": cost, "paid": paid,
"cashflow": [payment[i] - paid[i] for i in range(12)],
"sign_total": sign_total, "sign_cnt": sign_cnt,
}
def _aggregate_expenses(conn, table, tenants):
"""聚合多个 tenant 的平台费用到 12 个月"""
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
amount = [0]*12
incurred = [0]*12
for tenant in tenants:
exps = rows(conn, f"SELECT expense_month, amount, incurred_amount FROM {table} WHERE tenant=? AND expense_month LIKE ?", [tenant, f"{_year}-%"])
for e in exps:
m = (e.get("expense_month") or "")[:7]
if m in months:
i = months.index(m)
amount[i] += float(e.get("amount") or 0)
incurred[i] += float(e.get("incurred_amount") or 0)
return amount, incurred
def _build_overview_result(agg, exp_amount, exp_incurred):
"""根据聚合数据构建前端使用的 overview JSON"""
months = agg["months"]
rev = agg["rev"]
gross = agg["gross"]
payment = agg["payment"]
cost = agg["cost"]
paid = agg["paid"]
cf = agg["cashflow"]
# 部门损益情况
dept_profit = [gross[i] - exp_amount[i] for i in range(12)]
dept = {
"project_gross": gross,
"dept_expense": exp_amount,
"profit": dept_profit,
"annual": [sum(gross), sum(exp_amount), sum(dept_profit)],
}
# 部门现金流
bl_cf = [cf[i] - exp_incurred[i] for i in range(12)]
dept_cf = {
"project_cf": cf,
"dept_cf_item": exp_incurred,
"bl_cf": bl_cf,
"annual": [sum(cf), sum(exp_incurred), sum(bl_cf)],
}
# 项目经营详情
project = {
"sign": agg["sign_total"],
"revenue": rev,
"gross": gross,
"cost": cost,
"paid": paid,
"payment": payment,
"cashflow": cf,
"annual": [agg["sign_total"], sum(rev), sum(gross), sum(cost), sum(paid), sum(payment), sum(cf)],
}
return {"dept": dept, "dept_cf": dept_cf, "project": project, "months": months}
def _quarterly_from_monthly(arr12):
"""将 12 个月数组转换为 4 个季度"""
q_ranges = [(0,1,2),(3,4,5),(6,7,8),(9,10,11)]
return [arr12[q[0]]+arr12[q[1]]+arr12[q[2]] for q in q_ranges]
@bp.route("/api/overview/plan-monthly")
@login_required
def overview_plan_monthly():
conn = db()
agg = _aggregate_monthly_from_json(conn, "plan_finances", OVERVIEW_TENANTS)
exp_amount, exp_incurred = _aggregate_expenses(conn, "plan_expense_records", OVERVIEW_TENANTS)
conn.close()
return jsonify(_build_overview_result(agg, exp_amount, exp_incurred))
@bp.route("/api/overview/plan-quarterly")
@login_required
def overview_plan_quarterly():
conn = db()
agg = _aggregate_monthly_from_json(conn, "plan_finances", OVERVIEW_TENANTS)
exp_amount, exp_incurred = _aggregate_expenses(conn, "plan_expense_records", OVERVIEW_TENANTS)
conn.close()
# Convert monthly to quarterly
result = _build_overview_result(agg, exp_amount, exp_incurred)
for key in ["revenue","gross","payment","cost","paid","cashflow"]:
result["project"][key] = _quarterly_from_monthly(result["project"][key])
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
result["dept"]["profit"] = _quarterly_from_monthly(result["dept"]["profit"])
result["dept_cf"]["project_cf"] = _quarterly_from_monthly(result["dept_cf"]["project_cf"])
result["dept_cf"]["dept_cf_item"] = _quarterly_from_monthly(result["dept_cf"]["dept_cf_item"])
result["dept_cf"]["bl_cf"] = _quarterly_from_monthly(result["dept_cf"]["bl_cf"])
return jsonify(result)
@bp.route("/api/overview/finance-monthly")
@login_required
def overview_finance_monthly():
conn = db()
agg = _aggregate_monthly_from_json(conn, "project_finances", OVERVIEW_TENANTS)
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
conn.close()
return jsonify(_build_overview_result(agg, exp_amount, exp_incurred))
@bp.route("/api/overview/finance-quarterly")
@login_required
def overview_finance_quarterly():
conn = db()
agg = _aggregate_monthly_from_json(conn, "project_finances", OVERVIEW_TENANTS)
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
conn.close()
result = _build_overview_result(agg, exp_amount, exp_incurred)
for key in ["revenue","gross","payment","cost","paid","cashflow"]:
result["project"][key] = _quarterly_from_monthly(result["project"][key])
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
result["dept"]["profit"] = _quarterly_from_monthly(result["dept"]["profit"])
result["dept_cf"]["project_cf"] = _quarterly_from_monthly(result["dept_cf"]["project_cf"])
result["dept_cf"]["dept_cf_item"] = _quarterly_from_monthly(result["dept_cf"]["dept_cf_item"])
result["dept_cf"]["bl_cf"] = _quarterly_from_monthly(result["dept_cf"]["bl_cf"])
return jsonify(result)

View File

@@ -108,11 +108,12 @@ function renderExpense() {
}
// 模态框 monthOpts for form (with current value preselected)
var curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
var modalMonthOpts = '<option value="">请选择</option>';
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
for (var m2 = 1; m2 <= 12; m2++) {
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
}
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');

View File

@@ -144,7 +144,7 @@ function renderFinance() {
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions(new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'))}</select></label>
</div>
<div class="grid grid-cols-3 gap-3">
@@ -517,7 +517,7 @@ window.openFinanceModal = () => {
modal.classList.remove("hidden");
};
window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
window.addRevpayRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -531,7 +531,7 @@ window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note
if (window.lucide) window.lucide.createIcons();
};
window.addCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
window.addCostRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), expense_type = '', cost = '', paid = '', exp_note = '') => {
const tbody = document.querySelector("#costTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -1027,8 +1027,8 @@ function renderFinanceOverview() {
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
{ label: '项目毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
{ label: '部门费用', vals: expMonthly, annual: expAnnual },
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
@@ -1043,7 +1043,7 @@ function renderFinanceOverview() {
}
deptTbody += rowHtml + '</tr>';
}
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
var deptCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
// 业务线现金流卡片
var cfMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
@@ -1061,8 +1061,8 @@ function renderFinanceOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
{ label: '平台费用(支出)', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfMonthly, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1078,12 +1078,12 @@ function renderFinanceOverview() {
}
cfTbody += cfRowHtml + '</tr>';
}
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(支出)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-2.5">' +
deptCard +
cfCard +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-3 gap-2.5">' +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
@@ -1159,11 +1159,11 @@ function renderFinanceQuarterlyOverview() {
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = m.gross_annual || 0;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: grossQ, annual: grossAnnual },
{ label: '平台费用', vals: expQ, annual: expAnnual },
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
{ label: '部门费用', vals: expQ, annual: expAnnual },
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
@@ -1176,6 +1176,7 @@ function renderFinanceQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(dr.vals[qi], dr.vals[qi - 1]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
@@ -1195,8 +1196,8 @@ function renderFinanceQuarterlyOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidQ, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1208,14 +1209,15 @@ function renderFinanceQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(cfdr.vals[qi], cfdr.vals[qi - 1]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'</div>';
if (window.lucide) window.lucide.createIcons();
}

View File

@@ -0,0 +1,257 @@
// overview_finance.js — 发生总览模块5 个工作台数据聚合)
function renderOverviewFinance() {
var view = state.overviewView || "monthly";
if (view === 'matrix') { renderFinanceDeptMatrix(); return; }
if (view === 'cf_matrix') { renderFinanceCfMatrix(); return; }
if (view === 'project_matrix') { renderFinanceProjectMatrix(); return; }
var api = view === "quarterly" ? "/api/overview/finance-quarterly" : "/api/overview/finance-monthly";
var container = document.querySelector("#overview_finance");
if (!container) return;
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载中...</div>';
fetch(api).then(r => r.json()).then(data => {
if (state.overviewView !== view) return;
renderOverviewFinanceData(data, view);
}).catch(() => {
if (state.overviewView !== view) return;
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载失败</div>';
});
}
// ====== 项目经营详情报表(顶部定义,避免调用时未注册) ======
function renderFinanceProjectMatrix() {
var c = document.querySelector("#overview_finance");
if (!c) return;
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载项目经营详情矩阵...</div>';
fetch('/api/overview/finance-dept-project-matrix').then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
renderFinanceProjectMatrixData(data, c);
}).catch(function(e) {
c.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">加载失败: ' + (e.message || '未知错误') + '</div>';
});
}
function renderFinanceProjectMatrixData(data, container) {
try {
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
var tenants = data.tenants;
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
var keys = ["sign","revenue","gross","cost","paid","payment","cashflow"];
var labels = ["合同金额","确收金额","确收毛利","当期成本","当期流出","回款","项目现金流"];
var vBtns = financeBtnBar('project_matrix');
var cards = '';
for (var mi = 0; mi < 12; mi++) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">合计</th></tr>';
var tb = financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf);
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-slate-300" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
}
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
if (window.lucide) window.lucide.createIcons();
} catch(e) { container.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">渲染失败: ' + (e.message || '未知错误') + '</div>'; }
}
function financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf) {
var tb = '';
for (var ri = 0; ri < keys.length; ri++) {
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td>';
var ts = 0;
for (var ti = 0; ti < tenants.length; ti++) {
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
ts += v;
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === keys.length - 1) cls = v >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(v) + '</td>';
}
var tCls = 'text-slate-700';
if (ri === 2) tCls = 'text-green-600';
if (ri === keys.length - 1) tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td></tr>';
tb += rh;
}
return tb;
}
function renderOverviewFinanceData(data, view) {
const container = document.querySelector("#overview_finance");
if (!container) return;
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
const isQuarterly = view === "quarterly";
const headers = isQuarterly ? ['Q1','Q2','Q3','Q4'] : ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const cols = isQuarterly ? 4 : 12;
const d = data.dept, cf = data.dept_cf, p = data.project;
var btns = [];
btns.push('<button onclick="setOverviewFinanceView(\'monthly\')" class="finance-tab' + (view === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
btns.push('<button onclick="setOverviewFinanceView(\'quarterly\')" class="finance-tab' + (view === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
btns.push('<button onclick="setOverviewFinanceView(\'matrix\')" class="finance-tab' + (view === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
btns.push('<button onclick="setOverviewFinanceView(\'cf_matrix\')" class="finance-tab' + (view === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>');
btns.push('<button onclick="setOverviewFinanceView(\'project_matrix\')" class="finance-tab' + (view === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
var viewBtns = '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var hi = 0; hi < cols; hi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[hi] + '</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '项目毛利', vals: d.project_gross, annual: d.annual[0] },
{ label: '部门费用', vals: d.dept_expense, annual: d.annual[1] },
{ label: '利润', vals: d.profit, annual: d.annual[2] }
];
var deptTbody = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri], rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
var cellCls = dri === 2 ? (dr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[i]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
var deptCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
var cfRows = [
{ label: '项目现金流', vals: cf.project_cf, annual: cf.annual[0] },
{ label: '部门现金流', vals: cf.dept_cf_item, annual: cf.annual[1] },
{ label: '经营现金流', vals: cf.bl_cf, annual: cf.annual[2] }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfr = cfRows[cfri], cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfr.label + '</td>';
var cAnnualCls = cfri === 2 ? (cfr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cAnnualCls + ' whitespace-nowrap">' + moneyIntL(cfr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
var cCellCls = cfri === 2 ? (cfr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cCellCls + '">' + moneyIntL(cfr.vals[i]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var projVals = [p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
var projAnnual = p.annual;
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var phi = 0; phi < cols; phi++) { projThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[phi] + '</th>'; }
projThead += '</tr>';
var projTbody = '';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + (ri === 0 ? moneyIntL(p.sign) : moneyIntL(projAnnual[ri])) + '</td>';
for (var i = 0; i < cols; i++) {
var val = ri === 0 ? 0 : (projVals[ri - 1] ? projVals[ri - 1][i] : 0);
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
}
projTbody += rowHtml + '</tr>';
}
var projCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + projThead + '</thead><tbody>' + projTbody + '</tbody></table></div>', 'p-4');
container.innerHTML = '<div class="grid gap-2.5">' + viewBtns + deptCard + cfCard + projCard + '</div>';
if (window.lucide) window.lucide.createIcons();
}
window.setOverviewFinanceView = (v) => { state.overviewView = v; renderOverviewFinance(); };
// ====== 损益报表 ======
function renderFinanceDeptMatrix() {
var c = document.querySelector("#overview_finance");
if (!c) return;
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载损益矩阵...</div>';
fetch('/api/overview/finance-dept-matrix').then(r => r.json()).then(function(data) { renderFinanceDeptMatrixData(data, c); })
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
}
function renderFinanceDeptMatrixData(data, container) {
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
var tenants = data.tenants;
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
var vBtns = financeBtnBar('matrix');
var cards = '';
for (var mi = 0; mi < 12; mi++) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">合计</th></tr>';
var tb = financeMatrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-slate-300" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
}
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
if (window.lucide) window.lucide.createIcons();
}
// ====== 现金流报表 ======
function renderFinanceCfMatrix() {
var c = document.querySelector("#overview_finance");
if (!c) return;
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载现金流矩阵...</div>';
fetch('/api/overview/finance-dept-cf-matrix').then(r => r.json()).then(function(data) { renderFinanceCfMatrixData(data, c); })
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
}
function renderFinanceCfMatrixData(data, container) {
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
var tenants = data.tenants;
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
var vBtns = financeBtnBar('cf_matrix');
var cards = '';
for (var mi = 0; mi < 12; mi++) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">合计</th></tr>';
var tb = financeMatrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-slate-300" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
}
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
if (window.lucide) window.lucide.createIcons();
}
// ====== 共用工具 ======
function financeBtnBar(active) {
var btns = [];
btns.push('<button onclick="setOverviewFinanceView(\'monthly\')" class="finance-tab' + (active === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
btns.push('<button onclick="setOverviewFinanceView(\'quarterly\')" class="finance-tab' + (active === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
btns.push('<button onclick="setOverviewFinanceView(\'matrix\')" class="finance-tab' + (active === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
btns.push('<button onclick="setOverviewFinanceView(\'cf_matrix\')" class="finance-tab' + (active === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>');
btns.push('<button onclick="setOverviewFinanceView(\'project_matrix\')" class="finance-tab' + (active === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
return '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
}
function financeMatrixTbody(data, tenants, mi, keys, labels, mf) {
var tb = '';
for (var ri = 0; ri < keys.length; ri++) {
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td>';
var ts = 0;
for (var ti = 0; ti < tenants.length; ti++) {
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
ts += v;
var cls = v >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(v) + '</td>';
}
var tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td></tr>';
tb += rh;
}
return tb;
}

View File

@@ -0,0 +1,274 @@
// overview_plan.js — 预算总览模块5 个工作台数据聚合)
function renderOverviewPlan() {
var view = state.overviewView || "monthly";
var container = document.querySelector("#overview_plan");
if (!container) return;
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载中...</div>';
if (view === 'matrix') { renderDeptMatrix(); return; }
if (view === 'cf_matrix') { renderCfMatrix(); return; }
if (view === 'project_matrix') { renderProjectMatrix(); return; }
var api = view === "quarterly" ? "/api/overview/plan-quarterly" : "/api/overview/plan-monthly";
fetch(api).then(r => r.json()).then(data => {
if (state.overviewView !== view) return;
renderOverviewPlanData(data, view);
}).catch(() => {
if (state.overviewView !== view) return;
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载失败</div>';
});
}
function renderProjectMatrix() {
var c = document.querySelector("#overview_plan");
if (!c) return;
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载项目经营详情矩阵...</div>';
fetch('/api/overview/dept-project-matrix').then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
renderProjectMatrixData(data, c);
}).catch(function(e) {
c.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">加载失败: ' + (e.message || '未知错误') + '</div>';
});
}
function renderProjectMatrixData(data, container) {
try {
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
var tenants = data.tenants;
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
var keys = ["sign","revenue","gross","cost","paid","payment","cashflow"];
var labels = ["合同金额","确收金额","确收毛利","当期成本","当期流出","回款","项目现金流"];
var vBtns = btnBar('project_matrix');
var cards = '';
for (var mi = 0; mi < 12; mi++) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">合计</th></tr>';
var tb = projectMatrixTbody(data, tenants, mi, keys, labels, mf);
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-slate-300" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
}
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
if (window.lucide) window.lucide.createIcons();
} catch(e) { container.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">渲染失败: ' + (e.message || '未知错误') + '</div>'; }
}
function projectMatrixTbody(data, tenants, mi, keys, labels, mf) {
var tb = '';
for (var ri = 0; ri < keys.length; ri++) {
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td>';
var ts = 0;
for (var ti = 0; ti < tenants.length; ti++) {
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
ts += v;
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === keys.length - 1) cls = v >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(v) + '</td>';
}
var tCls = 'text-slate-700';
if (ri === 2) tCls = 'text-green-600';
if (ri === keys.length - 1) tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td></tr>';
tb += rh;
}
return tb;
}
function renderOverviewPlanData(data, view) {
const container = document.querySelector("#overview_plan");
if (!container) return;
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
const isQuarterly = view === "quarterly";
const headers = isQuarterly ? ['Q1','Q2','Q3','Q4'] : ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const cols = isQuarterly ? 4 : 12;
const d = data.dept;
const cf = data.dept_cf;
const p = data.project;
// 视图切换按钮
var btns = [];
btns.push('<button onclick="setOverviewView(\'monthly\')" class="finance-tab' + (view === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
btns.push('<button onclick="setOverviewView(\'quarterly\')" class="finance-tab' + (view === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
btns.push('<button onclick="setOverviewView(\'matrix\')" class="finance-tab' + (view === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
btns.push('<button onclick="setOverviewView(\'cf_matrix\')" class="finance-tab' + (view === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>'); btns.push('<button onclick="setOverviewView(\'project_matrix\')" class="finance-tab' + (view === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
var viewBtns = '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
// ====== 部门损益情况 ======
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var hi = 0; hi < cols; hi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[hi] + '</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '项目毛利', vals: d.project_gross, annual: d.annual[0] },
{ label: '部门费用', vals: d.dept_expense, annual: d.annual[1] },
{ label: '利润', vals: d.profit, annual: d.annual[2] }
];
var deptTbody = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
var cellCls = dri === 2 ? (dr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[i]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
var deptCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
// ====== 部门现金流 ======
var cfRows = [
{ label: '项目现金流', vals: cf.project_cf, annual: cf.annual[0] },
{ label: '部门现金流', vals: cf.dept_cf_item, annual: cf.annual[1] },
{ label: '经营现金流', vals: cf.bl_cf, annual: cf.annual[2] }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfr = cfRows[cfri];
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfr.label + '</td>';
var cAnnualCls = cfri === 2 ? (cfr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cAnnualCls + ' whitespace-nowrap">' + moneyIntL(cfr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
var cCellCls = cfri === 2 ? (cfr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cCellCls + '">' + moneyIntL(cfr.vals[i]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
// ====== 项目经营详情 ======
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var projVals = [p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
var projAnnual = p.annual;
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var phi = 0; phi < cols; phi++) { projThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[phi] + '</th>'; }
projThead += '</tr>';
var projTbody = '';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + (ri === 0 ? moneyIntL(p.sign) : moneyIntL(projAnnual[ri])) + '</td>';
for (var i = 0; i < cols; i++) {
var val = ri === 0 ? 0 : (projVals[ri - 1] ? projVals[ri - 1][i] : 0);
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
}
projTbody += rowHtml + '</tr>';
}
var projCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + projThead + '</thead><tbody>' + projTbody + '</tbody></table></div>', 'p-4');
container.innerHTML = '<div class="grid gap-2.5">' + viewBtns + deptCard + cfCard + projCard + '</div>';
if (window.lucide) window.lucide.createIcons();
}
window.setOverviewView = (v) => { state.overviewView = v; renderOverviewPlan(); };
// ====== 损益报表 ======
function renderDeptMatrix() {
var c = document.querySelector("#overview_plan");
if (!c) return;
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载损益矩阵...</div>';
fetch('/api/overview/dept-matrix').then(r => r.json()).then(function(data) { renderDeptMatrixData(data, c); })
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
}
function renderDeptMatrixData(data, container) {
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
var tenants = data.tenants;
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
var vBtns = btnBar('matrix');
var cards = '';
for (var mi = 0; mi < 12; mi++) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">合计</th></tr>';
var tb = matrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-slate-300" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
}
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
if (window.lucide) window.lucide.createIcons();
}
// ====== 现金流报表 ======
function renderCfMatrix() {
var c = document.querySelector("#overview_plan");
if (!c) return;
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载现金流矩阵...</div>';
fetch('/api/overview/dept-cf-matrix').then(r => r.json()).then(function(data) { renderCfMatrixData(data, c); })
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
}
function renderCfMatrixData(data, container) {
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
var tenants = data.tenants;
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
var vBtns = btnBar('cf_matrix');
var cards = '';
for (var mi = 0; mi < 12; mi++) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">合计</th></tr>';
var tb = matrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-slate-300" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
}
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
if (window.lucide) window.lucide.createIcons();
}
// ====== 共用工具 ======
function btnBar(active) {
var btns = [];
btns.push('<button onclick="setOverviewView(\'monthly\')" class="finance-tab' + (active === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
btns.push('<button onclick="setOverviewView(\'quarterly\')" class="finance-tab' + (active === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
btns.push('<button onclick="setOverviewView(\'matrix\')" class="finance-tab' + (active === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
btns.push('<button onclick="setOverviewView(\'cf_matrix\')" class="finance-tab' + (active === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>'); btns.push('<button onclick="setOverviewView(\'project_matrix\')" class="finance-tab' + (active === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
return '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
}
function matrixTbody(data, tenants, mi, keys, labels, mf) {
var tb = '';
for (var ri = 0; ri < keys.length; ri++) {
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td>';
var ts = 0;
for (var ti = 0; ti < tenants.length; ti++) {
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
ts += v;
var cls = v >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(v) + '</td>';
}
var tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td></tr>';
tb += rh;
}
return tb;
// ====== 项目经营详情报表 ======
}

View File

@@ -143,7 +143,7 @@ function renderPlan() {
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions(new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'))}</select></label>
</div>
<div class="grid grid-cols-3 gap-3">
@@ -257,9 +257,9 @@ function renderPlan() {
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
{ label: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '当期成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '当期流出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
@@ -307,9 +307,8 @@ function renderPlan() {
{ key: 'gross_rate', label: '毛利率' },
{ key: 'payment', label: '回款' },
{ key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '成本' },
{ key: 'paid', label: '付款' },
{ key: 'paid_rate', label: '付款率' },
{ key: 'cost', label: '当期成本' },
{ key: 'paid', label: '当期流出' },
{ key: 'cashflow', label: '现金流' },
{ key: 'pending', label: '待回款' },
];
@@ -321,7 +320,7 @@ function renderPlan() {
{ key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'cost', label: '当期成本' },
{ key: 'profit', label: '项目利润' },
];
@@ -332,7 +331,7 @@ function renderPlan() {
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
});
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 9 : 14;
const theadCols = isUnsigned ? 9 : 13;
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
@@ -365,7 +364,6 @@ function renderPlan() {
const payRVal = rev && payment ? Math.round(payment / rev * 100) : null;
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
@@ -394,7 +392,7 @@ function renderPlan() {
var bCf = bPay - ePaid;
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
}).join('');
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="16"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">当期成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">当期流出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
@@ -564,7 +562,7 @@ window.openPlanModal = () => {
modal.classList.remove("hidden");
};
window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
window.planAddRevpayRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#plan_revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -578,7 +576,7 @@ window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_n
if (window.lucide) window.lucide.createIcons();
};
window.planAddCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
window.planAddCostRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), expense_type = '', cost = '', paid = '', exp_note = '') => {
const tbody = document.querySelector("#plan_costTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -1118,8 +1116,8 @@ function renderPlanOverview() {
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
{ label: '项目毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
{ label: '部门费用', vals: expMonthly, annual: expAnnual },
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
@@ -1135,7 +1133,7 @@ function renderPlanOverview() {
}
deptTbody += rowHtml + '</tr>';
}
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
var deptCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
// 业务线现金流卡片
@@ -1153,8 +1151,8 @@ function renderPlanOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfMonthly, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1170,12 +1168,12 @@ function renderPlanOverview() {
}
cfTbody += cfRowHtml + '</tr>';
}
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-2.5">' +
deptCard +
cfCard +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 已付</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-3 gap-2.5">' +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
@@ -1255,11 +1253,11 @@ function renderPlanQuarterlyOverview() {
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = annualSums2.gross;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: grossQ, annual: grossAnnual },
{ label: '平台费用', vals: expQ, annual: expAnnual },
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
{ label: '部门费用', vals: expQ, annual: expAnnual },
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
@@ -1272,6 +1270,7 @@ function renderPlanQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(dr.vals[qi], dr.vals[qi - 1]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
@@ -1290,8 +1289,8 @@ function renderPlanQuarterlyOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidQ, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1303,14 +1302,15 @@ function renderPlanQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(cfdr.vals[qi], cfdr.vals[qi - 1]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'</div>';
if (window.lucide) window.lucide.createIcons();
}

View File

@@ -108,11 +108,12 @@ function renderPlanExpense() {
}
// 模态框 monthOpts for form (with current value preselected)
var curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
var modalMonthOpts = '<option value="">请选择</option>';
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
for (var m2 = 1; m2 <= 12; m2++) {
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
}
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');

View File

@@ -32,7 +32,7 @@ window.toggleTenantMenu = (event) => {
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
menu = document.createElement("div");
menu.id = "tenantMenu";
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
@@ -63,7 +63,7 @@ window.switchTenantFromMenu = (tenant) => {
};
function updateTenantLabel() {
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',
@@ -92,7 +92,7 @@ window.toggleTenantMenu = (event) => {
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',

View File

@@ -5,18 +5,20 @@ const state = {
data: null,
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
opFilter: "all",
finFilter: "projects",
finFilter: "overview",
selectedProject: null,
taskQuery: "",
taskView: localStorage.getItem("opc-task-view") || "detail",
finView: "overview",
finSort: null, // { key: 'col', asc: true }
planFilter: "projects",
planFilter: "overview",
planYear: new Date().getFullYear(),
finYear: new Date().getFullYear(),
planStatusFilter: "all",
planView: "overview",
planSort: null,
overviewView: "monthly", // 总览视图: monthly | quarterly
overviewFilter: "plan", // 总览筛选: plan | finance
proposalTab: "standard",
chart: null,
chart2: null,
@@ -119,7 +121,9 @@ function renderTable(headers, rows, rowClicks) {
}
var _loadingSteps = [
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return true; } },
{ fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
@@ -232,7 +236,7 @@ var _tabLoaded = {};
var _proposalsInitialized = false;
async function ensureTabData(tab) {
if (tab === 'home' || tab === 'performance') return;
if (tab === 'home' || tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
var resource = _tabResourceMap[tab];
if (!resource) return;
var dataKey = resource;
@@ -262,7 +266,7 @@ function switchTab(tab) {
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
// 更新顶部标题
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' };
const titleEl = document.querySelector('#workspaceTitle');
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
@@ -278,9 +282,9 @@ function updateSidebarTabs() {
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const tab = btn.dataset.tab;
if (isOverview) {
btn.style.display = (tab === "home" || tab === "plan" || tab === "finance") ? "" : "none";
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none";
} else {
btn.style.display = tab === "home" ? "none" : "";
btn.style.display = (tab === "overview_plan" || tab === "overview_finance" || tab === "home") ? "none" : "";
}
});
}
@@ -301,6 +305,8 @@ function renderActive() {
if (!state.data) return;
const tab = state.active;
if (tab === "home") renderHome();
else if (tab === "overview_plan") renderOverviewPlan();
else if (tab === "overview_finance") renderOverviewFinance();
else if (tab === "projects") renderProjects();
else if (tab === "proposals") renderProposals();
else if (tab === "products") renderProducts();
@@ -334,8 +340,16 @@ window.setFinView = (view) => {
state.finView = view;
renderFinance();
};
window.setFinQuarter = (q) => { state.finQuarter = q; state.finView = 'quarterly'; renderFinance(); };
window.setFinMonth = (month) => { state.finMonth = month; state.finView = 'monthly'; renderFinance(); };
window.setFinQuarter = (q) => {
if (state.finView === 'quarterly' && state.finQuarter === q) { state.finView = 'overview'; }
else { state.finQuarter = q; state.finView = 'quarterly'; }
renderFinance();
};
window.setFinMonth = (month) => {
if (state.finView === 'monthly' && state.finMonth === month) { state.finView = 'overview'; }
else { state.finMonth = month; state.finView = 'monthly'; }
renderFinance();
};
window.setFinYear = (year) => { state.finYear = parseInt(year); renderFinance(); };
window.switchFinFilter = (filter) => {
state.finFilter = filter;
@@ -353,8 +367,16 @@ window.setPlanView = (view) => {
state.planView = view;
renderPlan();
};
window.setPlanQuarter = (q) => { state.planQuarter = q; state.planView = 'quarterly'; renderPlan(); };
window.setPlanMonth = (month) => { state.planMonth = month; state.planView = 'monthly'; renderPlan(); };
window.setPlanQuarter = (q) => {
if (state.planView === 'quarterly' && state.planQuarter === q) { state.planView = 'overview'; }
else { state.planQuarter = q; state.planView = 'quarterly'; }
renderPlan();
};
window.setPlanMonth = (month) => {
if (state.planView === 'monthly' && state.planMonth === month) { state.planView = 'overview'; }
else { state.planMonth = month; state.planView = 'monthly'; }
renderPlan();
};
window.setPlanYear = (year) => { state.planYear = parseInt(year); renderPlan(); };
window.setPlanStatusFilter = (filter) => { state.planStatusFilter = filter; renderPlan(); };
window.switchPlanFilter = (filter) => {
@@ -372,11 +394,22 @@ window.switchTenant = async (tenant) => {
state.tenant = tenant;
state.selectedProject = null;
localStorage.setItem("opc-active-tenant", tenant);
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
document.querySelector("#workspaceTitle").textContent = tenant === "总览" ? "总览 OPC 工作台" : tenant.replace("·无界", "") + " OPC 工作台";
if (typeof updateTenantLabel === "function") updateTenantLabel();
state.planFilter = 'overview';
state.finFilter = 'overview';
updateSidebarTabs();
if (tenant === "总览") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
else if (state.active === 'home') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); }
if (tenant === "总览") {
state.active = "overview_plan";
localStorage.setItem("opc-active-tab", "overview_plan");
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan"));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan"));
} else if (state.active === 'home' || state.active === 'overview_plan' || state.active === 'overview_finance') {
state.active = 'plan';
localStorage.setItem("opc-active-tab", "plan");
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan"));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan"));
}
await load();
};

View File

@@ -53,6 +53,14 @@
<i data-lucide="folder-open-dot" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">已发生</span>
</div>
<div class="sidebar-tab" data-tab="overview_plan" onclick="switchTab('overview_plan')" title="预算总览">
<i data-lucide="bar-chart-horizontal" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">预算总览</span>
</div>
<div class="sidebar-tab" data-tab="overview_finance" onclick="switchTab('overview_finance')" title="发生总览">
<i data-lucide="pie-chart" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">发生总览</span>
</div>
<div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="Focus">
<i data-lucide="file-text" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">Focus</span>
@@ -76,7 +84,7 @@
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div class="flex items-center gap-3 w-full">
<div class="flex-1">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.18</span></p>
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.26</span></p>
<div class="flex items-center gap-4 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">
@@ -93,6 +101,8 @@
<main class="px-8 py-6">
<section id="home" class="panel active"></section>
<section id="overview_plan" class="panel"></section>
<section id="overview_finance" class="panel"></section>
<section id="projects" class="panel"></section>
<section id="proposals" class="panel"></section>
<section id="products" class="panel"></section>
@@ -132,6 +142,8 @@
<script src="{{ url_for('static', filename='modules/expense.js') }}"></script>
<script src="{{ url_for('static', filename='modules/plan.js') }}"></script>
<script src="{{ url_for('static', filename='modules/plan_expense.js') }}"></script>
<script src="{{ url_for('static', filename='modules/overview_plan.js') }}"></script>
<script src="{{ url_for('static', filename='modules/overview_finance.js') }}"></script>
<script src="{{ url_for('static', filename='modules/drawer.js') }}"></script>
<script src="{{ url_for('static', filename='modules/admin.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>