buhanka-finance-app 1.74.0 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.html +72 -12
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -309,7 +309,7 @@ tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--ac
309
309
 
310
310
  <script>
311
311
  /* ============================================================ БУХАНКА · Финансы v2 ============================================================ */
312
- const APP_VERSION='10.8';
312
+ const APP_VERSION='10.9.1';
313
313
  const SCHEMA=9; /* версия структуры данных; растёт при доработках */
314
314
  const STORE_KEY='buhanka_finance_v9';/* НЕ МЕНЯТЬ НИКОГДА — иначе данные «слетят» при обновлении файла */
315
315
  const LEGACY_KEYS=['buhanka_finance_v8','buhanka_finance_v7','buhanka_finance_v6','buhanka_finance_v5'];
@@ -821,8 +821,9 @@ const isCashOp=o=>o.cash!==false;
821
821
  /* ── каналы выручки: безнал / Яндекс Еда / наличные ── */
822
822
  const CHANNELS=[['bank','Безналичные'],['yandex','Яндекс Еда'],['cash','Наличные']];
823
823
  const CH_NAME=Object.fromEntries(CHANNELS);
824
+ const CH_ALIAS={acq:'bank',card:'bank',sbp:'bank',terminal:'bank'};
824
825
  function opChannel(o){
825
- if(o.ch) return o.ch;
826
+ if(o.ch) return CH_ALIAS[o.ch]||(CH_NAME[o.ch]?o.ch:'bank');
826
827
  const a=(ST.accounts||[]).find(x=>x.name===o.account);
827
828
  return (a&&accType(a)==='cash')?'cash':'bank';
828
829
  }
@@ -948,6 +949,7 @@ function cfGroupRaw(cat){
948
949
  if(/оборудован|ремонт|обеспечительн|капвлож/.test(c)) return 'inv';
949
950
  return 'op';
950
951
  }
952
+ function isIncomeCat(cat){ const c=(ST.opCats||[]).find(x=>x.name===cat); return c?c.grp==='in':(cat==='Выручка'); }
951
953
  function cfGroup(cat){
952
954
  const c=(ST.opCats||[]).find(x=>x.name===cat);
953
955
  if(c) return c.grp==='in'?'op':c.grp;
@@ -968,8 +970,10 @@ function planSet(mo,d,key,v){ if(isLocked(mo)){toast('Месяц закрыт');
968
970
  ST.plan=ST.plan||{}; ST.plan[mo]=ST.plan[mo]||{}; ST.plan[mo][d]=ST.plan[mo][d]||{};
969
971
  const n=parseNum(v); if(n==null)delete ST.plan[mo][d][key]; else ST.plan[mo][d][key]=Math.abs(n);
970
972
  save(); render(); }
971
- function fixedPlanDay(cat,d){ return (ST.fixed||[]).filter(f=>f.active&&f.category===cat&&parseInt(f.payDay)===d)
972
- .reduce((s,f)=>s+Number(f.amount||0),0); }
973
+ function fixedPlanDay(cat,d,mo){
974
+ if(mo&&(ST.operations||[]).some(o=>o.month===mo&&o.category===cat&&o.type==='Расход'&&isCashOp(o))) return 0;
975
+ return (ST.fixed||[]).filter(f=>f.active&&f.category===cat&&parseInt(f.payDay)===d)
976
+ .reduce((s,f)=>s+Number(f.amount||0),0); }
973
977
  /* последний день месяца, по который заведены операции */
974
978
  function lastOpDayOf(mi){ const pre=`${YEAR}-${String(mi+1).padStart(2,'0')}-`; let d=0;
975
979
  (ST.operations||[]).forEach(o=>{ if(o.date&&o.date.indexOf(pre)===0){ const n=Number(o.date.slice(8,10))||0; if(n>d)d=n; } });
@@ -1015,10 +1019,11 @@ function cfDayMatrix(mi){
1015
1019
  if(g==='fin'){ fin[o.category]=(fin[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
1016
1020
  if(g==='inv'){ inv[o.category]=(inv[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
1017
1021
  if(o.type==='Доход'){ const ch=opChannel(o); inc[ch]=(inc[ch]||0)+amt; }
1022
+ else if(isIncomeCat(o.category)){ const ch=opChannel(o); inc[ch]=(inc[ch]||0)-amt; } /* возврат покупки — минус к выручке */
1018
1023
  else exp[o.category]=(exp[o.category]||0)+amt; });
1019
1024
  if(!actual){ /* прогноз: постоянные расходы + ручные правки */
1020
1025
  CHANNELS.forEach(([k])=>{ const m=planVal(mo,d,'ch:'+k); if(m!=null) inc[k]=m; });
1021
- cats.op.forEach(c=>{ const m=planVal(mo,d,'ex:'+c); if(m!=null) exp[c]=m; else { const f=fixedPlanDay(c,d); if(f) exp[c]=(exp[c]||0)+f; } });
1026
+ cats.op.forEach(c=>{ const m=planVal(mo,d,'ex:'+c); if(m!=null) exp[c]=m; else { const f=fixedPlanDay(c,d,mo); if(f) exp[c]=(exp[c]||0)+f; } });
1022
1027
  cats.fin.forEach(c=>{ const m=planVal(mo,d,'fi:'+c); if(m!=null) fin[c]=-m; });
1023
1028
  cats.inv.forEach(c=>{ const m=planVal(mo,d,'in:'+c); if(m!=null) inv[c]=-m; });
1024
1029
  }
@@ -1970,15 +1975,70 @@ function renderCF(C){
1970
1975
  }
1971
1976
 
1972
1977
  /* ---------- CF обратный ---------- */
1978
+ /* сумма операций по статье со знаком: доход «+», расход «−» */
1979
+ function opsSigned(mm,cat){ return (ST.operations||[]).filter(o=>mm.includes(o.month)&&isCashOp(o)&&!isTransfer(o)&&(cat==null||o.category===cat))
1980
+ .reduce((s,o)=>s+(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)),0); }
1981
+ /* мост: чистая прибыль по начислению → фактическое движение денег */
1982
+ function cfiBridge(period){
1983
+ const mm=periodMonths(period);
1984
+ const pnlByCat={}, order=[];
1985
+ structOf('Общий').forEach(r=>{ if(r.role!=='value')return;
1986
+ const cs=rowCats(r); if(!cs.length)return;
1987
+ cs.forEach(c=>{ if(pnlByCat[c]==null){ pnlByCat[c]=0; order.push(c); } });
1988
+ pnlByCat[cs[0]]+=rowFact('Общий',mm,r); });
1989
+ const extra=[];
1990
+ (ST.operations||[]).forEach(o=>{ if(isTransfer(o)||!mm.includes(o.month)||!isCashOp(o))return;
1991
+ const g=cfGroup(o.category); if(g!=='op'&&g!=='in')return;
1992
+ if(pnlByCat[o.category]==null&&extra.indexOf(o.category)<0) extra.push(o.category); });
1993
+ const R2=v=>Math.round(v*100)/100;
1994
+ const rows=order.concat(extra).map(c=>{ const pnl=R2(pnlByCat[c]||0), cash=R2(opsSigned(mm,c));
1995
+ return {cat:c,pnl,cash,delta:R2(cash-pnl),isExtra:pnlByCat[c]==null}; });
1996
+ const net=R2(netProfit('Общий',period));
1997
+ const adj=R2(rows.reduce((s,x)=>s+x.delta,0));
1998
+ const cats=cfCats(), fin=[], inv=[];
1999
+ cats.fin.forEach(c=>{ const v=R2(opsSigned(mm,c)); if(v) fin.push({cat:c,v}); });
2000
+ cats.inv.forEach(c=>{ const v=R2(opsSigned(mm,c)); if(v) inv.push({cat:c,v}); });
2001
+ const finSum=R2(fin.reduce((s,x)=>s+x.v,0)), invSum=R2(inv.reduce((s,x)=>s+x.v,0));
2002
+ const opFlow=R2(net+adj), total=R2(opFlow+finSum+invSum), factTotal=R2(opsSigned(mm,null));
2003
+ return {rows,net,adj,fin,inv,finSum,invSum,opFlow,total,factTotal,diff:R2(total-factTotal)};
2004
+ }
1973
2005
  function renderCFI(C){
1974
- const P=ST.ui.period, locked=periodLocked(P);
2006
+ const P=ST.ui.period;
1975
2007
  document.getElementById('pageSub').textContent=`${P} · обратный (косвенный) метод`;
1976
- const cf=computeCF('cf_indirect',P);
1977
- let html='';
1978
- if(locked)html+=`<div class="note">🔒 Период закрыт — редактирование заблокировано.</div>`;
1979
- html+=`<div class="panel"><div class="hd"><h3>Косвенный расчёт денежного потока · ${P}</h3></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl">
1980
- <colgroup><col><col class="colmoney"></colgroup>
1981
- <thead><tr><th>Показатель</th><th class="num">Сумма, ₽</th></tr></thead><tbody>${cfRows(cf,'cf_indirect',P,locked)}</tbody></table></div></div></div>`;
2008
+ const B=cfiBridge(P);
2009
+ const money=v=>`<td class="num ${v<0?'neg':v>0?'pos':''}">${v?fmt(v):'—'}</td>`;
2010
+ let html=`<div class="cards">
2011
+ <div class="card"><div class="k">Чистая прибыль</div><div class="v ${B.net<0?'neg':'pos'}">${fmt(B.net)} ₽</div><div class="d muted">по начислению, PnL</div></div>
2012
+ <div class="card"><div class="k">Корректировки</div><div class="v ${B.adj<0?'neg':'pos'}">${fmt(B.adj)} ₽</div><div class="d muted">начисление → касса</div></div>
2013
+ <div class="card"><div class="k">Поток от операций</div><div class="v ${B.opFlow<0?'neg':'pos'}">${fmt(B.opFlow)} ₽</div></div>
2014
+ <div class="card"><div class="k">Денежный поток</div><div class="v ${B.total<0?'neg':'pos'}">${fmt(B.total)} ₽</div><div class="d muted">${B.diff?'⚠ расхождение '+fmt(B.diff):'сходится с операциями'}</div></div></div>`;
2015
+ html+=`<div class="panel"><div class="hd"><h3>От чистой прибыли к деньгам · ${P}</h3><div class="spacer"></div>
2016
+ <span class="muted small">разница = оплачено − начислено</span></div>
2017
+ <div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl" style="min-width:760px">
2018
+ <colgroup><col><col class="colmoney"><col class="colmoney"><col class="colmoney"></colgroup>
2019
+ <thead><tr><th>Показатель</th><th class="num">Начислено (PnL)</th><th class="num">Оплачено (касса)</th><th class="num">Разница</th></tr></thead><tbody>`;
2020
+ html+=`<tr class="subtotal"><td class="nmcell">Чистая прибыль по PnL</td><td class="num"></td><td class="num"></td>${money(B.net)}</tr>`;
2021
+ html+=`<tr class="header"><td colspan="4" class="nmcell">Корректировки: начисление → деньги</td></tr>`;
2022
+ B.rows.filter(x=>x.delta).sort((a,b)=>Math.abs(b.delta)-Math.abs(a.delta)).forEach(x=>{
2023
+ html+=`<tr class="value"><td class="nmcell">${esc(cfLabel(x.cat))}${x.isExtra?' <span class="muted small">(нет строки в PnL)</span>':''}</td>
2024
+ <td class="num">${x.pnl?fmt(x.pnl):'—'}</td><td class="num">${x.cash?fmt(x.cash):'—'}</td>${money(x.delta)}</tr>`; });
2025
+ const zero=B.rows.filter(x=>!x.delta&&(x.pnl||x.cash));
2026
+ if(zero.length) html+=`<tr class="value"><td class="nmcell muted">Совпадают начисление и оплата: ${zero.map(x=>esc(cfLabel(x.cat))).join(', ')}</td><td class="num"></td><td class="num"></td><td class="num">—</td></tr>`;
2027
+ html+=`<tr class="subtotal"><td class="nmcell">Итого корректировок</td><td class="num"></td><td class="num"></td>${money(B.adj)}</tr>`;
2028
+ html+=`<tr class="subtotal"><td class="nmcell">Поток от операционной деятельности</td><td class="num"></td><td class="num"></td>${money(B.opFlow)}</tr>`;
2029
+ if(B.fin.length){ html+=`<tr class="header"><td colspan="4" class="nmcell">Финансовая деятельность</td></tr>`;
2030
+ B.fin.forEach(x=>{ html+=`<tr class="value"><td class="nmcell">${esc(cfLabel(x.cat))}</td><td class="num"></td><td class="num">${fmt(x.v)}</td>${money(x.v)}</tr>`; });
2031
+ html+=`<tr class="subtotal"><td class="nmcell">Поток от финансовой деятельности</td><td class="num"></td><td class="num"></td>${money(B.finSum)}</tr>`; }
2032
+ if(B.inv.length){ html+=`<tr class="header"><td colspan="4" class="nmcell">Инвестиционная деятельность</td></tr>`;
2033
+ B.inv.forEach(x=>{ html+=`<tr class="value"><td class="nmcell">${esc(cfLabel(x.cat))}</td><td class="num"></td><td class="num">${fmt(x.v)}</td>${money(x.v)}</tr>`; });
2034
+ html+=`<tr class="subtotal"><td class="nmcell">Поток от инвестиционной деятельности</td><td class="num"></td><td class="num"></td>${money(B.invSum)}</tr>`; }
2035
+ html+=`<tr class="grand"><td class="nmcell">ИТОГО денежный поток</td><td class="num"></td><td class="num"></td>${money(B.total)}</tr>`;
2036
+ html+=`<tr class="value"><td class="nmcell muted">Контроль: сальдо всех операций за период</td><td class="num"></td><td class="num">${fmt(B.factTotal)}</td>
2037
+ <td class="num ${B.diff?'neg':'pos'}">${B.diff?fmt(B.diff):'сходится'}</td></tr>`;
2038
+ html+=`</tbody></table></div>
2039
+ <p class="hint" style="padding:10px 16px 0">Разница «оплачено − начислено» положительная — деньги остались в кассе (начислили, но ещё не заплатили);
2040
+ отрицательная — заплатили больше, чем попало в расходы месяца. Переводы между своими счетами в расчёт не входят.</p>
2041
+ </div></div>`;
1982
2042
  C.innerHTML=html;
1983
2043
  }
1984
2044
  function cfRows(cf,key,P,locked){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buhanka-finance-app",
3
- "version": "1.74.0",
3
+ "version": "1.76.0",
4
4
  "description": "Buhanka bakery finance dashboard — single-file web app",
5
5
  "license": "MIT",
6
6
  "files": [