// finance.js — 经营管理(财务)模块
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)} 万`;
function renderFinance() {
const pfs = state.data.projectFinances || [];
const ops = state.data.operations || [];
const fmTypesByTenant = {
"科普·无界": ["科普音频","科普视频","科普文章","科普专访","患教会","全品类科普","调研问卷"],
"科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
"医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
};
const fmTypes = fmTypesByTenant[state.tenant] || fmTypesByTenant["科普·无界"];
const tenantOps = (state.data.operations || []).filter(o => (o.project_name || "").includes(state.tenant.replace("·无界","")) || o.tenant === state.tenant);
const now = new Date();
const thisMonth = now.getMonth() + 1;
const displayMonths = [];
for (let i = 0; i < 4; i++) {
const m = thisMonth + i;
const mm = m > 12 ? m - 12 : m;
displayMonths.push({ key: "2026_" + String(mm).padStart(2, "0"), label: mm + "月" });
}
const months = displayMonths.map(d => d.key);
const monthLabels = displayMonths.map(d => d.label);
const signed = pfs.filter(x => x.status === "已签约");
const pending = pfs.filter(x => x.status === "待签约");
const sumSign = Math.round(signed.reduce((s,x) => s + (x.sign_amount||0), 0));
const sumPending = Math.round(pending.reduce((s,x) => s + (x.sign_amount||0), 0));
const monthRev = months.map(m => {
return signed.reduce((s, pf) => {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
const row = budget.find(b => (b.month || "").replace("-", "_") === m);
return s + (row ? (parseFloat(row.rev) || 0) : 0);
}, 0);
});
const monthGross = months.map(m => {
return signed.reduce((s, pf) => {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
const row = budget.find(b => (b.month || "").replace("-", "_") === m);
return s + (row ? (parseFloat(row.gross) || 0) : 0);
}, 0);
});
const thisMonthKey = displayMonths[0].key;
const thisMonthRev = monthRev[0];
const thisMonthGross = monthGross[0];
let monthPayment = 0, monthCost = 0;
for (const pf of pfs) {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
for (const b of budget) {
const bKey = (b.month || "").replace("-", "_");
if (bKey === thisMonthKey) {
monthPayment += parseFloat(b.payment || 0);
monthCost += parseFloat(b.cost || 0);
break;
}
}
}
monthPayment = Math.round(monthPayment);
monthCost = Math.round(monthCost);
const monthCashflow = monthPayment - monthCost;
const renderPfRow = (pf) => {
let budgetMap = {};
try {
const budget = JSON.parse(pf.budget_data || "[]");
budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; });
} catch (e) {}
const isRevView = state.finView !== "cashflow" && state.finView !== "overview" && state.finView !== "monthly" && state.finView !== "quarterly";
const mCols = months.map(m => {
const b = budgetMap[m] || {};
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `
${card(`
${finHeaderBase}
${finAddBtn}
`, "p-3")}
${(() => {
const METRIC_CARDS = [
{ label: '签约项目总数', value: ctx => ctx.signCnt, color: 'text-slate-700' },
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
{ label: '已确收', value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
{ label: '执行率', value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
{ label: '毛利率', value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '费用', value: ctx => money(ctx.sumExpense), color: 'text-orange-700' },
{ label: '已回款', value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '回款率', value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '应付', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '已付', value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '付款率', value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '现金流', value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '利润', value: ctx => { const v = ctx.sumGross - ctx.sumExpense; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt };
const cards = METRIC_CARDS.map(c => {
const v = c.value(ctx);
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
});
const thead = '
' +
'| 项目名称 | ' +
'状态 | ' +
'签约金额 | ' +
'已确收 | ' +
'执行率 | ' +
'毛利 | ' +
'毛利率 | ' +
'已回款 | ' +
'回款率 | ' +
'应付 | ' +
'已付 | ' +
'付款率 | ' +
'费用 | ' +
'现金流 | ' +
'利润 | ' +
'
';
const tbody = rows.length ? `
${rows.join("")}` : `
| ${emptyText} |
`;
return card(`
${cards.map(([l, v, c]) => '
' + l + '' + v + '
').join("")}
`, "p-4") +
card(`
`, "p-4");
};
const fmtNoUnit = (v) => v ? `
${Number(v||0).toLocaleString('zh-CN')}` : '
—';
const tdRow = (pf, rev, payment, cost, paid, gross, expense) => {
const cashflow = payment - paid;
const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '
—';
const payR = rev && payment ? Math.round(payment / rev * 100) + '%' : '
—';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '
—';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '
—';
const st = pf.status === '已签约' ? '
已签约' : '
待签约';
const cf = cashflow ? money(cashflow) : '
—';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const profit = gross - expense;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '
—';
return `
| ${esc(pf.customer_name)} | ${st} | ${money(pf.sign_amount)} | ${fmtNoUnit(rev)} | ${exe} | ${fmtNoUnit(gross)} | ${grossR} | ${fmtNoUnit(payment)} | ${payR} | ${fmtNoUnit(cost)} | ${fmtNoUnit(paid)} | ${paidR} | ${fmtNoUnit(expense)} | ${cf} | ${pfVal} |
`;
};
if (state.finView === 'monthly') {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const now = new Date();
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
if (!state.finMonth) state.finMonth = defaultMonth;
const selMonth = state.finMonth;
const rows = [];
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0, sumExpense = 0;
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
const b = bd.find(x => (x.month || "") === selMonth) || {};
const rev = Math.round(parseFloat(b.rev || 0) || 0);
const payment = Math.round(parseFloat(b.payment || 0) || 0);
const cost = Math.round(parseFloat(b.cost || 0) || 0);
const paid = Math.round(parseFloat(b.paid || 0) || 0);
const gross = Math.round(parseFloat(b.gross || 0) || 0);
const expense = Math.round(parseFloat(b.expense || 0) || 0);
if (!rev && !payment && !cost && !paid && !gross && !expense) return;
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross; sumExpense += expense;
rows.push(tdRow(pf, rev, payment, cost, paid, gross, expense));
});
const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约" && (x.sign_month || "").substring(0, 7) === selMonth).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, '该月份暂无数据');
}
if (state.finView === 'quarterly') {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
const now = new Date();
if (state.finQuarter === undefined) {
const saved = localStorage.getItem("opc-fin-quarter");
state.finQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
}
const selQ = state.finQuarter;
const qRange = qRanges[selQ];
const sumBudget = (pf, field) => {
let total = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total;
};
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0, sumExpense = 0;
const rows = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
const cost = sumBudget(pf, "cost");
const paid = sumBudget(pf, "paid");
const gross = sumBudget(pf, "gross");
const expense = sumBudget(pf, "expense");
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross; sumExpense += expense;
rows.push(tdRow(pf, rev, payment, cost, paid, gross, expense));
});
const signTotal = Math.round(allPfs.filter(x => { const m = parseInt((x.sign_month || "0").substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约" && qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, '该季度暂无数据');
}
// 总视图
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0, gross = 0, expense = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; cost += parseFloat(b.cost || 0) || 0; expense += parseFloat(b.expense || 0) || 0; });
const paid = parseFloat(pf.total_paid) || 0;
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross), expense: Math.round(expense) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0, sumExpense = 0;
const rows = [];
allPfs.forEach(pf => {
const t = calcTotals(pf);
sumRev += t.rev; sumPay += t.payment; sumCost += t.cost; sumPaid += t.paid; sumGross += t.gross; sumExpense += t.expense;
rows.push(tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross, t.expense));
});
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约").length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, '暂无数据');
})()}
`;
if (window.lucide) window.lucide.createIcons();
}
window.openFinanceModal = () => {
const modal = document.querySelector("#financeModal");
const form = modal.querySelector("form");
form.querySelector('[name="project_id"]').value = state.tenant;
const dept = form.querySelector('input[disabled]');
if (dept) dept.value = state.tenant;
const pfIdInput = form.querySelector('[name="pf_id"]');
if (!pfIdInput || !pfIdInput.value) {
initBudgetTable(null);
initTaskTable(null);
document.querySelector("#financeDeleteBtn").classList.add("hidden");
}
modal.classList.remove("hidden");
};
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '', paid = '', expense = '') => {
const tbody = document.querySelector("#budgetTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `