buhanka-finance-app 1.0.1 → 1.2.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.
- package/index.html +689 -60
- package/package.json +1 -1
package/index.html
CHANGED
|
@@ -94,6 +94,29 @@ tr:hover td{background:rgba(125,125,125,.05)}
|
|
|
94
94
|
.cf-fill-pos{background:rgba(63,178,127,.18)!important;font-weight:600}
|
|
95
95
|
.cf-fill-neg{background:rgba(224,104,95,.18)!important;font-weight:600}
|
|
96
96
|
.tbl-wrap{overflow:auto}
|
|
97
|
+
/* широкая таблица по дням */
|
|
98
|
+
table.wide{border-collapse:separate;border-spacing:0;table-layout:fixed}
|
|
99
|
+
table.wide th,table.wide td{padding:5px 8px;white-space:nowrap;font-size:12.5px;border-bottom:1px solid var(--line)}
|
|
100
|
+
table.wide th.nm,table.wide td.nm{position:sticky;left:0;z-index:3;background:var(--panel);width:230px;min-width:230px;max-width:230px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid var(--line2)}
|
|
101
|
+
table.wide thead th{position:sticky;top:0;z-index:4;background:var(--panel2)}
|
|
102
|
+
table.wide thead th.nm{z-index:5;background:var(--panel2)}
|
|
103
|
+
table.wide td.num,table.wide th.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
104
|
+
table.wide tr.sec>td{font-weight:800;background:var(--subbg)}
|
|
105
|
+
table.wide tr.sec>td.nm{color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
|
106
|
+
table.wide tr.tot>td{font-weight:800;background:var(--grandbg);border-top:1px solid var(--accent)}
|
|
107
|
+
table.wide tr.sub>td{font-weight:700;background:var(--subbg)}
|
|
108
|
+
table.wide tr.val td.nm{padding-left:22px;color:var(--muted)}
|
|
109
|
+
table.wide tr.acc td.nm{padding-left:22px;color:var(--muted2);font-style:italic}
|
|
110
|
+
table.wide col.plan,table.wide th.plan{background:rgba(232,163,61,.06)}
|
|
111
|
+
table.wide input.p{background:transparent;border:1px solid transparent;text-align:right;width:100%;padding:2px 0;font-size:12.5px;font-variant-numeric:tabular-nums;color:var(--accent)}
|
|
112
|
+
table.wide input.p:hover{border-color:var(--line2)}
|
|
113
|
+
table.wide input.p:focus{border-color:var(--accent);background:var(--panel2)}
|
|
114
|
+
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end}
|
|
115
|
+
.filters .f{display:flex;flex-direction:column;gap:3px}
|
|
116
|
+
.filters .f span{font-size:10.5px;color:var(--muted2);text-transform:uppercase;letter-spacing:.05em}
|
|
117
|
+
.filters select,.filters input{padding:6px 9px;font-size:12.5px}
|
|
118
|
+
.pager{display:flex;gap:6px;align-items:center;justify-content:flex-end;padding:10px 14px;color:var(--muted);font-size:12.5px}
|
|
119
|
+
.pager button{padding:4px 10px;font-size:12px}
|
|
97
120
|
/* Итоги во всех таблицах — заметнее */
|
|
98
121
|
tr.subtotal>td{font-weight:800;background:var(--subbg);border-top:1px solid var(--line2);border-bottom:1px solid var(--line2)}
|
|
99
122
|
tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
|
@@ -200,7 +223,7 @@ tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--ac
|
|
|
200
223
|
|
|
201
224
|
<script>
|
|
202
225
|
/* ============================================================ БУХАНКА · Финансы v2 ============================================================ */
|
|
203
|
-
const APP_VERSION='3.
|
|
226
|
+
const APP_VERSION='3.3';
|
|
204
227
|
const SCHEMA=1; /* версия структуры данных; растёт при доработках */
|
|
205
228
|
const STORE_KEY='buhanka_finance_v9';/* НЕ МЕНЯТЬ НИКОГДА — иначе данные «слетят» при обновлении файла */
|
|
206
229
|
const LEGACY_KEYS=['buhanka_finance_v8','buhanka_finance_v7','buhanka_finance_v6','buhanka_finance_v5'];
|
|
@@ -399,6 +422,11 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
|
|
|
399
422
|
if(!ST.keyRates)ST.keyRates=[{id:uid(),fromIdx:0,rate:16}]; if(!ST.loans)ST.loans=[]; if(!ST.shareholders)ST.shareholders=[]; if(!ST.employees)ST.employees=[];
|
|
400
423
|
if(!ST.timesheet)ST.timesheet={}; if(!ST.payAdj)ST.payAdj={}; if(!ST.audits)ST.audits={}; if(!ST.invOpening)ST.invOpening={}; if(!ST.checks)ST.checks={}; if(!ST.inventory)ST.inventory={};
|
|
401
424
|
if(!ST.todoRules)ST.todoRules=buildTodoRules(); if(!ST.todoDone)ST.todoDone={}; if(!ST.todoOnce)ST.todoOnce={};
|
|
425
|
+
if(!ST.plan)ST.plan={}; if(!ST.cashFact)ST.cashFact={};
|
|
426
|
+
if(!ST.merchants)ST.merchants={}; if(!ST.accMap)ST.accMap={}; if(!ST.impSeen)ST.impSeen={};
|
|
427
|
+
if(!ST.impRules||!ST.impRules.length)ST.impRules=defaultImpRules();
|
|
428
|
+
if(ST.startMonthIdx==null)ST.startMonthIdx=0;
|
|
429
|
+
if(!ST.ui.opsF)ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1};
|
|
402
430
|
if(!ST.taxConfig){ ST.taxConfig={}; (ST.points||[]).forEach(p=>ST.taxConfig[p]={mode:'revenue'}); } if(!ST.taxData)ST.taxData={};
|
|
403
431
|
if(ST.structures){[ST.structures.general,ST.structures.point].forEach(st=>(st||[]).forEach(r=>{ if(r&&r.role==='value'&&r.cat==='Налоги'&&r.source==='manual')r.source='tax'; }));}
|
|
404
432
|
if(!ST.capShares){ ST.capShares={}; (ST.shareholders||[]).forEach(sh=>{ for(let mi=0;mi<12;mi++){ if(mi>=(sh.fromIdx||0)&&(sh.toIdx==null||mi<=sh.toIdx)){ ST.capShares[mi]=ST.capShares[mi]||{}; ST.capShares[mi][sh.id]=Number(sh.share||0); } } }); }
|
|
@@ -439,6 +467,7 @@ const PAGES=[
|
|
|
439
467
|
{id:'loans',grp:'Финансирование',name:'Займы и кредиты',ic:'₽'},
|
|
440
468
|
{id:'ops',grp:'Данные',name:'Операции',ic:'≡'},
|
|
441
469
|
{id:'fixed',grp:'Данные',name:'Постоянные расходы',ic:'⧉'},
|
|
470
|
+
{id:'import',grp:'Данные',name:'Импорт выписок',ic:'⤓'},
|
|
442
471
|
{id:'wcap',grp:'Данные',name:'Запасы',ic:'▧'},
|
|
443
472
|
{id:'sched',grp:'Данные',name:'Расписание и ФОТ',ic:'☷'},
|
|
444
473
|
{id:'recon',grp:'Данные',name:'Сверка PnL ↔ CF',ic:'⚖'},
|
|
@@ -509,6 +538,98 @@ function netProfit(scope,period){ const c=computePnL(scope,period); const g=[...
|
|
|
509
538
|
|
|
510
539
|
/* ---------- CF calc ---------- */
|
|
511
540
|
const isCashOp=o=>o.cash!==false;
|
|
541
|
+
|
|
542
|
+
/* ── каналы выручки: безнал / Яндекс Еда / наличные ── */
|
|
543
|
+
const CHANNELS=[['bank','Безналичные'],['yandex','Яндекс Еда'],['cash','Наличные']];
|
|
544
|
+
const CH_NAME=Object.fromEntries(CHANNELS);
|
|
545
|
+
function opChannel(o){
|
|
546
|
+
if(o.ch) return o.ch;
|
|
547
|
+
const a=(ST.accounts||[]).find(x=>x.name===o.account);
|
|
548
|
+
return (a&&accType(a)==='cash')?'cash':'bank';
|
|
549
|
+
}
|
|
550
|
+
/* ── группировка статей по разделам CF ── */
|
|
551
|
+
function cfGroup(cat){
|
|
552
|
+
const c=String(cat||'').toLowerCase();
|
|
553
|
+
if(/займ|кредит|дивиденд|взнос учред/.test(c)) return 'fin';
|
|
554
|
+
if(/оборудован|ремонт|обеспечительн|капвлож/.test(c)) return 'inv';
|
|
555
|
+
return 'op';
|
|
556
|
+
}
|
|
557
|
+
function cfCats(){
|
|
558
|
+
const out={op:[],fin:[],inv:[]}, seen=new Set();
|
|
559
|
+
const push=c=>{ if(!c||seen.has(c))return; seen.add(c); out[cfGroup(c)].push(c); };
|
|
560
|
+
(ST.structures.point||[]).forEach(r=>{ if(r.role==='value'&&(r.sign||-1)===-1) push(r.cat); });
|
|
561
|
+
(ST.fixed||[]).forEach(f=>push(f.category));
|
|
562
|
+
(ST.operations||[]).forEach(o=>{ if(o.category!=='Выручка') push(o.category); });
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
/* ── ручные правки прогноза: ST.plan[месяц][день][ключ] ── */
|
|
566
|
+
function planVal(mo,d,key){ const m=ST.plan&&ST.plan[mo], dd=m&&m[d], v=dd&&dd[key];
|
|
567
|
+
return (v==null||v==='')?null:Number(v); }
|
|
568
|
+
function planSet(mo,d,key,v){ if(isLocked(mo)){toast('Месяц закрыт');render();return;}
|
|
569
|
+
ST.plan=ST.plan||{}; ST.plan[mo]=ST.plan[mo]||{}; ST.plan[mo][d]=ST.plan[mo][d]||{};
|
|
570
|
+
const n=parseNum(v); if(n==null)delete ST.plan[mo][d][key]; else ST.plan[mo][d][key]=Math.abs(n);
|
|
571
|
+
save(); render(); }
|
|
572
|
+
function fixedPlanDay(cat,d){ return (ST.fixed||[]).filter(f=>f.active&&f.category===cat&&parseInt(f.payDay)===d)
|
|
573
|
+
.reduce((s,f)=>s+Number(f.amount||0),0); }
|
|
574
|
+
function actualUntilOf(mi){ const t=new Date();
|
|
575
|
+
if(YEAR<t.getFullYear()||(YEAR===t.getFullYear()&&mi<t.getMonth())) return daysInMonth(mi);
|
|
576
|
+
if(YEAR===t.getFullYear()&&mi===t.getMonth()) return t.getDate();
|
|
577
|
+
return 0; }
|
|
578
|
+
|
|
579
|
+
/* ── матрица «статьи × дни» для CF прямого ── */
|
|
580
|
+
function cfDayMatrix(mi){
|
|
581
|
+
const mo=MONTHS[mi], N=daysInMonth(mi), au=actualUntilOf(mi), cats=cfCats();
|
|
582
|
+
const accs=(ST.accounts||[]).map(a=>a.name);
|
|
583
|
+
const mainAcc=((ST.accounts||[]).find(a=>accType(a)==='bank')||(ST.accounts||[])[0]||{}).name;
|
|
584
|
+
const bal={}; (ST.accounts||[]).forEach(a=>bal[a.name]=Number(a.opening||0));
|
|
585
|
+
(ST.operations||[]).filter(o=>isCashOp(o)&&MONTHS.indexOf(o.month)<mi&&inScope(o.month)).forEach(o=>{
|
|
586
|
+
if(bal[o.account]==null)return; bal[o.account]+= o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount); });
|
|
587
|
+
const days=[];
|
|
588
|
+
for(let d=1; d<=N; d++){
|
|
589
|
+
const ds=`${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
|
590
|
+
const ops=(ST.operations||[]).filter(o=>o.date===ds&&isCashOp(o));
|
|
591
|
+
const actual=d<=au;
|
|
592
|
+
const inc={bank:0,yandex:0,cash:0}, exp={}, fin={}, inv={};
|
|
593
|
+
ops.forEach(o=>{ const amt=Math.abs(o.amount), g=cfGroup(o.category);
|
|
594
|
+
if(g==='fin'){ fin[o.category]=(fin[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
|
|
595
|
+
if(g==='inv'){ inv[o.category]=(inv[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
|
|
596
|
+
if(o.type==='Доход'){ const ch=opChannel(o); inc[ch]=(inc[ch]||0)+amt; }
|
|
597
|
+
else exp[o.category]=(exp[o.category]||0)+amt; });
|
|
598
|
+
if(!actual){ /* прогноз: постоянные расходы + ручные правки */
|
|
599
|
+
CHANNELS.forEach(([k])=>{ const m=planVal(mo,d,'ch:'+k); if(m!=null) inc[k]=m; });
|
|
600
|
+
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; } });
|
|
601
|
+
cats.fin.forEach(c=>{ const m=planVal(mo,d,'fi:'+c); if(m!=null) fin[c]=-m; });
|
|
602
|
+
cats.inv.forEach(c=>{ const m=planVal(mo,d,'in:'+c); if(m!=null) inv[c]=-m; });
|
|
603
|
+
}
|
|
604
|
+
const inSum=CHANNELS.reduce((t,[k])=>t+(inc[k]||0),0);
|
|
605
|
+
const exSum=cats.op.reduce((t,c)=>t+(exp[c]||0),0);
|
|
606
|
+
const finSum=cats.fin.reduce((t,c)=>t+(fin[c]||0),0);
|
|
607
|
+
const invSum=cats.inv.reduce((t,c)=>t+(inv[c]||0),0);
|
|
608
|
+
const open={}; accs.forEach(a=>open[a]=bal[a]);
|
|
609
|
+
if(actual){ ops.forEach(o=>{ if(bal[o.account]==null)return; bal[o.account]+= o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount); }); }
|
|
610
|
+
else if(mainAcc!=null && bal[mainAcc]!=null){ bal[mainAcc]+= inSum-exSum+finSum+invSum; }
|
|
611
|
+
const close={}; accs.forEach(a=>close[a]=bal[a]);
|
|
612
|
+
const openTot=accs.reduce((t,a)=>t+open[a],0), closeTot=accs.reduce((t,a)=>t+close[a],0);
|
|
613
|
+
days.push({d,ds,actual,inc,exp,fin,inv,inSum,exSum,finSum,invSum,
|
|
614
|
+
opNet:inSum-exSum, net:inSum-exSum+finSum+invSum, open,close,openTot,closeTot});
|
|
615
|
+
}
|
|
616
|
+
return {mo,mi,N,au,cats,accs,days};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/* ── наличные по точкам (сверка кассы) ── */
|
|
620
|
+
function cashCalc(mo,point){
|
|
621
|
+
const prevIdx=MONTHS.indexOf(mo)-1;
|
|
622
|
+
const prev=prevIdx>=0 ? (ST.cashFact[MONTHS[prevIdx]]||{})[point] : null;
|
|
623
|
+
let start = (prev==null||prev==='') ? 0 : Number(prev);
|
|
624
|
+
const cashAcc=new Set((ST.accounts||[]).filter(a=>accType(a)==='cash').map(a=>a.name));
|
|
625
|
+
let inc=0,out=0;
|
|
626
|
+
(ST.operations||[]).filter(o=>o.month===mo&&isCashOp(o)&&o.point===point&&cashAcc.has(o.account))
|
|
627
|
+
.forEach(o=>{ if(o.type==='Доход')inc+=Math.abs(o.amount); else out+=Math.abs(o.amount); });
|
|
628
|
+
return {start,inc,out,calc:start+inc-out};
|
|
629
|
+
}
|
|
630
|
+
function cashFactSet(mo,point,v){ if(isLocked(mo)){toast('Месяц закрыт');render();return;}
|
|
631
|
+
ST.cashFact=ST.cashFact||{}; ST.cashFact[mo]=ST.cashFact[mo]||{};
|
|
632
|
+
const n=parseNum(v); if(n==null)delete ST.cashFact[mo][point]; else ST.cashFact[mo][point]=n; save(); render(); }
|
|
512
633
|
function activeMonths(period){ return periodMonths(period).filter(mo=>monthActive('Общий',mo)); }
|
|
513
634
|
function incomeSum(period){ const mm=periodMonths(period); return ST.operations.filter(o=>mm.includes(o.month)&&o.type==='Доход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
514
635
|
function expenseCatSum(period,cat){ const mm=periodMonths(period); return ST.operations.filter(o=>mm.includes(o.month)&&o.type==='Расход'&&o.category===cat&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
@@ -539,13 +660,16 @@ function computeCF(structKey,period){
|
|
|
539
660
|
}
|
|
540
661
|
|
|
541
662
|
/* ---------- accounts / cash ---------- */
|
|
542
|
-
function
|
|
663
|
+
function startIdx(){ return Math.max(0,Math.min(11,Number(ST.startMonthIdx||0))); }
|
|
664
|
+
function startMonth(){ return MONTHS[startIdx()]; }
|
|
665
|
+
function inScope(mo){ return MONTHS.indexOf(mo)>=startIdx(); }
|
|
666
|
+
function accountBalance(acc){ return Number(acc.opening||0)+ST.operations.reduce((s,o)=> s+((o.account===acc.name&&isCashOp(o)&&inScope(o.month))?(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)):0),0); }
|
|
543
667
|
function totalOpening(){ return ST.accounts.reduce((s,a)=>s+Number(a.opening||0),0); }
|
|
544
668
|
function monthNet(mo){ const inflow=ST.operations.filter(o=>o.month===mo&&o.type==='Доход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
545
669
|
const outOps=ST.operations.filter(o=>o.month===mo&&o.type==='Расход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
546
670
|
const fx=monthActive('Общий',mo)?ST.fixed.filter(f=>f.active).reduce((s,f)=>s+Number(f.amount||0),0):0;
|
|
547
671
|
const fot=schedSum(mo,'Общий'); return inflow-outOps-fx-fot; }
|
|
548
|
-
function openingForMonth(mi){ let b=totalOpening(); for(let i=
|
|
672
|
+
function openingForMonth(mi){ let b=totalOpening(); for(let i=startIdx();i<mi;i++) b+=monthNet(MONTHS[i]); return b; }
|
|
549
673
|
function dailyCF(mi){
|
|
550
674
|
const N=daysInMonth(mi); const today=new Date(); const curY=today.getFullYear(), curM=today.getMonth(), curD=today.getDate();
|
|
551
675
|
let actualUntil; if(YEAR<curY||(YEAR===curY&&mi<curM))actualUntil=N; else if(YEAR===curY&&mi===curM)actualUntil=curD; else actualUntil=0;
|
|
@@ -604,7 +728,7 @@ function render(){
|
|
|
604
728
|
document.querySelectorAll('#nav a').forEach(a=>a.classList.toggle('active',a.dataset.id===CUR));
|
|
605
729
|
renderPeriodBar();
|
|
606
730
|
document.getElementById('pageTitle').textContent=PAGES.find(p=>p.id===CUR).name;
|
|
607
|
-
({dash:renderDash,capital:renderCapital,audit:renderAudit,todo:renderTodo,checks:renderChecks,pnl:renderPnL,points:renderPoints,cf:renderCF,cfi:renderCFI,recon:renderRecon,taxes:renderTaxes,wcap:renderWcap,loans:renderLoans,ops:renderOps,fixed:renderFixed,sched:renderSched,settings:renderSettings,backup:renderBackup}[CUR])(document.getElementById('content'));
|
|
731
|
+
({dash:renderDash,capital:renderCapital,audit:renderAudit,todo:renderTodo,checks:renderChecks,pnl:renderPnL,points:renderPoints,cf:renderCF,cfi:renderCFI,recon:renderRecon,taxes:renderTaxes,wcap:renderWcap,loans:renderLoans,ops:renderOps,fixed:renderFixed,sched:renderSched,settings:renderSettings,backup:renderBackup,import:renderImport}[CUR])(document.getElementById('content'));
|
|
608
732
|
}
|
|
609
733
|
function renderPeriodBar(){
|
|
610
734
|
const P=ST.ui.period, mode=periodModeOf(P); let chips='';
|
|
@@ -1004,44 +1128,115 @@ function checksP(point,period){ return periodMonths(period).reduce((s,mo)=>s+((S
|
|
|
1004
1128
|
|
|
1005
1129
|
/* ---------- CF прямой (счета, динамика, дневной прогноз, отчёт) ---------- */
|
|
1006
1130
|
function renderCF(C){
|
|
1007
|
-
const P=ST.ui.period,
|
|
1131
|
+
const P=ST.ui.period, agg=isAggregate(P), locked=periodLocked(P);
|
|
1008
1132
|
document.getElementById('pageSub').textContent=`${P} · прямой метод`;
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1133
|
+
|
|
1134
|
+
/* ── 1. остатки по каждому счёту ── */
|
|
1135
|
+
let html=`<div class="cards">`;
|
|
1136
|
+
let tot=0;
|
|
1137
|
+
ST.accounts.forEach(a=>{ const b=accountBalance(a); tot+=b;
|
|
1138
|
+
html+=`<div class="card"><div class="k">${esc(a.name)}</div><div class="v" style="font-size:20px">${fmt(b)} ₽</div>
|
|
1139
|
+
<div class="d muted">${accType(a)==='cash'?'наличные':'банк'} · старт ${fmt(a.opening)}</div></div>`; });
|
|
1140
|
+
html+=`<div class="card" style="border-color:var(--accent)"><div class="k">Итого средств</div><div class="v">${fmt(tot)} ₽</div>
|
|
1141
|
+
<div class="d muted">${ST.accounts.length} ${ST.accounts.length===1?'счёт':'счетов'}</div></div></div>`;
|
|
1142
|
+
|
|
1143
|
+
/* ── 2. наличные по точкам ── */
|
|
1144
|
+
const m=primaryMonth(), lk=isLocked(m);
|
|
1145
|
+
html+=`<div class="panel"><div class="hd"><h3>Наличные по точкам · ${m}</h3><div class="spacer"></div>
|
|
1146
|
+
${lk?'<span class="tag lock">🔒 закрыт</span>':'<span class="muted small">факт — то, что реально в кассе точки</span>'}</div>
|
|
1147
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1148
|
+
<thead><tr><th>Точка</th><th class="num">На начало</th><th class="num">Приход нал.</th><th class="num">Расход нал.</th>
|
|
1149
|
+
<th class="num">Расчёт</th><th class="num" style="width:140px">Факт в кассе</th><th class="num">Расхождение</th></tr></thead><tbody>`;
|
|
1150
|
+
let tS=0,tI=0,tO=0,tC=0,tF=0,tCf=0,nF=0;
|
|
1151
|
+
ST.points.forEach(p=>{ const c=cashCalc(m,p); const fv=(ST.cashFact[m]||{})[p];
|
|
1152
|
+
const has=!(fv==null||fv==='');
|
|
1153
|
+
const diff=has?Number(fv)-c.calc:null;
|
|
1154
|
+
tS+=c.start; tI+=c.inc; tO+=c.out; tC+=c.calc;
|
|
1155
|
+
if(has){ tF+=Number(fv); tCf+=c.calc; nF++; }
|
|
1156
|
+
html+=`<tr><td>${esc(p)}</td><td class="num muted">${fmt(c.start)}</td><td class="num pos">${c.inc?fmt(c.inc):'—'}</td>
|
|
1157
|
+
<td class="num neg">${c.out?fmt(c.out):'—'}</td><td class="num" style="font-weight:600">${fmt(c.calc)}</td>
|
|
1158
|
+
<td class="num"><input class="money" ${lk?'disabled':''} value="${fv!=null&&fv!==''?fmtNum(fv):''}" placeholder="—" style="width:120px" onchange="cashFactSet('${m}','${p}',this.value)"></td>
|
|
1159
|
+
<td class="num ${diff==null?'muted':Math.abs(diff)<1?'pos':'neg'}">${diff==null?'—':(diff>0?'+':'')+fmt(diff)}</td></tr>`; });
|
|
1160
|
+
const tD=nF?tF-tCf:null;
|
|
1161
|
+
html+=`<tr class="subtotal"><td>Итого</td><td class="num">${fmt(tS)}</td><td class="num">${fmt(tI)}</td><td class="num">${fmt(tO)}</td>
|
|
1162
|
+
<td class="num">${fmt(tC)}</td><td class="num">${nF?fmt(tF):'—'}</td>
|
|
1163
|
+
<td class="num ${tD==null?'':Math.abs(tD)<1?'pos':'neg'}">${tD==null?'—':(tD>0?'+':'')+fmt(tD)}</td></tr>
|
|
1164
|
+
</tbody></table></div></div>
|
|
1165
|
+
<p class="hint" style="padding:0 15px 12px;margin:0">На начало берётся факт предыдущего месяца. Приход и расход — операции по наличным счетам, привязанные к точке. Итоговое расхождение считается только по точкам, где факт заполнен${nF?` (сейчас ${nF} из ${ST.points.length})`:''}.</p></div>`;
|
|
1166
|
+
|
|
1167
|
+
/* ── 3. деньги по дням в разрезе статей ── */
|
|
1168
|
+
if(agg){ html+=`<div class="note">Таблица по дням доступна при выборе конкретного месяца — сейчас выбран период «${P}».</div>`; }
|
|
1023
1169
|
else {
|
|
1024
|
-
const
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1170
|
+
const M=cfDayMatrix(MONTHS.indexOf(m));
|
|
1171
|
+
const dw=110, W=250+(M.N+1)*dw;
|
|
1172
|
+
const WD=['вс','пн','вт','ср','чт','пт','сб'];
|
|
1173
|
+
const num=v=>v?fmt(v):'—';
|
|
1174
|
+
const neg=v=>v?'('+fmt(Math.abs(v))+')':'—';
|
|
1175
|
+
const cell=(v,cls)=>`<td class="num ${cls||''}">${v}</td>`;
|
|
1176
|
+
const planCell=(day,key,shown)=>{
|
|
1177
|
+
if(day.actual) return `<td class="num">${shown}</td>`;
|
|
1178
|
+
const cur=planVal(M.mo,day.d,key);
|
|
1179
|
+
return `<td class="num"><input class="p" ${lk?'disabled':''} value="${cur!=null?fmtNum(cur):''}" placeholder="${shown==='—'?'':shown}" onchange="planSet('${M.mo}',${day.d},'${key}',this.value)"></td>`;
|
|
1180
|
+
};
|
|
1181
|
+
const row=(cls,name,cells,total)=>`<tr class="${cls}"><td class="nm">${name}</td>${cells}${cell(total,'')}</tr>`;
|
|
1182
|
+
const sumOf=fn=>M.days.reduce((t,x)=>t+fn(x),0);
|
|
1183
|
+
|
|
1184
|
+
html+=`<div class="panel"><div class="hd"><h3>Деньги по дням · ${m}</h3><div class="spacer"></div>
|
|
1185
|
+
<span class="tag">факт по ${M.au?String(M.au).padStart(2,'0')+'.'+String(M.mi+1).padStart(2,'0'):'—'}</span>
|
|
1186
|
+
<span class="tag lock">дальше прогноз</span></div>
|
|
1187
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap" style="max-height:70vh"><table class="wide" style="min-width:${W}px">
|
|
1188
|
+
<colgroup><col style="width:250px">${M.days.map(d=>`<col style="width:${dw}px" class="${d.actual?'':'plan'}">`).join('')}<col style="width:${dw+10}px"></colgroup>
|
|
1189
|
+
<thead><tr><th class="nm">Статья</th>${M.days.map(d=>`<th class="num ${d.actual?'':'plan'}">${String(d.d).padStart(2,'0')}.${String(M.mi+1).padStart(2,'0')}<div class="muted" style="font-weight:400;font-size:10.5px">${WD[new Date(YEAR,M.mi,d.d).getDay()]}</div></th>`).join('')}<th class="num">Итого</th></tr></thead><tbody>`;
|
|
1190
|
+
|
|
1191
|
+
html+=row('sec','Операционная деятельность',M.days.map(d=>cell(d.opNet>=0?num(d.opNet):neg(d.opNet),d.opNet>=0?'pos':'neg')).join(''),
|
|
1192
|
+
(()=>{const t=sumOf(x=>x.opNet);return `<b class="${t>=0?'pos':'neg'}">${t>=0?num(t):neg(t)}</b>`;})());
|
|
1193
|
+
html+=row('sub','Поступления',M.days.map(d=>cell(num(d.inSum))).join(''),`<b>${num(sumOf(x=>x.inSum))}</b>`);
|
|
1194
|
+
CHANNELS.forEach(([k,n])=>{ html+=row('val',n,M.days.map(d=>planCell(d,'ch:'+k,num(d.inc[k]||0))).join(''),num(sumOf(x=>x.inc[k]||0))); });
|
|
1195
|
+
html+=row('sub','Расходы',M.days.map(d=>cell(neg(d.exSum),'neg')).join(''),`<b class="neg">${neg(sumOf(x=>x.exSum))}</b>`);
|
|
1196
|
+
M.cats.op.forEach(c=>{ html+=row('val',esc(c),M.days.map(d=>planCell(d,'ex:'+c,neg(d.exp[c]||0))).join(''),neg(sumOf(x=>x.exp[c]||0))); });
|
|
1197
|
+
|
|
1198
|
+
if(M.cats.fin.length){
|
|
1199
|
+
html+=row('sec','Финансовая деятельность',M.days.map(d=>cell(d.finSum>=0?num(d.finSum):neg(d.finSum),d.finSum>=0?'pos':'neg')).join(''),
|
|
1200
|
+
(()=>{const t=sumOf(x=>x.finSum);return `<b class="${t>=0?'pos':'neg'}">${t>=0?num(t):neg(t)}</b>`;})());
|
|
1201
|
+
M.cats.fin.forEach(c=>{ html+=row('val',esc(c),M.days.map(d=>planCell(d,'fi:'+c,(d.fin[c]||0)>=0?num(d.fin[c]||0):neg(d.fin[c]||0))).join(''),
|
|
1202
|
+
(()=>{const t=sumOf(x=>x.fin[c]||0);return t>=0?num(t):neg(t);})()); });
|
|
1203
|
+
}
|
|
1204
|
+
if(M.cats.inv.length){
|
|
1205
|
+
html+=row('sec','Инвестиционная деятельность',M.days.map(d=>cell(d.invSum>=0?num(d.invSum):neg(d.invSum),d.invSum>=0?'pos':'neg')).join(''),
|
|
1206
|
+
(()=>{const t=sumOf(x=>x.invSum);return `<b class="${t>=0?'pos':'neg'}">${t>=0?num(t):neg(t)}</b>`;})());
|
|
1207
|
+
M.cats.inv.forEach(c=>{ html+=row('val',esc(c),M.days.map(d=>planCell(d,'in:'+c,(d.inv[c]||0)>=0?num(d.inv[c]||0):neg(d.inv[c]||0))).join(''),
|
|
1208
|
+
(()=>{const t=sumOf(x=>x.inv[c]||0);return t>=0?num(t):neg(t);})()); });
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
html+=row('sub','Деньги на начало периода',M.days.map(d=>cell(fmt(d.openTot))).join(''),fmt(M.days.length?M.days[0].openTot:0));
|
|
1212
|
+
M.accs.forEach(a=>{ html+=row('acc',esc(a),M.days.map(d=>cell(fmt(d.open[a]))).join(''),fmt(M.days.length?M.days[0].open[a]:0)); });
|
|
1213
|
+
html+=row('tot','Денежный поток за период',M.days.map(d=>cell(d.net>=0?num(d.net):neg(d.net),d.net>=0?'pos':'neg')).join(''),
|
|
1214
|
+
(()=>{const t=sumOf(x=>x.net);return `<b class="${t>=0?'pos':'neg'}">${t>=0?num(t):neg(t)}</b>`;})());
|
|
1215
|
+
html+=row('sub','Деньги на конец периода',M.days.map(d=>cell(fmt(d.closeTot))).join(''),fmt(M.days.length?M.days[M.days.length-1].closeTot:0));
|
|
1216
|
+
M.accs.forEach(a=>{ html+=row('acc',esc(a),M.days.map(d=>cell(fmt(d.close[a]))).join(''),fmt(M.days.length?M.days[M.days.length-1].close[a]:0)); });
|
|
1217
|
+
|
|
1218
|
+
html+=`</tbody></table></div></div>
|
|
1219
|
+
<p class="hint" style="padding:0 15px 12px;margin:0">Дни до сегодняшнего — факт из операций, дальше — прогноз: подтягиваются постоянные расходы по дню платежа, любую ячейку прогноза можно перебить вручную (оранжевые поля). Плановые суммы списываются с основного банковского счёта — по факту распределение уточнится операциями.</p></div>`;
|
|
1037
1220
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
<
|
|
1221
|
+
|
|
1222
|
+
/* ── 4. ДДС по месяцам ── */
|
|
1223
|
+
const cfM=MONTHS.map(mo=>computeCF('cf_direct',mo));
|
|
1224
|
+
const struct=structOf('cf_direct');
|
|
1225
|
+
html+=`<div class="panel"><div class="hd"><h3>ДДС по месяцам · ${YEAR}</h3><div class="spacer"></div>
|
|
1226
|
+
<span class="muted small">строки — в Конструкторе</span></div>
|
|
1227
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table class="wide" style="min-width:${250+13*110}px">
|
|
1228
|
+
<colgroup><col style="width:250px">${MONTHS.map(()=>`<col style="width:110px">`).join('')}<col style="width:120px"></colgroup>
|
|
1229
|
+
<thead><tr><th class="nm">Статья</th>${MSHORT.map(x=>`<th class="num">${x}</th>`).join('')}<th class="num">Год</th></tr></thead><tbody>`;
|
|
1230
|
+
struct.forEach((row,i)=>{
|
|
1231
|
+
if(row.role==='header'){ html+=`<tr class="sec"><td class="nm">${esc(row.name)}</td>${MONTHS.map(()=>'<td></td>').join('')}<td></td></tr>`; return; }
|
|
1232
|
+
const cls=row.role==='grand'?'tot':row.role==='subtotal'?'sub':'val';
|
|
1233
|
+
let year=0, cells='';
|
|
1234
|
+
MONTHS.forEach((mo,mi2)=>{ const x=cfM[mi2][i]; const v=x?x.val:0; year+=v;
|
|
1235
|
+
cells+=`<td class="num ${v<0?'neg':v>0?'pos':''}">${v?(v<0?'('+fmt(Math.abs(v))+')':fmt(v)):'—'}</td>`; });
|
|
1236
|
+
html+=`<tr class="${cls}"><td class="nm">${esc(row.name)}</td>${cells}<td class="num ${year<0?'neg':year>0?'pos':''}"><b>${year?(year<0?'('+fmt(Math.abs(year))+')':fmt(year)):'—'}</b></td></tr>`;
|
|
1237
|
+
});
|
|
1238
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1239
|
+
|
|
1045
1240
|
C.innerHTML=html;
|
|
1046
1241
|
}
|
|
1047
1242
|
|
|
@@ -1199,41 +1394,102 @@ function keyRateModal(id){ const ed=id?ST.keyRates.find(k=>k.id===id):null;
|
|
|
1199
1394
|
function keyRateDel(id){ ST.keyRates=ST.keyRates.filter(k=>k.id!==id); if(!ST.keyRates.length)ST.keyRates=[{id:uid(),date:'2026-01-01',rate:16}]; save(); closeModal(); render(); }
|
|
1200
1395
|
|
|
1201
1396
|
/* ---------- Операции ---------- */
|
|
1397
|
+
function opsFilter(){ return ST.ui.opsF||(ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1}); }
|
|
1398
|
+
function opsFSet(k,v){ const f=opsFilter(); f[k]=v; if(k!=='page')f.page=1; save(); render(); }
|
|
1399
|
+
function opsFReset(){ ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1}; save(); render(); }
|
|
1202
1400
|
function renderOps(C){
|
|
1203
1401
|
const P=ST.ui.period, mm=periodMonths(P), lk=!isAggregate(P)&&isLocked(primaryMonth());
|
|
1204
1402
|
document.getElementById('pageSub').textContent=`${P}`;
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1403
|
+
const f=opsFilter();
|
|
1404
|
+
const base=ST.operations.filter(o=>mm.includes(o.month));
|
|
1405
|
+
const allCats=[...new Set(base.map(o=>o.category))].sort((a,b)=>a.localeCompare(b,'ru'));
|
|
1406
|
+
const q=(f.q||'').trim().toLowerCase();
|
|
1407
|
+
const ops=base.filter(o=>
|
|
1408
|
+
(!f.type||o.type===f.type) && (!f.point||o.point===f.point) &&
|
|
1409
|
+
(!f.cat||o.category===f.cat) && (!f.acc||o.account===f.acc) &&
|
|
1410
|
+
(!q||((o.note||o.comment||'')+' '+o.category+' '+(o.account||'')).toLowerCase().includes(q)))
|
|
1411
|
+
.sort((a,b)=>a.date<b.date?1:a.date>b.date?-1:0);
|
|
1412
|
+
|
|
1413
|
+
const inflow=ops.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1414
|
+
const outflow=ops.filter(o=>o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1415
|
+
const chSum={}; CHANNELS.forEach(([k])=>chSum[k]=0);
|
|
1416
|
+
ops.filter(o=>o.type==='Доход'&&cfGroup(o.category)==='op').forEach(o=>{ const c=opChannel(o); chSum[c]=(chSum[c]||0)+Math.abs(o.amount); });
|
|
1417
|
+
|
|
1418
|
+
let html='';
|
|
1419
|
+
if(lk)html+=`<div class="note">🔒 Период закрыт — операции только для просмотра.</div>`;
|
|
1420
|
+
html+=`<div class="cards">
|
|
1421
|
+
<div class="card"><div class="k">Поступления</div><div class="v pos">${fmt(inflow)} ₽</div>
|
|
1422
|
+
<div class="d muted">безнал ${fmt(chSum.bank)} · Яндекс ${fmt(chSum.yandex)} · нал ${fmt(chSum.cash)}</div></div>
|
|
1423
|
+
<div class="card"><div class="k">Выбытия</div><div class="v neg">${fmt(outflow)} ₽</div><div class="d muted">${ops.filter(o=>o.type==='Расход').length} операций</div></div>
|
|
1424
|
+
<div class="card" style="border-color:var(--accent)"><div class="k">Итого за период</div><div class="v ${inflow-outflow>=0?'pos':'neg'}">${fmt(inflow-outflow)} ₽</div>
|
|
1425
|
+
<div class="d muted">${ops.length} из ${base.length} операций</div></div></div>`;
|
|
1426
|
+
|
|
1427
|
+
const sel=(cur,list,k)=>`<select onchange="opsFSet('${k}',this.value)"><option value="">— все —</option>${
|
|
1428
|
+
list.map(v=>`<option ${cur===v?'selected':''}>${esc(v)}</option>`).join('')}</select>`;
|
|
1429
|
+
html+=`<div class="panel"><div class="bd"><div class="filters">
|
|
1430
|
+
<label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход'],'type')}</label>
|
|
1431
|
+
<label class="f"><span>Точка</span>${sel(f.point,['Общий',...ST.points],'point')}</label>
|
|
1432
|
+
<label class="f"><span>Статья</span>${sel(f.cat,allCats,'cat')}</label>
|
|
1433
|
+
<label class="f"><span>Счёт</span>${sel(f.acc,ST.accounts.map(a=>a.name),'acc')}</label>
|
|
1434
|
+
<label class="f" style="flex:1;min-width:170px"><span>Поиск</span><input value="${esc(f.q||'')}" placeholder="заметка, статья, счёт" oninput="if(event.key!=='Enter')null" onchange="opsFSet('q',this.value)"></label>
|
|
1435
|
+
<label class="f"><span>Показывать</span><select onchange="opsFSet('size',this.value)">${
|
|
1436
|
+
['25','50','100','200','all'].map(v=>`<option value="${v}" ${String(f.size)===v?'selected':''}>${v==='all'?'все':v}</option>`).join('')}</select></label>
|
|
1437
|
+
<button class="ghost mini" onclick="opsFReset()">Сбросить</button>
|
|
1438
|
+
</div></div></div>`;
|
|
1439
|
+
|
|
1440
|
+
const size = f.size==='all' ? ops.length||1 : Number(f.size)||50;
|
|
1441
|
+
const pages = Math.max(1, Math.ceil(ops.length/size));
|
|
1442
|
+
const page = Math.min(Math.max(1, Number(f.page)||1), pages);
|
|
1443
|
+
const slice = ops.slice((page-1)*size, page*size);
|
|
1444
|
+
|
|
1207
1445
|
html+=`<div class="panel"><div class="hd"><h3>Операции · ${P}</h3><div class="spacer"></div>
|
|
1208
1446
|
<button class="primary mini" onclick="opModal()" ${lk?'disabled':''}>+ Операция</button></div>
|
|
1209
1447
|
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1210
|
-
<thead><tr><th>Дата</th><th>Тип</th><th>Статья</th><th>Точка</th><th>Счёт</th><th class="num">Сумма</th><th>Заметка</th><th></th></tr></thead><tbody>`;
|
|
1211
|
-
if(!
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1448
|
+
<thead><tr><th>Дата</th><th>Тип</th><th>Статья</th><th>Канал</th><th>Точка</th><th>Счёт</th><th class="num">Сумма</th><th>Заметка</th><th></th></tr></thead><tbody>`;
|
|
1449
|
+
if(!slice.length)html+=`<tr><td colspan="9"><div class="empty">${base.length?'Ничего не найдено — измените фильтры':'Нет операций за '+P}</div></td></tr>`;
|
|
1450
|
+
slice.forEach(o=>{ const emp=o.employeeId&&(ST.employees||[]).find(e=>e.id===o.employeeId);
|
|
1451
|
+
const isRev=o.type==='Доход'&&cfGroup(o.category)==='op';
|
|
1452
|
+
html+=`<tr><td class="small">${o.date}</td><td><span class="tag">${o.type}</span>${o.cash===false?' <span class="tag lock">неден.</span>':''}</td>
|
|
1453
|
+
<td>${esc(o.category)}${emp?` <span class="muted small">· ${esc(emp.name)}</span>`:''}</td>
|
|
1454
|
+
<td class="muted small">${isRev?CH_NAME[opChannel(o)]:''}</td>
|
|
1455
|
+
<td class="muted small">${esc(o.point||'')}</td><td class="muted small">${esc(o.account||'')}</td>
|
|
1215
1456
|
<td class="num ${o.type==='Доход'?'pos':'neg'}">${o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td>
|
|
1216
|
-
<td class="muted small">${esc(o.note||'')}</td>
|
|
1457
|
+
<td class="muted small">${esc(o.note||o.comment||'')}</td>
|
|
1217
1458
|
<td>${lk?'':`<div class="ctrls" style="opacity:1"><button class="icon" onclick="opModal('${o.id}')">✎</button><button class="icon" onclick="delOp('${o.id}')">✕</button></div>`}</td></tr>`; });
|
|
1218
|
-
html+=`</tbody></table></div
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1459
|
+
html+=`</tbody></table></div>`;
|
|
1460
|
+
if(pages>1) html+=`<div class="pager">
|
|
1461
|
+
<button class="ghost" ${page<=1?'disabled':''} onclick="opsFSet('page',1)">«</button>
|
|
1462
|
+
<button class="ghost" ${page<=1?'disabled':''} onclick="opsFSet('page',${page-1})">‹</button>
|
|
1463
|
+
<span>стр. ${page} из ${pages} · показано ${slice.length} из ${ops.length}</span>
|
|
1464
|
+
<button class="ghost" ${page>=pages?'disabled':''} onclick="opsFSet('page',${page+1})">›</button>
|
|
1465
|
+
<button class="ghost" ${page>=pages?'disabled':''} onclick="opsFSet('page',${pages})">»</button></div>`;
|
|
1466
|
+
html+=`</div></div>`;
|
|
1222
1467
|
C.innerHTML=html;
|
|
1223
1468
|
}
|
|
1224
|
-
function
|
|
1469
|
+
function defaultOpDate(){
|
|
1470
|
+
const P=ST.ui.period; const mi=MONTHS.indexOf(primaryMonth()); const t=new Date();
|
|
1471
|
+
const d=(YEAR===t.getFullYear()&&mi===t.getMonth())?t.getDate():1;
|
|
1472
|
+
return `${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
|
1473
|
+
}
|
|
1474
|
+
function opModal(id){ const ed=id?ST.operations.find(o=>o.id===id):null;
|
|
1475
|
+
const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
|
|
1225
1476
|
openModal(ed?'Изменить операцию':'Новая операция',`
|
|
1226
|
-
<div class="row"><label class="fld" style="flex:1">Дата<input type="date" id="op-date" value="${ed?ed.date:
|
|
1477
|
+
<div class="row"><label class="fld" style="flex:1">Дата<input type="date" id="op-date" value="${ed?ed.date:defaultOpDate()}"></label>
|
|
1227
1478
|
<label class="fld" style="flex:1">Тип<select id="op-type"><option ${ed&&ed.type==='Доход'?'selected':''}>Доход</option><option ${ed&&ed.type==='Расход'?'selected':''}>Расход</option></select></label></div>
|
|
1228
|
-
<label class="fld">Статья<input id="op-cat" list="catlist" value="${ed?esc(ed.category):''}"><datalist id="catlist">${cats.map(c=>`<option>${c}</option>`).join('')}</datalist></label>
|
|
1479
|
+
<label class="fld">Статья<input id="op-cat" list="catlist" value="${ed?esc(ed.category):'Выручка'}"><datalist id="catlist">${cats.map(c=>`<option>${c}</option>`).join('')}</datalist></label>
|
|
1229
1480
|
<div class="row"><label class="fld" style="flex:1">Точка<select id="op-point"><option>Общий</option>${ST.points.map(p=>`<option ${ed&&ed.point===p?'selected':''}>${p}</option>`).join('')}</select></label>
|
|
1230
1481
|
<label class="fld" style="flex:1">Счёт<select id="op-acc">${ST.accounts.map(a=>`<option ${ed&&ed.account===a.name?'selected':''}>${a.name}</option>`).join('')}</select></label></div>
|
|
1231
|
-
<label class="fld">Сумма, ₽<input id="op-amt" type="number" value="${ed?Math.abs(ed.amount):''}"></label>
|
|
1482
|
+
<div class="row"><label class="fld" style="flex:1">Сумма, ₽<input id="op-amt" type="number" value="${ed?Math.abs(ed.amount):''}"></label>
|
|
1483
|
+
<label class="fld" style="flex:1">Канал выручки<select id="op-ch"><option value="">по счёту (авто)</option>${
|
|
1484
|
+
CHANNELS.map(([k,n])=>`<option value="${k}" ${ed&&ed.ch===k?'selected':''}>${n}</option>`).join('')}</select></label></div>
|
|
1232
1485
|
<div class="row"><label class="fld" style="flex:1">Тип для CF<select id="op-cash"><option value="1" ${!ed||ed.cash!==false?'selected':''}>Денежный (касса)</option><option value="0" ${ed&&ed.cash===false?'selected':''}>Неденежный (только начисление)</option></select></label>
|
|
1233
1486
|
<label class="fld" style="flex:1">Сотрудник (для ФОТ)<select id="op-emp"><option value="">—</option>${(ST.employees||[]).map(e=>`<option value="${e.id}" ${ed&&ed.employeeId===e.id?'selected':''}>${esc(e.name)}</option>`).join('')}</select></label></div>
|
|
1234
|
-
<label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||''):''}" placeholder="комментарий к операции"></label
|
|
1487
|
+
<label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||ed.comment||''):''}" placeholder="комментарий к операции"></label>
|
|
1488
|
+
<p class="hint">Канал нужен только для поступлений: он делит выручку на безнал / Яндекс Еду / наличные в CF. «По счёту» — наличный счёт даёт наличные, остальные — безнал.</p>`,
|
|
1235
1489
|
()=>{ const date=val('op-date'); const month=MONTHS[new Date(date).getMonth()]; if(isLocked(month)){toast('Месяц закрыт');return;}
|
|
1236
|
-
const rec={date,month,type:val('op-type'),category:val('op-cat'),point:val('op-point'),account:val('op-acc'),
|
|
1490
|
+
const rec={date,month,type:val('op-type'),category:val('op-cat'),point:val('op-point'),account:val('op-acc'),
|
|
1491
|
+
amount:Number(val('op-amt'))||0,cash:val('op-cash')==='1',employeeId:val('op-emp')||null,
|
|
1492
|
+
ch:val('op-ch')||null,note:val('op-note'),comment:val('op-note')};
|
|
1237
1493
|
if(ed)Object.assign(ed,rec); else ST.operations.push({id:uid(),...rec}); save(); render(); });
|
|
1238
1494
|
}
|
|
1239
1495
|
function delOp(id){ if(confirm('Удалить операцию?')){ ST.operations=ST.operations.filter(o=>o.id!==id); save(); render(); } }
|
|
@@ -1388,11 +1644,17 @@ function settStruct(){
|
|
|
1388
1644
|
return html;
|
|
1389
1645
|
}
|
|
1390
1646
|
function settAccounts(){
|
|
1391
|
-
let html=`<div class="note">Счета делятся на <b>банковские</b> и <b>наличные</b
|
|
1647
|
+
let html=`<div class="note">Счета делятся на <b>банковские</b> и <b>наличные</b>. Порядок стрелками ↑↓ — в этом же порядке счета идут в списках, карточках и таблице по дням.</div>`;
|
|
1648
|
+
html+=`<div class="panel"><div class="hd"><h3>Начало учёта</h3></div><div class="bd">
|
|
1649
|
+
<div class="row" style="align-items:center;gap:10px">
|
|
1650
|
+
<label class="fld" style="max-width:220px">Учёт ведётся с месяца
|
|
1651
|
+
<select onchange="setStartMonth(this.value)">${MONTHS.map((mo,i)=>`<option value="${i}" ${startIdx()===i?'selected':''}>${mo} ${YEAR}</option>`).join('')}</select></label>
|
|
1652
|
+
<p class="hint" style="margin:0;flex:1">Операции более ранних месяцев не влияют на остатки. В колонке «Остаток на начало» укажите деньги на конец последнего несведённого месяца — например, на 31 июля, если учёт начинается с августа.</p>
|
|
1653
|
+
</div></div></div>`;
|
|
1392
1654
|
html+=`<div class="panel"><div class="hd"><h3>Счета</h3><div class="spacer"></div><button class="primary mini" onclick="accModal()">+ Счёт</button></div>
|
|
1393
|
-
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Счёт</th><th>Тип</th><th class="num">Остаток на
|
|
1655
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Счёт</th><th>Тип</th><th class="num">Остаток на начало ${startMonth().toLowerCase()}</th><th class="num">Текущий остаток</th><th style="width:110px"></th></tr></thead><tbody>`;
|
|
1394
1656
|
ST.accounts.forEach((a,i)=>html+=`<tr><td>${esc(a.name)}</td><td><span class="tag">${accType(a)==='cash'?'наличные':'банк'}</span></td><td class="num">${fmt(a.opening)}</td><td class="num">${fmt(accountBalance(a))}</td>
|
|
1395
|
-
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" onclick="accModal(${i})">✎</button><button class="icon" onclick="accDel(${i})">✕</button></div></td></tr>`);
|
|
1657
|
+
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" ${i===0?'disabled':''} onclick="accMove(${i},-1)" title="Выше">↑</button><button class="icon" ${i===ST.accounts.length-1?'disabled':''} onclick="accMove(${i},1)" title="Ниже">↓</button><button class="icon" onclick="accModal(${i})">✎</button><button class="icon" onclick="accDel(${i})">✕</button></div></td></tr>`);
|
|
1396
1658
|
html+=`</tbody></table></div></div></div>`;
|
|
1397
1659
|
return html;
|
|
1398
1660
|
}
|
|
@@ -1502,6 +1764,11 @@ function accModal(i){ const ed=(i!=null)?ST.accounts[i]:null; const t=ed?accType
|
|
|
1502
1764
|
if(ed){ const old=ed.name; ed.name=name; ed.opening=opening; ed.type=type; if(old!==name)ST.operations.forEach(o=>{if(o.account===old)o.account=name;}); }
|
|
1503
1765
|
else ST.accounts.push({id:uid(),name,opening,type}); save(); render(); });
|
|
1504
1766
|
}
|
|
1767
|
+
function setStartMonth(v){ const i=Math.max(0,Math.min(11,Number(v)||0));
|
|
1768
|
+
ST.startMonthIdx=i; if(!MONTHS.includes(ST.ui.period)||MONTHS.indexOf(ST.ui.period)<i) ST.ui.period=MONTHS[i];
|
|
1769
|
+
save(); render(); toast('Учёт с '+MONTHS[i]); }
|
|
1770
|
+
function accMove(i,dir){ const j=i+dir; if(j<0||j>=ST.accounts.length)return;
|
|
1771
|
+
const a=ST.accounts.splice(i,1)[0]; ST.accounts.splice(j,0,a); save(); render(); }
|
|
1505
1772
|
function accDel(i){ const a=ST.accounts[i]; if(confirm('Удалить счёт «'+a.name+'»?')){ ST.accounts.splice(i,1); save(); render(); } }
|
|
1506
1773
|
function addPoint(){ openModal('Новая точка',`<label class="fld">Название точки<input id="np-name" placeholder="Например: Ленина 12"></label>`,
|
|
1507
1774
|
()=>{ const n=val('np-name').trim(); if(!n)return; ST.points.push(n); ST.data[n]={}; ST.taxConfig=ST.taxConfig||{}; if(!ST.taxConfig[n])ST.taxConfig[n]={mode:'revenue'}; save(); render(); }); }
|
|
@@ -1808,6 +2075,368 @@ function renderBackup(C){
|
|
|
1808
2075
|
C.innerHTML=html;
|
|
1809
2076
|
}
|
|
1810
2077
|
|
|
2078
|
+
|
|
2079
|
+
/* ============================================================ ИМПОРТ ВЫПИСОК ============================================================ */
|
|
2080
|
+
/* --- минимальный читатель xlsx: zip + DecompressionStream + DOMParser, без библиотек --- */
|
|
2081
|
+
async function xlsxRead(buf){
|
|
2082
|
+
const dv=new DataView(buf), u8=new Uint8Array(buf), td=new TextDecoder();
|
|
2083
|
+
let eo=-1;
|
|
2084
|
+
for(let i=u8.length-22;i>=Math.max(0,u8.length-66000);i--){ if(dv.getUint32(i,true)===0x06054b50){eo=i;break;} }
|
|
2085
|
+
if(eo<0) throw new Error('файл не похож на .xlsx');
|
|
2086
|
+
const cnt=dv.getUint16(eo+10,true), cd=dv.getUint32(eo+16,true);
|
|
2087
|
+
const ent={}; let p=cd;
|
|
2088
|
+
for(let i=0;i<cnt;i++){
|
|
2089
|
+
if(dv.getUint32(p,true)!==0x02014b50) break;
|
|
2090
|
+
const method=dv.getUint16(p+10,true), csize=dv.getUint32(p+20,true),
|
|
2091
|
+
nl=dv.getUint16(p+28,true), el=dv.getUint16(p+30,true), cl=dv.getUint16(p+32,true),
|
|
2092
|
+
lho=dv.getUint32(p+42,true);
|
|
2093
|
+
ent[td.decode(u8.subarray(p+46,p+46+nl))]={method,csize,lho};
|
|
2094
|
+
p+=46+nl+el+cl;
|
|
2095
|
+
}
|
|
2096
|
+
const read=async name=>{ const f=ent[name]; if(!f) return null;
|
|
2097
|
+
const nl=dv.getUint16(f.lho+26,true), el=dv.getUint16(f.lho+28,true);
|
|
2098
|
+
const data=u8.subarray(f.lho+30+nl+el, f.lho+30+nl+el+f.csize);
|
|
2099
|
+
if(f.method===0) return td.decode(data);
|
|
2100
|
+
const st=new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
2101
|
+
return await new Response(st).text(); };
|
|
2102
|
+
const xml=async n=>{ const t=await read(n); return t?new DOMParser().parseFromString(t,'application/xml'):null; };
|
|
2103
|
+
const ssDoc=await xml('xl/sharedStrings.xml');
|
|
2104
|
+
const shared=ssDoc?[...ssDoc.getElementsByTagName('si')].map(si=>[...si.getElementsByTagName('t')].map(t=>t.textContent).join('')):[];
|
|
2105
|
+
const wb=await xml('xl/workbook.xml'), rels=await xml('xl/_rels/workbook.xml.rels');
|
|
2106
|
+
const relMap={}; if(rels)[...rels.getElementsByTagName('Relationship')].forEach(r=>relMap[r.getAttribute('Id')]=r.getAttribute('Target'));
|
|
2107
|
+
const colIdx=a=>{ let n=0; for(const ch of a) n=n*26+(ch.charCodeAt(0)-64); return n-1; };
|
|
2108
|
+
const sheets=[];
|
|
2109
|
+
const list=wb?[...wb.getElementsByTagName('sheet')]:[];
|
|
2110
|
+
for(let i=0;i<list.length;i++){
|
|
2111
|
+
const sh=list[i], rid=sh.getAttribute('r:id')||sh.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships','id');
|
|
2112
|
+
let target=relMap[rid]||('worksheets/sheet'+(i+1)+'.xml');
|
|
2113
|
+
if(target.startsWith('/')) target=target.slice(1); else if(!target.startsWith('xl/')) target='xl/'+target;
|
|
2114
|
+
const doc=await xml(target); if(!doc) continue;
|
|
2115
|
+
const rows=[];
|
|
2116
|
+
[...doc.getElementsByTagName('row')].forEach(r=>{
|
|
2117
|
+
const arr=[];
|
|
2118
|
+
[...r.getElementsByTagName('c')].forEach(c=>{
|
|
2119
|
+
const ref=(c.getAttribute('r')||'').replace(/[0-9]/g,''); const ci=ref?colIdx(ref):arr.length;
|
|
2120
|
+
const t=c.getAttribute('t'); let v=null;
|
|
2121
|
+
if(t==='inlineStr'){ const n=c.getElementsByTagName('t')[0]; v=n?n.textContent:''; }
|
|
2122
|
+
else { const n=c.getElementsByTagName('v')[0];
|
|
2123
|
+
if(n){ v = t==='s' ? (shared[+n.textContent]||'') : (t==='str'||t==='b') ? n.textContent : Number(n.textContent); } }
|
|
2124
|
+
arr[ci]=v;
|
|
2125
|
+
});
|
|
2126
|
+
rows.push(arr);
|
|
2127
|
+
});
|
|
2128
|
+
sheets.push({name:sh.getAttribute('name')||('Лист'+(i+1)), rows});
|
|
2129
|
+
}
|
|
2130
|
+
return sheets;
|
|
2131
|
+
}
|
|
2132
|
+
function xlToISO(v){
|
|
2133
|
+
if(v==null||v==='') return null;
|
|
2134
|
+
if(typeof v==='number'){ if(v<20000||v>80000) return null;
|
|
2135
|
+
const ms=Math.round((v-25569)*86400000), d=new Date(ms);
|
|
2136
|
+
return d.toISOString().slice(0,10); }
|
|
2137
|
+
const m=String(v).match(/(\d{4})-(\d{2})-(\d{2})/); if(m) return m[0];
|
|
2138
|
+
const m2=String(v).match(/(\d{2})\.(\d{2})\.(\d{4})/); if(m2) return `${m2[3]}-${m2[2]}-${m2[1]}`;
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
const numOf=v=>{ if(v==null||v==='')return 0; if(typeof v==='number')return v;
|
|
2142
|
+
const n=Number(String(v).replace(/\s|\u00a0/g,'').replace(',','.')); return isNaN(n)?0:n; };
|
|
2143
|
+
|
|
2144
|
+
/* --- правила автокатегоризации --- */
|
|
2145
|
+
function defaultImpRules(){ const R=(m,type,cat,extra)=>Object.assign({id:uid(),m,type,cat},extra||{});
|
|
2146
|
+
return [
|
|
2147
|
+
R('яндекс.еда','Доход','Выручка',{ch:'yandex'}),
|
|
2148
|
+
R('яндекс еда','Доход','Выручка',{ch:'yandex'}),
|
|
2149
|
+
R('енп','Расход','Налоги'),
|
|
2150
|
+
R('казначейство россии','Расход','Налоги'),
|
|
2151
|
+
R('фнс','Расход','Налоги'),
|
|
2152
|
+
R('взносы на обязательное страхование','Расход','Страховые взносы'),
|
|
2153
|
+
R('для зачисления на счет','Расход','ФОТ'),
|
|
2154
|
+
R('заработная плата','Расход','ФОТ'),
|
|
2155
|
+
R('оплата за продукцию','Расход','Себестоимость'),
|
|
2156
|
+
R('поставка товара','Расход','Себестоимость'),
|
|
2157
|
+
R('аренд','Расход','Аренда'),
|
|
2158
|
+
R('вывоз','Расход','Вывоз мусора'),
|
|
2159
|
+
R('мтс','Расход','Интернет'),
|
|
2160
|
+
R('ростелеком','Расход','Интернет'),
|
|
2161
|
+
R('лицензионного вознаграждения','Расход','Прочее'),
|
|
2162
|
+
R('комиссия банка','Расход','Прочее'),
|
|
2163
|
+
R('обслуживание счета','Расход','Прочее'),
|
|
2164
|
+
];
|
|
2165
|
+
}
|
|
2166
|
+
function applyRules(text,dir){
|
|
2167
|
+
const t=String(text||'').toLowerCase();
|
|
2168
|
+
for(const r of (ST.impRules||[])){ if(r.m && t.includes(String(r.m).toLowerCase())) return r; }
|
|
2169
|
+
return {type: dir==='in'?'Доход':'Расход', cat: dir==='in'?'Прочее':'Прочее'};
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/* --- разбор конкретных банков --- */
|
|
2173
|
+
function findHeader(rows,needles){
|
|
2174
|
+
for(let i=0;i<Math.min(rows.length,40);i++){
|
|
2175
|
+
const line=(rows[i]||[]).map(x=>String(x==null?'':x).toLowerCase()).join('|');
|
|
2176
|
+
if(needles.every(n=>line.includes(n))) return i;
|
|
2177
|
+
} return -1;
|
|
2178
|
+
}
|
|
2179
|
+
function colByName(row,name){
|
|
2180
|
+
for(let i=0;i<row.length;i++){ const v=String(row[i]==null?'':row[i]).toLowerCase();
|
|
2181
|
+
if(v.includes(name)) return i; } return -1;
|
|
2182
|
+
}
|
|
2183
|
+
function parseSber(sheet){
|
|
2184
|
+
const rows=sheet.rows, h=findHeader(rows,['назначение платежа','сумма по дебету']);
|
|
2185
|
+
if(h<0) return null;
|
|
2186
|
+
const H=rows[h];
|
|
2187
|
+
const cD=colByName(H,'дата проводки'), cDeb=colByName(H,'сумма по дебету'),
|
|
2188
|
+
cCr=colByName(H,'сумма по кредиту'), cDoc=colByName(H,'№ документа'), cP=colByName(H,'назначение платежа');
|
|
2189
|
+
let acc=''; for(let i=0;i<h;i++){ const line=(rows[i]||[]).map(x=>String(x==null?'':x)).join(' ');
|
|
2190
|
+
const m=line.match(/\b(408\d{17}|40\d{18})\b/); if(m){ acc=m[1]; break; } }
|
|
2191
|
+
const out=[];
|
|
2192
|
+
for(let i=h+1;i<rows.length;i++){
|
|
2193
|
+
const r=rows[i]||[]; const date=xlToISO(r[cD]); if(!date) continue;
|
|
2194
|
+
const deb=numOf(r[cDeb]), cr=numOf(r[cCr]); if(!deb&&!cr) continue;
|
|
2195
|
+
out.push({bank:'Сбер',acc,date,in:cr,out:deb,doc:String(r[cDoc]==null?'':r[cDoc]),
|
|
2196
|
+
cp:'',inn:'',purpose:String(r[cP]==null?'':r[cP]).replace(/\s+/g,' ').trim()});
|
|
2197
|
+
}
|
|
2198
|
+
return out.length?{bank:'Сбер',acc,rows:out}:null;
|
|
2199
|
+
}
|
|
2200
|
+
function parseTochka(sheet){
|
|
2201
|
+
const rows=sheet.rows, h=findHeader(rows,['назначение платежа','зачисление','списание']);
|
|
2202
|
+
if(h<0) return null;
|
|
2203
|
+
const H=rows[h];
|
|
2204
|
+
const cD=colByName(H,'дата операции')>=0?colByName(H,'дата операции'):colByName(H,'дата документа');
|
|
2205
|
+
const cAcc=colByName(H,'счёт')>=0?colByName(H,'счёт'):colByName(H,'счет');
|
|
2206
|
+
const cCp=colByName(H,'контрагент'), cInn=colByName(H,'инн контрагента'),
|
|
2207
|
+
cOut=colByName(H,'списание'), cIn=colByName(H,'зачисление'),
|
|
2208
|
+
cP=colByName(H,'назначение платежа'), cDoc=colByName(H,'номер документа');
|
|
2209
|
+
const out=[]; let acc='';
|
|
2210
|
+
for(let i=h+1;i<rows.length;i++){
|
|
2211
|
+
const r=rows[i]||[]; const date=xlToISO(r[cD]); if(!date) continue;
|
|
2212
|
+
const oa=numOf(r[cOut]), ia=numOf(r[cIn]); if(!oa&&!ia) continue;
|
|
2213
|
+
if(!acc&&cAcc>=0) acc=String(r[cAcc]==null?'':r[cAcc]);
|
|
2214
|
+
out.push({bank:'Точка',acc:String(cAcc>=0?(r[cAcc]||''):''),date,in:ia,out:oa,
|
|
2215
|
+
doc:String(cDoc>=0?(r[cDoc]||''):''),cp:String(cCp>=0?(r[cCp]||''):'').replace(/\s+/g,' ').trim(),
|
|
2216
|
+
inn:String(cInn>=0?(r[cInn]||''):''),purpose:String(cP>=0?(r[cP]||''):'').replace(/\s+/g,' ').trim()});
|
|
2217
|
+
}
|
|
2218
|
+
return out.length?{bank:'Точка',acc:acc||(out[0]&&out[0].acc)||'',rows:out}:null;
|
|
2219
|
+
}
|
|
2220
|
+
function parseStatement(sheets){
|
|
2221
|
+
for(const sh of sheets){ const r=parseSber(sh); if(r) return r; }
|
|
2222
|
+
for(const sh of sheets){ const r=parseTochka(sh); if(r) return r; }
|
|
2223
|
+
return null;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/* --- состояние экрана импорта (не сохраняется) --- */
|
|
2227
|
+
let IMP=null;
|
|
2228
|
+
function impKey(r){ return [r.bank,r.acc,r.date,r.doc,(r.in||0)+':'+(r.out||0),r.purpose.slice(0,60)].join('|'); }
|
|
2229
|
+
function impAccName(accNum){
|
|
2230
|
+
if(ST.accMap[accNum]) return ST.accMap[accNum];
|
|
2231
|
+
const bank=IMP&&IMP.bank;
|
|
2232
|
+
const guess=(ST.accounts.find(a=>bank&&a.name.toLowerCase().startsWith(bank.toLowerCase()))||ST.accounts.find(a=>accType(a)==='bank')||ST.accounts[0]);
|
|
2233
|
+
return guess?guess.name:'';
|
|
2234
|
+
}
|
|
2235
|
+
function impSetAcc(v){ if(!IMP)return; IMP.account=v; ST.accMap[IMP.acc]=v; save(); impBuild(); render(); }
|
|
2236
|
+
function impSetFold(v){ if(!IMP)return; IMP.fold=(v==='1'); impBuild(); render(); }
|
|
2237
|
+
function impSetMerch(num,point){ ST.merchants[num]=point||''; save(); impBuild(); render(); }
|
|
2238
|
+
function impRowSet(i,field,v){ if(!IMP)return; const d=IMP.drafts[i]; if(!d)return;
|
|
2239
|
+
if(field==='amount') d.amount=Number(parseNum(v)||0); else d[field]=v;
|
|
2240
|
+
if(field==='type'&&v==='Расход') d.ch=null;
|
|
2241
|
+
render(); }
|
|
2242
|
+
function impToggle(i){ if(!IMP)return; const d=IMP.drafts[i]; if(d){ d.off=!d.off; render(); } }
|
|
2243
|
+
function impToggleAll(on){ if(!IMP)return; IMP.drafts.forEach(d=>{ if(!d.dup) d.off=!on; }); render(); }
|
|
2244
|
+
|
|
2245
|
+
async function impFiles(files){
|
|
2246
|
+
if(!files||!files.length) return;
|
|
2247
|
+
try{
|
|
2248
|
+
const all=[]; let bank='',acc='';
|
|
2249
|
+
for(const f of files){
|
|
2250
|
+
const sheets=await xlsxRead(await f.arrayBuffer());
|
|
2251
|
+
const st=parseStatement(sheets);
|
|
2252
|
+
if(!st){ toast('Не распознал: '+f.name); continue; }
|
|
2253
|
+
if(bank&&st.bank!==bank){ alert('В одной загрузке файлы разных банков: '+bank+' и '+st.bank+'.\nЗагружайте выписки по одному счёту за раз.'); return; }
|
|
2254
|
+
if(acc&&st.acc&&st.acc!==acc){ alert('В одной загрузке разные счета: '+acc+' и '+st.acc+'.\nЗагружайте выписки по одному счёту за раз.'); return; }
|
|
2255
|
+
bank=st.bank; acc=acc||st.acc; all.push(...st.rows);
|
|
2256
|
+
}
|
|
2257
|
+
if(!all.length){ toast('Ничего не найдено в файлах'); return; }
|
|
2258
|
+
all.sort((a,b)=>a.date<b.date?-1:a.date>b.date?1:0);
|
|
2259
|
+
IMP={bank,acc:acc||all[0].acc,rows:all,fold:true,account:null,drafts:[]};
|
|
2260
|
+
IMP.account=impAccName(IMP.acc);
|
|
2261
|
+
impBuild(); render();
|
|
2262
|
+
toast(`${IMP.bank}: ${all.length} строк`);
|
|
2263
|
+
}catch(e){ alert('Ошибка чтения файла: '+(e&&e.message||e)); }
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/* --- превращаем строки выписки в черновик операций --- */
|
|
2267
|
+
function impBuild(){
|
|
2268
|
+
if(!IMP) return;
|
|
2269
|
+
const acqRe=/эквайринг/i, out=[], byDay={};
|
|
2270
|
+
IMP.merchUnknown=new Set();
|
|
2271
|
+
IMP.rows.forEach(r=>{
|
|
2272
|
+
const dup=!!ST.impSeen[impKey(r)];
|
|
2273
|
+
const isAcq = r.in>0 && acqRe.test(r.purpose);
|
|
2274
|
+
if(isAcq){
|
|
2275
|
+
const mm=r.purpose.match(/мерчант\s*№?\s*(\d+)/i);
|
|
2276
|
+
const cm=r.purpose.match(/комисси[яи]\s*[:\-]?\s*([\d]+[.,]?[\d]*)/i);
|
|
2277
|
+
const merch=mm?mm[1]:'';
|
|
2278
|
+
const fee=cm?Number(String(cm[1]).replace(/\.$/,'').replace(',','.')):0;
|
|
2279
|
+
const point=ST.merchants[merch]||'';
|
|
2280
|
+
if(merch&&!point) IMP.merchUnknown.add(merch);
|
|
2281
|
+
if(IMP.fold){
|
|
2282
|
+
const k=r.date+'|'+merch;
|
|
2283
|
+
if(!byDay[k]) byDay[k]={date:r.date,merch,net:0,fee:0,n:0,dup:true,keys:[]};
|
|
2284
|
+
byDay[k].net+=r.in; byDay[k].fee+=fee; byDay[k].n++;
|
|
2285
|
+
byDay[k].keys.push(impKey(r)); if(!dup) byDay[k].dup=false;
|
|
2286
|
+
} else {
|
|
2287
|
+
out.push({date:r.date,type:'Доход',cat:'Выручка',ch:'bank',point:point||'Общий',
|
|
2288
|
+
amount:Math.round((r.in+fee)*100)/100,purpose:r.purpose,dup,keys:[impKey(r)],acq:true,merch,fee});
|
|
2289
|
+
}
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
const dir=r.in>0?'in':'out';
|
|
2293
|
+
const rule=applyRules((r.purpose||'')+' '+(r.cp||''),dir);
|
|
2294
|
+
out.push({date:r.date,type:rule.type||(dir==='in'?'Доход':'Расход'),cat:rule.cat||'Прочее',
|
|
2295
|
+
ch:rule.ch||null,point:'Общий',amount:r.in>0?r.in:r.out,
|
|
2296
|
+
purpose:(r.cp?r.cp+' · ':'')+r.purpose,dup,keys:[impKey(r)]});
|
|
2297
|
+
});
|
|
2298
|
+
Object.values(byDay).forEach(g=>{
|
|
2299
|
+
const point=ST.merchants[g.merch]||'';
|
|
2300
|
+
out.push({date:g.date,type:'Доход',cat:'Выручка',ch:'bank',point:point||'Общий',
|
|
2301
|
+
amount:Math.round((g.net+g.fee)*100)/100,
|
|
2302
|
+
purpose:`Эквайринг, мерчант ${g.merch}, операций: ${g.n}`,
|
|
2303
|
+
dup:g.dup,keys:g.keys,acq:true,merch:g.merch,fee:Math.round(g.fee*100)/100});
|
|
2304
|
+
});
|
|
2305
|
+
out.sort((a,b)=>a.date<b.date?-1:a.date>b.date?1:(a.type<b.type?1:-1));
|
|
2306
|
+
out.forEach(d=>{ if(d.dup) d.off=true; });
|
|
2307
|
+
IMP.drafts=out;
|
|
2308
|
+
}
|
|
2309
|
+
function impCommit(){
|
|
2310
|
+
if(!IMP) return;
|
|
2311
|
+
const list=IMP.drafts.filter(d=>!d.off);
|
|
2312
|
+
if(!list.length){ toast('Нечего добавлять'); return; }
|
|
2313
|
+
const acc=IMP.account||impAccName(IMP.acc);
|
|
2314
|
+
const locked=[...new Set(list.map(d=>MONTHS[new Date(d.date).getMonth()]))].filter(isLocked);
|
|
2315
|
+
if(locked.length){ alert('Месяцы закрыты: '+locked.join(', ')+'. Откройте их в Конструкторе.'); return; }
|
|
2316
|
+
bkCreate('Перед импортом выписки '+IMP.bank,true,true);
|
|
2317
|
+
let n=0, fees=0;
|
|
2318
|
+
list.forEach(d=>{
|
|
2319
|
+
const month=MONTHS[new Date(d.date).getMonth()];
|
|
2320
|
+
ST.operations.push({id:uid(),date:d.date,month,type:d.type,category:d.cat,point:d.point||'Общий',
|
|
2321
|
+
account:acc,amount:Math.abs(d.amount),cash:true,ch:d.ch||null,employeeId:null,
|
|
2322
|
+
note:d.purpose.slice(0,180),comment:d.purpose.slice(0,180)}); n++;
|
|
2323
|
+
if(d.acq&&d.fee>0){
|
|
2324
|
+
ST.operations.push({id:uid(),date:d.date,month,type:'Расход',category:'Эквайринг',point:d.point||'Общий',
|
|
2325
|
+
account:acc,amount:Math.abs(d.fee),cash:true,ch:null,employeeId:null,
|
|
2326
|
+
note:'Комиссия эквайринга'+(d.merch?', мерчант '+d.merch:''),comment:'Комиссия эквайринга'}); n++; fees+=d.fee;
|
|
2327
|
+
}
|
|
2328
|
+
(d.keys||[]).forEach(k=>ST.impSeen[k]=1);
|
|
2329
|
+
});
|
|
2330
|
+
save(); IMP=null; render();
|
|
2331
|
+
toast(`Добавлено операций: ${n}${fees?', эквайринг '+fmt(fees)+' ₽':''}`);
|
|
2332
|
+
}
|
|
2333
|
+
function impCancel(){ IMP=null; render(); }
|
|
2334
|
+
function impRuleAdd(){ openModal('Правило импорта',
|
|
2335
|
+
`<label class="fld">Если в назначении или контрагенте встречается<input id="ir-m" placeholder="например: аренда"></label>
|
|
2336
|
+
<div class="row"><label class="fld" style="flex:1">Тип<select id="ir-t"><option>Расход</option><option>Доход</option></select></label>
|
|
2337
|
+
<label class="fld" style="flex:1">Статья<input id="ir-c" placeholder="Аренда"></label></div>
|
|
2338
|
+
<label class="fld">Канал (только для дохода)<select id="ir-ch"><option value="">—</option>${
|
|
2339
|
+
CHANNELS.map(([k,n])=>`<option value="${k}">${n}</option>`).join('')}</select></label>
|
|
2340
|
+
<p class="hint">Правила проверяются сверху вниз, срабатывает первое подходящее.</p>`,
|
|
2341
|
+
()=>{ const m=val('ir-m').trim(); if(!m)return;
|
|
2342
|
+
ST.impRules.push({id:uid(),m,type:val('ir-t'),cat:val('ir-c').trim()||'Прочее',ch:val('ir-ch')||null});
|
|
2343
|
+
save(); if(IMP)impBuild(); render(); }); }
|
|
2344
|
+
function impRuleDel(id){ ST.impRules=ST.impRules.filter(r=>r.id!==id); save(); if(IMP)impBuild(); render(); }
|
|
2345
|
+
function impRuleMove(id,dir){ const i=ST.impRules.findIndex(r=>r.id===id), j=i+dir;
|
|
2346
|
+
if(i<0||j<0||j>=ST.impRules.length)return; const r=ST.impRules.splice(i,1)[0]; ST.impRules.splice(j,0,r);
|
|
2347
|
+
save(); if(IMP)impBuild(); render(); }
|
|
2348
|
+
function impForget(){ if(!confirm('Забыть историю импорта? После этого ранее загруженные строки перестанут считаться дублями.'))return;
|
|
2349
|
+
ST.impSeen={}; save(); if(IMP)impBuild(); render(); }
|
|
2350
|
+
|
|
2351
|
+
function renderImport(C){
|
|
2352
|
+
document.getElementById('pageSub').textContent='банковские выписки Сбера и Точки';
|
|
2353
|
+
const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
|
|
2354
|
+
let html=`<div class="panel"><div class="hd"><h3>Загрузка выписки</h3><div class="spacer"></div>
|
|
2355
|
+
<span class="muted small">${Object.keys(ST.impSeen).length} строк уже импортировано</span>
|
|
2356
|
+
<button class="ghost mini" onclick="impForget()">Сбросить историю</button></div>
|
|
2357
|
+
<div class="bd">
|
|
2358
|
+
<input type="file" accept=".xlsx" multiple onchange="impFiles(this.files)" style="padding:9px">
|
|
2359
|
+
<p class="hint" style="margin:8px 0 0">Поддерживаются выгрузки в .xlsx: <b>СберБизнес</b> (выписка операций по лицевому счёту) и <b>Точка</b> (лист «Операции»). Можно выбрать несколько файлов сразу. Повторная загрузка того же файла не создаст дублей.</p>
|
|
2360
|
+
</div></div>`;
|
|
2361
|
+
|
|
2362
|
+
if(IMP){
|
|
2363
|
+
const total=IMP.drafts.length, dups=IMP.drafts.filter(d=>d.dup).length, on=IMP.drafts.filter(d=>!d.off).length;
|
|
2364
|
+
const dates=IMP.drafts.map(d=>d.date).sort();
|
|
2365
|
+
const acqFee=IMP.drafts.filter(d=>d.acq&&!d.off).reduce((s,d)=>s+(d.fee||0),0);
|
|
2366
|
+
const inSum=IMP.drafts.filter(d=>!d.off&&d.type==='Доход').reduce((s,d)=>s+d.amount,0);
|
|
2367
|
+
const outSum=IMP.drafts.filter(d=>!d.off&&d.type==='Расход').reduce((s,d)=>s+d.amount,0)+acqFee;
|
|
2368
|
+
html+=`<div class="cards">
|
|
2369
|
+
<div class="card"><div class="k">Выписка</div><div class="v" style="font-size:19px">${IMP.bank}</div><div class="d muted">счёт ${esc(IMP.acc||'—')}</div></div>
|
|
2370
|
+
<div class="card"><div class="k">Период</div><div class="v" style="font-size:19px">${dates[0]||'—'} → ${dates[dates.length-1]||'—'}</div><div class="d muted">${IMP.rows.length} строк выписки</div></div>
|
|
2371
|
+
<div class="card"><div class="k">К добавлению</div><div class="v">${on}</div><div class="d muted">${dups?dups+' уже импортировано ранее':'дублей нет'}</div></div>
|
|
2372
|
+
<div class="card"><div class="k">Приход / расход</div><div class="v" style="font-size:19px"><span class="pos">${fmt(inSum)}</span> / <span class="neg">${fmt(outSum)}</span></div>
|
|
2373
|
+
<div class="d muted">${acqFee?'в т.ч. эквайринг '+fmt(acqFee):'без эквайринга'}</div></div></div>`;
|
|
2374
|
+
|
|
2375
|
+
html+=`<div class="panel"><div class="bd"><div class="filters">
|
|
2376
|
+
<label class="f"><span>Зачислять на счёт</span><select onchange="impSetAcc(this.value)">${
|
|
2377
|
+
ST.accounts.map(a=>`<option ${IMP.account===a.name?'selected':''}>${esc(a.name)}</option>`).join('')}</select></label>
|
|
2378
|
+
<label class="f"><span>Эквайринг</span><select onchange="impSetFold(this.value)">
|
|
2379
|
+
<option value="1" ${IMP.fold?'selected':''}>сворачивать по дням</option>
|
|
2380
|
+
<option value="0" ${!IMP.fold?'selected':''}>каждая операция отдельно</option></select></label>
|
|
2381
|
+
<div class="spacer" style="flex:1"></div>
|
|
2382
|
+
<button class="ghost mini" onclick="impToggleAll(true)">Отметить все</button>
|
|
2383
|
+
<button class="ghost mini" onclick="impToggleAll(false)">Снять все</button>
|
|
2384
|
+
<button class="ghost mini" onclick="impCancel()">Отмена</button>
|
|
2385
|
+
<button class="primary mini" onclick="impCommit()">Добавить ${on} операций</button>
|
|
2386
|
+
</div></div></div>`;
|
|
2387
|
+
|
|
2388
|
+
const unknown=[...(IMP.merchUnknown||[])];
|
|
2389
|
+
if(unknown.length){
|
|
2390
|
+
html+=`<div class="panel"><div class="hd"><h3>Терминалы эквайринга → точки</h3><div class="spacer"></div>
|
|
2391
|
+
<span class="tag lock">${unknown.length} без привязки</span></div>
|
|
2392
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
2393
|
+
<thead><tr><th>Мерчант</th><th class="num">Операций</th><th class="num">Сумма</th><th style="width:280px">Точка</th></tr></thead><tbody>`;
|
|
2394
|
+
unknown.forEach(m2=>{ const ds=IMP.drafts.filter(d=>d.merch===m2);
|
|
2395
|
+
html+=`<tr><td class="small">№${m2}</td><td class="num">${ds.reduce((s,d)=>s+(d.n||1),0)}</td>
|
|
2396
|
+
<td class="num">${fmt(ds.reduce((s,d)=>s+d.amount,0))}</td>
|
|
2397
|
+
<td><select onchange="impSetMerch('${m2}',this.value)"><option value="">— выберите точку —</option>${
|
|
2398
|
+
ST.points.map(p=>`<option>${esc(p)}</option>`).join('')}</select></td></tr>`; });
|
|
2399
|
+
html+=`</tbody></table></div></div>
|
|
2400
|
+
<p class="hint" style="padding:0 15px 12px;margin:0">Номер мерчанта — это терминал конкретной пекарни. Привязка запоминается, в следующих выписках точка подставится сама.</p></div>`;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
html+=`<div class="panel"><div class="hd"><h3>Черновик операций</h3><div class="spacer"></div>
|
|
2404
|
+
<span class="muted small">снимите галочку, чтобы не импортировать строку</span></div>
|
|
2405
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap" style="max-height:60vh"><table>
|
|
2406
|
+
<thead><tr><th style="width:34px"></th><th>Дата</th><th style="width:110px">Тип</th><th style="width:190px">Статья</th>
|
|
2407
|
+
<th style="width:150px">Канал</th><th style="width:210px">Точка</th><th class="num" style="width:130px">Сумма</th><th>Назначение</th></tr></thead><tbody>`;
|
|
2408
|
+
IMP.drafts.forEach((d,i)=>{
|
|
2409
|
+
html+=`<tr style="${d.off?'opacity:.42':''}">
|
|
2410
|
+
<td><input type="checkbox" ${d.off?'':'checked'} onchange="impToggle(${i})"></td>
|
|
2411
|
+
<td class="small">${d.date}</td>
|
|
2412
|
+
<td><select onchange="impRowSet(${i},'type',this.value)"><option ${d.type==='Доход'?'selected':''}>Доход</option><option ${d.type==='Расход'?'selected':''}>Расход</option></select></td>
|
|
2413
|
+
<td><input list="impcats" value="${esc(d.cat)}" onchange="impRowSet(${i},'cat',this.value)" style="width:100%"></td>
|
|
2414
|
+
<td>${d.type==='Доход'?`<select onchange="impRowSet(${i},'ch',this.value)"><option value="">авто</option>${
|
|
2415
|
+
CHANNELS.map(([k,n])=>`<option value="${k}" ${d.ch===k?'selected':''}>${n}</option>`).join('')}</select>`:'<span class="muted small">—</span>'}</td>
|
|
2416
|
+
<td><select onchange="impRowSet(${i},'point',this.value)"><option>Общий</option>${
|
|
2417
|
+
ST.points.map(p=>`<option ${d.point===p?'selected':''}>${esc(p)}</option>`).join('')}</select></td>
|
|
2418
|
+
<td class="num ${d.type==='Доход'?'pos':'neg'}">${fmt(d.amount)}${d.acq&&d.fee?`<div class="muted small">экв. ${fmt(d.fee)}</div>`:''}</td>
|
|
2419
|
+
<td class="muted small" title="${esc(d.purpose)}">${esc(d.purpose.slice(0,80))}${d.dup?' <span class="tag lock">уже импортировано</span>':''}</td></tr>`;
|
|
2420
|
+
});
|
|
2421
|
+
html+=`</tbody></table></div></div></div><datalist id="impcats">${cats.map(c=>`<option>${esc(c)}</option>`).join('')}</datalist>`;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
html+=`<div class="panel"><div class="hd"><h3>Правила автокатегоризации</h3><div class="spacer"></div>
|
|
2425
|
+
<button class="primary mini" onclick="impRuleAdd()">+ Правило</button></div>
|
|
2426
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
2427
|
+
<thead><tr><th>Если встречается</th><th>Тип</th><th>Статья</th><th>Канал</th><th style="width:130px"></th></tr></thead><tbody>`;
|
|
2428
|
+
(ST.impRules||[]).forEach((r,i)=>{ html+=`<tr><td class="small">${esc(r.m)}</td><td><span class="tag">${r.type}</span></td>
|
|
2429
|
+
<td>${esc(r.cat)}</td><td class="muted small">${r.ch?CH_NAME[r.ch]:'—'}</td>
|
|
2430
|
+
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px">
|
|
2431
|
+
<button class="icon" ${i===0?'disabled':''} onclick="impRuleMove('${r.id}',-1)">↑</button>
|
|
2432
|
+
<button class="icon" ${i===(ST.impRules.length-1)?'disabled':''} onclick="impRuleMove('${r.id}',1)">↓</button>
|
|
2433
|
+
<button class="icon" onclick="impRuleDel('${r.id}')">✕</button></div></td></tr>`; });
|
|
2434
|
+
html+=`</tbody></table></div></div>
|
|
2435
|
+
<p class="hint" style="padding:0 15px 12px;margin:0">Строки эквайринга Сбера обрабатываются отдельно: из назначения берётся комиссия, в отчётность попадает <b>валовая</b> выручка (зачисление + комиссия), а сама комиссия — расходом «Эквайринг». На счёт при этом ложится ровно то, что пришло.</p></div>`;
|
|
2436
|
+
|
|
2437
|
+
C.innerHTML=html;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
1811
2440
|
/* ---------- init ---------- */
|
|
1812
2441
|
try{ load(); migrate(); schemaGuard(); render(); bkStart(); bootNote(); cloudBoot(); if(!STORAGE_OK){ const t=document.getElementById('toast'); if(t){t.textContent='Хранилище недоступно — изменения не сохранятся между сессиями. Используйте Экспорт.'; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),4000);} } }
|
|
1813
2442
|
catch(err){ const c=document.getElementById('content'); if(c)c.innerHTML='<div style="padding:30px;font:16px sans-serif;color:#e0685f">Ошибка запуска: '+(err&&err.message||err)+'</div>'; console.error(err); }
|