buhanka-finance-app 1.1.0 → 1.3.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 +375 -7
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -223,8 +223,8 @@ tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--ac
223
223
 
224
224
  <script>
225
225
  /* ============================================================ БУХАНКА · Финансы v2 ============================================================ */
226
- const APP_VERSION='3.2';
227
- const SCHEMA=1; /* версия структуры данных; растёт при доработках */
226
+ const APP_VERSION='3.4';
227
+ const SCHEMA=2; /* версия структуры данных; растёт при доработках */
228
228
  const STORE_KEY='buhanka_finance_v9';/* НЕ МЕНЯТЬ НИКОГДА — иначе данные «слетят» при обновлении файла */
229
229
  const LEGACY_KEYS=['buhanka_finance_v8','buhanka_finance_v7','buhanka_finance_v6','buhanka_finance_v5'];
230
230
  const AUTODL_KEY='buhanka_last_download';
@@ -249,7 +249,7 @@ function buildStructure(){
249
249
  return [
250
250
  S("Выручка","value",1,'manual','Выручка'),S("Себестоимость","value",-1,'manual','Себестоимость'),
251
251
  S("Маржинальная прибыль","subtotal"),S("Прямые расходы","header"),
252
- S("Списания","value",-1,'manual','Списания'),S("Эквайринг","value",-1,'manual','Эквайринг'),
252
+ S("Списания","value",-1,'manual','Списания'),S("Эквайринг","value",-1,'ops','Эквайринг'),
253
253
  S("Аренда","value",-1,'fixed','Аренда'),S("Субаренда","value",1,'manual','Субаренда'),
254
254
  S("Расходные материалы","value",-1,'manual','Расходные материалы'),S("ФОТ","value",-1,'manual','ФОТ'),
255
255
  S("Уборщица","value",-1,'manual','Уборщица'),S("Вывоз мусора","value",-1,'fixed','Вывоз мусора'),
@@ -371,10 +371,13 @@ function load(){
371
371
  }
372
372
  function schemaGuard(){
373
373
  const cur=Number(ST._schema||0);
374
- if(cur<SCHEMA){
375
- if((ST.operations||[]).length || bkCells(ST)) bkCreate('Перед обновлением файла до v'+APP_VERSION,true,true);
376
- ST._schema=SCHEMA; save();
374
+ if(cur>=SCHEMA) return;
375
+ if((ST.operations||[]).length || bkCells(ST)) bkCreate('Перед обновлением файла до v'+APP_VERSION,true,true);
376
+ if(cur<2){ /* v3.4: «Эквайринг» считается из операций, выручка остаётся ручной (из 1С) */
377
+ [ (ST.structures||{}).general, (ST.structures||{}).point ].forEach(st=>(st||[]).forEach(r=>{
378
+ if(r&&r.role==='value'&&r.cat==='Эквайринг'&&(r.source==='manual'||!r.source)) r.source='ops'; }));
377
379
  }
380
+ ST._schema=SCHEMA; save();
378
381
  }
379
382
  function legacyFound(){ const out=[];
380
383
  LEGACY_KEYS.forEach(k=>{ try{ const v=localStorage.getItem(k); if(v) out.push({key:k,size:v.length}); }catch(e){} });
@@ -423,6 +426,8 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
423
426
  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={};
424
427
  if(!ST.todoRules)ST.todoRules=buildTodoRules(); if(!ST.todoDone)ST.todoDone={}; if(!ST.todoOnce)ST.todoOnce={};
425
428
  if(!ST.plan)ST.plan={}; if(!ST.cashFact)ST.cashFact={};
429
+ if(!ST.merchants)ST.merchants={}; if(!ST.accMap)ST.accMap={}; if(!ST.impSeen)ST.impSeen={};
430
+ if(!ST.impRules||!ST.impRules.length)ST.impRules=defaultImpRules();
426
431
  if(ST.startMonthIdx==null)ST.startMonthIdx=0;
427
432
  if(!ST.ui.opsF)ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1};
428
433
  if(!ST.taxConfig){ ST.taxConfig={}; (ST.points||[]).forEach(p=>ST.taxConfig[p]={mode:'revenue'}); } if(!ST.taxData)ST.taxData={};
@@ -465,6 +470,7 @@ const PAGES=[
465
470
  {id:'loans',grp:'Финансирование',name:'Займы и кредиты',ic:'₽'},
466
471
  {id:'ops',grp:'Данные',name:'Операции',ic:'≡'},
467
472
  {id:'fixed',grp:'Данные',name:'Постоянные расходы',ic:'⧉'},
473
+ {id:'import',grp:'Данные',name:'Импорт выписок',ic:'⤓'},
468
474
  {id:'wcap',grp:'Данные',name:'Запасы',ic:'▧'},
469
475
  {id:'sched',grp:'Данные',name:'Расписание и ФОТ',ic:'☷'},
470
476
  {id:'recon',grp:'Данные',name:'Сверка PnL ↔ CF',ic:'⚖'},
@@ -725,7 +731,7 @@ function render(){
725
731
  document.querySelectorAll('#nav a').forEach(a=>a.classList.toggle('active',a.dataset.id===CUR));
726
732
  renderPeriodBar();
727
733
  document.getElementById('pageTitle').textContent=PAGES.find(p=>p.id===CUR).name;
728
- ({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'));
734
+ ({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'));
729
735
  }
730
736
  function renderPeriodBar(){
731
737
  const P=ST.ui.period, mode=periodModeOf(P); let chips='';
@@ -2072,6 +2078,368 @@ function renderBackup(C){
2072
2078
  C.innerHTML=html;
2073
2079
  }
2074
2080
 
2081
+
2082
+ /* ============================================================ ИМПОРТ ВЫПИСОК ============================================================ */
2083
+ /* --- минимальный читатель xlsx: zip + DecompressionStream + DOMParser, без библиотек --- */
2084
+ async function xlsxRead(buf){
2085
+ const dv=new DataView(buf), u8=new Uint8Array(buf), td=new TextDecoder();
2086
+ let eo=-1;
2087
+ for(let i=u8.length-22;i>=Math.max(0,u8.length-66000);i--){ if(dv.getUint32(i,true)===0x06054b50){eo=i;break;} }
2088
+ if(eo<0) throw new Error('файл не похож на .xlsx');
2089
+ const cnt=dv.getUint16(eo+10,true), cd=dv.getUint32(eo+16,true);
2090
+ const ent={}; let p=cd;
2091
+ for(let i=0;i<cnt;i++){
2092
+ if(dv.getUint32(p,true)!==0x02014b50) break;
2093
+ const method=dv.getUint16(p+10,true), csize=dv.getUint32(p+20,true),
2094
+ nl=dv.getUint16(p+28,true), el=dv.getUint16(p+30,true), cl=dv.getUint16(p+32,true),
2095
+ lho=dv.getUint32(p+42,true);
2096
+ ent[td.decode(u8.subarray(p+46,p+46+nl))]={method,csize,lho};
2097
+ p+=46+nl+el+cl;
2098
+ }
2099
+ const read=async name=>{ const f=ent[name]; if(!f) return null;
2100
+ const nl=dv.getUint16(f.lho+26,true), el=dv.getUint16(f.lho+28,true);
2101
+ const data=u8.subarray(f.lho+30+nl+el, f.lho+30+nl+el+f.csize);
2102
+ if(f.method===0) return td.decode(data);
2103
+ const st=new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
2104
+ return await new Response(st).text(); };
2105
+ const xml=async n=>{ const t=await read(n); return t?new DOMParser().parseFromString(t,'application/xml'):null; };
2106
+ const ssDoc=await xml('xl/sharedStrings.xml');
2107
+ const shared=ssDoc?[...ssDoc.getElementsByTagName('si')].map(si=>[...si.getElementsByTagName('t')].map(t=>t.textContent).join('')):[];
2108
+ const wb=await xml('xl/workbook.xml'), rels=await xml('xl/_rels/workbook.xml.rels');
2109
+ const relMap={}; if(rels)[...rels.getElementsByTagName('Relationship')].forEach(r=>relMap[r.getAttribute('Id')]=r.getAttribute('Target'));
2110
+ const colIdx=a=>{ let n=0; for(const ch of a) n=n*26+(ch.charCodeAt(0)-64); return n-1; };
2111
+ const sheets=[];
2112
+ const list=wb?[...wb.getElementsByTagName('sheet')]:[];
2113
+ for(let i=0;i<list.length;i++){
2114
+ const sh=list[i], rid=sh.getAttribute('r:id')||sh.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships','id');
2115
+ let target=relMap[rid]||('worksheets/sheet'+(i+1)+'.xml');
2116
+ if(target.startsWith('/')) target=target.slice(1); else if(!target.startsWith('xl/')) target='xl/'+target;
2117
+ const doc=await xml(target); if(!doc) continue;
2118
+ const rows=[];
2119
+ [...doc.getElementsByTagName('row')].forEach(r=>{
2120
+ const arr=[];
2121
+ [...r.getElementsByTagName('c')].forEach(c=>{
2122
+ const ref=(c.getAttribute('r')||'').replace(/[0-9]/g,''); const ci=ref?colIdx(ref):arr.length;
2123
+ const t=c.getAttribute('t'); let v=null;
2124
+ if(t==='inlineStr'){ const n=c.getElementsByTagName('t')[0]; v=n?n.textContent:''; }
2125
+ else { const n=c.getElementsByTagName('v')[0];
2126
+ if(n){ v = t==='s' ? (shared[+n.textContent]||'') : (t==='str'||t==='b') ? n.textContent : Number(n.textContent); } }
2127
+ arr[ci]=v;
2128
+ });
2129
+ rows.push(arr);
2130
+ });
2131
+ sheets.push({name:sh.getAttribute('name')||('Лист'+(i+1)), rows});
2132
+ }
2133
+ return sheets;
2134
+ }
2135
+ function xlToISO(v){
2136
+ if(v==null||v==='') return null;
2137
+ if(typeof v==='number'){ if(v<20000||v>80000) return null;
2138
+ const ms=Math.round((v-25569)*86400000), d=new Date(ms);
2139
+ return d.toISOString().slice(0,10); }
2140
+ const m=String(v).match(/(\d{4})-(\d{2})-(\d{2})/); if(m) return m[0];
2141
+ const m2=String(v).match(/(\d{2})\.(\d{2})\.(\d{4})/); if(m2) return `${m2[3]}-${m2[2]}-${m2[1]}`;
2142
+ return null;
2143
+ }
2144
+ const numOf=v=>{ if(v==null||v==='')return 0; if(typeof v==='number')return v;
2145
+ const n=Number(String(v).replace(/\s|\u00a0/g,'').replace(',','.')); return isNaN(n)?0:n; };
2146
+
2147
+ /* --- правила автокатегоризации --- */
2148
+ function defaultImpRules(){ const R=(m,type,cat,extra)=>Object.assign({id:uid(),m,type,cat},extra||{});
2149
+ return [
2150
+ R('яндекс.еда','Доход','Выручка',{ch:'yandex'}),
2151
+ R('яндекс еда','Доход','Выручка',{ch:'yandex'}),
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
+ R('лицензионного вознаграждения','Расход','Прочее'),
2165
+ R('комиссия банка','Расход','Прочее'),
2166
+ R('обслуживание счета','Расход','Прочее'),
2167
+ ];
2168
+ }
2169
+ function applyRules(text,dir){
2170
+ const t=String(text||'').toLowerCase();
2171
+ for(const r of (ST.impRules||[])){ if(r.m && t.includes(String(r.m).toLowerCase())) return r; }
2172
+ return {type: dir==='in'?'Доход':'Расход', cat: dir==='in'?'Прочее':'Прочее'};
2173
+ }
2174
+
2175
+ /* --- разбор конкретных банков --- */
2176
+ function findHeader(rows,needles){
2177
+ for(let i=0;i<Math.min(rows.length,40);i++){
2178
+ const line=(rows[i]||[]).map(x=>String(x==null?'':x).toLowerCase()).join('|');
2179
+ if(needles.every(n=>line.includes(n))) return i;
2180
+ } return -1;
2181
+ }
2182
+ function colByName(row,name){
2183
+ for(let i=0;i<row.length;i++){ const v=String(row[i]==null?'':row[i]).toLowerCase();
2184
+ if(v.includes(name)) return i; } return -1;
2185
+ }
2186
+ function parseSber(sheet){
2187
+ const rows=sheet.rows, h=findHeader(rows,['назначение платежа','сумма по дебету']);
2188
+ if(h<0) return null;
2189
+ const H=rows[h];
2190
+ const cD=colByName(H,'дата проводки'), cDeb=colByName(H,'сумма по дебету'),
2191
+ cCr=colByName(H,'сумма по кредиту'), cDoc=colByName(H,'№ документа'), cP=colByName(H,'назначение платежа');
2192
+ let acc=''; for(let i=0;i<h;i++){ const line=(rows[i]||[]).map(x=>String(x==null?'':x)).join(' ');
2193
+ const m=line.match(/\b(408\d{17}|40\d{18})\b/); if(m){ acc=m[1]; break; } }
2194
+ const out=[];
2195
+ for(let i=h+1;i<rows.length;i++){
2196
+ const r=rows[i]||[]; const date=xlToISO(r[cD]); if(!date) continue;
2197
+ const deb=numOf(r[cDeb]), cr=numOf(r[cCr]); if(!deb&&!cr) continue;
2198
+ out.push({bank:'Сбер',acc,date,in:cr,out:deb,doc:String(r[cDoc]==null?'':r[cDoc]),
2199
+ cp:'',inn:'',purpose:String(r[cP]==null?'':r[cP]).replace(/\s+/g,' ').trim()});
2200
+ }
2201
+ return out.length?{bank:'Сбер',acc,rows:out}:null;
2202
+ }
2203
+ function parseTochka(sheet){
2204
+ const rows=sheet.rows, h=findHeader(rows,['назначение платежа','зачисление','списание']);
2205
+ if(h<0) return null;
2206
+ const H=rows[h];
2207
+ const cD=colByName(H,'дата операции')>=0?colByName(H,'дата операции'):colByName(H,'дата документа');
2208
+ const cAcc=colByName(H,'счёт')>=0?colByName(H,'счёт'):colByName(H,'счет');
2209
+ const cCp=colByName(H,'контрагент'), cInn=colByName(H,'инн контрагента'),
2210
+ cOut=colByName(H,'списание'), cIn=colByName(H,'зачисление'),
2211
+ cP=colByName(H,'назначение платежа'), cDoc=colByName(H,'номер документа');
2212
+ const out=[]; let acc='';
2213
+ for(let i=h+1;i<rows.length;i++){
2214
+ const r=rows[i]||[]; const date=xlToISO(r[cD]); if(!date) continue;
2215
+ const oa=numOf(r[cOut]), ia=numOf(r[cIn]); if(!oa&&!ia) continue;
2216
+ if(!acc&&cAcc>=0) acc=String(r[cAcc]==null?'':r[cAcc]);
2217
+ out.push({bank:'Точка',acc:String(cAcc>=0?(r[cAcc]||''):''),date,in:ia,out:oa,
2218
+ doc:String(cDoc>=0?(r[cDoc]||''):''),cp:String(cCp>=0?(r[cCp]||''):'').replace(/\s+/g,' ').trim(),
2219
+ inn:String(cInn>=0?(r[cInn]||''):''),purpose:String(cP>=0?(r[cP]||''):'').replace(/\s+/g,' ').trim()});
2220
+ }
2221
+ return out.length?{bank:'Точка',acc:acc||(out[0]&&out[0].acc)||'',rows:out}:null;
2222
+ }
2223
+ function parseStatement(sheets){
2224
+ for(const sh of sheets){ const r=parseSber(sh); if(r) return r; }
2225
+ for(const sh of sheets){ const r=parseTochka(sh); if(r) return r; }
2226
+ return null;
2227
+ }
2228
+
2229
+ /* --- состояние экрана импорта (не сохраняется) --- */
2230
+ let IMP=null;
2231
+ function impKey(r){ return [r.bank,r.acc,r.date,r.doc,(r.in||0)+':'+(r.out||0),r.purpose.slice(0,60)].join('|'); }
2232
+ function impAccName(accNum){
2233
+ if(ST.accMap[accNum]) return ST.accMap[accNum];
2234
+ const bank=IMP&&IMP.bank;
2235
+ const guess=(ST.accounts.find(a=>bank&&a.name.toLowerCase().startsWith(bank.toLowerCase()))||ST.accounts.find(a=>accType(a)==='bank')||ST.accounts[0]);
2236
+ return guess?guess.name:'';
2237
+ }
2238
+ function impSetAcc(v){ if(!IMP)return; IMP.account=v; ST.accMap[IMP.acc]=v; save(); impBuild(); render(); }
2239
+ function impSetFold(v){ if(!IMP)return; IMP.fold=(v==='1'); impBuild(); render(); }
2240
+ function impSetMerch(num,point){ ST.merchants[num]=point||''; save(); impBuild(); render(); }
2241
+ function impRowSet(i,field,v){ if(!IMP)return; const d=IMP.drafts[i]; if(!d)return;
2242
+ if(field==='amount') d.amount=Number(parseNum(v)||0); else d[field]=v;
2243
+ if(field==='type'&&v==='Расход') d.ch=null;
2244
+ render(); }
2245
+ function impToggle(i){ if(!IMP)return; const d=IMP.drafts[i]; if(d){ d.off=!d.off; render(); } }
2246
+ function impToggleAll(on){ if(!IMP)return; IMP.drafts.forEach(d=>{ if(!d.dup) d.off=!on; }); render(); }
2247
+
2248
+ async function impFiles(files){
2249
+ if(!files||!files.length) return;
2250
+ try{
2251
+ const all=[]; let bank='',acc='';
2252
+ for(const f of files){
2253
+ const sheets=await xlsxRead(await f.arrayBuffer());
2254
+ const st=parseStatement(sheets);
2255
+ if(!st){ toast('Не распознал: '+f.name); continue; }
2256
+ if(bank&&st.bank!==bank){ alert('В одной загрузке файлы разных банков: '+bank+' и '+st.bank+'.\nЗагружайте выписки по одному счёту за раз.'); return; }
2257
+ if(acc&&st.acc&&st.acc!==acc){ alert('В одной загрузке разные счета: '+acc+' и '+st.acc+'.\nЗагружайте выписки по одному счёту за раз.'); return; }
2258
+ bank=st.bank; acc=acc||st.acc; all.push(...st.rows);
2259
+ }
2260
+ if(!all.length){ toast('Ничего не найдено в файлах'); return; }
2261
+ all.sort((a,b)=>a.date<b.date?-1:a.date>b.date?1:0);
2262
+ IMP={bank,acc:acc||all[0].acc,rows:all,fold:true,account:null,drafts:[]};
2263
+ IMP.account=impAccName(IMP.acc);
2264
+ impBuild(); render();
2265
+ toast(`${IMP.bank}: ${all.length} строк`);
2266
+ }catch(e){ alert('Ошибка чтения файла: '+(e&&e.message||e)); }
2267
+ }
2268
+
2269
+ /* --- превращаем строки выписки в черновик операций --- */
2270
+ function impBuild(){
2271
+ if(!IMP) return;
2272
+ const acqRe=/эквайринг/i, out=[], byDay={};
2273
+ IMP.merchUnknown=new Set();
2274
+ IMP.rows.forEach(r=>{
2275
+ const dup=!!ST.impSeen[impKey(r)];
2276
+ const isAcq = r.in>0 && acqRe.test(r.purpose);
2277
+ if(isAcq){
2278
+ const mm=r.purpose.match(/мерчант\s*№?\s*(\d+)/i);
2279
+ const cm=r.purpose.match(/комисси[яи]\s*[:\-]?\s*([\d]+[.,]?[\d]*)/i);
2280
+ const merch=mm?mm[1]:'';
2281
+ const fee=cm?Number(String(cm[1]).replace(/\.$/,'').replace(',','.')):0;
2282
+ const point=ST.merchants[merch]||'';
2283
+ if(merch&&!point) IMP.merchUnknown.add(merch);
2284
+ if(IMP.fold){
2285
+ const k=r.date+'|'+merch;
2286
+ if(!byDay[k]) byDay[k]={date:r.date,merch,net:0,fee:0,n:0,dup:true,keys:[]};
2287
+ byDay[k].net+=r.in; byDay[k].fee+=fee; byDay[k].n++;
2288
+ byDay[k].keys.push(impKey(r)); if(!dup) byDay[k].dup=false;
2289
+ } else {
2290
+ out.push({date:r.date,type:'Доход',cat:'Выручка',ch:'bank',point:point||'Общий',
2291
+ amount:Math.round((r.in+fee)*100)/100,purpose:r.purpose,dup,keys:[impKey(r)],acq:true,merch,fee});
2292
+ }
2293
+ return;
2294
+ }
2295
+ const dir=r.in>0?'in':'out';
2296
+ const rule=applyRules((r.purpose||'')+' '+(r.cp||''),dir);
2297
+ out.push({date:r.date,type:rule.type||(dir==='in'?'Доход':'Расход'),cat:rule.cat||'Прочее',
2298
+ ch:rule.ch||null,point:'Общий',amount:r.in>0?r.in:r.out,
2299
+ purpose:(r.cp?r.cp+' · ':'')+r.purpose,dup,keys:[impKey(r)]});
2300
+ });
2301
+ Object.values(byDay).forEach(g=>{
2302
+ const point=ST.merchants[g.merch]||'';
2303
+ out.push({date:g.date,type:'Доход',cat:'Выручка',ch:'bank',point:point||'Общий',
2304
+ amount:Math.round((g.net+g.fee)*100)/100,
2305
+ purpose:`Эквайринг, мерчант ${g.merch}, операций: ${g.n}`,
2306
+ dup:g.dup,keys:g.keys,acq:true,merch:g.merch,fee:Math.round(g.fee*100)/100});
2307
+ });
2308
+ out.sort((a,b)=>a.date<b.date?-1:a.date>b.date?1:(a.type<b.type?1:-1));
2309
+ out.forEach(d=>{ if(d.dup) d.off=true; });
2310
+ IMP.drafts=out;
2311
+ }
2312
+ function impCommit(){
2313
+ if(!IMP) return;
2314
+ const list=IMP.drafts.filter(d=>!d.off);
2315
+ if(!list.length){ toast('Нечего добавлять'); return; }
2316
+ const acc=IMP.account||impAccName(IMP.acc);
2317
+ const locked=[...new Set(list.map(d=>MONTHS[new Date(d.date).getMonth()]))].filter(isLocked);
2318
+ if(locked.length){ alert('Месяцы закрыты: '+locked.join(', ')+'. Откройте их в Конструкторе.'); return; }
2319
+ bkCreate('Перед импортом выписки '+IMP.bank,true,true);
2320
+ let n=0, fees=0;
2321
+ list.forEach(d=>{
2322
+ const month=MONTHS[new Date(d.date).getMonth()];
2323
+ ST.operations.push({id:uid(),date:d.date,month,type:d.type,category:d.cat,point:d.point||'Общий',
2324
+ account:acc,amount:Math.abs(d.amount),cash:true,ch:d.ch||null,employeeId:null,
2325
+ note:d.purpose.slice(0,180),comment:d.purpose.slice(0,180)}); n++;
2326
+ if(d.acq&&d.fee>0){
2327
+ ST.operations.push({id:uid(),date:d.date,month,type:'Расход',category:'Эквайринг',point:d.point||'Общий',
2328
+ account:acc,amount:Math.abs(d.fee),cash:true,ch:null,employeeId:null,
2329
+ note:'Комиссия эквайринга'+(d.merch?', мерчант '+d.merch:''),comment:'Комиссия эквайринга'}); n++; fees+=d.fee;
2330
+ }
2331
+ (d.keys||[]).forEach(k=>ST.impSeen[k]=1);
2332
+ });
2333
+ save(); IMP=null; render();
2334
+ toast(`Добавлено операций: ${n}${fees?', эквайринг '+fmt(fees)+' ₽':''}`);
2335
+ }
2336
+ function impCancel(){ IMP=null; render(); }
2337
+ function impRuleAdd(){ openModal('Правило импорта',
2338
+ `<label class="fld">Если в назначении или контрагенте встречается<input id="ir-m" placeholder="например: аренда"></label>
2339
+ <div class="row"><label class="fld" style="flex:1">Тип<select id="ir-t"><option>Расход</option><option>Доход</option></select></label>
2340
+ <label class="fld" style="flex:1">Статья<input id="ir-c" placeholder="Аренда"></label></div>
2341
+ <label class="fld">Канал (только для дохода)<select id="ir-ch"><option value="">—</option>${
2342
+ CHANNELS.map(([k,n])=>`<option value="${k}">${n}</option>`).join('')}</select></label>
2343
+ <p class="hint">Правила проверяются сверху вниз, срабатывает первое подходящее.</p>`,
2344
+ ()=>{ const m=val('ir-m').trim(); if(!m)return;
2345
+ ST.impRules.push({id:uid(),m,type:val('ir-t'),cat:val('ir-c').trim()||'Прочее',ch:val('ir-ch')||null});
2346
+ save(); if(IMP)impBuild(); render(); }); }
2347
+ function impRuleDel(id){ ST.impRules=ST.impRules.filter(r=>r.id!==id); save(); if(IMP)impBuild(); render(); }
2348
+ function impRuleMove(id,dir){ const i=ST.impRules.findIndex(r=>r.id===id), j=i+dir;
2349
+ if(i<0||j<0||j>=ST.impRules.length)return; const r=ST.impRules.splice(i,1)[0]; ST.impRules.splice(j,0,r);
2350
+ save(); if(IMP)impBuild(); render(); }
2351
+ function impForget(){ if(!confirm('Забыть историю импорта? После этого ранее загруженные строки перестанут считаться дублями.'))return;
2352
+ ST.impSeen={}; save(); if(IMP)impBuild(); render(); }
2353
+
2354
+ function renderImport(C){
2355
+ document.getElementById('pageSub').textContent='банковские выписки Сбера и Точки';
2356
+ const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
2357
+ let html=`<div class="panel"><div class="hd"><h3>Загрузка выписки</h3><div class="spacer"></div>
2358
+ <span class="muted small">${Object.keys(ST.impSeen).length} строк уже импортировано</span>
2359
+ <button class="ghost mini" onclick="impForget()">Сбросить историю</button></div>
2360
+ <div class="bd">
2361
+ <input type="file" accept=".xlsx" multiple onchange="impFiles(this.files)" style="padding:9px">
2362
+ <p class="hint" style="margin:8px 0 0">Поддерживаются выгрузки в .xlsx: <b>СберБизнес</b> (выписка операций по лицевому счёту) и <b>Точка</b> (лист «Операции»). Можно выбрать несколько файлов сразу. Повторная загрузка того же файла не создаст дублей.</p>
2363
+ </div></div>`;
2364
+
2365
+ if(IMP){
2366
+ const total=IMP.drafts.length, dups=IMP.drafts.filter(d=>d.dup).length, on=IMP.drafts.filter(d=>!d.off).length;
2367
+ const dates=IMP.drafts.map(d=>d.date).sort();
2368
+ const acqFee=IMP.drafts.filter(d=>d.acq&&!d.off).reduce((s,d)=>s+(d.fee||0),0);
2369
+ const inSum=IMP.drafts.filter(d=>!d.off&&d.type==='Доход').reduce((s,d)=>s+d.amount,0);
2370
+ const outSum=IMP.drafts.filter(d=>!d.off&&d.type==='Расход').reduce((s,d)=>s+d.amount,0)+acqFee;
2371
+ html+=`<div class="cards">
2372
+ <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>
2373
+ <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>
2374
+ <div class="card"><div class="k">К добавлению</div><div class="v">${on}</div><div class="d muted">${dups?dups+' уже импортировано ранее':'дублей нет'}</div></div>
2375
+ <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>
2376
+ <div class="d muted">${acqFee?'в т.ч. эквайринг '+fmt(acqFee):'без эквайринга'}</div></div></div>`;
2377
+
2378
+ html+=`<div class="panel"><div class="bd"><div class="filters">
2379
+ <label class="f"><span>Зачислять на счёт</span><select onchange="impSetAcc(this.value)">${
2380
+ ST.accounts.map(a=>`<option ${IMP.account===a.name?'selected':''}>${esc(a.name)}</option>`).join('')}</select></label>
2381
+ <label class="f"><span>Эквайринг</span><select onchange="impSetFold(this.value)">
2382
+ <option value="1" ${IMP.fold?'selected':''}>сворачивать по дням</option>
2383
+ <option value="0" ${!IMP.fold?'selected':''}>каждая операция отдельно</option></select></label>
2384
+ <div class="spacer" style="flex:1"></div>
2385
+ <button class="ghost mini" onclick="impToggleAll(true)">Отметить все</button>
2386
+ <button class="ghost mini" onclick="impToggleAll(false)">Снять все</button>
2387
+ <button class="ghost mini" onclick="impCancel()">Отмена</button>
2388
+ <button class="primary mini" onclick="impCommit()">Добавить ${on} операций</button>
2389
+ </div></div></div>`;
2390
+
2391
+ const unknown=[...(IMP.merchUnknown||[])];
2392
+ if(unknown.length){
2393
+ html+=`<div class="panel"><div class="hd"><h3>Терминалы эквайринга → точки</h3><div class="spacer"></div>
2394
+ <span class="tag lock">${unknown.length} без привязки</span></div>
2395
+ <div class="bd" style="padding:0"><div class="tbl-wrap"><table>
2396
+ <thead><tr><th>Мерчант</th><th class="num">Операций</th><th class="num">Сумма</th><th style="width:280px">Точка</th></tr></thead><tbody>`;
2397
+ unknown.forEach(m2=>{ const ds=IMP.drafts.filter(d=>d.merch===m2);
2398
+ html+=`<tr><td class="small">№${m2}</td><td class="num">${ds.reduce((s,d)=>s+(d.n||1),0)}</td>
2399
+ <td class="num">${fmt(ds.reduce((s,d)=>s+d.amount,0))}</td>
2400
+ <td><select onchange="impSetMerch('${m2}',this.value)"><option value="">— выберите точку —</option>${
2401
+ ST.points.map(p=>`<option>${esc(p)}</option>`).join('')}</select></td></tr>`; });
2402
+ html+=`</tbody></table></div></div>
2403
+ <p class="hint" style="padding:0 15px 12px;margin:0">Номер мерчанта — это терминал конкретной пекарни. Привязка запоминается, в следующих выписках точка подставится сама.</p></div>`;
2404
+ }
2405
+
2406
+ html+=`<div class="panel"><div class="hd"><h3>Черновик операций</h3><div class="spacer"></div>
2407
+ <span class="muted small">снимите галочку, чтобы не импортировать строку</span></div>
2408
+ <div class="bd" style="padding:0"><div class="tbl-wrap" style="max-height:60vh"><table>
2409
+ <thead><tr><th style="width:34px"></th><th>Дата</th><th style="width:110px">Тип</th><th style="width:190px">Статья</th>
2410
+ <th style="width:150px">Канал</th><th style="width:210px">Точка</th><th class="num" style="width:130px">Сумма</th><th>Назначение</th></tr></thead><tbody>`;
2411
+ IMP.drafts.forEach((d,i)=>{
2412
+ html+=`<tr style="${d.off?'opacity:.42':''}">
2413
+ <td><input type="checkbox" ${d.off?'':'checked'} onchange="impToggle(${i})"></td>
2414
+ <td class="small">${d.date}</td>
2415
+ <td><select onchange="impRowSet(${i},'type',this.value)"><option ${d.type==='Доход'?'selected':''}>Доход</option><option ${d.type==='Расход'?'selected':''}>Расход</option></select></td>
2416
+ <td><input list="impcats" value="${esc(d.cat)}" onchange="impRowSet(${i},'cat',this.value)" style="width:100%"></td>
2417
+ <td>${d.type==='Доход'?`<select onchange="impRowSet(${i},'ch',this.value)"><option value="">авто</option>${
2418
+ CHANNELS.map(([k,n])=>`<option value="${k}" ${d.ch===k?'selected':''}>${n}</option>`).join('')}</select>`:'<span class="muted small">—</span>'}</td>
2419
+ <td><select onchange="impRowSet(${i},'point',this.value)"><option>Общий</option>${
2420
+ ST.points.map(p=>`<option ${d.point===p?'selected':''}>${esc(p)}</option>`).join('')}</select></td>
2421
+ <td class="num ${d.type==='Доход'?'pos':'neg'}">${fmt(d.amount)}${d.acq&&d.fee?`<div class="muted small">экв. ${fmt(d.fee)}</div>`:''}</td>
2422
+ <td class="muted small" title="${esc(d.purpose)}">${esc(d.purpose.slice(0,80))}${d.dup?' <span class="tag lock">уже импортировано</span>':''}</td></tr>`;
2423
+ });
2424
+ html+=`</tbody></table></div></div></div><datalist id="impcats">${cats.map(c=>`<option>${esc(c)}</option>`).join('')}</datalist>`;
2425
+ }
2426
+
2427
+ html+=`<div class="panel"><div class="hd"><h3>Правила автокатегоризации</h3><div class="spacer"></div>
2428
+ <button class="primary mini" onclick="impRuleAdd()">+ Правило</button></div>
2429
+ <div class="bd" style="padding:0"><div class="tbl-wrap"><table>
2430
+ <thead><tr><th>Если встречается</th><th>Тип</th><th>Статья</th><th>Канал</th><th style="width:130px"></th></tr></thead><tbody>`;
2431
+ (ST.impRules||[]).forEach((r,i)=>{ html+=`<tr><td class="small">${esc(r.m)}</td><td><span class="tag">${r.type}</span></td>
2432
+ <td>${esc(r.cat)}</td><td class="muted small">${r.ch?CH_NAME[r.ch]:'—'}</td>
2433
+ <td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px">
2434
+ <button class="icon" ${i===0?'disabled':''} onclick="impRuleMove('${r.id}',-1)">↑</button>
2435
+ <button class="icon" ${i===(ST.impRules.length-1)?'disabled':''} onclick="impRuleMove('${r.id}',1)">↓</button>
2436
+ <button class="icon" onclick="impRuleDel('${r.id}')">✕</button></div></td></tr>`; });
2437
+ html+=`</tbody></table></div></div>
2438
+ <p class="hint" style="padding:0 15px 12px;margin:0">Строки эквайринга Сбера обрабатываются отдельно: из назначения берётся комиссия, в отчётность попадает <b>валовая</b> выручка (зачисление + комиссия), а сама комиссия — расходом «Эквайринг». На счёт при этом ложится ровно то, что пришло.</p></div>`;
2439
+
2440
+ C.innerHTML=html;
2441
+ }
2442
+
2075
2443
  /* ---------- init ---------- */
2076
2444
  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);} } }
2077
2445
  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); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buhanka-finance-app",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Buhanka bakery finance dashboard — single-file web app",
5
5
  "license": "MIT",
6
6
  "files": [