/** @OnlyCurrentDoc */ /* * Sirenic : le fichier clients prêt, modèle Google Sheets, version 2026-11-12. * Code servi par https://api.sirenic.eu/modeles/fichier-clients-pret.gs ; à coller dans Extensions > Apps Script, fichier Code.gs. * Mode d'emploi : https://api.sirenic.eu/modeles/fichier-clients-pret-mode-d-emploi.txt * Sirenic: the ready customer file, Google Sheets template. Paste into Extensions > Apps Script, file Code.gs. * Instructions: https://api.sirenic.eu/modeles/fichier-clients-pret-instructions.txt * * Le script ne lit et n'écrit que ce classeur, et n'appelle que https://api.sirenic.eu. Votre clé reste dans les propriétés du script pour votre * seul compte Google, jamais dans une cellule ; un éditeur du classeur peut modifier le script : ne la collez que dans un classeur * dont vous connaissez les éditeurs, de préférence une clé à part, révocable depuis votre compte. The script reads and writes this * spreadsheet only, and calls https://api.sirenic.eu only. Your key stays in the script properties for your Google account only, never in a cell; * an editor of the spreadsheet can change the script: only paste it in a spreadsheet whose editors you know, preferably a * separate key, revocable from your account. */ var SIRENIC = { "base": "https://api.sirenic.eu", "cheminReglages": "/modeles/fichier-clients-pret.json", "enTeteModele": "sheets/2026-11-12", "inscription": "https://api.sirenic.eu/compte?utm_source=template&utm_campaign=tableur-sheets", "octetsMaxAdresse": 2000, "dureeMaxMs": 330000, "proprieteCle": "SIRENIC_CLE", "ongletJournal": "Journal Sirenic", "modeDEmploi": { "fr": "Sirenic : le fichier clients prêt, modèle Google Sheets\nVersion 2026-11-12. In English: https://api.sirenic.eu/modeles/fichier-clients-pret-instructions.txt\n\nCE QUE FAIT LE MODÈLE\nIl complète chaque ligne de votre fichier clients par le lot d'identité de Sirenic (/v1/identite/lot) : SIREN, SIRET du siège, état de l'entreprise et date de cessation, numéro de TVA contrôlé au VIES, doublons sur tout le fichier. Il ne lit que les colonnes d'entrée ci-dessous, et n'envoie que la ligne lue de chaque client.\n\nINSTALLER, UNE FOIS PAR CLASSEUR\n1. Ouvrez votre fichier dans Google Sheets ; la première ligne porte les titres des colonnes.\n2. Extensions > Apps Script : remplacez le contenu du fichier Code.gs par le code servi à https://api.sirenic.eu/modeles/fichier-clients-pret.gs, puis enregistrez.\n3. Rechargez le classeur : le menu Sirenic apparaît. Au premier lancement, Google demande votre autorisation : le modèle lit et écrit ce classeur seul, et appelle https://api.sirenic.eu.\n4. Sirenic > Saisir ma clé : collez votre clé (elle commence par srn_live_). Elle est gardée dans les propriétés du script pour votre seul compte Google, jamais dans une cellule. Un éditeur du classeur peut modifier le script : ne collez votre clé que dans un classeur dont vous connaissez les éditeurs, de préférence une clé à part pour le modèle, révocable depuis votre compte. Sirenic > Oublier ma clé la retire.\nPas encore de clé : https://api.sirenic.eu/compte?utm_source=template&utm_campaign=tableur-sheets\n\nLES COLONNES LUES (le titre de la première ligne, lu sans casse ni accents, tout signe autre qu'une lettre ou un chiffre lu comme _ : « N° SIREN » se lit n_siren)\n- SIREN : siren, n_siren, no_siren, num_siren, numero_siren, siren_client\n- SIRET : siret, n_siret, no_siret, num_siret, numero_siret, siret_client\n- numéro de TVA français : tva, n_tva, no_tva, num_tva, numero_tva, numero_de_tva, tva_intracommunautaire, n_tva_intracommunautaire, vat, vat_number, vat_id\n- nom : denomination, raison_sociale, societe, entreprise, company_name, legal_name, company, nom_client, nom, name, client\n- code postal : code_postal, cp, codepostal, postcode, postal_code, zip, zip_code\n- adresse : adresse, adresse_postale, adresse_complete, address, postal_address, street_address\nPour chaque entrée, la colonne au titre le plus précis l'emporte, quelle que soit sa place (dans l'ordre des listes ci-dessus : denomination ou raison_sociale avant nom) ; une colonne nom ou name à côté d'une colonne de prénom (prenom, prenoms, first_name, firstname, given_name) est le nom d'un contact : elle n'est jamais envoyée.\nChaque ligne part par son SIREN s'il est rempli, sinon par son SIRET, sinon par son numéro de TVA français, sinon par son nom et son code postal ; sans code postal, le modèle prend le dernier code postal à cinq chiffres de la colonne d'adresse. Une valeur présente mais illisible n'est jamais remplacée par la colonne suivante : la ligne est marquée entree_illisible. Un SIREN, un SIRET ou un code postal dont le tableur a retiré les zéros de tête les retrouve.\n\nLES COLONNES AJOUTÉES PAR SIRENIC (à droite du fichier)\nChaque titre commence par sirenic_ et se lit tel quel, à la lettre près : une de vos colonnes (« État », « A revoir », « Résolution ») n'est jamais prise pour une colonne de Sirenic, ni remplie, ni écrasée ; une colonne qui porte exactement l'un de ces titres est remplie par Sirenic. Une colonne manquante est ajoutée à droite.\n- sirenic_statut : l'état de la ligne : complete, a_completer (rien d'envoyable), entree_illisible, ligne_trop_longue, a_verifier (une réponse sans lignes lisibles, qui a pu être débitée : la ligne ne repart pas seule), ou non_envoyee_ quand le passage s'est arrêté avant elle\n- sirenic_resolution : la résolution de la route (resolu, a_confirmer, introuvable, siren_invalide, siren_inconnu, non_diffusible)\n- sirenic_siren : le SIREN retenu\n- sirenic_siret_siege : le SIRET du siège\n- sirenic_denomination : la dénomination, pour une personne morale en diffusion totale seulement\n- sirenic_etat : l'état de l'entreprise (actif, cesse)\n- sirenic_date_cessation : la date de cessation d'une entreprise cessée\n- sirenic_tva : le numéro de TVA intracommunautaire calculé\n- sirenic_tva_vies : son contrôle au VIES (valide, invalide, indisponible, non_verifie), avec le motif d'un numéro non contrôlé\n- sirenic_candidats : pour une ligne a_confirmer, les personnes morales proposées : SIREN, dénomination et score\n- sirenic_doublon_de : le numéro de la ligne plus haute qui porte le même SIREN, sur tout le fichier\n- sirenic_a_revoir : les raisons de revoir la ligne (liste ci-dessous), vide sinon\n- sirenic_date_lecture : le jour (UTC) de la dernière lecture de l'identité\n- sirenic_ligne_envoyee : la ligne envoyée, telle que le modèle l'a lue dans vos colonnes : elle dit si l'entrée a changé depuis (comparée sans la casse, les accents ni les blancs)\n- sirenic_requete : le numéro de requête de l'appel qui a servi la ligne, celui de la colonne numero_requete du journal\n- sirenic_changements_depuis : la date depuis laquelle les changements ont été lus\n- sirenic_changements_verdict : le verdict de la relecture (nouveautes, rien_de_nouveau, lecture_incomplete, ligne_non_resolue, non_diffusible, budget_lot_epuise)\n- sirenic_changements_nombre : le nombre de changements\n- sirenic_changements_derniers : les plus récents : date et type\n- sirenic_date_relecture : le jour (UTC) depuis lequel la prochaine relecture lit les changements : celui de la dernière relecture complète, ou la date de départ gardée d'une relecture incomplète\n\nLA COLONNE sirenic_a_revoir\n- entree_illisible : une valeur présente mais illisible (SIREN, SIRET, TVA, nom ou code postal) : rien n'est envoyé pour la ligne\n- ligne_trop_longue : la ligne lue (le nom surtout) est trop longue pour l'adresse d'un appel de Google : rien n'est envoyé ; raccourcissez le nom\n- code_postal_manquant : un nom sans code postal, ni dans sa colonne ni dans l'adresse\n- nom_manquant : un code postal ou une adresse sans nom\n- non_envoyee : le passage s'est arrêté avant la ligne : relancez-le\n- reponse_a_verifier : le service a répondu sans lignes lisibles : l'appel a pu être débité (journal, numéro dans sirenic_requete) ; la ligne ne repart pas seule : effacez sa cellule sirenic_statut (pour une relecture, ce code) pour la renvoyer, elle se paiera à nouveau\n- feuille_changee : la feuille a changé pendant le passage : le résultat déjà reçu pour la même entrée a été posé sur cette ligne ; vérifiez-la\n- introuvable : aucune entreprise trouvée\n- a_confirmer : plusieurs entreprises possibles : voir sirenic_candidats\n- siren_invalide : la clé du SIREN est fausse\n- siren_inconnu : SIREN absent du stock Sirene servi\n- non_diffusible : ni nom ni adresse servis (entrepreneur individuel, ou unité non diffusible) ; SIREN, SIRET du siège, état et TVA le sont\n- recherche_partielle : une partie de la recherche n'a pas pu être lue : pour relire la ligne, effacez sa cellule sirenic_statut puis relancez Compléter ; elle se paie à nouveau\n- cessee : l'entreprise est cessée\n- tva_invalide : le VIES dit le numéro de TVA invalide\n- tva_non_verifiee : le numéro de TVA n'a pas pu être contrôlé au VIES\n- doublon : même SIREN qu'une ligne plus haute (sirenic_doublon_de)\n- a_change : des changements depuis la dernière passe\n- changements_incomplets : la relecture n'a pas tout lu : sa date de départ est gardée, la prochaine relecture, un autre jour (UTC), relit toute la période\nSirenic > Lignes à revoir pose un filtre sur cette colonne.\n\nCOMPLÉTER LE FICHIER\nSirenic > Compléter le fichier. Chaque appel envoie au plus le nombre de lignes qu'un appel gratuit admet, lu sur https://api.sirenic.eu au début de chaque passage : avec une clé gratuite, un appel entre dans les lignes gratuites du mois s'il tient dans ce qui en reste ; sinon, il se paie en entier sur vos crédits, aux prix de la grille publiée, et ce qui reste des lignes gratuites sert un appel plus court. Les lignes identiques ne partent qu'une fois. Une ligne complétée ne repart jamais, sauf si vous changez son entrée. Une ligne seule part avec une copie d'elle-même : la route exige deux lignes ; hors des lignes gratuites, la copie coûte une ligne (dans les lignes gratuites du mois, elle en prend une), et le journal le dit. Google arrête un script après quelques minutes : le passage s'arrête avant et le dit ; relancez-le pour continuer.\nNe modifiez pas la feuille pendant un passage. Avant d'écrire, le modèle relit l'entrée de chaque ligne : si elle a bougé (ligne insérée, supprimée ou triée, entrée modifiée), il s'arrête, n'écrit rien sur une autre ligne, et pose les résultats déjà reçus sur les lignes de même entrée (code feuille_changee), pour ne rien payer deux fois.\n\nLE JOURNAL\nL'onglet Journal Sirenic garde une ligne par appel (horodatage_utc, action, lignes_envoyees, statut_http, resultat, credits_debites, lignes_gratuites_restantes, credits_restants, numero_requete), lue dans la réponse : vous y voyez ce que chaque appel a débité et les lignes gratuites qui restent ce mois.\n\nERREURS\n- 401 : la clé est refusée (inconnue, expirée ou mal copiée) ; le passage s'arrête.\n- 402 : l'appel ne tient pas dans les lignes gratuites qui restent ce mois, et le solde de crédits ne le couvre pas ; rien n'est facturé, le passage s'arrête et dit le prix de l'appel et le solde, lus dans la réponse.\n- 429 : le service limite le débit ; le modèle attend le délai annoncé et réessaie une fois, puis s'arrête et dit quand réessayer.\n- 503 : le service est momentanément indisponible ; rien n'est facturé ; même attente, même nouvel essai.\n- 200 sans lignes lisibles : l'appel a pu être débité ; le passage s'arrête, ses lignes portent sirenic_statut a_verifier et le numéro de requête, et ne repartent pas seules.\nUne réponse en erreur n'est jamais facturée. Les lignes d'un appel arrêté portent sirenic_statut non_envoyee_.\n\nCE QUI A CHANGÉ DEPUIS MA DERNIÈRE PASSE\nÀ la fin d'un passage, le modèle propose la relecture. Sirenic > Ce qui a changé depuis ma dernière passe renvoie les lignes resolu par leur SIREN, avec la date de leur dernière lecture (paramètre depuis), et écrit sirenic_changements_verdict, sirenic_changements_nombre et sirenic_changements_derniers ; l'identité est relue en même temps. Le prix d'une ligne relue est celui de la grille avec depuis ; les appels suivent la même règle des lignes gratuites. Une ligne seule part avec une copie d'elle-même : la route exige deux lignes ; hors des lignes gratuites, la copie coûte une ligne (dans les lignes gratuites du mois, elle en prend une), et le journal le dit. Une ligne lue ou relue aujourd'hui (UTC) ne repart pas le même jour : une relecture arrêtée reprend aux autres lignes. Une relecture incomplète garde sa date de départ : la suivante relit toute la période.\n\nEXPORTER\nSirenic > Exporter en CSV : un fichier séparé par des points-virgules, avec BOM, qui s'ouvre tel quel dans Excel. Une cellule qui commencerait une formule (=, +, -, @) y reçoit une apostrophe devant. Le menu Fichier > Télécharger de Google Sheets reste disponible.\n\nDONNÉES PERSONNELLES\nSirenic ne sert jamais le nom ni l'adresse d'un entrepreneur individuel : sa ligne porte la résolution non_diffusible, avec son SIREN, le SIRET du siège, l'état et la TVA. Les candidats d'une ligne a_confirmer sont des personnes morales. L'API ne recopie jamais le nom saisi dans sa réponse ni dans ses journaux. Votre fichier reste dans votre classeur.\n\nLANGUE\nLe menu et les messages suivent la langue du classeur (Fichier > Paramètres).\n", "en": "Sirenic: the ready customer file, Google Sheets template\nVersion 2026-11-12. En français : https://api.sirenic.eu/modeles/fichier-clients-pret-mode-d-emploi.txt\n\nWHAT THE TEMPLATE DOES\nIt completes each row of your customer file with Sirenic's batch identity route (/v1/identite/lot): SIREN, head office SIRET, company status and cessation date, VAT number checked on VIES, duplicates over the whole file. It only reads the input columns below, and only sends the row it read for each customer.\n\nINSTALL, ONCE PER SPREADSHEET\n1. Open your file in Google Sheets; the first row carries the column headers.\n2. Extensions > Apps Script: replace the content of the Code.gs file with the code served at https://api.sirenic.eu/modeles/fichier-clients-pret.gs, then save.\n3. Reload the spreadsheet: the Sirenic menu appears. On first run, Google asks for your permission: the template reads and writes this spreadsheet only, and calls https://api.sirenic.eu.\n4. Sirenic > Enter my key: paste your key (it starts with srn_live_). It is kept in the script properties for your Google account only, never in a cell. An editor of the spreadsheet can change the script: only paste your key in a spreadsheet whose editors you know, preferably a separate key for the template, revocable from your account. Sirenic > Forget my key removes it.\nNo key yet: https://api.sirenic.eu/compte?utm_source=template&utm_campaign=tableur-sheets\n\nTHE COLUMNS READ (the header of the first row, read case and accents aside, any sign other than a letter or a digit read as _: \"VAT number\" reads vat_number)\n- SIREN: siren, n_siren, no_siren, num_siren, numero_siren, siren_client\n- SIRET: siret, n_siret, no_siret, num_siret, numero_siret, siret_client\n- French VAT number: tva, n_tva, no_tva, num_tva, numero_tva, numero_de_tva, tva_intracommunautaire, n_tva_intracommunautaire, vat, vat_number, vat_id\n- name: denomination, raison_sociale, societe, entreprise, company_name, legal_name, company, nom_client, nom, name, client\n- postcode: code_postal, cp, codepostal, postcode, postal_code, zip, zip_code\n- address: adresse, adresse_postale, adresse_complete, address, postal_address, street_address\nFor each input, the column with the most precise header wins, wherever it stands (in the order of the lists above: denomination or raison_sociale before nom); a nom or name column next to a first name column (prenom, prenoms, first_name, firstname, given_name) is a contact's name: it is never sent.\nEach row is sent by its SIREN when filled, else by its SIRET, else by its French VAT number, else by its name and postcode; with no postcode, the template takes the last five-digit postcode of the address column. A value present but unreadable is never replaced by the next column: the row is marked entree_illisible. A SIREN, SIRET or postcode whose leading zeros the spreadsheet dropped gets them back.\n\nTHE COLUMNS ADDED BY SIRENIC (right of the file)\nEach header starts with sirenic_ and is read as is, letter for letter: one of your columns (\"État\", \"A revoir\", \"Status\") is never taken for a Sirenic column, never filled nor overwritten; a column that carries exactly one of these headers is filled by Sirenic. A missing column is added on the right.\n- sirenic_statut : the row state: complete, a_completer (nothing to send), entree_illisible, ligne_trop_longue, a_verifier (an answer without readable rows, which may have been charged: the row is not sent again on its own), or non_envoyee_ when the pass stopped before it\n- sirenic_resolution : the route's resolution (resolu, a_confirmer, introuvable, siren_invalide, siren_inconnu, non_diffusible)\n- sirenic_siren : the SIREN found\n- sirenic_siret_siege : the head office SIRET\n- sirenic_denomination : the legal name, for a legal person with full disclosure only\n- sirenic_etat : the company status (actif, cesse)\n- sirenic_date_cessation : the cessation date of a ceased company\n- sirenic_tva : the computed intra-EU VAT number\n- sirenic_tva_vies : its check on VIES (valide, invalide, indisponible, non_verifie), with the reason of an unchecked number\n- sirenic_candidats : for an a_confirmer row, the legal persons proposed: SIREN, legal name and score\n- sirenic_doublon_de : the number of the higher row that carries the same SIREN, over the whole file\n- sirenic_a_revoir : the reasons to review the row (list below), empty otherwise\n- sirenic_date_lecture : the day (UTC) of the last identity read\n- sirenic_ligne_envoyee : the row sent, as the template read it from your columns: it tells whether the input changed since (compared case, accents and spaces aside)\n- sirenic_requete : the request number of the call that served the row, the one in the numero_requete column of the log\n- sirenic_changements_depuis : the date from which changes were read\n- sirenic_changements_verdict : the re-read verdict (nouveautes, rien_de_nouveau, lecture_incomplete, ligne_non_resolue, non_diffusible, budget_lot_epuise)\n- sirenic_changements_nombre : the number of changes\n- sirenic_changements_derniers : the most recent ones: date and type\n- sirenic_date_relecture : the day (UTC) from which the next re-read reads changes: that of the last complete re-read, or the start date kept from an incomplete re-read\n\nTHE sirenic_a_revoir COLUMN\n- entree_illisible : a value present but unreadable (SIREN, SIRET, VAT, name or postcode): nothing is sent for the row\n- ligne_trop_longue : the row read (mostly the name) is too long for the address of a Google call: nothing is sent; shorten the name\n- code_postal_manquant : a name with no postcode, neither in its column nor in the address\n- nom_manquant : a postcode or an address with no name\n- non_envoyee : the pass stopped before the row: run it again\n- reponse_a_verifier : the service answered without readable rows: the call may have been charged (log, number in sirenic_requete); the row is not sent again on its own: clear its sirenic_statut cell (for a re-read, this code) to send it again, it will be paid again\n- feuille_changee : the sheet changed during the pass: the result already received for the same input was set on this row; check it\n- introuvable : no company found\n- a_confirmer : several possible companies: see sirenic_candidats\n- siren_invalide : the SIREN check digit is wrong\n- siren_inconnu : SIREN absent from the Sirene stock served\n- non_diffusible : no name and no address served (sole proprietor, or restricted unit); SIREN, head office SIRET, status and VAT are\n- recherche_partielle : part of the search could not be read: to read the row again, clear its sirenic_statut cell then run Complete again; it is paid again\n- cessee : the company has ceased\n- tva_invalide : VIES says the VAT number is invalid\n- tva_non_verifiee : the VAT number could not be checked on VIES\n- doublon : same SIREN as a higher row (sirenic_doublon_de)\n- a_change : changes since the last pass\n- changements_incomplets : the re-read did not read everything: its start date is kept, the next re-read, on another day (UTC), reads the whole period again\nSirenic > Rows to review sets a filter on this column.\n\nCOMPLETE THE FILE\nSirenic > Complete the file. Each call sends at most the number of rows a free call accepts, read from https://api.sirenic.eu at the start of each pass: with a free key, a call falls within the month's free lines if it fits in what is left of them; otherwise it is paid in full from your credits, at the published price list, and what is left of the free lines serves a shorter call. Identical rows are sent once. A completed row is never sent again, unless you change its input. A single row is sent with a copy of itself: the route requires two rows; outside the free lines, the copy costs one row (within the month's free lines, it takes one of them), and the log says so. Google stops a script after a few minutes: the pass stops before and says so; run it again to continue.\nDo not edit the sheet during a pass. Before writing, the template reads the input of each row again: if it moved (row inserted, deleted or sorted, input edited), it stops, writes nothing on another row, and sets the results already received on the rows with the same input (code feuille_changee), so that nothing is paid twice.\n\nTHE LOG\nThe Journal Sirenic sheet keeps one row per call (horodatage_utc, action, lignes_envoyees, statut_http, resultat, credits_debites, lignes_gratuites_restantes, credits_restants, numero_requete), read from the response: it shows what each call charged and the free lines left this month.\n\nERRORS\n- 401: the key is refused (unknown, expired or badly copied); the pass stops.\n- 402: the call does not fit in the free lines left this month, and the credit balance does not cover it; nothing is charged, the pass stops and gives the call price and the balance, read from the response.\n- 429: the service is rate limiting; the template waits the announced delay and tries once more, then stops and says when to try again.\n- 503: the service is temporarily unavailable; nothing is charged; same wait, same retry.\n- 200 without readable rows: the call may have been charged; the pass stops, its rows carry sirenic_statut a_verifier and the request number, and are not sent again on their own.\nAn answer in error is never charged. The rows of a stopped call carry sirenic_statut non_envoyee_.\n\nWHAT HAS CHANGED SINCE MY LAST PASS\nAt the end of a pass, the template offers the re-read. Sirenic > What has changed since my last pass sends the resolu rows again by their SIREN, with the date of their last read (parameter depuis), and writes sirenic_changements_verdict, sirenic_changements_nombre and sirenic_changements_derniers; the identity is read again at the same time. A re-read row costs the price list's price with depuis; calls follow the same free lines rule. A single row is sent with a copy of itself: the route requires two rows; outside the free lines, the copy costs one row (within the month's free lines, it takes one of them), and the log says so. A row read or re-read today (UTC) is not sent again the same day: a stopped re-read goes on with the other rows. An incomplete re-read keeps its start date: the next one reads the whole period again.\n\nEXPORT\nSirenic > Export as CSV: a semicolon-separated file, with BOM, that opens as is in Excel. A cell that would start a formula (=, +, -, @) gets an apostrophe in front. Google Sheets' File > Download menu stays available.\n\nPERSONAL DATA\nSirenic never serves the name or the address of a sole proprietor: the row carries the resolution non_diffusible, with its SIREN, head office SIRET, status and VAT. The candidates of an a_confirmer row are legal persons. The API never echoes the typed name in its response or its logs. Your file stays in your spreadsheet.\n\nLANGUAGE\nThe menu and messages follow the spreadsheet language (File > Settings).\n" } }; var SirenicFichierClients = (function () { "use strict"; var ALIAS_ENTREE = {"siren":["siren","n_siren","no_siren","num_siren","numero_siren","siren_client"],"siret":["siret","n_siret","no_siret","num_siret","numero_siret","siret_client"],"tva":["tva","n_tva","no_tva","num_tva","numero_tva","numero_de_tva","tva_intracommunautaire","n_tva_intracommunautaire","vat","vat_number","vat_id"],"nom":["denomination","raison_sociale","societe","entreprise","company_name","legal_name","company","nom_client","nom","name","client"],"code_postal":["code_postal","cp","codepostal","postcode","postal_code","zip","zip_code"],"adresse":["adresse","adresse_postale","adresse_complete","address","postal_address","street_address"]}; var COLONNES_ENTREE = ["siren","siret","tva","nom","code_postal","adresse"]; var COLONNES_IDENTITE = ["sirenic_statut","sirenic_resolution","sirenic_siren","sirenic_siret_siege","sirenic_denomination","sirenic_etat","sirenic_date_cessation","sirenic_tva","sirenic_tva_vies","sirenic_candidats","sirenic_doublon_de","sirenic_a_revoir","sirenic_date_lecture","sirenic_ligne_envoyee","sirenic_requete"]; var COLONNES_CHANGEMENTS = ["sirenic_changements_depuis","sirenic_changements_verdict","sirenic_changements_nombre","sirenic_changements_derniers","sirenic_date_relecture"]; var COLONNES_SORTIE = COLONNES_IDENTITE.concat(COLONNES_CHANGEMENTS); var STATUTS_LIGNE = ["complete","a_completer","entree_illisible","ligne_trop_longue","a_verifier","non_envoyee_temps","non_envoyee_401","non_envoyee_402","non_envoyee_403","non_envoyee_429","non_envoyee_503","non_envoyee_feuille_changee","non_envoyee_erreur"]; var CODES_A_REVOIR = ["entree_illisible","ligne_trop_longue","code_postal_manquant","nom_manquant","non_envoyee","reponse_a_verifier","feuille_changee","a_confirmer","introuvable","siren_invalide","siren_inconnu","non_diffusible","recherche_partielle","cessee","tva_invalide","tva_non_verifiee","doublon","a_change","changements_incomplets"]; var COLONNES_JOURNAL = ["horodatage_utc","action","lignes_envoyees","statut_http","resultat","credits_debites","lignes_gratuites_restantes","credits_restants","numero_requete"]; var ATTENTES_MS = {"max":60000,"defaut":10000,"margeAppel":15000}; var TEXTES = {"fr":{"titre":"Sirenic","menu_cle":"Saisir ma clé","menu_completer":"Compléter le fichier","menu_relecture":"Ce qui a changé depuis ma dernière passe","menu_a_revoir":"Lignes à revoir","menu_csv":"Exporter en CSV","menu_oublier_cle":"Oublier ma clé","menu_aide":"Mode d'emploi","cle_demande":"Collez votre clé Sirenic (elle commence par srn_live_). Elle est gardée pour votre seul compte Google, jamais dans une cellule. Un éditeur du classeur peut modifier le script : ne collez votre clé que dans un classeur dont vous connaissez les éditeurs, de préférence une clé à part pour le modèle, révocable depuis votre compte.","cle_mal_formee":"Cette valeur n'a pas la forme d'une clé Sirenic (srn_live_ suivi de lettres et de chiffres). Rien n'est enregistré.","cle_enregistree":"Clé enregistrée pour vous seul. Menu Sirenic > Compléter le fichier.","cle_oubliee":"Clé retirée de ce script pour votre compte.","cle_manquante":"Aucune clé : menu Sirenic > Saisir ma clé. Pas encore de clé : {inscription}","passage_en_cours":"Un passage est déjà en cours sur ce classeur. Attendez sa fin.","feuille_vide":"La feuille active n'a pas de ligne sous ses titres.","feuille_journal":"Ouvrez la feuille de vos clients, pas le journal.","reglages_illisibles":"Les réglages du modèle n'ont pas pu être lus sur api.sirenic.eu : rien n'est envoyé. Réessayez dans un moment.","aucune_colonne":"Aucune colonne lue : la première ligne doit porter au moins un titre reconnu (siren, siret, tva, denomination ou nom, code_postal, adresse). Voir le mode d'emploi.","rien_a_relire":"Rien à relire : aucune ligne complétée et résolue (sirenic_resolution resolu), à l'entrée inchangée, lue ou relue avant aujourd'hui (UTC). Une ligne lue ou relue aujourd'hui ne repart pas le même jour.","rien_a_envoyer":"Rien à envoyer : chaque ligne est déjà complétée, sans entrée lisible, ou recopiée d'une ligne identique.","fin_completer":"{completes} ligne(s) complétée(s), {appels} appel(s) ; {a_revoir} ligne(s) à revoir (colonne sirenic_a_revoir, menu Lignes à revoir).","fin_relecture":"{relues} entreprise(s) relue(s), {appels} appel(s) ; {changees} avec des changements (code a_change).","fin_journal":"L'onglet Journal Sirenic dit, pour chaque appel, les crédits débités et les lignes gratuites qui restent ce mois, lus dans la réponse.","proposer_relecture":"La date de ce passage est notée sur chaque ligne (sirenic_date_lecture). Pour savoir plus tard ce qui a changé chez ces clients : menu Sirenic > Ce qui a changé depuis ma dernière passe.","complement":"Une ligne seule part avec une copie d'elle-même : la route exige deux lignes par appel. Dans les lignes gratuites du mois, la copie en prend une ; hors des lignes gratuites, elle coûte une ligne (colonne credits_debites du journal).","journal_copie":"copie d'une ligne seule : la route exige deux lignes ; hors des lignes gratuites, la copie coûte une ligne","arret_temps":"Google arrête un script après quelques minutes : ce passage s'est arrêté avant. Relancez la même commande : les lignes complétées ne repartent pas.","arret_temps_relecture":"Google arrête un script après quelques minutes : ce passage s'est arrêté avant. Relancez la même commande : les lignes relues aujourd'hui ne repartent pas, la relecture reprend aux autres.","arret_feuille_changee":"Arrêt : la feuille a changé pendant le passage (ligne insérée, supprimée ou triée, ou entrée modifiée). Rien n'est écrit sur une ligne dont l'entrée a bougé ; {placees} ligne(s) de même entrée ont reçu un résultat déjà reçu (code feuille_changee) ; {perdus} résultat(s) reçu(s) n'ont plus de ligne de même entrée. Relancez la même commande : les lignes complétées ne repartent pas.","arret_reponse_a_verifier":"Arrêt : le service a répondu (code HTTP {statut}, numéro de requête {requete}) sans lignes lisibles. Cet appel a pu être débité (colonne credits_debites du journal) : ses lignes portent sirenic_statut a_verifier et ne repartent pas seules. Pour en renvoyer une, effacez sa cellule sirenic_statut ; elle se paiera à nouveau.","arret_401":"La clé est refusée ({code}) : clé inconnue, expirée ou mal copiée. Menu Sirenic > Saisir ma clé.","arret_402":"Arrêt : cet appel ne tient pas dans les lignes gratuites qui restent ce mois, et le solde de crédits ne le couvre pas (prix de l'appel : {prix} €, solde : {solde} €). Rien n'est facturé.","arret_403":"Arrêt : le compte de cette clé est fermé ou suspendu ({code}).","arret_429":"Arrêt : le service limite le débit ({code}). Réessayez dans {secondes} secondes ; rien n'est facturé.","arret_503":"Arrêt : le service est momentanément indisponible ({code}) ; rien n'est facturé. Réessayez dans un moment.","arret_erreur":"Arrêt : réponse inattendue du service (code HTTP {statut}, {code}) ; rien n'est écrit pour les lignes restantes.","a_revoir_absente":"Pas encore de colonne sirenic_a_revoir : complétez d'abord le fichier.","a_revoir_filtre":"Filtre posé sur sirenic_a_revoir : seules les lignes à revoir restent visibles. Données > Supprimer le filtre pour tout revoir.","csv_pret":"Le fichier CSV est prêt : séparateur point-virgule, BOM, il s'ouvre tel quel dans Excel.","csv_lien":"Télécharger le CSV","csv_repli":"Si le téléchargement ne part pas : Fichier > Télécharger > Valeurs séparées par des virgules (séparateur virgule, sans BOM)."},"en":{"titre":"Sirenic","menu_cle":"Enter my key","menu_completer":"Complete the file","menu_relecture":"What has changed since my last pass","menu_a_revoir":"Rows to review","menu_csv":"Export as CSV","menu_oublier_cle":"Forget my key","menu_aide":"Instructions","cle_demande":"Paste your Sirenic key (it starts with srn_live_). It is kept for your Google account only, never in a cell. An editor of the spreadsheet can change the script: only paste your key in a spreadsheet whose editors you know, preferably a separate key for the template, revocable from your account.","cle_mal_formee":"This value is not shaped like a Sirenic key (srn_live_ followed by letters and digits). Nothing is saved.","cle_enregistree":"Key saved for you only. Menu Sirenic > Complete the file.","cle_oubliee":"Key removed from this script for your account.","cle_manquante":"No key: menu Sirenic > Enter my key. No key yet: {inscription}","passage_en_cours":"A pass is already running on this spreadsheet. Wait for it to end.","feuille_vide":"The active sheet has no row under its headers.","feuille_journal":"Open the sheet of your customers, not the log.","reglages_illisibles":"The template settings could not be read from api.sirenic.eu: nothing is sent. Try again in a moment.","aucune_colonne":"No column read: the first row must carry at least one recognised header (siren, siret, tva or vat, denomination or name, code_postal or postcode, adresse or address). See the instructions.","rien_a_relire":"Nothing to re-read: no completed and resolved row (sirenic_resolution resolu), with an unchanged input, read or re-read before today (UTC). A row read or re-read today is not sent again the same day.","rien_a_envoyer":"Nothing to send: every row is already completed, has no readable input, or is copied from an identical row.","fin_completer":"{completes} row(s) completed, {appels} call(s); {a_revoir} row(s) to review (column sirenic_a_revoir, menu Rows to review).","fin_relecture":"{relues} company(ies) re-read, {appels} call(s); {changees} with changes (code a_change).","fin_journal":"The Journal Sirenic sheet gives, for each call, the credits charged and the free lines left this month, read from the response.","proposer_relecture":"The date of this pass is noted on each row (sirenic_date_lecture). To learn later what has changed at these customers: menu Sirenic > What has changed since my last pass.","complement":"A single row is sent with a copy of itself: the route requires two rows per call. Within the month's free lines, the copy takes one of them; outside the free lines, it costs one row (credits_debites column of the log).","journal_copie":"copy of a single row: the route requires two rows; outside the free lines, the copy costs one row","arret_temps":"Google stops a script after a few minutes: this pass stopped before. Run the same command again: completed rows are not sent again.","arret_temps_relecture":"Google stops a script after a few minutes: this pass stopped before. Run the same command again: rows re-read today are not sent again, the re-read goes on with the others.","arret_feuille_changee":"Stopped: the sheet changed during the pass (row inserted, deleted or sorted, or input edited). Nothing is written on a row whose input moved; {placees} row(s) with the same input received a result already received (code feuille_changee); {perdus} result(s) received no longer have a row with the same input. Run the same command again: completed rows are not sent again.","arret_reponse_a_verifier":"Stopped: the service answered (HTTP code {statut}, request number {requete}) without readable rows. This call may have been charged (credits_debites column of the log): its rows carry sirenic_statut a_verifier and are not sent again on their own. To send one again, clear its sirenic_statut cell; it will be paid again.","arret_401":"The key is refused ({code}): unknown, expired or badly copied. Menu Sirenic > Enter my key.","arret_402":"Stopped: this call does not fit in the free lines left this month, and the credit balance does not cover it (call price: {prix} EUR, balance: {solde} EUR). Nothing is charged.","arret_403":"Stopped: the account of this key is closed or suspended ({code}).","arret_429":"Stopped: the service is rate limiting ({code}). Try again in {secondes} seconds; nothing is charged.","arret_503":"Stopped: the service is temporarily unavailable ({code}); nothing is charged. Try again in a moment.","arret_erreur":"Stopped: unexpected answer from the service (HTTP code {statut}, {code}); nothing is written for the remaining rows.","a_revoir_absente":"No sirenic_a_revoir column yet: complete the file first.","a_revoir_filtre":"Filter set on sirenic_a_revoir: only rows to review stay visible. Data > Remove filter to see them all.","csv_pret":"The CSV file is ready: semicolon separator, BOM, it opens as is in Excel.","csv_lien":"Download the CSV","csv_repli":"If the download does not start: File > Download > Comma-separated values (comma separator, no BOM)."}}; var PRENOMS = {"titres":["prenom","prenoms","first_name","firstname","given_name"],"nomsDeContact":["nom","name"]}; var ENTETES_REPONSE = {"creditsDebites":"x-credits-charged","lignesGratuitesRestantes":"x-free-quota-lines-remaining","creditsRestants":"x-credits-remaining","requete":"x-request-id"}; /* Les colonnes de résultat de l'identité : celles qu'une réponse remplit, et qu'une entrée changée vide. */ var CHAMPS_IDENTITE = ["sirenic_resolution", "sirenic_siren", "sirenic_siret_siege", "sirenic_denomination", "sirenic_etat", "sirenic_date_cessation", "sirenic_tva", "sirenic_tva_vies", "sirenic_candidats"]; /* Espaces (insécables compris), points et tirets entre les groupes de chiffres d'un identifiant. */ var SEPARATEURS = /[\s.\-]/g; var CODE_POSTAL_DANS_ADRESSE = /(?:^|[^0-9])((?:0[1-9]|[1-8][0-9]|9[0-8])[0-9]{3})(?![0-9])/g; var FORME_CLE = /^srn_live_[0-9A-Za-z]{43}$/; function textes(langue) { return TEXTES[langue === "fr" ? "fr" : "en"]; } function formater(modele, valeurs) { return String(modele).replace(/\{([a-z_]+)\}/g, function (tout, nom) { return valeurs && valeurs[nom] !== undefined && valeurs[nom] !== null ? String(valeurs[nom]) : tout; }); } function estDate(v) { return Object.prototype.toString.call(v) === "[object Date]"; } function deuxChiffres(n) { return (n < 10 ? "0" : "") + n; } /* Le texte d'une cellule : un nombre entier sans exposant ni décimale, une date au jour (AAAA-MM-JJ), le reste rogné. */ function texteDe(v) { if (v === null || v === undefined) return ""; if (typeof v === "number") { if (!isFinite(v)) return ""; return Math.floor(v) === v && Math.abs(v) < 1e21 ? v.toFixed(0) : String(v); } if (estDate(v)) { if (isNaN(v.getTime())) return ""; return v.getFullYear() + "-" + deuxChiffres(v.getMonth() + 1) + "-" + deuxChiffres(v.getDate()); } return String(v).trim(); } function jourUtc(date) { return date.toISOString().slice(0, 10); } function normaliserTitre(t) { return texteDe(t) .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); } /* Les colonnes d'une feuille par leur titre : l'index (à partir de 0) de chaque colonne d'entrée et de sortie, -1 sinon ; le premier titre reconnu l'emporte. Une colonne de Sirenic se reconnaît à son titre EXACT (préfixe sirenic_, R3) : un titre du client qui lui ressemble (« État », « a revoir ») reste au client ; une colonne d'entrée se reconnaît à son titre normalisé, le plus précis de sa liste d'abord (R9). */ function trouverColonnes(titres) { var entree = {}; var sortie = {}; var normalises = []; var prenom = false; var i; for (i = 0; i < COLONNES_ENTREE.length; i++) entree[COLONNES_ENTREE[i]] = -1; for (i = 0; i < COLONNES_SORTIE.length; i++) sortie[COLONNES_SORTIE[i]] = -1; for (i = 0; i < titres.length; i++) { var t = titres[i]; if (typeof t === "string" && Object.prototype.hasOwnProperty.call(sortie, t)) { if (sortie[t] === -1) sortie[t] = i; continue; } var n = normaliserTitre(t); if (n === "") continue; normalises.push({ index: i, titre: n }); if (PRENOMS.titres.indexOf(n) >= 0) prenom = true; } /* Chaque colonne d'entrée prend le titre le plus précis de sa liste (le rang de l'alias), puis le plus à gauche (R9) ; un nom de contact (« nom » ou « name ») n'est jamais pris quand la feuille a une colonne de prénom. */ for (var j = 0; j < COLONNES_ENTREE.length; j++) { var c = COLONNES_ENTREE[j]; var meilleur = -1; for (var k = 0; k < normalises.length; k++) { var rang = ALIAS_ENTREE[c].indexOf(normalises[k].titre); if (rang < 0) continue; if (c === "nom" && prenom && PRENOMS.nomsDeContact.indexOf(normalises[k].titre) >= 0) continue; if (meilleur < 0 || rang < meilleur) { meilleur = rang; entree[c] = normalises[k].index; } } } var aucune = true; for (i = 0; i < COLONNES_ENTREE.length; i++) if (entree[COLONNES_ENTREE[i]] >= 0) aucune = false; return { entree: entree, sortie: sortie, aucuneEntree: aucune }; } /* Un identifiant de « longueur » chiffres : un nombre du tableur, dont les zéros de tête sont tombés, les retrouve (un nombre dans une colonne SIREN ou SIRET ne peut être que cet identifiant) ; un texte perd ses espaces, points et tirets ; sinon null. */ function identifiant(v, longueur) { if (typeof v === "number") { var n = texteDe(v); if (!/^[0-9]+$/.test(n) || /^0+$/.test(n) || n.length > longueur) return null; while (n.length < longueur) n = "0" + n; return n; } var t = texteDe(v).replace(SEPARATEURS, ""); return new RegExp("^[0-9]{" + longueur + "}$").test(t) ? t : null; } function codePostal(v) { var t = typeof v === "number" ? texteDe(v) : texteDe(v).replace(/\s/g, ""); if (/^[0-9]{5}$/.test(t)) return t; if (/^[0-9]{4}$/.test(t)) return "0" + t; return null; } /* Le dernier code postal à cinq chiffres d'une adresse (choix E9 du chef), ou null. */ function codePostalDeLAdresse(v) { var t = texteDe(v); var dernier = null; var m; CODE_POSTAL_DANS_ADRESSE.lastIndex = 0; while ((m = CODE_POSTAL_DANS_ADRESSE.exec(t)) !== null) dernier = m[1]; return dernier; } /* Le nom tel qu'il part : les séparateurs de la route et les caractères de contrôle lus comme des espaces, les blancs réduits ; null s'il n'a ni lettre ni chiffre, ou s'il ne se code pas dans une adresse. */ function nettoyerNom(v, reglages) { var t = texteDe(v); t = t.split(reglages.separateur).join(" "); t = t.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim(); if (!/[\p{L}\p{N}]/u.test(t)) return null; try { encodeURIComponent(t); } catch (e) { return null; } return t; } function cleDuNom(nom) { return nom.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/\s+/g, " ").trim(); } /* Une valeur relue dans une colonne de Sirenic : sans l'apostrophe que valeurPourCellule pose devant un texte qui commencerait une formule, et que le tableur peut garder dans une cellule au format texte (R11). */ function sansApostrophe(t) { return /^'[=+\-@]/.test(t) ? t.slice(1) : t; } /* La ligne envoyée (relue dans sirenic_ligne_envoyee) est-elle celle que l'entrée donne aujourd'hui ? Comparée sans la casse, les accents, les blancs ni l'apostrophe ajoutée (R7, R11) : une entrée réécrite dans une autre casse n'est pas une entrée changée, et ne se paie pas une seconde fois. */ function memeLigne(envoyee, ligne) { if (typeof envoyee !== "string" || envoyee === "" || typeof ligne !== "string") return false; return cleDuNom(sansApostrophe(envoyee)) === cleDuNom(ligne); } /* La ligne d'un client, lue dans ses colonnes d'entrée : null (rien de saisi), une ligne à envoyer (forme, ligne, clé de regroupement), ou un statut sans envoi (entree_illisible, a_completer) et son code. Une valeur présente mais illisible n'est jamais remplacée par la colonne suivante. */ function lireEntree(e, reglages) { var present = {}; var rien = true; for (var i = 0; i < COLONNES_ENTREE.length; i++) { var c = COLONNES_ENTREE[i]; present[c] = texteDe(e[c]) !== ""; if (present[c]) rien = false; } if (rien) return null; if (present.siren) { var s = identifiant(e.siren, 9); return s ? { forme: "siren", ligne: s, cle: "siren:" + s } : { statut: "entree_illisible", code: "entree_illisible", champ: "siren" }; } if (present.siret) { var t = identifiant(e.siret, 14); return t ? { forme: "siret", ligne: t, cle: "siret:" + t } : { statut: "entree_illisible", code: "entree_illisible", champ: "siret" }; } if (present.tva) { var v = texteDe(e.tva).replace(SEPARATEURS, "").toUpperCase(); return /^FR[0-9]{11}$/.test(v) ? { forme: "tva", ligne: v, cle: "tva:" + v } : { statut: "entree_illisible", code: "entree_illisible", champ: "tva" }; } if (present.nom) { var nom = nettoyerNom(e.nom, reglages); if (nom === null) return { statut: "entree_illisible", code: "entree_illisible", champ: "nom" }; var cp = null; if (present.code_postal) { cp = codePostal(e.code_postal); if (cp === null) return { statut: "entree_illisible", code: "entree_illisible", champ: "code_postal" }; } else if (present.adresse) { cp = codePostalDeLAdresse(e.adresse); } if (cp === null) return { statut: "a_completer", code: "code_postal_manquant" }; var sep = reglages.separateur_nom_code_postal; return { forme: "nom_cp", ligne: nom + sep + cp, cle: "nom_cp:" + cleDuNom(nom) + sep + cp }; } return { statut: "a_completer", code: "nom_manquant" }; } /* Les réglages servis par api.sirenic.eu, lus à chaque passage (choix Q3 A) ; null s'ils manquent ou n'ont pas leur forme. */ function lireReglages(o) { function entier(n) { return typeof n === "number" && isFinite(n) && Math.floor(n) === n && n > 0; } if (!o || typeof o !== "object") return null; if (typeof o.route !== "string" || o.route.charAt(0) !== "/") return null; if (typeof o.parametre_lignes !== "string" || typeof o.parametre_depuis !== "string") return null; if (typeof o.separateur !== "string" || o.separateur.length !== 1) return null; if (typeof o.separateur_nom_code_postal !== "string" || o.separateur_nom_code_postal.length !== 1) return null; if (!entier(o.lignes_min) || !entier(o.lignes_max) || o.lignes_min > o.lignes_max) return null; if (o.lignes_gratuites_par_appel !== null && !entier(o.lignes_gratuites_par_appel)) return null; if (!entier(o.octets_max_ligne_requete)) return null; if (!o.duree_max_appel_s || !entier(o.duree_max_appel_s.identite) || !entier(o.duree_max_appel_s.changements)) return null; return o; } /* Les lignes par appel : les lignes au plus d'un appel gratuit des réglages (sans elles, le maximum de la route), entre le minimum et le maximum. */ function lignesParAppel(r) { var n = r.lignes_gratuites_par_appel === null ? r.lignes_max : r.lignes_gratuites_par_appel; return Math.min(Math.max(n, r.lignes_min), r.lignes_max); } function sansBarreFinale(base) { return String(base).replace(/\/+$/, ""); } /* encodeURIComponent laisse ! ' ( ) * : ils sont codés aussi, pour que la longueur de l'adresse soit celle qui part, quoi que le support recode (R21). */ function encoder(t) { return encodeURIComponent(t).replace(/[!'()*]/g, function (c) { return "%" + c.charCodeAt(0).toString(16).toUpperCase(); }); } function adresseDuLot(base, r, lignes, depuis) { var a = sansBarreFinale(base) + r.route + "?" + r.parametre_lignes + "=" + encoder(lignes.join(r.separateur)); if (depuis) a += "&" + r.parametre_depuis + "=" + encoder(depuis); return a; } /* Les octets de la ligne de requête que le service borne : « GET HTTP/1.1 » et sa fin de ligne. */ function octetsLigneRequete(adresse, base) { return ("GET " + adresse.slice(sansBarreFinale(base).length) + " HTTP/1.1\r\n").length; } /* La découpe des éléments ({ ligne, cle }) en appels : au plus lignesParAppel lignes, l'adresse sous octetsMaxAdresse et la ligne de requête sous la borne du service ; un appel trop court reçoit une ligne de l'appel d'avant, sinon des copies de sa première ligne (complement) ; un élément qui ne tient pas seul est rendu dans tropLongs. */ function decouper(elements, o) { var r = o.reglages; var max = lignesParAppel(r); function tient(items) { var a = adresseDuLot(o.base, r, items.map(function (x) { return x.ligne; }), o.depuis); return a.length <= o.octetsMaxAdresse && octetsLigneRequete(a, o.base) <= r.octets_max_ligne_requete; } var appels = []; var tropLongs = []; var courant = []; for (var i = 0; i < elements.length; i++) { var x = elements[i]; if (!tient([x])) { tropLongs.push(x); continue; } if (courant.length < max && tient(courant.concat([x]))) { courant.push(x); continue; } appels.push(courant); courant = [x]; } if (courant.length > 0) appels.push(courant); var rendus = []; for (var k = 0; k < appels.length; k++) { var items = appels[k]; var complement = 0; if (items.length < r.lignes_min && k === appels.length - 1 && rendus.length > 0) { var avant = rendus[rendus.length - 1]; while (items.length < r.lignes_min && avant.utiles > r.lignes_min) { var essai = [avant.items[avant.utiles - 1]].concat(items); if (!tient(essai)) break; items = essai; avant.items = avant.items.slice(0, avant.utiles - 1); avant.utiles -= 1; } } var avecCopies = items.slice(); while (avecCopies.length < r.lignes_min) avecCopies.push(items[0]); if (avecCopies.length > items.length && !tient(avecCopies)) { tropLongs = tropLongs.concat(items); continue; } complement = avecCopies.length - items.length; rendus.push({ items: avecCopies, utiles: items.length, complement: complement }); } return { appels: rendus, tropLongs: tropLongs }; } function entetesMinuscules(h) { var m = {}; if (!h || typeof h !== "object") return m; for (var k in h) { if (!Object.prototype.hasOwnProperty.call(h, k)) continue; var v = h[k]; m[String(k).toLowerCase()] = Array.isArray(v) ? String(v[0]) : String(v); } return m; } function lireJson(texte) { if (typeof texte !== "string" || texte === "") return null; try { return JSON.parse(texte); } catch (e) { return null; } } /* Les codes a_revoir, une liste fermée dans son ordre ; un code inconnu (une valeur neuve de la route) vient à la fin. */ function joindreCodes(codes) { var vus = {}; var liste = []; for (var i = 0; i < codes.length; i++) { if (!codes[i] || vus[codes[i]]) continue; vus[codes[i]] = true; liste.push(codes[i]); } liste.sort(function (a, b) { var ia = CODES_A_REVOIR.indexOf(a); var ib = CODES_A_REVOIR.indexOf(b); return (ia < 0 ? CODES_A_REVOIR.length : ia) - (ib < 0 ? CODES_A_REVOIR.length : ib); }); return liste.join(", "); } function codesDe(texte) { return texte ? String(texte).split(", ") : []; } function avecCode(texte, code, present) { var codes = codesDe(texte).filter(function (c) { return c !== code; }); if (present) codes.push(code); return joindreCodes(codes); } function candidatsEnTexte(cs) { if (!Array.isArray(cs)) return ""; return cs .map(function (c) { return String(c.siren || "") + " " + String(c.denomination || "") + (typeof c.score_confiance === "number" ? " (" + c.score_confiance + ")" : ""); }) .join("; "); } /* L'identité d'une ligne servie, dans les colonnes du modèle, et ses codes a_revoir : jamais que ce que la route sert. */ function identiteDe(l) { var tva = l.tva && typeof l.tva === "object" ? l.tva : null; var v = { sirenic_resolution: l.resolution || "", sirenic_siren: l.siren || "", sirenic_siret_siege: l.siret_siege || "", sirenic_denomination: l.denomination || "", sirenic_etat: l.etat || "", sirenic_date_cessation: l.date_cessation || "", sirenic_tva: tva && tva.numero ? tva.numero : "", sirenic_tva_vies: tva ? String(tva.vies) + (tva.motif ? " (" + tva.motif + ")" : "") : "", sirenic_candidats: candidatsEnTexte(l.candidats), }; var codes = []; if (l.resolution && l.resolution !== "resolu") codes.push(String(l.resolution)); if (Array.isArray(l.etages_abandonnes) && l.etages_abandonnes.length > 0) codes.push("recherche_partielle"); if (l.etat === "cesse") codes.push("cessee"); if (tva) { if (tva.vies === "invalide") codes.push("tva_invalide"); else if (tva.vies !== "valide") codes.push("tva_non_verifiee"); } return { valeurs: v, codes: codes }; } function derniersEnTexte(ds) { if (!Array.isArray(ds)) return ""; return ds.map(function (d) { return String(d.date || "") + " " + String(d.type || ""); }).join("; "); } /* Les changements d'une ligne relue. Une lecture incomplète garde sa date de départ dans sirenic_date_relecture (R2) : la prochaine relecture relit toute la fenêtre, depuis la même date ; une lecture complète y pose le jour du passage. */ function changementsDe(l, depuis, aujourdhui) { var c = l.changements && typeof l.changements === "object" ? l.changements : null; var incomplete = (c && c.lecture_complete === false) || l.motif_sans_changements === "budget_lot_epuise"; var v = { sirenic_changements_depuis: depuis, sirenic_changements_verdict: c ? String(c.verdict) : l.motif_sans_changements ? String(l.motif_sans_changements) : "", sirenic_changements_nombre: c && typeof c.nombre_total === "number" ? String(c.nombre_total) : "", sirenic_changements_derniers: c ? derniersEnTexte(c.dernieres) : "", sirenic_date_relecture: incomplete ? depuis : aujourdhui, }; var codes = []; if (c && typeof c.nombre_total === "number" && c.nombre_total > 0) codes.push("a_change"); if (incomplete) codes.push("changements_incomplets"); return { valeurs: v, codes: codes }; } function viderResultat(g) { var i; for (i = 0; i < CHAMPS_IDENTITE.length; i++) g.sortie[CHAMPS_IDENTITE[i]] = ""; for (i = 0; i < COLONNES_CHANGEMENTS.length; i++) g.sortie[COLONNES_CHANGEMENTS[i]] = ""; g.sortie.sirenic_doublon_de = ""; g.sortie.sirenic_date_lecture = ""; g.sortie.sirenic_ligne_envoyee = ""; g.sortie.sirenic_requete = ""; g.sale = true; } function poserSansResultat(g, statut, codes) { viderResultat(g); g.sortie.sirenic_statut = statut; g.sortie.sirenic_a_revoir = joindreCodes(codes); } /* L'identité servie, posée sur une rangée : sa PROPRE ligne lue (R7 : une rangée regroupée par la clé du nom ne prend jamais la ligne d'une autre), le jour et le numéro de requête de l'appel qui l'a servie. */ function poserIdentite(g, identite, ligne, aujourdhui, requete) { viderResultat(g); for (var k in identite.valeurs) g.sortie[k] = identite.valeurs[k]; g.sortie.sirenic_statut = "complete"; g.sortie.sirenic_date_lecture = aujourdhui; g.sortie.sirenic_ligne_envoyee = ligne; g.sortie.sirenic_requete = requete || ""; g.sortie.sirenic_a_revoir = joindreCodes(identite.codes); } function copierResultat(source, g) { for (var i = 0; i < COLONNES_SORTIE.length; i++) { var c = COLONNES_SORTIE[i]; if (c !== "sirenic_doublon_de") g.sortie[c] = source.sortie[c]; } if (g.lu && g.lu.ligne) g.sortie.sirenic_ligne_envoyee = g.lu.ligne; g.sale = true; } function nouveauPassage(o) { return { mode: o.mode === "relecture" ? "relecture" : "completer", base: o.base, reglages: o.reglages, aujourdhui: o.aujourdhui, octetsMaxAdresse: o.octetsMaxAdresse, langue: o.langue === "fr" ? "fr" : "en", rangees: [], groupes: {}, appels: [], index: 0, essais: 0, refus: 0, attenteMs: 0, refusSansRang: 0, journal: [], journalEcrit: 0, arret: null, erreur: null, parNumero: {}, titresInstantane: [], resultats: {}, feuilleChangee: false, faits: { appels: 0, lignes: 0, complement: false, relues: 0, placees: 0, perdus: 0 }, }; } /* L'instantané des cellules d'entrée d'une rangée, en texte : avant chaque écriture, la couche du support relit l'entrée de la rangée et ne l'écrit que si elle est inchangée (R5). */ function brutDe(entree) { var b = {}; for (var i = 0; i < COLONNES_ENTREE.length; i++) b[COLONNES_ENTREE[i]] = texteDe(entree[COLONNES_ENTREE[i]]); return b; } /* Un passage : les rangées de la feuille (sous la ligne des titres), les réglages lus, le jour UTC, la longueur d'adresse que le support admet. mode « completer » (l'identité des lignes à compléter) ou « relecture » (les changements des lignes résolues depuis leur dernière lecture). */ function creerPassage(o) { var p = nouveauPassage(o); var col = trouverColonnes(o.titres || []); p.colonnes = col; p.titresInstantane = (o.titres || []).map(texteDe); if (p.mode === "completer" && col.aucuneEntree) { p.erreur = "aucune_colonne"; return p; } var premiere = typeof o.premiereLigne === "number" ? o.premiereLigne : 2; var rangees = o.rangees || []; for (var i = 0; i < rangees.length; i++) { var ligne = rangees[i] || []; var entree = {}; var sortie = {}; var j; for (j = 0; j < COLONNES_ENTREE.length; j++) { var ce = COLONNES_ENTREE[j]; entree[ce] = col.entree[ce] >= 0 ? ligne[col.entree[ce]] : ""; } for (j = 0; j < COLONNES_SORTIE.length; j++) { var cs = COLONNES_SORTIE[j]; sortie[cs] = col.sortie[cs] >= 0 ? sansApostrophe(texteDe(ligne[col.sortie[cs]])) : ""; } var g = { numero: premiere + i, entree: entree, sortie: sortie, brut: brutDe(entree), lu: lireEntree(entree, o.reglages), sale: false, nonEcrite: false }; p.rangees.push(g); p.parNumero[g.numero] = g; } if (p.mode === "completer") planifierCompletion(p); else planifierRelecture(p); return p; } function planifierCompletion(p) { var faits = {}; var ordre = []; var groupes = {}; for (var i = 0; i < p.rangees.length; i++) { var g = p.rangees[i]; var lu = g.lu; if (lu === null) continue; if (lu.statut) { poserSansResultat(g, lu.statut, [lu.code]); continue; } if (g.sortie.sirenic_statut === "complete" && memeLigne(g.sortie.sirenic_ligne_envoyee, lu.ligne)) { if (!faits[lu.cle]) faits[lu.cle] = g; continue; } /* Une réponse à vérifier (un 2xx illisible, R6) a pu être débitée : la ligne ne repart pas seule tant que son entrée ne change pas ; l'utilisateur efface sirenic_statut pour la renvoyer. */ if (g.sortie.sirenic_statut === "a_verifier" && memeLigne(g.sortie.sirenic_ligne_envoyee, lu.ligne)) continue; if (!groupes[lu.cle]) { groupes[lu.cle] = { ligne: lu.ligne, rangees: [] }; ordre.push(lu.cle); } groupes[lu.cle].rangees.push(g); } var elements = []; for (var k = 0; k < ordre.length; k++) { var cle = ordre[k]; if (faits[cle]) { for (var m = 0; m < groupes[cle].rangees.length; m++) copierResultat(faits[cle], groupes[cle].rangees[m]); continue; } elements.push({ ligne: groupes[cle].ligne, cle: cle }); } p.groupes = groupes; var d = decouper(elements, { base: p.base, reglages: p.reglages, depuis: null, octetsMaxAdresse: p.octetsMaxAdresse }); for (var t = 0; t < d.tropLongs.length; t++) { var rs = groupes[d.tropLongs[t].cle].rangees; for (var u = 0; u < rs.length; u++) poserSansResultat(rs[u], "ligne_trop_longue", ["ligne_trop_longue"]); } p.appels = d.appels.map(function (a) { return { items: a.items, utiles: a.utiles, complement: a.complement, depuis: null }; }); } function planifierRelecture(p) { var parSiren = {}; var ordre = []; for (var i = 0; i < p.rangees.length; i++) { var g = p.rangees[i]; var lu = g.lu; if (lu === null || lu.statut) continue; if (g.sortie.sirenic_statut !== "complete" || !memeLigne(g.sortie.sirenic_ligne_envoyee, lu.ligne)) continue; var siren = g.sortie.sirenic_siren; if (!/^[0-9]{9}$/.test(siren)) continue; /* R1 : une ligne lue ou relue aujourd'hui (UTC) ne repart pas le même jour ; une reprise après un arrêt ne relit que les autres, et un fichier plus grand qu'un passage finit. R6 : une relecture à vérifier ne repart pas seule. */ var lue = g.sortie.sirenic_date_lecture; if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(lue) && lue >= p.aujourdhui) continue; if (codesDe(g.sortie.sirenic_a_revoir).indexOf("reponse_a_verifier") >= 0) continue; if (!parSiren[siren]) { if (g.sortie.sirenic_resolution !== "resolu") continue; var depuis = g.sortie.sirenic_date_relecture || g.sortie.sirenic_date_lecture; if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(depuis) || depuis >= p.aujourdhui) continue; parSiren[siren] = { ligne: siren, depuis: depuis, rangees: [] }; ordre.push(siren); } parSiren[siren].rangees.push(g); } var parDate = {}; var dates = []; for (var k = 0; k < ordre.length; k++) { var e = parSiren[ordre[k]]; if (!parDate[e.depuis]) { parDate[e.depuis] = []; dates.push(e.depuis); } parDate[e.depuis].push({ ligne: e.ligne, cle: e.ligne }); } dates.sort(); p.groupes = parSiren; for (var n = 0; n < dates.length; n++) { var d = decouper(parDate[dates[n]], { base: p.base, reglages: p.reglages, depuis: dates[n], octetsMaxAdresse: p.octetsMaxAdresse }); for (var a = 0; a < d.appels.length; a++) { p.appels.push({ items: d.appels[a].items, utiles: d.appels[a].utiles, complement: d.appels[a].complement, depuis: dates[n] }); } } } /* Le prochain appel du passage, ou null (fini, arrêté, ou plus le temps : tempsRestantMs doit couvrir l'attente, la durée au plus d'un appel lue dans les réglages et une marge). Rend l'adresse, les en-têtes du cœur et l'attente avant l'appel ; la couche du support ajoute la clé et son en-tête de modèle. */ function prochainAppel(p, tempsRestantMs) { if (p.erreur || p.arret || p.index >= p.appels.length) return null; var appel = p.appels[p.index]; var duree = (p.mode === "relecture" ? p.reglages.duree_max_appel_s.changements : p.reglages.duree_max_appel_s.identite) * 1000; if (typeof tempsRestantMs === "number" && tempsRestantMs < p.attenteMs + duree + ATTENTES_MS.margeAppel) { arreter(p, "non_envoyee_temps", { code: "temps" }); return null; } return { url: adresseDuLot(p.base, p.reglages, appel.items.map(function (x) { return x.ligne; }), appel.depuis), entetes: { Accept: "application/json" }, attenteMs: p.attenteMs, lignes: appel.items.length, }; } function retryAfterMs(entetes) { var v = entetes["retry-after"]; if (v === undefined || !/^[0-9]+$/.test(String(v).trim())) return null; return parseInt(v, 10) * 1000; } function noterJournal(p, appel, rep, entetes, resultat) { p.journal.push([ rep && rep.horodatage ? String(rep.horodatage) : "", p.mode, String(appel.items.length), String(rep && typeof rep.statut === "number" ? rep.statut : 0), resultat, entetes[ENTETES_REPONSE.creditsDebites] || "", entetes[ENTETES_REPONSE.lignesGratuitesRestantes] || "", entetes[ENTETES_REPONSE.creditsRestants] || "", entetes[ENTETES_REPONSE.requete] || "", ]); } /* L'arrêt du passage : son statut (celui des lignes qui ne partent pas), ses détails pour le message, et la clé de son message quand elle n'est pas celle que le statut donne. En mode completer, les lignes des appels qui ne partent pas portent le statut, chacune écrite après contrôle de son entrée (R5). */ function arreter(p, statut, details, cleMessage) { p.arret = { statut: statut, details: details || {}, message: cleMessage || null }; if (p.mode !== "completer") return; for (var i = p.index; i < p.appels.length; i++) { var appel = p.appels[i]; for (var j = 0; j < appel.utiles; j++) { var rs = p.groupes[appel.items[j].cle].rangees; for (var k = 0; k < rs.length; k++) poserSansResultat(rs[k], statut, ["non_envoyee"]); } } } function appelSuivant(p) { p.index += 1; p.essais = 0; p.refus = 0; p.refusSansRang = 0; p.attenteMs = 0; } /* Le résultat d'une clé, posé sur une rangée : l'identité (et les changements d'une relecture), la ligne de la rangée elle-même (R7), le numéro de requête. */ function appliquerResultat(p, g, res) { if (p.mode === "completer") { poserIdentite(g, res.identite, g.lu.ligne, p.aujourdhui, res.requete); return; } var ligneEnvoyee = g.sortie.sirenic_ligne_envoyee; poserIdentite(g, res.identite, ligneEnvoyee, p.aujourdhui, res.requete); for (var c in res.changements.valeurs) g.sortie[c] = res.changements.valeurs[c]; g.sortie.sirenic_a_revoir = joindreCodes(res.identite.codes.concat(res.changements.codes)); } function integrerSucces(p, appel, corps, requete) { var lignes = corps.lignes; for (var i = 0; i < appel.utiles; i++) { var l = lignes[i] || {}; var source = typeof l.doublon_de === "number" && lignes[l.doublon_de - 1] ? lignes[l.doublon_de - 1] : l; var cle = appel.items[i].cle; /* Le résultat reçu est gardé par sa clé le temps du passage : si la feuille bouge, il se pose sur la ligne de même entrée au lieu d'être payé une seconde fois (R5). */ var res = { identite: identiteDe(source), changements: p.mode === "relecture" ? changementsDe(source, appel.depuis, p.aujourdhui) : null, requete: requete }; p.resultats[cle] = res; var groupe = p.groupes[cle]; for (var k = 0; k < groupe.rangees.length; k++) appliquerResultat(p, groupe.rangees[k], res); if (p.mode === "relecture") p.faits.relues += 1; } p.faits.appels += 1; p.faits.lignes += appel.items.length; if (appel.complement > 0) p.faits.complement = true; } /* Un 2xx (ou un 3xx) sans les lignes attendues a pu être débité (le rail ne rend que les statuts à partir de 400) : ses lignes portent son numéro de requête et ne repartent pas seules (R6). En relecture, l'identité déjà lue reste. */ function marquerAVerifier(p, appel, requete) { for (var i = 0; i < appel.utiles; i++) { var rs = p.groupes[appel.items[i].cle].rangees; for (var k = 0; k < rs.length; k++) { var g = rs[k]; if (p.mode === "completer") { poserSansResultat(g, "a_verifier", ["reponse_a_verifier"]); g.sortie.sirenic_ligne_envoyee = g.lu.ligne; } else { g.sortie.sirenic_a_revoir = avecCode(g.sortie.sirenic_a_revoir, "reponse_a_verifier", true); g.sale = true; } g.sortie.sirenic_requete = requete; } } } /* Ce que la couche du support a reçu : { statut (0 sans réponse), entetes, corps (texte), horodatage }. */ function integrerReponse(p, rep) { if (p.erreur || p.arret || p.index >= p.appels.length) return; var appel = p.appels[p.index]; var entetes = entetesMinuscules(rep && rep.entetes); var corps = lireJson(rep && rep.corps); var statut = rep && typeof rep.statut === "number" ? rep.statut : 0; var code = corps && typeof corps.error === "string" ? corps.error : ""; var requete = entetes[ENTETES_REPONSE.requete] || ""; if (statut === 200 && corps && Array.isArray(corps.lignes) && corps.lignes.length === appel.items.length) { noterJournal(p, appel, rep, entetes, appel.complement > 0 ? "ok ; " + textes(p.langue).journal_copie : "ok"); integrerSucces(p, appel, corps, requete); appelSuivant(p); return; } if (statut >= 200 && statut < 400) { noterJournal(p, appel, rep, entetes, "reponse_a_verifier"); marquerAVerifier(p, appel, requete); appelSuivant(p); arreter(p, "non_envoyee_erreur", { statut: statut, requete: requete || "?" }, "arret_reponse_a_verifier"); return; } if (statut === 400 && code === "lignes_invalides" && corps && Array.isArray(corps.lignes_refusees) && p.refus < appel.items.length) { var refusees = {}; var lisibles = 0; for (var i = 0; i < corps.lignes_refusees.length; i++) { var rang = corps.lignes_refusees[i] && corps.lignes_refusees[i].rang; if (typeof rang === "number" && rang >= 1 && rang <= appel.items.length) { refusees[appel.items[rang - 1].cle] = true; lisibles += 1; } } /* R19 : un refus sans rang lisible ne retire aucune ligne ; le même appel n'est renvoyé qu'une fois, puis l'arrêt. */ if (lisibles === 0 && p.refusSansRang < 1) { noterJournal(p, appel, rep, entetes, "lignes_invalides, nouvel essai"); p.refusSansRang += 1; return; } if (lisibles > 0) { noterJournal(p, appel, rep, entetes, "lignes_invalides"); p.refus += 1; var gardes = []; for (var j = 0; j < appel.utiles; j++) { var x = appel.items[j]; if (refusees[x.cle]) { var rs = p.groupes[x.cle].rangees; for (var k = 0; k < rs.length; k++) poserSansResultat(rs[k], "entree_illisible", ["entree_illisible"]); } else gardes.push(x); } if (gardes.length === 0) { appelSuivant(p); return; } var avecCopies = gardes.slice(); while (avecCopies.length < p.reglages.lignes_min) avecCopies.push(gardes[0]); appel.items = avecCopies; appel.utiles = gardes.length; appel.complement = avecCopies.length - gardes.length; return; } } if ((statut === 429 || statut === 503) && p.essais < 1) { var attente = retryAfterMs(entetes); if (attente === null) attente = ATTENTES_MS.defaut; if (attente <= ATTENTES_MS.max) { noterJournal(p, appel, rep, entetes, (code || String(statut)) + ", nouvel essai"); p.essais += 1; p.attenteMs = attente; return; } } noterJournal(p, appel, rep, entetes, statut === 0 ? "sans_reponse" : code || "http_" + statut); var details = { code: code || (statut === 0 ? "sans_reponse" : "http_" + statut), statut: statut }; if (statut === 402 && corps) { details.prix = typeof corps.prix === "number" ? corps.prix : "?"; details.solde = typeof corps.solde === "number" ? corps.solde : "?"; } if (statut === 429) { var ms = retryAfterMs(entetes); details.secondes = ms === null ? "?" : Math.ceil(ms / 1000); } var connu = statut === 401 || statut === 402 || statut === 403 || statut === 429 || statut === 503; arreter(p, connu ? "non_envoyee_" + statut : "non_envoyee_erreur", details); } /* Les doublons sur TOUT le fichier : une ligne complétée dont le SIREN est celui d'une ligne plus haute porte le numéro de cette ligne (doublon_de) et le code doublon. */ function recalculerDoublons(p) { var premier = {}; for (var i = 0; i < p.rangees.length; i++) { var g = p.rangees[i]; if (g.sortie.sirenic_statut === "") continue; var s = g.sortie.sirenic_siren; if (g.sortie.sirenic_statut === "complete" && /^[0-9]{9}$/.test(s)) { if (premier[s] === undefined) { premier[s] = g.numero; g.sortie.sirenic_doublon_de = ""; } else g.sortie.sirenic_doublon_de = String(premier[s]); } else g.sortie.sirenic_doublon_de = ""; g.sortie.sirenic_a_revoir = avecCode(g.sortie.sirenic_a_revoir, "doublon", g.sortie.sirenic_doublon_de !== ""); } } /* Les colonnes de sortie de chaque rangée, dans l'ordre de COLONNES_SORTIE, doublons recalculés. */ function sorties(p) { if (!p.erreur) recalculerDoublons(p); return { colonnes: COLONNES_SORTIE.slice(), lignes: p.rangees.map(function (g) { return COLONNES_SORTIE.map(function (c) { return g.sortie[c]; }); }), }; } /* Les lignes du journal pas encore écrites par la couche du support (dans l'ordre de COLONNES_JOURNAL). */ function journalAEcrire(p) { var neuves = p.journal.slice(p.journalEcrit); p.journalEcrit = p.journal.length; return neuves; } /* Les titres de la feuille une fois les colonnes de Sirenic ajoutées par la couche du support : l'instantané auquel chaque écriture compare la feuille (R5). */ function fixerTitres(p, titres) { p.titresInstantane = titres.map(texteDe); p.colonnes = trouverColonnes(titres); } function largeurEntree(p) { var l = 0; for (var i = 0; i < COLONNES_ENTREE.length; i++) l = Math.max(l, p.colonnes.entree[COLONNES_ENTREE[i]] + 1); return l; } function feuilleAChange(p) { p.feuilleChangee = true; if (!p.arret) arreter(p, "non_envoyee_feuille_changee", {}); } /* R5 : avant d'écrire, la couche du support relit la ligne des titres et les cellules d'entrée des rangées à écrire (bloc : à partir de la rangée « premiere », les colonnes 1 à largeurEntree) ; rend les numéros des rangées dont l'entrée est inchangée, les seules qu'elle écrit. Une colonne déplacée ne laisse rien écrire ; une rangée dont l'entrée a changé n'est pas écrite. Au premier changement, le passage s'arrête (aucun appel de plus) et les résultats déjà reçus se posent par leur entrée (placerParEntree) : rien n'est écrit sur une autre ligne, rien n'est payé deux fois. */ function controlerEntrees(p, titres, premiere, bloc) { var ok = {}; var i; var structure = true; titres = titres || []; for (i = 0; i < COLONNES_ENTREE.length; i++) { var ie = p.colonnes.entree[COLONNES_ENTREE[i]]; if (ie >= 0 && texteDe(titres[ie]) !== p.titresInstantane[ie]) structure = false; } for (i = 0; i < COLONNES_SORTIE.length; i++) { var is = p.colonnes.sortie[COLONNES_SORTIE[i]]; if (is < 0 || titres[is] !== COLONNES_SORTIE[i]) structure = false; } if (!structure) { feuilleAChange(p); return ok; } var change = false; for (var k = 0; k < bloc.length; k++) { var g = p.parNumero[premiere + k]; if (!g) continue; var cellules = bloc[k] || []; var meme = true; for (i = 0; i < COLONNES_ENTREE.length; i++) { var c = COLONNES_ENTREE[i]; var idx = p.colonnes.entree[c]; if (idx >= 0 && texteDe(cellules[idx]) !== g.brut[c]) meme = false; } if (meme) ok[g.numero] = true; else change = true; } if (change) feuilleAChange(p); return ok; } /* Les rangées à écrire depuis la dernière écriture (R8 : celles que l'appel, la planification ou l'arrêt ont touchées, jamais toute la feuille), dans l'ordre, avec leurs colonnes de Sirenic dans l'ordre de COLONNES_SORTIE ; la couche relit leur entrée (controlerEntrees) avant de les écrire. */ function ecrituresEnAttente(p) { var lignes = []; for (var i = 0; i < p.rangees.length; i++) { var g = p.rangees[i]; if (!g.sale) continue; g.sale = false; lignes.push({ numero: g.numero, valeurs: COLONNES_SORTIE.map(function (c) { return g.sortie[c]; }) }); } var l = largeurs(p); return { lignes: lignes, largeurTitres: l.titres, largeurEntree: l.entree }; } /* Les largeurs que la couche relit avant d'écrire : la ligne des titres de l'instantané, les colonnes jusqu'à la dernière colonne d'entrée. */ function largeurs(p) { return { titres: Math.max(1, p.titresInstantane.length), entree: Math.max(1, largeurEntree(p)) }; } /* La fin du passage (R8) : les deux colonnes que les doublons sur tout le fichier changent, pour toutes les rangées ; la couche ne les écrit que si TOUTE l'entrée relue est inchangée (controlerEntrees sur toutes les rangées). */ function colonnesDeFin(p) { recalculerDoublons(p); return { colonnes: ["sirenic_doublon_de", "sirenic_a_revoir"], premiere: p.rangees.length > 0 ? p.rangees[0].numero : 2, lignes: p.rangees.map(function (g) { return [g.sortie.sirenic_doublon_de, g.sortie.sirenic_a_revoir]; }), }; } /* R5 : la feuille a changé pendant le passage. Chaque résultat reçu se pose sur les rangées de la feuille relue dont l'entrée donne la même clé (même entrée, même réponse) et qui ne l'ont pas déjà ; elles portent le code feuille_changee. Rien n'est écrit sur une rangée d'une autre entrée ; un résultat qui n'a plus de ligne de même entrée est compté. */ function placerParEntree(p, titres, rangees) { var col = trouverColonnes(titres || []); var q = { rangees: [] }; var places = {}; var lignes = []; if (!col.aucuneEntree) { for (var i = 0; i < (rangees || []).length; i++) { var ligne = rangees[i] || []; var entree = {}; var sortie = {}; var j; for (j = 0; j < COLONNES_ENTREE.length; j++) { var ce = COLONNES_ENTREE[j]; entree[ce] = col.entree[ce] >= 0 ? ligne[col.entree[ce]] : ""; } for (j = 0; j < COLONNES_SORTIE.length; j++) { var cs = COLONNES_SORTIE[j]; sortie[cs] = col.sortie[cs] >= 0 ? sansApostrophe(texteDe(ligne[col.sortie[cs]])) : ""; } var g = { numero: 2 + i, entree: entree, sortie: sortie, lu: lireEntree(entree, p.reglages), sale: false, place: false }; q.rangees.push(g); var lu = g.lu; if (lu === null || lu.statut) continue; var cle = p.mode === "completer" ? lu.cle : g.sortie.sirenic_siren; var res = Object.prototype.hasOwnProperty.call(p.resultats, cle) ? p.resultats[cle] : null; if (!res) continue; var dejaLue = g.sortie.sirenic_statut === "complete" && memeLigne(g.sortie.sirenic_ligne_envoyee, lu.ligne); if (p.mode === "completer" && dejaLue) { places[cle] = true; continue; } if (p.mode === "relecture") { if (!dejaLue) continue; if (g.sortie.sirenic_date_lecture >= p.aujourdhui) { places[cle] = true; continue; } } appliquerResultat(p, g, res); g.sortie.sirenic_a_revoir = avecCode(g.sortie.sirenic_a_revoir, "feuille_changee", true); g.place = true; places[cle] = true; } recalculerDoublons(q); for (var k = 0; k < q.rangees.length; k++) { var r = q.rangees[k]; if (r.place) lignes.push({ numero: r.numero, valeurs: COLONNES_SORTIE.map(function (c) { return r.sortie[c]; }) }); } } var perdus = 0; for (var c in p.resultats) if (Object.prototype.hasOwnProperty.call(p.resultats, c) && !places[c]) perdus += 1; p.faits.placees = lignes.length; p.faits.perdus = perdus; return { lignes: lignes }; } function messageErreur(p, valeurs) { return formater(textes(p.langue)[p.erreur] || p.erreur, valeurs || {}); } /* La fin du passage : les sorties, les comptes et le message dans la langue du passage, relecture proposée après un passage complet qui a résolu au moins une ligne. */ function finirPassage(p) { var t = textes(p.langue); var s = sorties(p); var comptes = { completes: 0, a_revoir: 0, resolues: 0, changees: 0 }; for (var i = 0; i < p.rangees.length; i++) { var g = p.rangees[i].sortie; if (g.sirenic_statut === "complete") comptes.completes += 1; if (g.sirenic_statut !== "" && g.sirenic_a_revoir !== "") comptes.a_revoir += 1; if (g.sirenic_statut === "complete" && g.sirenic_resolution === "resolu") comptes.resolues += 1; if (codesDe(g.sirenic_a_revoir).indexOf("a_change") >= 0) comptes.changees += 1; } var phrases = []; if (p.erreur) phrases.push(messageErreur(p)); else if (p.appels.length === 0 && p.mode === "completer") phrases.push(t.rien_a_envoyer); else if (p.appels.length === 0) phrases.push(t.rien_a_relire); if (!p.erreur && p.mode === "completer" && (p.appels.length > 0 || comptes.completes > 0)) { phrases.push(formater(t.fin_completer, { completes: comptes.completes, appels: p.faits.appels, a_revoir: comptes.a_revoir })); } if (!p.erreur && p.mode === "relecture" && p.appels.length > 0) { phrases.push(formater(t.fin_relecture, { relues: p.faits.relues, appels: p.faits.appels, changees: comptes.changees })); } if (p.faits.complement) phrases.push(t.complement); if (p.arret) { var cle = p.arret.message; if (!cle && p.arret.statut === "non_envoyee_temps") cle = p.mode === "relecture" ? "arret_temps_relecture" : "arret_temps"; else if (!cle && p.arret.statut === "non_envoyee_erreur") cle = "arret_erreur"; else if (!cle) cle = "arret_" + p.arret.statut.slice("non_envoyee_".length); var details = { placees: p.faits.placees, perdus: p.faits.perdus }; for (var d in p.arret.details) if (Object.prototype.hasOwnProperty.call(p.arret.details, d)) details[d] = p.arret.details[d]; phrases.push(formater(t[cle], details)); } if (p.faits.appels > 0) phrases.push(t.fin_journal); if (!p.arret && !p.erreur && p.mode === "completer" && p.faits.appels > 0 && comptes.resolues > 0) phrases.push(t.proposer_relecture); return { sorties: s, comptes: comptes, arret: p.arret ? p.arret.statut : null, proposerRelecture: !p.arret && !p.erreur && p.mode === "completer" && p.faits.appels > 0 && comptes.resolues > 0, message: phrases.join("\n\n"), }; } /* Une cellule CSV aux conventions des exports de Sirenic (celluleCsv de src/csv.ts) : guillemets doublés, cellule citée dès qu'elle porte « ; », un guillemet ou un saut de ligne. */ function celluleCsv(v) { var t = v === null || v === undefined ? "" : String(v); return /[";\n]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t; } /* Une cellule qui commencerait une formule à l'ouverture dans un tableur (=, +, @, tabulation, retour chariot, ou « - » devant autre chose qu'un nombre) reçoit une apostrophe dans l'export (R12) : une donnée du client ou une dénomination servie ne s'exécute jamais. celluleCsv reste celle de src/csv.ts. */ function sansFormule(v) { var t = v === null || v === undefined ? "" : String(v); if (/^[=+@\t\r]/.test(t) || (/^-/.test(t) && !/^-[0-9]+([.,][0-9]+)?$/.test(t))) return "'" + t; return t; } /* Le CSV d'un tableau de lignes : BOM, séparateur « ; », une ligne par rangée, fin de ligne finale. */ function versCsv(lignes) { return "\uFEFF" + lignes.map(function (l) { return l.map(function (v) { return celluleCsv(sansFormule(v)); }).join(";"); }).join("\n") + "\n"; } function nomDeFichierCsv(nom) { var t = texteDe(nom).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); return (t || "fichier-clients") + ".csv"; } /* Une valeur écrite dans un tableur : un texte qui commence par =, +, - ou @ reçoit une apostrophe, pour rester un texte. */ function valeurPourCellule(v) { return typeof v === "string" && /^[=+\-@]/.test(v) ? "'" + v : v; } function cleBienFormee(cle) { return typeof cle === "string" && FORME_CLE.test(cle); } return { COLONNES_ENTREE: COLONNES_ENTREE, COLONNES_SORTIE: COLONNES_SORTIE, COLONNES_JOURNAL: COLONNES_JOURNAL, ENTETES_REPONSE: ENTETES_REPONSE, STATUTS_LIGNE: STATUTS_LIGNE, CODES_A_REVOIR: CODES_A_REVOIR, textes: textes, formater: formater, texteDe: texteDe, jourUtc: jourUtc, normaliserTitre: normaliserTitre, trouverColonnes: trouverColonnes, lireEntree: lireEntree, codePostalDeLAdresse: codePostalDeLAdresse, lireReglages: lireReglages, lignesParAppel: lignesParAppel, adresseDuLot: adresseDuLot, octetsLigneRequete: octetsLigneRequete, decouper: decouper, creerPassage: creerPassage, prochainAppel: prochainAppel, integrerReponse: integrerReponse, sorties: sorties, fixerTitres: fixerTitres, controlerEntrees: controlerEntrees, ecrituresEnAttente: ecrituresEnAttente, colonnesDeFin: colonnesDeFin, largeurs: largeurs, placerParEntree: placerParEntree, journalAEcrire: journalAEcrire, messageErreur: messageErreur, finirPassage: finirPassage, celluleCsv: celluleCsv, versCsv: versCsv, nomDeFichierCsv: nomDeFichierCsv, valeurPourCellule: valeurPourCellule, cleBienFormee: cleBienFormee, }; })(); function sirenicLangue_() { var locale = ""; try { locale = String(SpreadsheetApp.getActive().getSpreadsheetLocale() || ""); } catch (e) { locale = ""; } return locale.toLowerCase().indexOf("fr") === 0 ? "fr" : "en"; } function sirenicAlerte_(texte) { var ui = SpreadsheetApp.getUi(); ui.alert("Sirenic", texte, ui.ButtonSet.OK); } function onOpen() { var t = SirenicFichierClients.textes(sirenicLangue_()); SpreadsheetApp.getUi() .createMenu("Sirenic") .addItem(t.menu_cle, "sirenicSaisirCle") .addItem(t.menu_completer, "sirenicCompleter") .addItem(t.menu_relecture, "sirenicRelecture") .addSeparator() .addItem(t.menu_a_revoir, "sirenicLignesARevoir") .addItem(t.menu_csv, "sirenicExporterCsv") .addSeparator() .addItem(t.menu_oublier_cle, "sirenicOublierCle") .addItem(t.menu_aide, "sirenicAide") .addToUi(); } function sirenicSaisirCle() { var ui = SpreadsheetApp.getUi(); var t = SirenicFichierClients.textes(sirenicLangue_()); var reponse = ui.prompt("Sirenic", t.cle_demande, ui.ButtonSet.OK_CANCEL); if (reponse.getSelectedButton() !== ui.Button.OK) return; var cle = String(reponse.getResponseText() || "").trim(); if (!SirenicFichierClients.cleBienFormee(cle)) { sirenicAlerte_(t.cle_mal_formee); return; } PropertiesService.getUserProperties().setProperty(SIRENIC.proprieteCle, cle); sirenicAlerte_(t.cle_enregistree); } function sirenicOublierCle() { PropertiesService.getUserProperties().deleteProperty(SIRENIC.proprieteCle); sirenicAlerte_(SirenicFichierClients.textes(sirenicLangue_()).cle_oubliee); } function sirenicCompleter() { sirenicPasser_("completer"); } function sirenicRelecture() { sirenicPasser_("relecture"); } function sirenicReglages_() { try { var r = UrlFetchApp.fetch(SIRENIC.base + SIRENIC.cheminReglages, { method: "get", headers: { Accept: "application/json", "X-Sirenic-Modele": SIRENIC.enTeteModele }, muteHttpExceptions: true, followRedirects: false, }); if (r.getResponseCode() !== 200) return null; return SirenicFichierClients.lireReglages(JSON.parse(r.getContentText("UTF-8"))); } catch (e) { return null; } } function sirenicAppeler_(appel, cle) { var entetes = {}; for (var k in appel.entetes) entetes[k] = appel.entetes[k]; entetes["X-Api-Key"] = cle; entetes["X-Sirenic-Modele"] = SIRENIC.enTeteModele; try { var r = UrlFetchApp.fetch(appel.url, { method: "get", headers: entetes, muteHttpExceptions: true, followRedirects: false }); return { statut: r.getResponseCode(), entetes: r.getAllHeaders(), corps: r.getContentText("UTF-8"), horodatage: new Date().toISOString() }; } catch (e) { return { statut: 0, entetes: {}, corps: "", horodatage: new Date().toISOString() }; } } /* Les colonnes de sortie de la feuille, reconnues à leur titre exact : celles qui manquent sont ajoutées à droite, au format texte (un SIREN garde ses zéros de tête) ; aucune colonne du client n'est reprise. Rend les plages contiguës à écrire ({ colonne (1 pour la première), depuis, nombre } dans l'ordre du cœur), l'index de chaque colonne et les titres après ajout. */ function sirenicColonnesSortie_(feuille, titres, lignes) { var trouvees = SirenicFichierClients.trouverColonnes(titres).sortie; var noms = SirenicFichierClients.COLONNES_SORTIE; var manquantes = []; var index = {}; var suivante = titres.length; for (var i = 0; i < noms.length; i++) { if (trouvees[noms[i]] >= 0) index[noms[i]] = trouvees[noms[i]]; else { index[noms[i]] = suivante; manquantes.push(noms[i]); suivante += 1; } } if (manquantes.length > 0) feuille.getRange(1, titres.length + 1, 1, manquantes.length).setValues([manquantes]); var plages = []; for (var j = 0; j < noms.length; j++) { var dernier = plages.length > 0 ? plages[plages.length - 1] : null; if (dernier && dernier.colonne + dernier.nombre === index[noms[j]] + 1) dernier.nombre += 1; else plages.push({ colonne: index[noms[j]] + 1, depuis: j, nombre: 1 }); } if (lignes > 0) { for (var p = 0; p < plages.length; p++) feuille.getRange(2, plages[p].colonne, lignes, plages[p].nombre).setNumberFormat("@"); } return { plages: plages, index: index, titres: titres.concat(manquantes) }; } /* Écrit des rangées ({ numero, valeurs dans l'ordre du cœur }), par suites de numéros contigus, plage par plage. */ function sirenicEcrireRangees_(feuille, colonnes, rangees) { var k = 0; while (k < rangees.length) { var suite = [rangees[k]]; while (k + suite.length < rangees.length && rangees[k + suite.length].numero === suite[0].numero + suite.length) suite.push(rangees[k + suite.length]); for (var p = 0; p < colonnes.plages.length; p++) { var pl = colonnes.plages[p]; var bloc = suite.map(function (r) { return r.valeurs.slice(pl.depuis, pl.depuis + pl.nombre).map(SirenicFichierClients.valeurPourCellule); }); feuille.getRange(suite[0].numero, pl.colonne, suite.length, pl.nombre).setValues(bloc); } k += suite.length; } } /* Les rangées touchées depuis la dernière écriture (R8 : jamais toute la feuille), chacune écrite seulement si son entrée relue est inchangée (R5) ; puis le journal. */ function sirenicEcrireAttente_(feuille, colonnes, passage) { var attente = SirenicFichierClients.ecrituresEnAttente(passage); var lignes = attente.lignes; if (lignes.length > 0) { var premiere = lignes[0].numero; var derniere = lignes[lignes.length - 1].numero; var titres = feuille.getRange(1, 1, 1, attente.largeurTitres).getValues()[0]; var bloc = feuille.getRange(premiere, 1, derniere - premiere + 1, attente.largeurEntree).getValues(); var ok = SirenicFichierClients.controlerEntrees(passage, titres, premiere, bloc); sirenicEcrireRangees_(feuille, colonnes, lignes.filter(function (r) { return ok[r.numero] === true; })); } sirenicEcrireJournal_(feuille, passage); } function sirenicEcrireJournal_(feuille, passage) { var entrees = SirenicFichierClients.journalAEcrire(passage); if (entrees.length === 0) return; var classeur = SpreadsheetApp.getActive(); var journal = classeur.getSheetByName(SIRENIC.ongletJournal); var colonnes = SirenicFichierClients.COLONNES_JOURNAL; if (!journal) { journal = classeur.insertSheet(SIRENIC.ongletJournal); journal.getRange(1, 1, 1, colonnes.length).setValues([colonnes]); feuille.activate(); } var plage = journal.getRange(journal.getLastRow() + 1, 1, entrees.length, colonnes.length); plage.setNumberFormat("@"); plage.setValues(entrees); } /* La fin du passage : les doublons sur tout le fichier (deux colonnes, toutes les rangées), écrits seulement si toute l'entrée relue est inchangée ; sinon, ou si la feuille a changé plus tôt, les résultats reçus se posent sur les lignes de même entrée de la feuille relue (R5). */ function sirenicEcrireFin_(feuille, colonnes, passage) { var fin = SirenicFichierClients.colonnesDeFin(passage); var largeurs = SirenicFichierClients.largeurs(passage); if (!passage.feuilleChangee && fin.lignes.length > 0) { var titres = feuille.getRange(1, 1, 1, largeurs.titres).getValues()[0]; var bloc = feuille.getRange(fin.premiere, 1, fin.lignes.length, largeurs.entree).getValues(); var ok = SirenicFichierClients.controlerEntrees(passage, titres, fin.premiere, bloc); if (!passage.feuilleChangee && Object.keys(ok).length === fin.lignes.length) { for (var c = 0; c < fin.colonnes.length; c++) { var colonne = colonnes.index[fin.colonnes[c]] + 1; var valeurs = fin.lignes.map(function (l) { return [SirenicFichierClients.valeurPourCellule(l[c])]; }); feuille.getRange(fin.premiere, colonne, valeurs.length, 1).setValues(valeurs); } } } if (passage.feuilleChangee) { var tout = feuille.getDataRange().getValues(); var placement = SirenicFichierClients.placerParEntree(passage, tout[0] || [], tout.slice(1)); if (placement.lignes.length > 0) sirenicEcrireRangees_(feuille, sirenicColonnesSortie_(feuille, tout[0] || [], tout.length - 1), placement.lignes); } sirenicEcrireJournal_(feuille, passage); } function sirenicPasser_(mode) { var debut = Date.now(); var langue = sirenicLangue_(); var t = SirenicFichierClients.textes(langue); var verrou = LockService.getDocumentLock(); if (!verrou.tryLock(1000)) { sirenicAlerte_(t.passage_en_cours); return; } try { var cle = PropertiesService.getUserProperties().getProperty(SIRENIC.proprieteCle); if (!cle) { sirenicAlerte_(SirenicFichierClients.formater(t.cle_manquante, { inscription: SIRENIC.inscription })); return; } var feuille = SpreadsheetApp.getActiveSheet(); if (feuille.getName() === SIRENIC.ongletJournal) { sirenicAlerte_(t.feuille_journal); return; } var valeurs = feuille.getDataRange().getValues(); if (valeurs.length < 2) { sirenicAlerte_(t.feuille_vide); return; } var reglages = sirenicReglages_(); if (reglages === null) { sirenicAlerte_(t.reglages_illisibles); return; } var passage = SirenicFichierClients.creerPassage({ mode: mode, titres: valeurs[0], rangees: valeurs.slice(1), premiereLigne: 2, reglages: reglages, base: SIRENIC.base, aujourdhui: SirenicFichierClients.jourUtc(new Date()), octetsMaxAdresse: SIRENIC.octetsMaxAdresse, langue: langue, }); if (passage.erreur) { sirenicAlerte_(SirenicFichierClients.messageErreur(passage)); return; } var colonnes = sirenicColonnesSortie_(feuille, valeurs[0], valeurs.length - 1); SirenicFichierClients.fixerTitres(passage, colonnes.titres); /* R8 : le temps d'une écriture entre dans le budget, deux fois (l'écriture de l'appel et celle de la fin) : un appel débité ne part jamais sans le temps d'écrire sa réponse. */ var ecriture = 0; var t0 = Date.now(); sirenicEcrireAttente_(feuille, colonnes, passage); ecriture = Date.now() - t0; var appel; while ((appel = SirenicFichierClients.prochainAppel(passage, SIRENIC.dureeMaxMs - (Date.now() - debut) - 2 * ecriture)) !== null) { if (appel.attenteMs > 0) Utilities.sleep(appel.attenteMs); SirenicFichierClients.integrerReponse(passage, sirenicAppeler_(appel, cle)); t0 = Date.now(); sirenicEcrireAttente_(feuille, colonnes, passage); ecriture = Math.max(ecriture, Date.now() - t0); } sirenicEcrireAttente_(feuille, colonnes, passage); sirenicEcrireFin_(feuille, colonnes, passage); sirenicAlerte_(SirenicFichierClients.finirPassage(passage).message); } finally { verrou.releaseLock(); } } function sirenicLignesARevoir() { var t = SirenicFichierClients.textes(sirenicLangue_()); var feuille = SpreadsheetApp.getActiveSheet(); var titres = feuille.getDataRange().getValues()[0] || []; var colonne = SirenicFichierClients.trouverColonnes(titres).sortie.sirenic_a_revoir; if (colonne < 0) { sirenicAlerte_(t.a_revoir_absente); return; } var filtre = feuille.getFilter(); if (filtre) filtre.remove(); feuille .getDataRange() .createFilter() .setColumnFilterCriteria(colonne + 1, SpreadsheetApp.newFilterCriteria().whenCellNotEmpty().build()); sirenicAlerte_(t.a_revoir_filtre); } function sirenicHtml_(texte) { return String(texte).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function sirenicJsonPourScript_(texte) { return JSON.stringify(texte).replace(/" + sirenicHtml_(t.csv_pret) + "

" + '

' + sirenicHtml_(t.csv_lien) + "

" + '

' + sirenicHtml_(t.csv_repli) + "

" + "'; SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput(html).setWidth(480).setHeight(220), t.menu_csv); } function sirenicAide() { var langue = sirenicLangue_(); var t = SirenicFichierClients.textes(langue); var html = '
' +
    sirenicHtml_(SIRENIC.modeDEmploi[langue]) +
    "
"; SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput(html).setWidth(720).setHeight(560), t.menu_aide); }