diff --git a/backend/flask_app.py b/backend/flask_app.py index 1ba601d..160619f 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -28,6 +28,19 @@ if not _secret_key: ) app.secret_key = _secret_key app.config["TEMPLATES_AUTO_RELOAD"] = True + +# 自动读取 git tag 作为版本号,保持左上角与发布 tag 一致 +import subprocess as _sp +def _git_version(): + try: + tags = _sp.check_output( + ["git", "tag", "--sort=-v:refname"], + cwd=_backend_dir, stderr=_sp.DEVNULL, + ).decode().strip().split("\n") + return tags[0] if tags and tags[0] else "dev" + except Exception: + return "dev" +app.jinja_env.globals["app_version"] = _git_version() app.register_blueprint(bp) # 模块级执行迁移(保留 gunicorn --preload 行为:导入即触发) diff --git a/static/modules/finance.js b/static/modules/finance.js index 13d2722..2dad419 100644 --- a/static/modules/finance.js +++ b/static/modules/finance.js @@ -98,7 +98,7 @@ function renderFinance() { })(); const sm = pf.sign_month || ""; const signMonthCell = `${sm || '—'}`; - return `${esc(pf.customer_name)}${signMonthCell}${money(pf.sign_amount)}${mCols}${totalCol}`; + return `${esc(pf.customer_name)}${signMonthCell}${money(pf.sign_amount)}${mCols}${totalCol}`; }; const now2 = new Date(); @@ -119,7 +119,7 @@ function renderFinance() { const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3); const activeMonths = qMonths[activeQ]; const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027]; - const finHeaderBase = `
年份:||季度:${qLabels.map((q,i) => ``).join('')}|月度:${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return ``; }).join('')}
`; + const finHeaderBase = `
年份:||季度:${qLabels.map((q,i) => ``).join('')}|月度:${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return ``; }).join('')}
`; const finAddBtn = ``; const isOverviewMode = false; @@ -349,13 +349,13 @@ function renderFinance() { const payR = payRVal !== null ? '' + payRVal + '%' : ''; const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : ''; const payable = cost - paid; - const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-700'; + const payableCls = payable > 0 ? 'text-green-600' : payable < 0 ? 'text-red-600' : 'text-slate-700'; const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : ''; 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 pending = rev - payment; - const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700'; + const pendingCls = pending > 0 ? 'text-green-600' : pending < 0 ? 'text-red-600' : 'text-slate-700'; const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : ''; var budgetArr = []; var expenseArr = []; @@ -379,11 +379,11 @@ function renderFinance() { var bCf = bPay - ePaid; return '' + m + '' + (bRev ? money(bRev) : '—') + '' + (bGross ? money(bGross) : '—') + '' + (bPay ? money(bPay) : '—') + '' + (eCost ? money(eCost) : '—') + '' + (ePaid ? money(ePaid) : '—') + '' + (bCf ? money(bCf) : '—') + '' + (bRev - bPay ? money(bRev - bPay) : '—') + ''; }).join(''); - return `${esc(pf.customer_name)}${money(pf.sign_amount)}${fmtNoUnit(rev)}${fmtNoUnit(gross)}${grossR}${fmtNoUnit(payment)}${pendingVal}${payR}${fmtNoUnit(cost)}${fmtNoUnit(paid)}${payableVal}${paidR}${cf}${detailRows}
月份确收毛利回款成本支出现金流应收
`; + return `${esc(pf.customer_name)}${money(pf.sign_amount)}${fmtNoUnit(rev)}${fmtNoUnit(gross)}${grossR}${fmtNoUnit(payment)}${pendingVal}${payR}${fmtNoUnit(cost)}${fmtNoUnit(paid)}${payableVal}${paidR}${cf}${detailRows}
月份确收毛利回款成本支出现金流应收
`; }; // 待签约简版行 const tdRowUnsigned = (pf, cost, gross) => { - return `${esc(pf.customer_name)}${money(pf.sign_amount)}${pf.sign_month || '—'}${fmtNoUnit(gross)}${fmtNoUnit(cost)}`; + return `${esc(pf.customer_name)}${money(pf.sign_amount)}${pf.sign_month || '—'}${fmtNoUnit(gross)}${fmtNoUnit(cost)}`; }; if (state.finView === 'monthly') { @@ -1104,11 +1104,12 @@ function renderFinanceOverview() { for (var dri = 0; dri < 3; dri++) { var dr = deptRows[dri]; var rowCls = dri === 2 ? 'font-semibold' : ''; - var valCls = dri === 2 ? 'text-green-600' : 'text-slate-700'; + var valCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700'; var rowHtml = '' + dr.label + ''; rowHtml += '' + moneyIntL(dr.annual) + ''; for (var dmi = 0; dmi < 12; dmi++) { - rowHtml += '' + moneyIntL(dr.vals[dmi]) + ''; + var cellCls = dri === 2 ? (dr.vals[dmi] >= 0 ? 'text-green-600' : 'text-red-600') : valCls; + rowHtml += '' + moneyIntL(dr.vals[dmi]) + ''; } deptTbody += rowHtml + ''; } @@ -1151,7 +1152,7 @@ function renderFinanceOverview() { } var cfCard = card('

部门现金流 部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入

' + deptThead + '' + cfTbody + '
', 'p-4'); - const yearSelectOv = `
📅 选择年份:
`; + const yearSelectOv = `
📅 选择年份:
`; document.querySelector("#financeOverview").innerHTML = yearSelectOv + '
' + deptCard + cfCard + @@ -1292,7 +1293,7 @@ function renderFinanceQuarterlyOverview() { cfTbody += rowHtml + ''; } - const yearSelectQ = `
📅 选择年份:
`; + const yearSelectQ = `
📅 选择年份:
`; document.querySelector("#financeQuarterlyOverview").innerHTML = yearSelectQ + '
' + card('

部门损益情况 部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出

' + deptThead + '' + deptTbody + '
', 'p-4') + card('

部门现金流 部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入

' + deptThead + '' + cfTbody + '
', 'p-4') + @@ -1343,7 +1344,21 @@ window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; loc // ---- 项目标记上下文菜单 ---- -if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })(); +if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { color: {} }; } catch(e) { return { color: {} }; } })(); +if (!state._fmMarks.color) state._fmMarks.color = {}; + +const FM_COLORS = [ + { key: 'red', label: '标红', hex: '#ef4444', bg: 'bg-red-50' }, + { key: 'yellow', label: '标黄', hex: '#eab308', bg: 'bg-yellow-50' }, + { key: 'blue', label: '标蓝', hex: '#3b82f6', bg: 'bg-blue-50' }, + { key: 'green', label: '标绿', hex: '#22c55e', bg: 'bg-green-50' }, + { key: 'purple', label: '标紫', hex: '#a855f7', bg: 'bg-purple-50' }, +]; +const fmMarkBg = function(id) { + var c = (state._fmMarks.color||{})[id]; + return c ? { red:'bg-red-50',yellow:'bg-yellow-50',blue:'bg-blue-50',green:'bg-green-50',purple:'bg-purple-50'}[c] || '' : ''; +}; + window.finShowCtx = function(e, pfId) { e.preventDefault(); var menu = document.getElementById('finCtxMenu'); @@ -1352,12 +1367,27 @@ window.finShowCtx = function(e, pfId) { menu.id = 'finCtxMenu'; menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px'; menu.innerHTML = '
项目标记
' + - '
标红
'; + FM_COLORS.map(function(c) { + return '
' + c.label + '
'; + }).join('') + + '
' + + '
取消标记
'; menu.addEventListener('click', function(ev) { + var target = ev.target.closest('[data-act]'); + if (!target) return; ev.stopPropagation(); + var act = target.getAttribute('data-act'); var id = menu.getAttribute('data-pfid'); menu.style.display = 'none'; - if (state._fmMarks.red[id]) { delete state._fmMarks.red[id]; } else { state._fmMarks.red[id] = true; } + if (act === 'clear') { + delete state._fmMarks.color[id]; + } else { + if (state._fmMarks.color[id] === act) { + delete state._fmMarks.color[id]; + } else { + state._fmMarks.color[id] = act; + } + } localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks)); renderFinance(); }); @@ -1366,15 +1396,6 @@ window.finShowCtx = function(e, pfId) { menu.setAttribute('data-pfid', pfId); menu.style.display = 'block'; menu.style.left = e.pageX + 'px'; - menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px'; + menu.style.top = Math.min(e.pageY, window.innerHeight - 280) + 'px'; }; document.addEventListener('click', function() { var m = document.getElementById('finCtxMenu'); if (m) m.style.display = 'none'; }); - -window.finCtxMenu_act = function(e, type) { - e.stopPropagation(); - var pfId = document.getElementById("finCtxMenu").getAttribute("data-pfid"); - if (type === "clear") { delete state._fmMarks.red[pfId]; delete state._fmMarks.pin[pfId]; } - else { if (state._fmMarks[type][pfId]) { delete state._fmMarks[type][pfId]; } else { state._fmMarks[type][pfId] = true; } } - localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks)); - renderFinance(); -}; diff --git a/static/modules/plan.js b/static/modules/plan.js index 96b1ec0..91dc31b 100644 --- a/static/modules/plan.js +++ b/static/modules/plan.js @@ -103,15 +103,15 @@ function renderPlan() { const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0"); if (!state.planMonth) state.planMonth = defaultMonth2; if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3); - if (!state.planYear) state.planYear = now2.getFullYear(); - if (!state.planView) state.planView = 'annual'; + if (state.planYear == null) state.planYear = now2.getFullYear(); + if (!['annual','quarterly','monthly'].includes(state.planView)) state.planView = 'annual'; const qLabels = ['Q1','Q2','Q3','Q4']; const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]]; const nowD = new Date(); const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3); const activeMonths = qMonths[activeQ]; - const yearOpts = [2024,2025,2026,2027]; - const finHeaderBase = `
|年份:||季度:${qLabels.map((q,i) => ``).join('')}|月度:${activeMonths.map(m => { const ms = `${state.planYear}-${String(m).padStart(2,'0')}`; return ``; }).join('')}
`; + const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027]; + const finHeaderBase = `
年份:||季度:${qLabels.map((q,i) => ``).join('')}|月度:${activeMonths.map(m => { const ms = `${state.planYear}-${String(m).padStart(2,'0')}`; return ``; }).join('')}
`; const finAddBtn = ``; const isOverviewMode = false; @@ -369,10 +369,10 @@ function renderPlan() { const cf = cashflow ? money(cashflow) : ''; const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600'; const payable = cost - paid; - const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-400'; + const payableCls = payable > 0 ? 'text-green-600' : payable < 0 ? 'text-red-600' : 'text-slate-400'; const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : ''; const pending = rev - payment; - const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700'; + const pendingCls = pending > 0 ? 'text-green-600' : pending < 0 ? 'text-red-600' : 'text-slate-700'; const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : ''; var budgetArr = []; var expenseArr = []; @@ -463,12 +463,12 @@ function renderPlan() { 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; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.planYear) total += parseFloat(b[field] || 0); }); } catch (e) {} + try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && (y === state.planYear || state.planYear === 0)) 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; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.planYear) total += parseFloat(e[field] || 0); }); } catch (e) {} + try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && (y === state.planYear || state.planYear === 0)) total += parseFloat(e[field] || 0); }); } catch (e) {} return total; }; let dataItems = []; @@ -1113,6 +1113,8 @@ function renderPlanOverview() { for (var ei = 0; ei < planExp.length; ei++) { var em = (planExp[ei].expense_month || '').substring(0, 7); var mi = parseInt(em.substring(5)) - 1; + var emYear = parseInt(em.substring(0, 4)) || 0; + if (state.planYear !== 0 && emYear && emYear !== state.planYear) continue; if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[ei].amount || 0); } var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei]; @@ -1149,6 +1151,8 @@ function renderPlanOverview() { for (var epi = 0; epi < planExp.length; epi++) { var epm = (planExp[epi].expense_month || '').substring(0, 7); var epmi = parseInt(epm.substring(5)) - 1; + var epmYear = parseInt(epm.substring(0, 4)) || 0; + if (state.planYear !== 0 && epmYear && epmYear !== state.planYear) continue; if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[epi].incurred_amount || 0); } var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi]; @@ -1175,14 +1179,15 @@ function renderPlanOverview() { } var cfCard = card('

部门现金流 部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入

' + deptThead + '' + cfTbody + '
', 'p-4'); - document.querySelector("#planOverview").innerHTML = '
' + + const yearSelectOv = `
📅 选择年份:
`; + document.querySelector("#planOverview").innerHTML = yearSelectOv + '
' + deptCard + cfCard + card('

项目经营详情 所有项目产生的签单,确收,毛利,回款,成本,支出,现金流

' + thead + '' + tbody + '
', 'p-4') + '
' + - card('

月度签约趋势

' + state.planYear + '
', 'p-4') + - card('

月度确收与毛利

' + state.planYear + '
', 'p-4') + - card('

月度回款与已付

' + state.planYear + '
', 'p-4') + + card('

月度签约趋势

' + (state.planYear===0 ? '所有年份' : state.planYear) + '
', 'p-4') + + card('

月度确收与毛利

' + (state.planYear===0 ? '所有年份' : state.planYear) + '
', 'p-4') + + card('

月度回款与已付

' + (state.planYear===0 ? '所有年份' : state.planYear) + '
', 'p-4') + '
'; if (window.lucide) window.lucide.createIcons(); @@ -1312,7 +1317,8 @@ function renderPlanQuarterlyOverview() { cfTbody += rowHtml + ''; } - document.querySelector("#planQuarterlyOverview").innerHTML = '
' + + const yearSelectQ = `
📅 选择年份:
`; + document.querySelector("#planQuarterlyOverview").innerHTML = yearSelectQ + '
' + card('

部门损益情况 部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出

' + deptThead + '' + deptTbody + '
', 'p-4') + card('

部门现金流 部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入

' + deptThead + '' + cfTbody + '
', 'p-4') + card('

项目经营详情 所有项目产生的签单,确收,毛利,回款,成本,支出,现金流

' + thead + '' + tbody + '
', 'p-4') + diff --git a/templates/index.html b/templates/index.html index 7c8e144..cc19cce 100644 --- a/templates/index.html +++ b/templates/index.html @@ -106,7 +106,7 @@
-

OPC Manager v1.2.0.41

+

OPC Manager {{ app_version }}

科普 OPC 工作台