Firmoscop › Google Sheets

Verifică o coloană întreagă de CUI-uri, fără să ieși din foaie

Dacă ții portofoliul de clienți în Google Sheets, verificarea lor nu mai cere un al doilea tab. Scrii o formulă lângă coloana de CUI-uri și primești denumirea, verdictul de risc, semnalele și ultimele cifre de bilanț, pentru toate firmele deodată.

Vezi pașii de instalare

Un minut de instalat · merge pe contul gratuit · fără aprobări

01 — Cum arată

O formulă, o coloană, un tabel

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

Un minut, fără aprobări

1. Copiază-ți cheia APIDin contul tău, pe app.firmoscop.ro → secțiunea Chei API. Contul e gratuit; dacă n-ai unul, se face pe loc.
2. Deschide editorul de scripturiÎn foaia ta: meniul ExtensiiApps Script. Se deschide un tab nou, cu un fișier gol.
3. Lipește scriptulȘterge ce e în fișier, lipește scriptul de mai jos și salvează (Ctrl+S).
4. Întoarce-te în foaie și reîncarc-oApare un meniu nou, Firmoscop. Alege Conectează contul și lipește cheia. Google îți cere o dată permisiunea ca scriptul să iasă pe internet.
5. Scrie formula=FIRMOSCOP(A2:A200) într-o celulă goală, la dreapta coloanei cu CUI-uri.

Descarcă scriptul sau deschide-l ca text →

Scriptul

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.');
}

Coloanele pe care le poți cere

NumeCe conține
denumireDenumirea din Registrul Comerțului
verdictverde / galben / roșu
semnaleSemnalele de risc, separate prin punct și virgulă
cifra_afaceriCifra de afaceri din ultimul bilanț depus
profitRezultatul net din același an
an_bilantAnul bilanțului din care vin cifrele
judetJudețul sediului social
nr_regNumărul de ordine din registrul comerțului
infiintareData înmatriculării
datoriiDatorii totale din ultimul bilanț
capitaluriCapitaluri proprii
angajatiNumăr mediu de salariați
recomandareRecomandarea de termen de plată
linkLinkul către profilul public
actualizatData 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.

Ce consumă, mai exact

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ă.

Întrebări frecvente

Cum verific mai multe firme deodată în Google Sheets?

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.

Ce coloane pot cere?

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.

Costă ceva?

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.

Unde îmi iau cheia API?

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.

De ce nu apare în Google Workspace Marketplace?

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.

Merge și în Excel?

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.

Vezi și

Verifică orice firmă înainte să lucrezi cu ea

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.