// plan.js — 计划管理模块
console.log('plan.js loaded');
function renderPlan() {
const pfs = state.data.planFinances || [];
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;
const pending = pfs;
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.planView !== "cashflow" && state.planView !== "overview" && state.planView !== "monthly" && state.planView !== "quarterly";
const mCols = months.map(m => {
const b = budgetMap[m] || {};
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `
计划提醒:计划也是预算,只有真正能够列出计划的预算,才有靠谱的落地可能性
${planFilterTabs}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' ? card(`
${finHeaderBase}
${finAddBtn}
`, "p-4") : ''}
${(() => {
if (state.planFilter === 'overview') {
setTimeout(() => renderPlanOverview(), 10);
return `
`;
}
if (state.planFilter === 'expense') {
setTimeout(() => renderPlanExpense(), 10);
return `
`;
}
const isUnsigned = false;
const METRIC_CARDS = [
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
{ label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
{ label: '成本', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '项目利润', value: ctx => { const v = ctx.sumGross; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ 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 => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '执行率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
{ 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' },
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
].filter(c => true);
// 列排序
if (!state.planSort) state.planSort = { key: null, asc: true };
const sortData = (data, key) => {
if (!key || !state.planSort.key) return data;
var asc = state.planSort.asc ? 1 : -1;
return data.slice().sort(function(a, b) {
var va = a[key], vb = b[key];
if (typeof va === 'string') va = va || '', vb = vb || '';
else { va = parseFloat(va) || 0; vb = parseFloat(vb) || 0; }
if (va < vb) return -1 * asc;
if (va > vb) return 1 * asc;
return 0;
});
};
const buildThead = (cols, sortKey) => {
var h = '
';
cols.forEach(function(c) {
var arrow = sortKey ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
var clickable = sortKey ? ' cursor-pointer select-none' : '';
h += '| ' + c.label + arrow + ' | ';
});
h += '
';
return h;
};
const SIGNED_COLS = [
{ key: 'customer_name', label: '项目名称' },
,
{ key: 'sign_amount', label: '签约金额' },
{ key: 'rev', label: '已确收' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
{ key: 'payment', label: '已回款' },
{ key: 'paid', label: '已付' },
{ key: 'cashflow', label: '现金流' },
{ key: 'exe_rate', label: '执行率' },
{ key: 'gross_rate', label: '毛利率' },
{ key: 'pay_rate', label: '回款率' },
{ key: 'paid_rate', label: '付款率' },
];
const UNSIGNED_COLS = [
{ key: 'customer_name', label: '项目名称' },
,
{ key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, 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 = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 6 : 13;
const tbody = rows.length ? `
${rows.join("")}` : `
| ${emptyText} |
`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return card(`
${cards.map(([l, v, c]) => '
' + l + '' + v + '
').join("")}
`, "p-4") +
card(`
项目财务明细
${subtitle}
`, "p-4");
};
const fmtNoUnit = (v) => v ? `
${Number(v||0).toLocaleString('zh-CN')}` : '
—';
const tdRow = (pf, rev, payment, cost, paid, gross) => {
const cashflow = payment - paid;
const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '
—';
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 ? '
' + payRVal + '%' : '
—';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '
—';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '
—';
const cf = cashflow ? money(cashflow) : '
—';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '
—';
return `
| ${esc(pf.customer_name)} | ${money(pf.sign_amount)} | ${fmtNoUnit(rev)} | ${fmtNoUnit(gross)} | ${fmtNoUnit(cost)} | ${pfVal} | ${fmtNoUnit(payment)} | ${fmtNoUnit(paid)} | ${cf} | ${exe} | ${grossR} | ${payR} | ${paidR} |
`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '
—';
return `
| ${esc(pf.customer_name)} | ${money(pf.sign_amount)} | ${pf.sign_month || '—'} | ${fmtNoUnit(gross)} | ${fmtNoUnit(cost)} | ${pfVal} |
`;
};
if (state.planView === 'monthly') {
const allPfs = pfs;
const now = new Date();
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
if (!state.planMonth) state.planMonth = defaultMonth;
const selMonth = state.planMonth;
var dataItems = [];
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
const b = bd.find(x => (x.month || "") === selMonth) || {};
const e = ed.find(x => (x.month || "") === selMonth) || {};
const rev = Math.round(parseFloat(b.rev || 0) || 0);
const payment = Math.round(parseFloat(b.payment || 0) || 0);
const gross = Math.round(parseFloat(b.gross || 0) || 0);
const cost = Math.round(parseFloat(e.cost || 0) || 0);
const paid = Math.round(parseFloat(e.paid || 0) || 0);
if (!rev && !payment && !cost && !paid && !gross) return;
dataItems.push({
pf: pf,
customer_name: pf.customer_name || '',
sign_amount: pf.sign_amount || 0,
sign_month: pf.sign_month || '',
rev: rev, gross: gross, cost: cost,
profit: gross, payment: payment, paid: paid,
cashflow: payment - paid,
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
});
});
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK);
const rows = [];
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
dataItems.forEach(d => {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
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.sign_month || "").substring(0, 7) === selMonth).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
}
if (state.planView === 'quarterly') {
const allPfs = pfs;
const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
const now = new Date();
if (state.planQuarter === undefined) {
const saved = localStorage.getItem("opc-fin-quarter");
state.planQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
}
const selQ = state.planQuarter;
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;
};
const sumExpense = (pf, field) => {
let total = 0;
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total;
};
let dataItems = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
const gross = sumBudget(pf, "gross");
const cost = sumExpense(pf, "cost");
const paid = sumExpense(pf, "paid");
if (!rev && !payment && !cost && !paid && !gross) return;
dataItems.push({
pf: pf,
customer_name: pf.customer_name || '',
sign_amount: pf.sign_amount || 0,
sign_month: pf.sign_month || '',
rev: rev, gross: gross, cost: cost,
profit: gross, payment: payment, paid: paid,
cashflow: payment - paid,
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
});
});
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
dataItems.forEach(d => {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
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 => qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
}
// 总视图
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; });
let cost = 0, paid = 0;
expense.forEach(e => { cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 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;
var dataItems = allPfs.map(pf => {
var t = calcTotals(pf);
var cashflow = t.payment - t.paid;
return {
pf: pf,
customer_name: pf.customer_name || '',
sign_amount: pf.sign_amount || 0,
rev: t.rev, gross: t.gross, cost: t.cost,
profit: t.gross, payment: t.payment, paid: t.paid,
cashflow: cashflow,
exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
};
});
var sortK = null;
if (state.planSort && state.planSort.key && state.planSort.key.startsWith('sig|')) sortK = state.planSort.key.split('|')[1];
dataItems = sortData(dataItems, sortK);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
dataItems.forEach(d => {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
})()}
`;
if (window.lucide) window.lucide.createIcons();
}
window.openPlanModal = () => {
const modal = document.querySelector("#planModal");
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) {
planInitRevpayTable(null);
planInitCostTable(null);
planInitTaskTable(null);
document.querySelector("#planDeleteBtn").classList.add("hidden");
}
modal.classList.remove("hidden");
};
window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#plan_revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `