From 8a78c756b78b7b386695c0ee2d172850eaaaeca0 Mon Sep 17 00:00:00 2001 From: mac Date: Mon, 6 Jul 2026 15:39:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=A1=B9=E7=9B=AE=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=87=8D=E6=9E=84=20=E2=80=94=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20+=20=E9=85=8D=E7=BD=AE=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=20+=20=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心重构: - 提取 renderView() / tdRow() 统一函数,消除三视图 16000+ 字符重复代码 - METRIC_CARDS 配置驱动,调整卡片顺序/单位只需改配置 - 筛选区统一为下拉框(视图 + 状态 + 日期),删除筛选:前缀 - 日历选择器移至顶部工具栏,卡片内不再重复 功能优化: - 项目状态简化为已签约/待签约 - 新增签约项目总数卡片(月度/季度按签约月份过滤) - 签约金额/已确收/已回款/应付/已付改为万元单位 - 项目名称固定300px + 省略号 + title悬停 - body min-width 1400px + overflow-x:auto - 首页费用金额→应付金额 代码清理: - 删除 monthOpts/quarterOpts/qLabels 等死代码 - 删除 purchase.js(采购→费用模块已另表迁移) - 财务→项目(侧边栏),采购→费用(全域重命名) --- backend/migrations/tables.py | 2 +- backend/routes.py | 8 +- static/modules/expense.js | 103 ++++++++++++++++ static/modules/finance.js | 219 ++++++++++++++++++++++------------- static/modules/home.js | 2 +- static/modules/purchase.js | 103 ---------------- static/modules/utils.js | 2 +- static/styles.css | 3 +- templates/index.html | 16 +-- 9 files changed, 259 insertions(+), 199 deletions(-) create mode 100644 static/modules/expense.js delete mode 100644 static/modules/purchase.js 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 => ``).join("") : ''}
项目名称采购内容供应商金额采购日期状态分类备注
${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) || '—'}
暂无费用记录
合计(${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(`
${thead}${tbody}
`, "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 ``; }).join("")}
项目名称状态签约金额已确收执行率已回款回款率应付已付付款率毛利毛利率现金流
${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) : ''}
`, "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 => ``).join("") : ''}
项目名称采购内容供应商金额采购日期状态分类备注
${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) || '—'}
暂无采购记录
合计(${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 @@ 首页
-