buhanka-finance-app 1.1.0 → 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.
Files changed (2) hide show
  1. package/index.html +367 -2
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -223,7 +223,7 @@ 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';
226
+ const APP_VERSION='3.3';
227
227
  const SCHEMA=1; /* версия структуры данных; растёт при доработках */
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'];
@@ -423,6 +423,8 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
423
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={};
424
424
  if(!ST.todoRules)ST.todoRules=buildTodoRules(); if(!ST.todoDone)ST.todoDone={}; if(!ST.todoOnce)ST.todoOnce={};
425
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();
426
428
  if(ST.startMonthIdx==null)ST.startMonthIdx=0;
427
429
  if(!ST.ui.opsF)ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1};
428
430
  if(!ST.taxConfig){ ST.taxConfig={}; (ST.points||[]).forEach(p=>ST.taxConfig[p]={mode:'revenue'}); } if(!ST.taxData)ST.taxData={};
@@ -465,6 +467,7 @@ const PAGES=[
465
467
  {id:'loans',grp:'Финансирование',name:'Займы и кредиты',ic:'₽'},
466
468
  {id:'ops',grp:'Данные',name:'Операции',ic:'≡'},
467
469
  {id:'fixed',grp:'Данные',name:'Постоянные расходы',ic:'⧉'},
470
+ {id:'import',grp:'Данные',name:'Импорт выписок',ic:'⤓'},
468
471
  {id:'wcap',grp:'Данные',name:'Запасы',ic:'▧'},
469
472
  {id:'sched',grp:'Данные',name:'Расписание и ФОТ',ic:'☷'},
470
473
  {id:'recon',grp:'Данные',name:'Сверка PnL ↔ CF',ic:'⚖'},
@@ -725,7 +728,7 @@ function render(){
725
728
  document.querySelectorAll('#nav a').forEach(a=>a.classList.toggle('active',a.dataset.id===CUR));
726
729
  renderPeriodBar();
727
730
  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'));
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'));
729
732
  }
730
733
  function renderPeriodBar(){
731
734
  const P=ST.ui.period, mode=periodModeOf(P); let chips='';
@@ -2072,6 +2075,368 @@ function renderBackup(C){
2072
2075
  C.innerHTML=html;
2073
2076
  }
2074
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
+
2075
2440
  /* ---------- init ---------- */
2076
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);} } }
2077
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); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buhanka-finance-app",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Buhanka bakery finance dashboard — single-file web app",
5
5
  "license": "MIT",
6
6
  "files": [