// 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 inContract = 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 sumContract = Math.round(inContract.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 `
${finHeaderBase}
${finAddBtn}
${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 monthSet = new Set([defaultMonth]);
allPfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet.add(b.month); }); });
const sortedMonths = [...monthSet].sort().reverse();
const monthOpts = sortedMonths.map(m => `
`).join("");
const fmt = (v) => v ? `
${money(v)}` : '
—';
const fmtDiff = (v) => { if (!v) return '
—'; return `
${money(Math.abs(v))}`; };
const rows = [];
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0, sumGross=0;
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
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);
if (!rev && !payment && !cost && !paid && !gross) return;
const payDiff = rev - payment;
const costDiff = cost - paid;
const cashflow = payment - paid;
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid; sumGross+=gross;
rows.push(`
| ${esc(pf.customer_name)} | ${pf.status==='已签约'?'已签约':pf.status==='流程中'?'流程中':'待签约'} | ${money(pf.sign_amount)} | ${fmt(rev)} | ${rev&&pf.sign_amount?Math.round(rev/pf.sign_amount*100)+'%':'—'} | ${fmt(payment)} | ${rev&&payment?Math.round(payment/rev*100)+'%':'—'} | ${fmt(cost)} | ${fmt(paid)} | ${cost&&paid?Math.round(paid/cost*100)+'%':'—'} | ${fmt(gross)} | ${rev&&gross?Math.round(gross/rev*100)+'%':'—'} | ${cashflow ? money(cashflow) : '—'} |
`);
});
return card(`
月份:
${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].map(([l,v,c])=>'
'+l+''+v+'
').join("")})()}
| 项目名称 | 状态 | 签约金额 | 已确收 | 执行率 | 已回款 | 回款率 | 应付 | 已付 | 付款率 | 毛利 | 毛利率 | 现金流 |
${rows.length ? rows.join("") : '| 该月份暂无数据 |
'}
`, "p-4");
})() : 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 qLabels = ["Q1 (1-3月)", "Q2 (4-6月)", "Q3 (7-9月)", "Q4 (10-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 quarterOpts = qLabels.map((l, i) => `
`).join("");
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;
};
const fmt = (v) => v ? `
${money(v)}` : '
—';
const fmtDiff = (v) => { if (!v) return '
—'; return `
${money(Math.abs(v))}`; };
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
const cost = sumBudget(pf, "cost");
const paid = sumBudget(pf, "paid");
const gross = sumBudget(pf, "gross");
const payDiff = rev - payment;
const costDiff = cost - paid;
const cashflow = payment - paid;
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
rows.push(`
| ${esc(pf.customer_name)} | ${pf.status==='已签约'?'已签约':pf.status==='流程中'?'流程中':'待签约'} | ${money(pf.sign_amount)} | ${fmt(rev)} | ${rev&&pf.sign_amount?Math.round(rev/pf.sign_amount*100)+'%':'—'} | ${fmt(payment)} | ${rev&&payment?Math.round(payment/rev*100)+'%':'—'} | ${fmt(cost)} | ${fmt(paid)} | ${cost&&paid?Math.round(paid/cost*100)+'%':'—'} | ${fmt(gross)} | ${rev&&gross?Math.round(gross/rev*100)+'%':'—'} | ${cashflow ? money(cashflow) : '—'} |
`);
});
return card(`
季度:
${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].map(([l,v,c])=>'
'+l+''+v+'
').join("")})()}
| 项目名称 | 状态 | 签约金额 | 已确收 | 执行率 | 已回款 | 回款率 | 应付 | 已付 | 付款率 | 毛利 | 毛利率 | 现金流 |
${rows.length ? rows.join("") : '| 该季度暂无数据 |
'}
`, "p-4");
})() : (() => {
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev||0)||0; gross += parseFloat(b.gross||0)||0; payment += parseFloat(b.payment||0)||0; cost += parseFloat(b.cost||0)||0; });
const paid = parseFloat(pf.total_paid) || 0;
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0, sumGross=0;
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; sumGross+=t.gross; });
const fmt = (v) => v ? `
${money(v)}` : '
—';
const fmtDiff = (v) => { if (!v) return '
—'; return `
${money(Math.abs(v))}`; };
return card(`
${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].map(([l,v,c])=>'
'+l+''+v+'
').join("")})()}
| 项目名称 | 状态 | 签约金额 | 已确收 | 执行率 | 已回款 | 回款率 | 应付 | 已付 | 付款率 | 毛利 | 毛利率 | 现金流 |
${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; return `| ${esc(pf.customer_name)} | ${pf.status==='已签约'?'已签约':pf.status==='流程中'?'流程中':'待签约'} | ${money(pf.sign_amount)} | ${fmt(t.rev)} | ${t.rev&&pf.sign_amount?Math.round(t.rev/pf.sign_amount*100)+'%':'—'} | ${fmt(t.payment)} | ${t.rev&&t.payment?Math.round(t.payment/t.rev*100)+'%':'—'} | ${fmt(t.cost)} | ${fmt(t.paid)} | ${t.cost&&t.paid?Math.round(t.paid/t.cost*100)+'%':'—'} | ${fmt(t.gross)} | ${t.rev&&t.gross?Math.round(t.gross/t.rev*100)+'%':'—'} | ${cashflow ? money(cashflow) : '—'} |
`; }).join("")}
`, "p-4");
})()}
`;
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 = '') => {
const tbody = document.querySelector("#budgetTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `