buhanka-finance-app 1.7.0 → 1.9.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 +84 -46
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -235,7 +235,7 @@ tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--ac
235
235
 
236
236
  <script>
237
237
  /* ============================================================ БУХАНКА · Финансы v2 ============================================================ */
238
- const APP_VERSION='3.9';
238
+ const APP_VERSION='4.1';
239
239
  const SCHEMA=3; /* версия структуры данных; растёт при доработках */
240
240
  const STORE_KEY='buhanka_finance_v9';/* НЕ МЕНЯТЬ НИКОГДА — иначе данные «слетят» при обновлении файла */
241
241
  const LEGACY_KEYS=['buhanka_finance_v8','buhanka_finance_v7','buhanka_finance_v6','buhanka_finance_v5'];
@@ -573,6 +573,9 @@ function opChannel(o){
573
573
  }
574
574
  const FIN_BASE=['Получение займа','Возврат займа','Дивиденды','Прочее (фин)'];
575
575
  const INV_BASE=['Покупка оборудования','Ремонт','Обеспечительные платежи','Прочее (инвест)'];
576
+ /* в CF статья называется «Закупки», в PnL остаётся «Себестоимость» */
577
+ const CF_LABEL={'Себестоимость':'Закупки'};
578
+ const cfLabel=c=>CF_LABEL[c]||c;
576
579
  /* ── группировка статей по разделам CF ── */
577
580
  function cfGroup(cat){
578
581
  if(FIN_BASE.includes(cat)) return 'fin';
@@ -1282,18 +1285,18 @@ function renderCF(C){
1282
1285
  html+=row('sub','Поступления',M.days.map(d=>cell(num(d.inSum))).join(''),`<b>${num(sumOf(x=>x.inSum))}</b>`);
1283
1286
  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))); });
1284
1287
  html+=row('sub','Расходы',M.days.map(d=>cell(neg(d.exSum),'neg')).join(''),`<b class="neg">${neg(sumOf(x=>x.exSum))}</b>`);
1285
- 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))); });
1288
+ M.cats.op.forEach(c=>{ html+=row('val',esc(cfLabel(c)),M.days.map(d=>planCell(d,'ex:'+c,neg(d.exp[c]||0))).join(''),neg(sumOf(x=>x.exp[c]||0))); });
1286
1289
 
1287
1290
  if(M.cats.fin.length){
1288
1291
  html+=row('sec','Финансовая деятельность',M.days.map(d=>cell(d.finSum>=0?num(d.finSum):neg(d.finSum),d.finSum>=0?'pos':'neg')).join(''),
1289
1292
  (()=>{const t=sumOf(x=>x.finSum);return `<b class="${t>=0?'pos':'neg'}">${t>=0?num(t):neg(t)}</b>`;})());
1290
- 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(''),
1293
+ M.cats.fin.forEach(c=>{ html+=row('val',esc(cfLabel(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(''),
1291
1294
  (()=>{const t=sumOf(x=>x.fin[c]||0);return t>=0?num(t):neg(t);})()); });
1292
1295
  }
1293
1296
  if(M.cats.inv.length){
1294
1297
  html+=row('sec','Инвестиционная деятельность',M.days.map(d=>cell(d.invSum>=0?num(d.invSum):neg(d.invSum),d.invSum>=0?'pos':'neg')).join(''),
1295
1298
  (()=>{const t=sumOf(x=>x.invSum);return `<b class="${t>=0?'pos':'neg'}">${t>=0?num(t):neg(t)}</b>`;})());
1296
- 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(''),
1299
+ M.cats.inv.forEach(c=>{ html+=row('val',esc(cfLabel(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(''),
1297
1300
  (()=>{const t=sumOf(x=>x.inv[c]||0);return t>=0?num(t):neg(t);})()); });
1298
1301
  }
1299
1302
 
@@ -1334,11 +1337,11 @@ function renderCF(C){
1334
1337
  html+=rowM('sub','Поступления',x=>x.inSum);
1335
1338
  CHANNELS.forEach(([k,n])=>{ html+=rowM('val',n,x=>x.inc[k]||0); });
1336
1339
  html+=rowM('sub','Расходы',x=>x.exSum,'neg');
1337
- catsY.op.forEach(c=>{ html+=rowM('val',esc(c),x=>x.exp[c]||0,'neg'); });
1340
+ catsY.op.forEach(c=>{ html+=rowM('val',esc(cfLabel(c)),x=>x.exp[c]||0,'neg'); });
1338
1341
  html+=rowM('sec','Финансовая деятельность',x=>x.finSum,'sgn');
1339
- catsY.fin.forEach(c=>{ html+=rowM('val',esc(c),x=>x.fin[c]||0,'sgn'); });
1342
+ catsY.fin.forEach(c=>{ html+=rowM('val',esc(cfLabel(c)),x=>x.fin[c]||0,'sgn'); });
1340
1343
  html+=rowM('sec','Инвестиционная деятельность',x=>x.invSum,'sgn');
1341
- catsY.inv.forEach(c=>{ html+=rowM('val',esc(c),x=>x.inv[c]||0,'sgn'); });
1344
+ catsY.inv.forEach(c=>{ html+=rowM('val',esc(cfLabel(c)),x=>x.inv[c]||0,'sgn'); });
1342
1345
  {
1343
1346
  let cells='',first=null,last=null;
1344
1347
  MM.forEach(M=>{ if(!M){ cells+='<td></td>'; return; }
@@ -2299,7 +2302,7 @@ function defaultImpRules(){ const R=(m,type,cat,extra)=>Object.assign({id:uid(),
2299
2302
  function applyRules(text,dir){
2300
2303
  const t=String(text||'').toLowerCase();
2301
2304
  for(const r of (ST.impRules||[])){ if(r.m && t.includes(String(r.m).toLowerCase())) return r; }
2302
- return {type: dir==='in'?'Доход':'Расход', cat: dir==='in'?'Прочее':'Прочее'};
2305
+ return {type: dir==='in'?'Доход':'Расход', cat:'Прочее'};
2303
2306
  }
2304
2307
 
2305
2308
  /* --- разбор конкретных банков --- */
@@ -2400,6 +2403,7 @@ async function impFiles(files){
2400
2403
  function impBuild(){
2401
2404
  if(!IMP) return;
2402
2405
  const acqRe=/эквайринг/i, out=[], byDay={};
2406
+ const R2=v=>Math.round(v*100)/100;
2403
2407
  IMP.merchUnknown=new Set();
2404
2408
  IMP.rows.forEach(r=>{
2405
2409
  const dup=!!ST.impSeen[impKey(r)];
@@ -2409,59 +2413,80 @@ function impBuild(){
2409
2413
  const cm=r.purpose.match(/комисси[яи]\s*[:\-]?\s*([\d]+[.,]?[\d]*)/i);
2410
2414
  const merch=mm?mm[1]:'';
2411
2415
  const fee=cm?Number(String(cm[1]).replace(/\.$/,'').replace(',','.')):0;
2412
- const point=ST.merchants[merch]||'';
2413
- if(merch&&!point) IMP.merchUnknown.add(merch);
2416
+ if(merch&&!ST.merchants[merch]) IMP.merchUnknown.add(merch);
2414
2417
  if(IMP.fold){
2415
2418
  const k=r.date+'|'+merch;
2416
2419
  if(!byDay[k]) byDay[k]={date:r.date,merch,net:0,fee:0,n:0,dup:true,keys:[]};
2417
2420
  byDay[k].net+=r.in; byDay[k].fee+=fee; byDay[k].n++;
2418
2421
  byDay[k].keys.push(impKey(r)); if(!dup) byDay[k].dup=false;
2419
2422
  } else {
2420
- out.push({date:r.date,type:'Доход',cat:'Выручка',ch:'bank',point:point||'Общий',
2421
- amount:Math.round((r.in+fee)*100)/100,purpose:r.purpose,dup,keys:[impKey(r)],acq:true,merch,fee});
2423
+ out.push(...acqPair({date:r.date,merch,net:r.in,fee,n:1,dup,keys:[impKey(r)]},R2));
2422
2424
  }
2423
2425
  return;
2424
2426
  }
2425
2427
  const dir=r.in>0?'in':'out';
2426
2428
  const rule=applyRules((r.purpose||'')+' '+(r.cp||''),dir);
2427
2429
  out.push({date:r.date,type:rule.type||(dir==='in'?'Доход':'Расход'),cat:rule.cat||'Прочее',
2428
- ch:rule.ch||null,point:'Общий',amount:r.in>0?r.in:r.out,
2429
- purpose:(r.cp?r.cp+' · ':'')+r.purpose,dup,keys:[impKey(r)]});
2430
- });
2431
- Object.values(byDay).forEach(g=>{
2432
- const point=ST.merchants[g.merch]||'';
2433
- out.push({date:g.date,type:'Доход',cat:'Выручка',ch:'bank',point:point||'Общий',
2434
- amount:Math.round((g.net+g.fee)*100)/100,
2435
- purpose:`Эквайринг, мерчант ${g.merch}, операций: ${g.n}`,
2436
- dup:g.dup,keys:g.keys,acq:true,merch:g.merch,fee:Math.round(g.fee*100)/100});
2430
+ ch:rule.ch||null,point:rule.point||'Общий',acc2:null,amount:r.in>0?r.in:r.out,
2431
+ purpose:(r.cp?r.cp+' · ':'')+r.purpose,
2432
+ sig:(r.cp||r.purpose||'').slice(0,32).toLowerCase(),
2433
+ dup,keys:[impKey(r)]});
2437
2434
  });
2435
+ Object.values(byDay).forEach(g=>{ out.push(...acqPair(g,R2)); });
2438
2436
  out.sort((a,b)=>a.date<b.date?-1:a.date>b.date?1:(a.type<b.type?1:-1));
2439
2437
  out.forEach(d=>{ if(d.dup) d.off=true; });
2440
2438
  IMP.drafts=out;
2441
2439
  }
2440
+ /* эквайринг = валовая выручка + отдельная строка комиссии */
2441
+ function acqPair(g,R2){
2442
+ const point=ST.merchants[g.merch]||'Общий';
2443
+ const base={date:g.date,dup:g.dup,keys:g.keys,merch:g.merch,acc2:null,sig:'эквайринг:'+g.merch};
2444
+ const rows=[Object.assign({},base,{type:'Доход',cat:'Выручка',ch:'bank',point,amount:R2(g.net+g.fee),
2445
+ purpose:`Эквайринг${g.merch?', мерчант '+g.merch:''} · операций ${g.n} · зачислено ${fmt(g.net)}, комиссия ${fmt(g.fee)}`})];
2446
+ if(g.fee>0) rows.push(Object.assign({},base,{type:'Расход',cat:'Эквайринг',ch:null,point,amount:R2(g.fee),
2447
+ purpose:`Комиссия эквайринга${g.merch?', мерчант '+g.merch:''}`}));
2448
+ return rows;
2449
+ }
2450
+ /* применить классификацию строки ко всем похожим */
2451
+ function impApplySimilar(i){
2452
+ const d=IMP&&IMP.drafts[i]; if(!d) return;
2453
+ let n=0;
2454
+ IMP.drafts.forEach((x,j)=>{ if(j===i||x.sig!==d.sig) return;
2455
+ x.type=d.type; x.cat=d.cat; x.ch=d.ch; x.point=d.point; x.acc2=d.acc2; x.off=d.off; n++; });
2456
+ render(); toast(n?('Применено к '+n+' строкам'):'Похожих строк нет');
2457
+ }
2458
+ /* сохранить классификацию строки как правило */
2459
+ function impSaveRule(i){
2460
+ const d=IMP&&IMP.drafts[i]; if(!d) return;
2461
+ openModal('Новое правило из строки',
2462
+ `<label class="fld">Если встречается<input id="ir2-m" value="${esc((d.sig||'').trim())}"></label>
2463
+ <p class="hint">Строка: ${esc(d.purpose.slice(0,120))}<br>Будет проставляться: <b>${d.type} · ${esc(d.cat)}</b>${d.ch?' · '+CH_NAME[d.ch]:''}${d.point&&d.point!=='Общий'?' · '+esc(d.point):''}</p>`,
2464
+ ()=>{ const m=val('ir2-m').trim(); if(!m)return;
2465
+ ST.impRules.unshift({id:uid(),m,type:d.type,cat:d.cat,ch:d.ch||null,point:d.point!=='Общий'?d.point:null});
2466
+ save(); impBuild(); render(); toast('Правило сохранено'); });
2467
+ }
2442
2468
  function impCommit(){
2443
2469
  if(!IMP) return;
2444
2470
  const list=IMP.drafts.filter(d=>!d.off);
2445
2471
  if(!list.length){ toast('Нечего добавлять'); return; }
2446
2472
  const acc=IMP.account||impAccName(IMP.acc);
2473
+ const bad=list.filter(d=>d.type==='Перевод'&&(!d.acc2||d.acc2===acc));
2474
+ if(bad.length){ alert('У '+bad.length+' переводов не выбран счёт-получатель (или он совпадает со счётом выписки).'); return; }
2447
2475
  const locked=[...new Set(list.map(d=>MONTHS[new Date(d.date).getMonth()]))].filter(isLocked);
2448
2476
  if(locked.length){ alert('Месяцы закрыты: '+locked.join(', ')+'. Откройте их в Конструкторе.'); return; }
2449
2477
  bkCreate('Перед импортом выписки '+IMP.bank,true,true);
2450
- let n=0, fees=0;
2478
+ let n=0;
2451
2479
  list.forEach(d=>{
2452
2480
  const month=MONTHS[new Date(d.date).getMonth()];
2453
- ST.operations.push({id:uid(),date:d.date,month,type:d.type,category:d.cat,point:d.point||'Общий',
2454
- account:acc,amount:Math.abs(d.amount),cash:true,ch:d.ch||null,employeeId:null,
2481
+ ST.operations.push({id:uid(),date:d.date,month,type:d.type,
2482
+ category:d.type==='Перевод'?'Перевод':d.cat,point:d.point||'Общий',
2483
+ account:acc,account2:d.type==='Перевод'?d.acc2:null,
2484
+ amount:Math.abs(d.amount),cash:true,ch:d.type==='Доход'?(d.ch||null):null,employeeId:null,
2455
2485
  note:d.purpose.slice(0,180),comment:d.purpose.slice(0,180)}); n++;
2456
- if(d.acq&&d.fee>0){
2457
- ST.operations.push({id:uid(),date:d.date,month,type:'Расход',category:'Эквайринг',point:d.point||'Общий',
2458
- account:acc,amount:Math.abs(d.fee),cash:true,ch:null,employeeId:null,
2459
- note:'Комиссия эквайринга'+(d.merch?', мерчант '+d.merch:''),comment:'Комиссия эквайринга'}); n++; fees+=d.fee;
2460
- }
2461
2486
  (d.keys||[]).forEach(k=>ST.impSeen[k]=1);
2462
2487
  });
2463
2488
  save(); IMP=null; render();
2464
- toast(`Добавлено операций: ${n}${fees?', эквайринг '+fmt(fees)+' ₽':''}`);
2489
+ toast('Добавлено операций: '+n);
2465
2490
  }
2466
2491
  function impCancel(){ IMP=null; render(); }
2467
2492
  function impRuleAdd(){ openModal('Правило импорта',
@@ -2495,15 +2520,16 @@ function renderImport(C){
2495
2520
  if(IMP){
2496
2521
  const total=IMP.drafts.length, dups=IMP.drafts.filter(d=>d.dup).length, on=IMP.drafts.filter(d=>!d.off).length;
2497
2522
  const dates=IMP.drafts.map(d=>d.date).sort();
2498
- const acqFee=IMP.drafts.filter(d=>d.acq&&!d.off).reduce((s,d)=>s+(d.fee||0),0);
2523
+ const acqFee=IMP.drafts.filter(d=>!d.off&&d.cat==='Эквайринг').reduce((s,d)=>s+d.amount,0);
2499
2524
  const inSum=IMP.drafts.filter(d=>!d.off&&d.type==='Доход').reduce((s,d)=>s+d.amount,0);
2500
- const outSum=IMP.drafts.filter(d=>!d.off&&d.type==='Расход').reduce((s,d)=>s+d.amount,0)+acqFee;
2525
+ const outSum=IMP.drafts.filter(d=>!d.off&&d.type==='Расход').reduce((s,d)=>s+d.amount,0);
2526
+ const trSum=IMP.drafts.filter(d=>!d.off&&d.type==='Перевод').reduce((s,d)=>s+d.amount,0);
2501
2527
  html+=`<div class="cards">
2502
2528
  <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>
2503
2529
  <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>
2504
2530
  <div class="card"><div class="k">К добавлению</div><div class="v">${on}</div><div class="d muted">${dups?dups+' уже импортировано ранее':'дублей нет'}</div></div>
2505
2531
  <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>
2506
- <div class="d muted">${acqFee?'в т.ч. эквайринг '+fmt(acqFee):'без эквайринга'}</div></div></div>`;
2532
+ <div class="d muted">${acqFee?'в т.ч. эквайринг '+fmt(acqFee):'без эквайринга'}${trSum?' · переводы '+fmt(trSum):''}</div></div></div>`;
2507
2533
 
2508
2534
  html+=`<div class="panel"><div class="bd"><div class="filters">
2509
2535
  <label class="f"><span>Зачислять на счёт</span><select onchange="impSetAcc(this.value)">${
@@ -2524,8 +2550,8 @@ function renderImport(C){
2524
2550
  <span class="tag lock">${unknown.length} без привязки</span></div>
2525
2551
  <div class="bd" style="padding:0"><div class="tbl-wrap"><table>
2526
2552
  <thead><tr><th>Мерчант</th><th class="num">Операций</th><th class="num">Сумма</th><th style="width:280px">Точка</th></tr></thead><tbody>`;
2527
- unknown.forEach(m2=>{ const ds=IMP.drafts.filter(d=>d.merch===m2);
2528
- html+=`<tr><td class="small">№${m2}</td><td class="num">${ds.reduce((s,d)=>s+(d.n||1),0)}</td>
2553
+ unknown.forEach(m2=>{ const ds=IMP.drafts.filter(d=>d.merch===m2&&d.type==='Доход');
2554
+ html+=`<tr><td class="small">№${m2}</td><td class="num">${ds.length}</td>
2529
2555
  <td class="num">${fmt(ds.reduce((s,d)=>s+d.amount,0))}</td>
2530
2556
  <td><select onchange="impSetMerch('${m2}',this.value)"><option value="">— выберите точку —</option>${
2531
2557
  ST.points.map(p=>`<option>${esc(p)}</option>`).join('')}</select></td></tr>`; });
@@ -2536,20 +2562,32 @@ function renderImport(C){
2536
2562
  html+=`<div class="panel"><div class="hd"><h3>Черновик операций</h3><div class="spacer"></div>
2537
2563
  <span class="muted small">снимите галочку, чтобы не импортировать строку</span></div>
2538
2564
  <div class="bd" style="padding:0"><div class="tbl-wrap" style="max-height:60vh"><table>
2539
- <thead><tr><th style="width:34px"></th><th>Дата</th><th style="width:110px">Тип</th><th style="width:190px">Статья</th>
2540
- <th style="width:150px">Канал</th><th style="width:210px">Точка</th><th class="num" style="width:130px">Сумма</th><th>Назначение</th></tr></thead><tbody>`;
2565
+ <thead><tr><th style="width:34px"></th><th style="width:96px">Дата</th><th style="width:118px">Тип</th><th style="width:180px">Статья</th>
2566
+ <th style="width:140px">Канал / на счёт</th><th style="width:190px">Точка</th><th class="num" style="width:120px">Сумма</th>
2567
+ <th style="width:78px"></th><th>Назначение</th></tr></thead><tbody>`;
2541
2568
  IMP.drafts.forEach((d,i)=>{
2569
+ const isTr=d.type==='Перевод';
2542
2570
  html+=`<tr style="${d.off?'opacity:.42':''}">
2543
2571
  <td><input type="checkbox" ${d.off?'':'checked'} onchange="impToggle(${i})"></td>
2544
2572
  <td class="small">${d.date}</td>
2545
- <td><select onchange="impRowSet(${i},'type',this.value)"><option ${d.type==='Доход'?'selected':''}>Доход</option><option ${d.type==='Расход'?'selected':''}>Расход</option></select></td>
2546
- <td><input list="impcats" value="${esc(d.cat)}" onchange="impRowSet(${i},'cat',this.value)" style="width:100%"></td>
2547
- <td>${d.type==='Доход'?`<select onchange="impRowSet(${i},'ch',this.value)"><option value="">авто</option>${
2548
- CHANNELS.map(([k,n])=>`<option value="${k}" ${d.ch===k?'selected':''}>${n}</option>`).join('')}</select>`:'<span class="muted small">—</span>'}</td>
2573
+ <td><select onchange="impRowSet(${i},'type',this.value)">${
2574
+ ['Доход','Расход','Перевод'].map(t=>`<option ${d.type===t?'selected':''}>${t}</option>`).join('')}</select></td>
2575
+ <td>${isTr?'<span class="muted small">перевод между счетами</span>':
2576
+ `<input list="impcats" value="${esc(d.cat)}" onchange="impRowSet(${i},'cat',this.value)" style="width:100%">`}</td>
2577
+ <td>${isTr
2578
+ ? `<select onchange="impRowSet(${i},'acc2',this.value)"><option value="">— на счёт —</option>${
2579
+ ST.accounts.filter(a=>a.name!==IMP.account).map(a=>`<option ${d.acc2===a.name?'selected':''}>${esc(a.name)}</option>`).join('')}</select>`
2580
+ : d.type==='Доход'
2581
+ ? `<select onchange="impRowSet(${i},'ch',this.value)"><option value="">авто</option>${
2582
+ CHANNELS.map(([k,n])=>`<option value="${k}" ${d.ch===k?'selected':''}>${n}</option>`).join('')}</select>`
2583
+ : '<span class="muted small">—</span>'}</td>
2549
2584
  <td><select onchange="impRowSet(${i},'point',this.value)"><option>Общий</option>${
2550
2585
  ST.points.map(p=>`<option ${d.point===p?'selected':''}>${esc(p)}</option>`).join('')}</select></td>
2551
- <td class="num ${d.type==='Доход'?'pos':'neg'}">${fmt(d.amount)}${d.acq&&d.fee?`<div class="muted small">экв. ${fmt(d.fee)}</div>`:''}</td>
2552
- <td class="muted small" title="${esc(d.purpose)}">${esc(d.purpose.slice(0,80))}${d.dup?' <span class="tag lock">уже импортировано</span>':''}</td></tr>`;
2586
+ <td class="num ${isTr?'muted':d.type==='Доход'?'pos':'neg'}">${isTr?'⇄ ':''}${fmt(d.amount)}</td>
2587
+ <td><div class="ctrls" style="opacity:1;gap:3px;flex-wrap:nowrap;white-space:nowrap">
2588
+ <button class="icon" title="Применить эту классификацию ко всем похожим строкам" onclick="impApplySimilar(${i})">⤳</button>
2589
+ <button class="icon" title="Сохранить как правило" onclick="impSaveRule(${i})">+</button></div></td>
2590
+ <td class="muted small" title="${esc(d.purpose)}">${esc(d.purpose.slice(0,70))}${d.dup?' <span class="tag lock">уже импортировано</span>':''}</td></tr>`;
2553
2591
  });
2554
2592
  html+=`</tbody></table></div></div></div><datalist id="impcats">${cats.map(c=>`<option>${esc(c)}</option>`).join('')}</datalist>`;
2555
2593
  }
@@ -2557,15 +2595,15 @@ function renderImport(C){
2557
2595
  html+=`<div class="panel"><div class="hd"><h3>Правила автокатегоризации</h3><div class="spacer"></div>
2558
2596
  <button class="primary mini" onclick="impRuleAdd()">+ Правило</button></div>
2559
2597
  <div class="bd" style="padding:0"><div class="tbl-wrap"><table>
2560
- <thead><tr><th>Если встречается</th><th>Тип</th><th>Статья</th><th>Канал</th><th style="width:130px"></th></tr></thead><tbody>`;
2598
+ <thead><tr><th>Если встречается</th><th>Тип</th><th>Статья</th><th>Канал</th><th>Точка</th><th style="width:130px"></th></tr></thead><tbody>`;
2561
2599
  (ST.impRules||[]).forEach((r,i)=>{ html+=`<tr><td class="small">${esc(r.m)}</td><td><span class="tag">${r.type}</span></td>
2562
- <td>${esc(r.cat)}</td><td class="muted small">${r.ch?CH_NAME[r.ch]:'—'}</td>
2600
+ <td>${esc(r.cat)}</td><td class="muted small">${r.ch?CH_NAME[r.ch]:'—'}</td><td class="muted small">${r.point?esc(r.point):'—'}</td>
2563
2601
  <td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px">
2564
2602
  <button class="icon" ${i===0?'disabled':''} onclick="impRuleMove('${r.id}',-1)">↑</button>
2565
2603
  <button class="icon" ${i===(ST.impRules.length-1)?'disabled':''} onclick="impRuleMove('${r.id}',1)">↓</button>
2566
2604
  <button class="icon" onclick="impRuleDel('${r.id}')">✕</button></div></td></tr>`; });
2567
2605
  html+=`</tbody></table></div></div>
2568
- <p class="hint" style="padding:0 15px 12px;margin:0">Строки эквайринга Сбера обрабатываются отдельно: из назначения берётся комиссия, в отчётность попадает <b>валовая</b> выручка (зачисление + комиссия), а сама комиссия — расходом «Эквайринг». На счёт при этом ложится ровно то, что пришло.</p></div>`;
2606
+ <p class="hint" style="padding:0 15px 12px;margin:0">Правила проверяются сверху вниз, срабатывает первое совпадение. Быстрее всего разбирать выписку так: настроили строку → <b>⤳</b> применяет её классификацию ко всем похожим, <b>+</b> сохраняет правило, чтобы в следующий раз проставилось само. Эквайринг Сбера приходит двумя строками: валовая выручка (зачисление + комиссия) и отдельная строка комиссии — обе можно править и исключать по отдельности.</p></div>`;
2569
2607
 
2570
2608
  C.innerHTML=html;
2571
2609
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buhanka-finance-app",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Buhanka bakery finance dashboard — single-file web app",
5
5
  "license": "MIT",
6
6
  "files": [