01 — Cum arată
Pui CUI-urile pe coloana A și scrii într-o celulă goală:
=FIRMOSCOP(A2:A200)
Ceri anume coloanele care îți trebuie, în ordinea ta:
=FIRMOSCOP(A2:A200; "denumire,verdict,semnale")
Sau o singură valoare, de folosit în mijlocul altei formule — de pildă ca să colorezi condiționat rândurile roșii:
=FIRMOSCOP(A2; "verdict")
02 — Instalare
=FIRMOSCOP(A2:A200) într-o celulă goală, la dreapta coloanei cu CUI-uri.Același fișier pe care îl vei primi și din Marketplace, când listarea va fi gata. Nu trimite nimic altundeva decât la API-ul Firmoscop și atinge doar foaia în care e instalat.
/**
* 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.');
}
| Nume | Ce conține |
|---|---|
denumire | Denumirea din Registrul Comerțului |
verdict | verde / galben / roșu |
semnale | Semnalele de risc, separate prin punct și virgulă |
cifra_afaceri | Cifra de afaceri din ultimul bilanț depus |
profit | Rezultatul net din același an |
an_bilant | Anul bilanțului din care vin cifrele |
judet | Județul sediului social |
nr_reg | Numărul de ordine din registrul comerțului |
infiintare | Data înmatriculării |
datorii | Datorii totale din ultimul bilanț |
capitaluri | Capitaluri proprii |
angajati | Număr mediu de salariați |
recomandare | Recomandarea de termen de plată |
link | Linkul către profilul public |
actualizat | Data verificării |
Celulele rămân goale acolo unde firma n-a depus bilanț — un zero ar fi o cifră falsă, pe care ai putea-o aduna într-un total.
Verdictele fără semnale de risc nu costă niciun credit; se plătește doar verdictul care poartă un semnal real. Re-verificarea aceleiași firme în șapte zile nu se taxează din nou. În plus, foaia ține răspunsurile în cache șase ore, deci recalcularea de la fiecare tastare nu atinge deloc serverul. Pentru o listă de 200 de firme, o verificare pe zi rămâne comod în alocația gratuită.
Pui CUI-urile pe o coloană și scrii într-o celulă goală formula =FIRMOSCOP(A2:A200). Formula întoarce un tabel cu denumirea, verdictul, semnalele de risc, cifra de afaceri și profitul din ultimul bilanț depus, pentru toate firmele din coloană deodată. Datele vin din surse publice oficiale, aceleași ca pe site.
Al doilea argument al formulei alege coloanele: =FIRMOSCOP(A2:A200; "denumire,verdict,cifra_afaceri"). Disponibile sunt denumire, verdict, judet, nr_reg, infiintare, semnale, recomandare, cifra_afaceri, profit, datorii, capitaluri, angajati, an_bilant, link și actualizat. Fără al doilea argument primești setul obișnuit.
Funcționează pe contul gratuit, cu creditele lunare incluse. Verdictele fără semnale de risc nu consumă credit deloc; se plătește doar verdictul care poartă un semnal real. Re-verificarea aceleiași firme în șapte zile nu se taxează din nou, iar foaia ține răspunsurile în cache șase ore, deci nu consumă nimic la fiecare tastare.
Din contul tău de pe firmoscop.ro, la secțiunea pentru dezvoltatori. O lipești o singură dată, din meniul Firmoscop din foaie. Cheia se salvează în documentul respectiv, deci e vizibilă pentru oricine poate edita foaia — dacă lucrezi cu date sensibile, folosește o cheie separată pentru foile partajate.
Deocamdată se instalează prin copierea scriptului în Apps Script, ceea ce durează un minut și nu cere nicio aprobare. Listarea în Marketplace e în pregătire; până atunci pașii de mai jos sunt calea completă și nu se schimbă când listarea apare.
Nu direct — scriptul e scris pentru Google Apps Script. Din Excel poți folosi API-ul nostru prin Power Query sau exportul CSV din contul tău. Documentația API e pe pagina pentru dezvoltatori.
Verdict verde / galben / roșu din surse oficiale — stare juridică, insolvențe, bilanțuri și cât e de solidă financiar.
Caută o firmăInformații din surse publice oficiale, la data verificării. Verdict orientativ, nu garanție de plată sau rating de credit. Surse: ANAF, Ministerul Finanțelor, portalul instanțelor, BPI, Registrul Comerțului.