diff --git a/backend/migrations/tables.py b/backend/migrations/tables.py
index df6e67e..5cfb3d6 100644
--- a/backend/migrations/tables.py
+++ b/backend/migrations/tables.py
@@ -147,7 +147,7 @@ def migrate_create_tables():
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
- """CREATE TABLE IF NOT EXISTS purchase_records (
+ """CREATE TABLE IF NOT EXISTS expense_records (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_name VARCHAR(200) NOT NULL DEFAULT '',
diff --git a/backend/routes.py b/backend/routes.py
index 4dc6f3f..3d20fc9 100644
--- a/backend/routes.py
+++ b/backend/routes.py
@@ -24,7 +24,7 @@ TABLES = {
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
- "purchase": ("purchase_records", ["project_name", "purchase_item", "supplier", "amount", "purchase_date", "status", "category", "notes", "tenant"]),
+ "expense": ("expense_records", ["project_name", "purchase_item", "supplier", "amount", "purchase_date", "status", "category", "notes", "tenant"]),
}
# ---------- 鉴权装饰器 ----------
@@ -322,7 +322,7 @@ def bootstrap():
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
"risks": [],
}
- return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "purchase": [], "tenant": tenant, "tenants": allowed})
+ return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "expense": [], "tenant": tenant, "tenants": allowed})
def q(sql, *args):
return rows(conn, sql, args)
@@ -333,7 +333,7 @@ def bootstrap():
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
- purchase = q("SELECT * FROM purchase_records WHERE tenant=? ORDER BY id DESC", tenant)
+ expense = q("SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", tenant)
signed_pfs = [x for x in pfs if x["status"] == "已签约"]
def parse_budget(pf):
@@ -436,7 +436,7 @@ def bootstrap():
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
}
- return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "purchase": purchase, "tenant": tenant, "tenants": allowed})
+ return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "expense": expense, "tenant": tenant, "tenants": allowed})
finally:
conn.close()
diff --git a/static/modules/expense.js b/static/modules/expense.js
new file mode 100644
index 0000000..b7cdebb
--- /dev/null
+++ b/static/modules/expense.js
@@ -0,0 +1,103 @@
+// expense.js — 费用管理模块
+
+function renderExpense() {
+ const records = state.data.expense || [];
+ const money = (v) => `¥ ${(Number(v || 0)).toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
+ const badge = (s) => {
+ const m = { "已审批": "green", "已采购": "blue", "已完成": "slate", "待审批": "amber" };
+ const c = m[s] || "amber";
+ return `${s}`;
+ };
+ const esc = (s) => (s || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+
+ const totalAmount = records.reduce((s, r) => s + (r.amount || 0), 0);
+
+ document.querySelector("#expense").innerHTML = `
+
+
费用记录${records.length}
+
费用金额${money(totalAmount)}
+
待审批${records.filter(r => r.status === '待审批').length}
+
+
+
费用列表 (${records.length})
+
+
+
+
| 项目名称 | 采购内容 | 供应商 | 金额 | 采购日期 | 状态 | 分类 | 备注 |
${records.length ? records.map(r => `| ${esc(r.project_name)} | ${esc(r.purchase_item)} | ${esc(r.supplier)} | ${money(r.amount)} | ${r.purchase_date || '—'} | ${badge(r.status)} | ${esc(r.category)} | ${esc(r.notes) || '—'} |
`).join("") : '| 暂无费用记录 |
'}| 合计(${records.length} 条) | ${money(totalAmount)} | |
+
`;
+ if (window.lucide) window.lucide.createIcons();
+}
+
+window.openExpenseModal = () => {
+ const form = document.querySelector("#expenseModal form");
+ form.reset();
+ form.querySelector('[name="expense_id"]').value = "";
+ document.querySelector("#expenseModalTitle").textContent = "新增费用";
+ document.querySelector("#expenseDeleteBtn").classList.add("hidden");
+ document.querySelector("#expenseModal").classList.remove("hidden");
+};
+
+window.closeExpenseModal = () => {
+ document.querySelector("#expenseModal").classList.add("hidden");
+};
+
+window.openExpenseEdit = (id) => {
+ const r = (state.data.expense || []).find(x => x.id === id);
+ if (!r) return;
+ const form = document.querySelector("#expenseModal form");
+ form.reset();
+ const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
+ setVal("expense_id", r.id);
+ setVal("project_name", r.project_name);
+ setVal("purchase_item", r.purchase_item);
+ setVal("supplier", r.supplier);
+ setVal("amount", r.amount);
+ setVal("purchase_date", r.purchase_date);
+ setVal("status", r.status);
+ setVal("category", r.category);
+ setVal("notes", r.notes);
+ document.querySelector("#expenseModalTitle").textContent = "编辑费用";
+ document.querySelector("#expenseDeleteBtn").classList.remove("hidden");
+ document.querySelector("#expenseModal").classList.remove("hidden");
+};
+
+window.saveExpense = async (event) => {
+ event.preventDefault();
+ const form = event.target;
+ const data = Object.fromEntries(new FormData(form).entries());
+ const isEdit = !!data.expense_id;
+ delete data.expense_id;
+
+ const url = isEdit
+ ? `/api/expense/${form.querySelector('[name="expense_id"]').value}`
+ : "/api/expense";
+ const method = isEdit ? "PUT" : "POST";
+
+ const resp = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { ...data, tenant: state.tenant } }) });
+ if (!resp.ok) { alert("保存失败"); return; }
+
+ // Refresh data
+ const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
+ if (bResp.ok) {
+ const bd = await bResp.json();
+ state.data = bd;
+ applyUserTenants();
+ renderExpense();
+ }
+ closeExpenseModal();
+};
+
+window.deleteExpenseItem = async () => {
+ const id = document.querySelector("#expenseModal form [name='expense_id']").value;
+ if (!id || !confirm("确定删除?")) return;
+ const resp = await fetch(`/api/expense/${id}`, { method: "DELETE" });
+ if (!resp.ok) { alert("删除失败"); return; }
+ const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
+ if (bResp.ok) {
+ const bd = await bResp.json();
+ state.data = bd;
+ applyUserTenants();
+ renderExpense();
+ }
+ closeExpenseModal();
+};
diff --git a/static/modules/finance.js b/static/modules/finance.js
index 3fe5b28..8a635d4 100644
--- a/static/modules/finance.js
+++ b/static/modules/finance.js
@@ -25,12 +25,10 @@ function renderFinance() {
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 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 = [];
@@ -103,11 +101,23 @@ function renderFinance() {
return `| ${esc(pf.customer_name)} | ${signMonthCell}${money(pf.sign_amount)} | ${mCols}${totalCol}
`;
};
- const finHeaderBase = `|筛选:状态:`;
+ const now2 = new Date();
+ const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
+ if (!state.finMonth) state.finMonth = defaultMonth2;
+ if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
+ const monthSet2 = new Set([defaultMonth2]);
+ pfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet2.add(b.month); }); });
+ const allMonths = [...monthSet2].sort().reverse();
+ const qLabels2 = ["Q1 (1-3月)","Q2 (4-6月)","Q3 (7-9月)","Q4 (10-12月)"];
+ const toolMonthSelect = allMonths.map(m => '').join("");
+ const toolQuarterSelect = qLabels2.map((l,i) => '').join("");
+ const toolDateSelect = state.finView==='monthly'?'月份:':state.finView==='quarterly'?'季度:':'';
+
+ const finHeaderBase = `视图:状态:${toolDateSelect}`;
const finAddBtn = ``;
document.querySelector("#finance").innerHTML = `
-
${finHeaderBase}
${finAddBtn}
+ ${card(`
${finHeaderBase}
${finAddBtn}
`, "p-3")}
- ${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 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 => 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 },
+ ];
+
+ 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 = '' +
+ '| 项目名称 | ' +
+ '状态 | ' +
+ '签约金额 | ' +
+ '已确收 | ' +
+ '执行率 | ' +
+ '毛利 | ' +
+ '毛利率 | ' +
+ '已回款 | ' +
+ '回款率 | ' +
+ '应付 | ' +
+ '已付 | ' +
+ '付款率 | ' +
+ '现金流 | ' +
+ '
';
+ const tbody = rows.length ? `${rows.join("")}` : `| ${emptyText} |
`;
+
+ return card(`${cards.map(([l, v, c]) => '
' + l + '' + v + '
').join("")}
`, "p-4") +
+ card(``, "p-4");
};
- 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 fmtNoUnit = (v) => v ? `${Number(v||0).toLocaleString('zh-CN')}` : '—';
+ const tdRow = (pf, rev, payment, cost, paid, gross) => {
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 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';
+ return `| ${esc(pf.customer_name)} | ${st} | ${money(pf.sign_amount)} | ${fmtNoUnit(rev)} | ${exe} | ${fmtNoUnit(gross)} | ${grossR} | ${fmtNoUnit(payment)} | ${payR} | ${fmtNoUnit(cost)} | ${fmtNoUnit(paid)} | ${paidR} | ${cf} |
`;
+ };
+
+ 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;
+ 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;
+ sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
+ rows.push(tdRow(pf, rev, payment, cost, paid, 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.status === "已签约" && (x.sign_month || "").substring(0, 7) === selMonth).length;
+ return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, 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;
+ 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");
+ sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
+ rows.push(tdRow(pf, rev, payment, cost, paid, 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 => x.status === "已签约" && 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 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; });
+ 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");
+ let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 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;
+ rows.push(tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross));
+ });
+ 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, signTotal, signCnt, '暂无数据');
})()}
`;
if (window.lucide) window.lucide.createIcons();
diff --git a/static/modules/home.js b/static/modules/home.js
index 8b190d7..c36426b 100644
--- a/static/modules/home.js
+++ b/static/modules/home.js
@@ -67,7 +67,7 @@ window.toggleFinCard = (id) => {
["产品迭代", m.upcoming_products, "products"],
].map(([label, value, tab]) => ``).join("")}
`}
- ${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}${tblCard("回款金额", rows4)}${tblCard("费用金额", rows5)}
+ ${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}${tblCard("回款金额", rows4)}${tblCard("应付金额", rows5)}
${card(`
月度签约趋势
2026`, "p-4")}
${card(`
月度确收与毛利
2026`, "p-4")}
diff --git a/static/modules/purchase.js b/static/modules/purchase.js
deleted file mode 100644
index 7af8dfa..0000000
--- a/static/modules/purchase.js
+++ /dev/null
@@ -1,103 +0,0 @@
-// purchase.js — 采购管理模块
-
-function renderPurchase() {
- const records = state.data.purchase || [];
- const money = (v) => `¥ ${(Number(v || 0)).toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
- const badge = (s) => {
- const m = { "已审批": "green", "已采购": "blue", "已完成": "slate", "待审批": "amber" };
- const c = m[s] || "amber";
- return `
${s}`;
- };
- const esc = (s) => (s || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
-
- const totalAmount = records.reduce((s, r) => s + (r.amount || 0), 0);
-
- document.querySelector("#purchase").innerHTML = `
-
-
采购记录${records.length}
-
采购金额${money(totalAmount)}
-
待审批${records.filter(r => r.status === '待审批').length}
-
-
-
采购列表 (${records.length})
-
-
-
-
| 项目名称 | 采购内容 | 供应商 | 金额 | 采购日期 | 状态 | 分类 | 备注 |
${records.length ? records.map(r => `| ${esc(r.project_name)} | ${esc(r.purchase_item)} | ${esc(r.supplier)} | ${money(r.amount)} | ${r.purchase_date || '—'} | ${badge(r.status)} | ${esc(r.category)} | ${esc(r.notes) || '—'} |
`).join("") : '| 暂无采购记录 |
'}| 合计(${records.length} 条) | ${money(totalAmount)} | |
-
`;
- if (window.lucide) window.lucide.createIcons();
-}
-
-window.openPurchaseModal = () => {
- const form = document.querySelector("#purchaseModal form");
- form.reset();
- form.querySelector('[name="purchase_id"]').value = "";
- document.querySelector("#purchaseModalTitle").textContent = "新增采购";
- document.querySelector("#purchaseDeleteBtn").classList.add("hidden");
- document.querySelector("#purchaseModal").classList.remove("hidden");
-};
-
-window.closePurchaseModal = () => {
- document.querySelector("#purchaseModal").classList.add("hidden");
-};
-
-window.openPurchaseEdit = (id) => {
- const r = (state.data.purchase || []).find(x => x.id === id);
- if (!r) return;
- const form = document.querySelector("#purchaseModal form");
- form.reset();
- const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
- setVal("purchase_id", r.id);
- setVal("project_name", r.project_name);
- setVal("purchase_item", r.purchase_item);
- setVal("supplier", r.supplier);
- setVal("amount", r.amount);
- setVal("purchase_date", r.purchase_date);
- setVal("status", r.status);
- setVal("category", r.category);
- setVal("notes", r.notes);
- document.querySelector("#purchaseModalTitle").textContent = "编辑采购";
- document.querySelector("#purchaseDeleteBtn").classList.remove("hidden");
- document.querySelector("#purchaseModal").classList.remove("hidden");
-};
-
-window.savePurchase = async (event) => {
- event.preventDefault();
- const form = event.target;
- const data = Object.fromEntries(new FormData(form).entries());
- const isEdit = !!data.purchase_id;
- delete data.purchase_id;
-
- const url = isEdit
- ? `/api/purchase/${form.querySelector('[name="purchase_id"]').value}`
- : "/api/purchase";
- const method = isEdit ? "PUT" : "POST";
-
- const resp = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { ...data, tenant: state.tenant } }) });
- if (!resp.ok) { alert("保存失败"); return; }
-
- // Refresh data
- const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
- if (bResp.ok) {
- const bd = await bResp.json();
- state.data = bd;
- applyUserTenants();
- renderPurchase();
- }
- closePurchaseModal();
-};
-
-window.deletePurchaseItem = async () => {
- const id = document.querySelector("#purchaseModal form [name='purchase_id']").value;
- if (!id || !confirm("确定删除?")) return;
- const resp = await fetch(`/api/purchase/${id}`, { method: "DELETE" });
- if (!resp.ok) { alert("删除失败"); return; }
- const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
- if (bResp.ok) {
- const bd = await bResp.json();
- state.data = bd;
- applyUserTenants();
- renderPurchase();
- }
- closePurchaseModal();
-};
diff --git a/static/modules/utils.js b/static/modules/utils.js
index 80abc87..ca68a08 100644
--- a/static/modules/utils.js
+++ b/static/modules/utils.js
@@ -147,7 +147,7 @@ function render() {
renderProposals();
renderProducts();
renderFinance();
- renderPurchase();
+ renderExpense();
if (window.lucide) window.lucide.createIcons();
}
diff --git a/static/styles.css b/static/styles.css
index 9b6f4df..4f65346 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -1,5 +1,6 @@
body {
- min-width: 1180px;
+ min-width: 1400px;
+ overflow-x: auto;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
diff --git a/templates/index.html b/templates/index.html
index b71b605..c7352fb 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -49,13 +49,13 @@
首页
-