/** * Firmoscop pentru Google Sheets — verifică o coloană de CUI-uri direct în foaie. * * DE CE EXISTĂ: contabilul, care e ICP-ul #1, își ține portofoliul de clienți în * spreadsheet. Până acum, ca să afle verdictul a 200 de firme, trebuia să iasă * din foaie. Aici formula stă lângă datele lui, iar când partajează foaia, * formula pleacă odată cu ea — singura buclă de distribuție reală pe care o are * categoria asta. * * Fișierul e sursa UNICĂ a scriptului: aceeași conținut e servit și de pagina * publică /sheets (calea copy-paste, fără review Google), iar un test din * monolit pică dacă cele două se despart. * * LIMITE ALE PLATFORMEI de care depinde designul de mai jos: * - funcțiile custom rulează FĂRĂ identitatea utilizatorului, deci * `PropertiesService.getUserProperties()` nu merge; cheia stă pe DOCUMENT. * - au un plafon de ~30 de secunde per evaluare, iar Sheets le re-evaluează des * (la fiecare editare din foaie), deci fără cache ar arde credite și cote. * - `UrlFetchApp` cere autorizare, care se obține prima oară din meniu. */ var FIRMOSCOP_API = 'https://firmoscop.ro'; /** Plafonul serverului per cerere (`MAX_BATCH` din src/server.ts). */ var LOT = 20; /** Cât ține un verdict în cache-ul documentului. Verdictele se schimbă în zile, * nu în minute, iar Sheets re-evaluează formula la fiecare editare din foaie. */ var CACHE_SEC = 21600; // 6 ore var CHEIE_PROP = 'FIRMOSCOP_API_KEY'; /** Coloanele pe care le poate cere formula, în ordinea implicită. */ var CAMPURI = { denumire: function (r) { return r.identity ? r.identity.name : ''; }, verdict: function (r) { return r.verdict || ''; }, judet: function (r) { return r.identity ? (r.identity.county || '') : ''; }, nr_reg: function (r) { return r.identity ? (r.identity.registrationNumber || '') : ''; }, infiintare: function (r) { return r.identity ? (r.identity.registeredAt || '') : ''; }, semnale: function (r) { var s = (r.signals || []).filter(function (x) { return x.severity === 'critical' || x.severity === 'warn'; }); return s.map(function (x) { return x.label; }).join('; '); }, recomandare: function (r) { return r.recommendation || ''; }, cifra_afaceri: function (r) { return anBilant(r, 'turnover'); }, profit: function (r) { return anBilant(r, 'netResult'); }, datorii: function (r) { return anBilant(r, 'totalDebt'); }, capitaluri: function (r) { return anBilant(r, 'equity'); }, angajati: function (r) { return anBilant(r, 'employees'); }, an_bilant: function (r) { var f = ultimulBilant(r); return f ? f.year : ''; }, link: function (r) { return FIRMOSCOP_API + '/firma/' + r.cui; }, actualizat: function (r) { return r.snapshotAt || ''; }, }; var IMPLICITE = ['denumire', 'verdict', 'semnale', 'cifra_afaceri', 'profit', 'an_bilant']; function ultimulBilant(r) { var f = r.financials || []; if (!f.length) return null; // Raportul le dă descrescător, dar nu ne bazăm pe ordinea altcuiva. return f.reduce(function (a, b) { return b.year > a.year ? b : a; }); } function anBilant(r, camp) { var f = ultimulBilant(r); if (!f) return ''; // `null` din JSON înseamnă „nedepus", nu zero — un 0 în foaie ar fi o cifră // falsă pe care cineva ar putea-o aduna. return f[camp] === null || f[camp] === undefined ? '' : f[camp]; } /** * Verifică una sau mai multe firme românești după CUI. * * @param {A2:A200} cui CUI-ul firmei sau o coloană de CUI-uri. * @param {"denumire,verdict"} campuri Opțional. Coloanele dorite, separate prin virgulă. * Disponibile: denumire, verdict, judet, nr_reg, infiintare, semnale, * recomandare, cifra_afaceri, profit, datorii, capitaluri, angajati, * an_bilant, link, actualizat. * @param {TRUE} antet Opțional. TRUE adaugă un rând de antet. Implicit FALSE. * @return Datele publice ale firmelor, din surse oficiale. * @customfunction */ function FIRMOSCOP(cui, campuri, antet) { var cerute = parseCampuri(campuri); var lista = aplatizeaza(cui); var singur = !Array.isArray(cui) && lista.length === 1; if (!lista.length) return singur ? '' : [['']]; var cheie = cheieApi(); if (!cheie) { throw new Error( 'Firmoscop: nu e conectat niciun cont. Deschide meniul Firmoscop → Conectează contul și lipește cheia din app.firmoscop.ro → Chei API.', ); } var dupaCui = incarca(lista, cheie); var randuri = lista.map(function (c) { var r = dupaCui[c]; return cerute.map(function (camp) { if (!r) return ''; if (r.__eroare) return camp === cerute[0] ? r.__eroare : ''; return CAMPURI[camp](r); }); }); if (antet === true) randuri.unshift(cerute.slice()); // O singură celulă cerută, un singur câmp → o singură valoare, ca formula să // se poată folosi în mijlocul unei alte expresii, nu doar ca tabel. if (singur && cerute.length === 1 && antet !== true) return randuri[0][0]; return randuri; } function parseCampuri(campuri) { if (campuri === undefined || campuri === null || campuri === '') return IMPLICITE.slice(); var brute = String(campuri).split(',').map(function (s) { return s.trim().toLowerCase(); }); var cerute = brute.filter(function (s) { return s !== ''; }); var necunoscute = cerute.filter(function (s) { return !CAMPURI[s]; }); if (necunoscute.length) { throw new Error( 'Firmoscop: câmp necunoscut — ' + necunoscute.join(', ') + '. Disponibile: ' + Object.keys(CAMPURI).join(', ') + '.', ); } return cerute.length ? cerute : IMPLICITE.slice(); } /** Celule → listă de CUI-uri curate, în ordinea din foaie, fără duplicate la interogare. */ function aplatizeaza(intrare) { var brut = Array.isArray(intrare) ? [].concat.apply([], intrare) : [intrare]; return brut.map(function (v) { if (v === null || v === undefined) return ''; // `RO 12345678`, `12345678.0` (Sheets citește CUI-ul ca număr) și spațiile // din copy-paste sunt cazul normal, nu excepția. return String(v).replace(/\.0+$/, '').replace(/[^0-9]/g, ''); }).filter(function (c) { return c !== ''; }); } function cheieApi() { return PropertiesService.getDocumentProperties().getProperty(CHEIE_PROP); } /** * Prefixul cheilor de cache. `CacheService` n-are „șterge tot", deci golirea se * face schimbând prefixul: intrările vechi rămân pe loc, dar devin inaccesibile * și expiră singure. Fără asta, butonul din meniu ar spune că a golit ceva și * n-ar goli nimic. */ function prefixCache() { var v = PropertiesService.getDocumentProperties().getProperty('FIRMOSCOP_CACHE_BUST') || '0'; return 'fs' + v + '_'; } /** * Aduce verdictele, din cache unde există și de la API pentru restul. * Returnează un dicționar cui → raport (sau `{__eroare}` pentru rândul acela). */ function incarca(lista, cheie) { var unice = []; var vazut = {}; lista.forEach(function (c) { if (!vazut[c]) { vazut[c] = true; unice.push(c); } }); var cache = CacheService.getDocumentCache(); var prefix = prefixCache(); var rezultat = {}; var lipsa = []; unice.forEach(function (c) { var brut = cache ? cache.get(prefix + c) : null; if (brut) { try { rezultat[c] = JSON.parse(brut); return; } catch (e) { /* cache corupt → reia */ } } lipsa.push(c); }); for (var i = 0; i < lipsa.length; i += LOT) { var lot = lipsa.slice(i, i + LOT); var raspuns = cereLot(lot, cheie); lot.forEach(function (c) { var r = raspuns[c] || { __eroare: 'Firmoscop: fără răspuns pentru ' + c }; rezultat[c] = r; if (cache && !r.__eroare) { try { cache.put(prefix + c, JSON.stringify(r), CACHE_SEC); } catch (e) { /* peste plafonul de 100KB */ } } }); } return rezultat; } /** Un lot de maxim `LOT` CUI-uri, cu reîncercare pe 429/5xx. */ function cereLot(lot, cheie) { var raspuns = trimite(lot, cheie); var iesire = {}; (raspuns.results || []).forEach(function (r) { if (!r || !r.cui) return; iesire[String(r.cui)] = r.error ? { __eroare: 'Firmoscop: ' + (r.message || r.error) } : r; }); return iesire; } function trimite(lot, cheie) { var incercari = 0; while (true) { var res = UrlFetchApp.fetch(FIRMOSCOP_API + '/api/v1/verify/batch', { method: 'post', contentType: 'application/json', headers: { Authorization: 'Bearer ' + cheie }, payload: JSON.stringify({ cuis: lot }), muteHttpExceptions: true, }); var cod = res.getResponseCode(); if (cod === 200) { try { return JSON.parse(res.getContentText()); } catch (e) { throw new Error('Firmoscop: răspuns necitibil de la server.'); } } // Plafonul e ținut pe CHEIE, nu pe adresa IP (vezi `gateBucket` din // src/ratelimit.ts), deci un 429 e aproape sigur al foii tale, nu al // altcuiva care iese prin aceeași adresă Google. Rămâne o plasă pe IP, larg // dimensionată, care se poate atinge doar la mulți utilizatori simultani. // Trei încercări cu pauză crescătoare, apoi mesaj clar în celulă. if ((cod === 429 || cod >= 500) && incercari < 3) { incercari++; Utilities.sleep(1000 * Math.pow(2, incercari)); continue; } if (cod === 401 || cod === 403) { throw new Error('Firmoscop: cheia API nu e validă sau a fost rotită. Reconectează contul din meniul Extensii → Firmoscop.'); } if (cod === 402) { throw new Error('Firmoscop: creditele lunare s-au terminat. Vezi firmoscop.ro/preturi.'); } if (cod === 429) { throw new Error('Firmoscop: prea multe cereri într-un minut. Încearcă din nou peste un minut.'); } throw new Error('Firmoscop: serverul a răspuns ' + cod + '.'); } } /* — meniul (rulează cu identitatea utilizatorului, spre deosebire de formule) — */ function onOpen() { SpreadsheetApp.getUi() .createMenu('Firmoscop') .addItem('Conectează contul', 'conecteazaCont') .addItem('Verifică conexiunea', 'verificaConexiunea') .addItem('Golește cache-ul', 'golesteCache') .addToUi(); } function onInstall(e) { onOpen(e); } function conecteazaCont() { var ui = SpreadsheetApp.getUi(); var raspuns = ui.prompt( 'Conectează contul Firmoscop', 'Lipește cheia API din app.firmoscop.ro → Chei API.\n\n' + 'Cheia se salvează în acest document și e vizibilă pentru oricine îl poate edita.', ui.ButtonSet.OK_CANCEL, ); if (raspuns.getSelectedButton() !== ui.Button.OK) return; var cheie = raspuns.getResponseText().trim(); if (!cheie) { ui.alert('Nu am primit nicio cheie.'); return; } PropertiesService.getDocumentProperties().setProperty(CHEIE_PROP, cheie); ui.alert('Gata. Scrie =FIRMOSCOP(A2:A100) într-o celulă goală.'); } function verificaConexiunea() { var ui = SpreadsheetApp.getUi(); var cheie = cheieApi(); if (!cheie) { ui.alert('Niciun cont conectat. Folosește „Conectează contul".'); return; } var res = UrlFetchApp.fetch(FIRMOSCOP_API + '/api/v1/me', { headers: { Authorization: 'Bearer ' + cheie }, muteHttpExceptions: true, }); ui.alert(res.getResponseCode() === 200 ? 'Conexiunea merge.' : 'Cheia nu e acceptată (cod ' + res.getResponseCode() + ').'); } function golesteCache() { // Schimbă prefixul citit de `prefixCache()` — intrările vechi devin // inaccesibile și expiră singure. Vezi comentariul de la `prefixCache`. PropertiesService.getDocumentProperties().setProperty('FIRMOSCOP_CACHE_BUST', String(new Date().getTime())); SpreadsheetApp.getUi().alert('Cache golit. Recalculează foaia (Ctrl+R) ca să reia verdictele.'); }