Créer une API Web pour interagir avec Sage 100

Mon association utilise Sage 100 pour sa comptabilité et sa gestion commerciale. On avait le projet de permettre d'adhérer par HelloAsso tout en gardant Sage comme le registre officiel, donc avec un moyen pour nous de mettre à jour des données d'adhérents sur Sage suite à une adhésion sur HelloAsso. Mais voilà, il n'y a pas d'API Sage qui permettrait de faire ça, à part quelques services propriétaires et payants. La seule méthode officielle pour interagir avec Sage est de créer des applications Windows utilisant la bibliothèque « objets métiers », c'est à dire une DLL avec des entrées COM dessus, bien années 2000, attendez je vais chercher ma Beyblade !

La solution que j'ai trouvé c'est de développer deux API : Passage, une API en C# collée à Sage utilisant les objets métiers dont je vais parler ici, et un module de notre intranet maison pour faire le lien entre HelloAsso et Passage sur lequel je passerai brièvement.

Le but de cet article hautement spécifique est de faire gagner du temps à des structures comme la mienne qui se retrouvent un peu perdues face à la boîte noire qu'est Sage lorsqu'il faut interagir avec ses données, en présentant le cheminement que j'ai suivi pour atteindre mes objectifs.

À noter : je me concentre sur mon cas d'usage qui est une interaction avec un Sage 100 on-premise, c'est à dire installé sur un serveur à nous, je ne sais pas du tout comment fonctionne les versions cloud de Sage. Il s'agit aussi de la version française de Sage 100 qui est assez différente de la version internationale, donc traduire ses recherches pour avoir des résultats internationaux n'est pas forcément judicieux.

Sur l'absence de documentation sur le Web

Sage ne diffuse que peu de documentation sur le Web sur comment développer une application utilisant ses objets métiers, parce qu'ils vendent des formations à 720€ les 3 demi-journées, ce qui est un peu trop prohibitif à mon goût vu ce qu'on paye déjà en licences logicielles. Il va donc falloir sortir ses meilleures requêtes sur votre moteur de recherche de prédilection pour trouver les obscurs messages de forum datant d'il y a 15 ans qui vous permettront de surmonter tel ou tel obstacle. Si vous avez un prestataire qui gère votre installation Sage, c'est possible de lui demander quelques conseils mais il risque de vous diriger rapidement vers le site de formation ou de vouloir vous facturer une intervention.

J'ai quand même trouvé pas mal d'infos dans deux documents que je vous link ci-dessous (attention c'est pour Sage v9). Le premier est la doc' des objets métiers qui présente les principes d'utilisation avec quelques exemples de cas d'usage, pour la plupart en Visual Basic, parfois en C#. C'est très superficiel mais il y a quand même pas mal d'infos bonnes à prendre. J'ai appris l'existence du second par des messages cryptiques de forum expliquant qu'il fallait se référer au « strucfic » pour comprendre l'organisation de la base de données. Le quoi ?! Arrêtez d'inventer des mots, il y a déjà assez d'information à intégrer. En fait c'est le petit nom du document « Structure des fichiers », qui décrit en détail l'organisation de la base de données SQL utilisée par Sage et sur laquelle on reviendra plus tard.

Je vous conseille de survoler au moins le premier document, et de garder le deuxième sous le coude pour plus tard.

Créer un projet C# avec les objets métiers Sage

Le langage le plus représenté dans les exemples qu'on peut trouver en ligne c'est du Visual Basic, mais je n'avais vraiment pas envie d'introduire ce langage dans notre stack. Mon association utilise du PHP partout mais il n'y a aucune documentation officielle ou pas sur l'utilisation des objets métiers via l'extension COM de PHP donc je me suis orienté vers la moins pire des alternatives, C#, pour développer Passage.

C'est important de noter que je n'y connaissais presque rien en C# mais ça ne m'a trop gêné, les tutoriels sur le MSDN étant corrects, si on les consulte en anglais car les traductions françaises sont automatisées et régulièrement catastrophiques.

Préparation

Le système de licence Sage étant ce qu'il est, le plus simple est probablement de travailler directement sur le serveur où est installé Sage, mais organisez-vous comme ça vous semble le mieux.

Préparez votre environnement de développement C# avec Visual Studio, installez les objets métiers (installeur ci-dessous), créez une base de données de test sur SQL Server—votre prestataire Sage doit pouvoir vous aider ici, moi j'ai dupliqué notre base de production pour pouvoir tester des trucs sur des données réalistes sans toucher aux données de production.

Téléchargez l'installeur objets métiers par ici.

Le projet de base

Je vais être très synthétique ici, j'ai suivi les tutoriels pour créer une API Web avec ASP.NET Core. Je ne comprends rien au mille dénominations de technos Web chez Microsoft, mais ça avait l'air d'être le truc moderne fin 2023 sur lequel aller. Je vous laisse explorer le MSDN pour la création de projet et les principes du framework.

Créer une API Web avec ASP.NET Core

À un moment vous allez devoir ajouter les objets métiers Sage comme dépendances à votre projet. Je ne me rappelle plus comment j'ai fait dans le mille-feuilles de menus et d'options sur Visual Studio, il faut ajouter une « référence COM » quelque part et vous trouvez les objets métiers Sage dans une immense liste… Là aussi, les moteurs de recherches sont vos meilleurs alliés. Je sais juste qu'à force d'essayer je me suis retrouvé avec cette section dans mon fichier de projet Passage.csproj :

  <ItemGroup>
    <COMReference Include="Objets100cLib">
      <WrapperTool>tlbimp</WrapperTool>
      <VersionMinor>2</VersionMinor>
      <VersionMajor>9</VersionMajor>
      <Guid>8b42efd1-11de-4af5-8f95-2901702d7a46</Guid>
      <Lcid>0</Lcid>
      <Isolated>false</Isolated>
      <EmbedInteropTypes>true</EmbedInteropTypes>
    </COMReference>
  </ItemGroup>

Vous êtes prêt lorsque vous avez une petite API qui tourne avec, disons, un ApiController de test que vous pouvez interroger, si possible le Swagger d'activé pour tester vos endpoints et que vous pouvez importer les objets métiers Sage dans votre code :

using Objets100cLib;

Lire des infos contenues dans Sage via SQL

J'en ai pas parlé jusqu'ici mais c'est importer de savoir qu'on peut lire l'intégralité du contenu stocké dans Sage sans toucher à Sage ni aux objets métiers mais simplement en lisant la base de données SQL Server ! Les données sont réparties sur beaucoup de tables que le PDF structure des fichiers documente copieusement. Par exemple la table F_DOCENTETE contient tous les entêtes de document dont les factures, F_DOCLIGNE contient chaque ligne de facture, F_ARTICLE les articles, F_COMPTET les comptes tiers et leurs champs d'informations libres, etc.

Dans la pratique, j'ai trouvé que c'était souvent plus simple de faire une bonne grosse requête SQL directement sur la base plutôt que de s'embêter avec l'API des objets métiers pour créer des collections et galérer à filtrer les résultats.

Exemple de requête pour trouver les numéros de compte tiers avec une adresse e-mail donnée :

SELECT CT_Num
FROM F_COMPTET
WHERE CT_EMail = 'client@example.com'

Ou encore vérifier qu'une référence de facture existe :

SELECT DO_Ref
FROM F_DOCLIGNE
WHERE DO_Ref = 'REF549832'

Se connecter à Sage avec les objets métiers

Mais alors attends, pourquoi se prendre la tête avec des objets métiers si on peut taper directement dans la base de données ? Parce qu'il y a beaucoup de triggers et de colonnes obscures qui sont compliquées à mettre à jour à la main et vous risquez de corrompre votre Sage si vous les modifiez vous-même. Autant pour la lecture il n'y a pas de problème, autant je ne me risquerai pas à exécuter le moindre INSERT ou UPDATE sur les tables de Sage.

Avant de passer à la phase modification, on va donc regarder comment utiliser les objets métiers pour se connecter à Sage.

Ouvrir et fermer une connexion

Selon l'offre de Sage qu'on a, on peut avoir plusieurs module auxquels se connecter. Ici on a la comptabilité et la gestion commerciale, et ça fait deux modules auxquels on peut se connecter via deux objets différents dans l'API des objets métiers. La base compta est gérée via l'objet BSCPTAApplication100c et la base gestion commerciale (que je vais abréger en gescom) est gérée via l'objet BSCIALApplication100c. On ouvre et referme la connexion à ces deux objets avec une même interface, mais les deux objets permettent ensuite d'accéder à différentes parties de Sage. Par exemple on manipule les comptes tiers avec la base compta mais on manipule les documents de vente avec la base gescom.

Le code suivant permet d'ouvrir une connexion à chacune des deux bases puis de les refermer :

using Objets100cLib;

namespace Passage
{
    internal class Sage
    {
        public static readonly string Server = @"MON-SERVEUR\SAGE100";
        public static readonly string Database = "MA-BASE-DE-TEST";
        public static readonly string User = "MON-USER";
        public static readonly string Password = "MON-MDP";

        public static void DemoConnexion()
        {
            // Ouverture de la base Comptabilité.
            var cpta = new BSCPTAApplication100c();
            cpta.CompanyServer = Server;
            cpta.CompanyDatabaseName = Database;
            cpta.Loggable.UserName = User;
            cpta.Loggable.UserPwd = Password;
            cpta.Open();
            if (cpta.IsOpen)
            {
                Console.WriteLine("Connecté à la base compta.");
                // Ici on peut interagir avec la base compta, par exemple :
                //   cpta.FactoryClient.ReadNumero(...)
                // Et on la referme après utilisation.
                cpta.Close();
            }
            else
            {
                Console.WriteLine("Échec de l'ouverture de la base compta.");
            }

            // Ouverture de la base Gestion Commerciale.
            var cial = new BSCIALApplication100c();
            cial.CompanyServer = Server;
            cial.CompanyDatabaseName = Database;
            cial.Loggable.UserName = User;
            cial.Loggable.UserPwd = Password;
            cial.Open();
            if (cial.IsOpen)
            {
                Console.WriteLine("Connecté à la base gescom.");
                // Ici on peut interagir avec la base commerciale, par exemple :
                //   cial.CreateProcess_Document(DocumentType.DocumentTypeVenteCommande)
                // Et on la referme après utilisation.
                cial.Close();
            }
            else
            {
                Console.WriteLine("Échec de l'ouverture de la base gescom.");
            }
        }
    }
}

Dans certains cas vous serez amené à devoir utiliser les deux bases en simultané et à leur permettre d'interagir entre elle, en mettant la base compta à l'attribut CptaApplication de la base gescom, sous peine de vous prendre des erreurs comme quoi une des deux bases est inaccessible :

cial.CptaApplication = cpta;

Récupérer un objet et ses propriétés

Pour vérifier que vous pouvez lire les infos depuis la base compta vous pouvez essayer de lire les infos d'un client, par exemple le client n°1000 :

if (cpta.FactoryClient.ExistNumero(1000))
    objClient = cpta.FactoryClient.ReadNumero(1000);
if (objClient == null)
    return;

Console.WriteLine($"Intitulé du compte 1000 : {objClient.CT_Intitule}");

Pour vérifier que vous pouvez lire les infos depuis la base gescom vous pouvez essayer de lire les infos d'un article, par exemple le prix d'achat de l'article qui porte la référence « BOOK1312 » :

if (!cial.FactoryArticle.ExistReference("BOOK1312"))
    return AdhesionResult.Result.ARTICLE_NOT_FOUND;
IBOArticle3 article = cial.FactoryArticle.ReadReference("BOOK1312");
double prixAchat = article.AR_PrixAchat;

Euh CT_Intitule ? AR_PrixAchat ? Et ça sort d'où ces commandes ExistNumero, ReadReference ? Tout est dans le PDF objets métiers !

Dans la suite de l'article je ne recopie pas toutes ces lignes, mais toute manipulation en lecture et en écriture avec les objets métiers doit se faire avec la ou les bonnes connexions ouvertes. C'est relativement rapide donc à moins d'un gros flux de requêtes ça n'a pas l'air de poser problème d'ouvrir et fermer la connexion à chaque opération.

Récupérer un ensemble d'objets

Je ne vais pas rentrer dans les détails ici mais la plupart des objets qui peuvent se lister ont une classe associée nommée Factory, e.g. IBOArticleFactory3, qui renvoient des collections, et ces objets peuvent être énumérés. Là encore il y a des explications succinctes mais suffisantes dans les PDF. Comme expliqué plus haut, je trouve que l'interface est trop limitée par rapport à une requête SQL, mais peut-être que je n'ai juste pas passé assez de temps à essayer d'arriver à mes fins avec leur API.

Modifier des infos contenues dans Sage

On va donc voir ici comment utiliser les objets métiers pour ça. Si c'est pas déjà fait, je vous conseille de parcourir le PDF objets métiers car je vais me concentrer sur des exemples d'utilisation concrets et qu'il y a des concepts expliqués à ne pas louper comme la persistance des données, les champs par défaut de certains objets lors de leur création, etc. Cela dit, le PDF n'est pas toujours très précis et il vaut mieux faire ses tests ponctués de Console.WriteLine pour s'assurer qu'on a bien tout compris.

Exemple simple : changer l'intitulé d'un client

On chope l'objet client qui porte le numéro 123 et on met son intitulé de compte (nom de société, nom prénom, etc) en majuscules. Oui c'est nul mais c'est pour la démo :

// 1) On récupère l'objet client.
if (cpta.FactoryClient.ExistNumero(123))
    client = cpta.FactoryClient.ReadNumero(123);
if (client == null)
    return;
// 2) On fait notre traitement, on change l'attribut qui va bien.
string nom = client.CT_Intitule;
nom = nom.ToUpper();
client.CT_Intitule = nom;
// 3) On sauvegarde nos changements.
client.Write();

Ce qu'il faut noter ici c'est qu'on peut lire et écrire les attributs de nos objets métiers comme on veut, mais que les modifications ne sont pas enregistrés en base de données tant qu'on n'a pas utilisé Write() (ou WriteDefault()). C'est seulement après cet appel que les modifications sont écrites en base de données. La validation des données—la taille des strings, des valeurs autorisées ou non, etc—se fait à deux endroits, parfois à l'assignation d'une valeur dans un champ, et parfois au moment de l'écriture, et c'est pourquoi il vaut mieux englober toute ses manipulation dans des gros blocs de capture d'exception parce que ça peut péter à de multiples endroits.

Exemple un peu moins simple : changer tout un tas d'infos client

Pareil que l'exemple précédent mais avec plus de modifications. On imagine un objet adhesion qui contiendrait les infos fournies par une API externe sur une nouvelle adhésion, et qu'on voudrait utiliser pour mettre à jour les infos de nos comptes clients.

Notez ici l'existence de l'objet client.LivraisonPrincipal, de type IBOClientLivraison3, et qu'on met à jour en simultané de notre objet client. Dans les deux cas, les infos d'adresse sont dans un sous-objet astucieusement nommé Adresse.

// On remplace tout un tas de champ par ceux de notre objet adhésion.
// Notez comment on tronque certaines chaînes, car elles peuvent avoir
// une limite de taille (pas toujours clairement précisée d'ailleurs…)
client.CT_Intitule = adhesion.FullName;
client.CT_Classement = Utilities.Truncate(adhesion.FullName, 17);
client.CT_Contact = adhesion.FullName;
client.LivraisonPrincipal.LI_Contact = adhesion.FullName;
client.LivraisonPrincipal.LI_Intitule = adhesion.FullName;

address = Utilities.Truncate(adhesion.Address, 35).ToUpper();
client.Adresse.Adresse = address;
client.Adresse.Complement = "";
client.LivraisonPrincipal.Adresse.Adresse = address;
client.LivraisonPrincipal.Adresse.Complement = "";

zipCode = Utilities.Truncate(adhesion.ZipCode, 9).ToUpper();
client.Adresse.CodePostal = zipCode;
client.LivraisonPrincipal.Adresse.CodePostal = zipCode;

city = Utilities.Truncate(adhesion.City, 35).ToUpper();
client.Adresse.Ville = city;
client.LivraisonPrincipal.Adresse.Ville = city;

payerCountry = Utilities.Truncate(adhesion.PayerCountry, 35).ToUpper();
client.Adresse.Pays = payerCountry;
client.LivraisonPrincipal.Adresse.Pays = payerCountry;

phone = Utilities.Truncate(adhesion.phone, 21);
client.Telecom.Telephone = phone;
client.LivraisonPrincipal.Telecom.Telephone = phone;

// On met à jour un champ d'information libre, identifié par son nom dans Sage.
// Attention à bien utiliser le bon type, SSMS peut être utile ici
// pour récupérer le type de la colonne correspondante.
DateTime dateTime = Utilities.ParseRfc3339(adhesion.Date) ?? DateTime.Now;
client.InfoLibre["Dernière année d'adhésion"] = dateTime.Year.ToString();

// On écrit l'objet client mais aussi l'objet LivraisonPrincipal.
// Pas sûr que ça soit super utile mais j'ai pas bien compris
// à quel point les objets s'écrivaient en cascade ou pas,
// et ça ne coute pas grand chose de toute façon.
client.Write();
client.LivraisonPrincipal.Write();

Exemple avancé : créer une facture

Dans ce dernier exemple, je vais créer une facture et l'enregistrer. On utilise un objet un peu particulier, le résultat de CreateProcess_Document, qui émule les actions qu'un utilisateur pourrait avoir en utilisant l'interface graphique de Sage (son « processus », d'où le nom de “process”).

// On va créer une facture pour un article avec cette référence et cette gamme.
string refArticle = "BOOK1312";
string refGamme = "Collector";

// On crée le process. On va interagir à la fois avec cet objet,
// et aussi avec l'objet IBODocumentVente3 qu'il contient.
DocumentType docType = DocumentType.DocumentTypeVenteCommande;
IPMDocument process = cial.CreateProcess_Document(docType);
IBODocumentVente3 facture = (IBODocumentVente3)process.Document;
// On commence par écrire les attributs par défaut :
facture.SetDefault();
// On attribue un objet client récupéré plus tôt :
facture.SetDefaultClient(client);
// On attribue un numéro de pièce :
facture.SetDefaultDO_Piece();
// On modifie quelques attributs supplémentaires selon nos besoins :
facture.LieuLivraison = client.LivraisonPrincipal;
facture.DepotStockage = cial.FactoryDepot.ReadIntitule(adhesion.Depot);
facture.DO_NoWeb = "CMD_123456";
facture.DO_Ref = "XX123456";
facture.DO_Date = dateTime; // objet de type DateTime

// Est-ce l'article demandé existe bien ?
if (!cial.FactoryArticle.ExistReference(refArticle))
    return "ARTICLE_NOT_FOUND";
IBOArticle3 article = cial.FactoryArticle.ReadReference(refArticle);

// Est-ce que la gamme de l'article demandée existe bien ?
if (!article.FactoryArticleGammeEnum1.ExistEnumere(refGammeType))
    return "GAMME_NOT_FOUND";
IBOArticleGammeEnum3 gammeType = article.FactoryArticleGammeEnum1.ReadEnumere(refGammeType);

// On ajoute notre ligne de facture avec un article en utilisant sa gamme.
// Voir également dans les docs : AddArticle, AddArticleDoubleGamme.
process.AddArticleMonoGamme(gammeType, 1);

// Création de l'acompte.
// Bon là je fais n'importe quoi, c'est pour l'exemple.
if (adhesion.ItemAmount > 0)
{
    IBODocumentAcompte3 acompte = (IBODocumentAcompte3)facture.FactoryDocumentAcompte.Create();
    acompte.SetDefault();
    acompte.DR_Date = dateTime;
    acompte.DR_Montant = ((double)amount) / 100; // attention aux types…
    // Je mets le bon réglement…
    if (!acompte.Reglement.FactoryReglement.ExistIntitule("ABC"))
        return "REGLEMENT_NOT_FOUND";
    acompte.Reglement = acompte.Reglement.FactoryReglement.ReadIntitule("ABC");
    // Je mets le bon code journal pour l'acompte…
    if (!cpta.FactoryJournal.ExistNumero("DEF"))
        return "CODE_JOURNAL_NOT_FOUND";
    acompte.Reglement.JournalClient = cpta.FactoryJournal.ReadNumero("DEF");
    // Enfin, je n'oublie pas d'écrire mon objet d'acompte.
    acompte.Write();
}

// L'objet process a un attribut qui permet de vérifier
// s'il n'y a pas d'erreur à la création.
if (!process.CanProcess)
{
    foreach (IFailInfo error in process.Errors)
    {
        Logger.LogError(
            "Impossible de créer le document : {n} {text}",
            error.Indice,
            error.Text
        );
    }
    return "CANT_CREATE_DOCUMENT";
}
Logger.LogInformation("Document prêt pour création.");

// Enfin, on enregistre les objets.
process.Process();
Logger.LogInformation("Document créé.");
client.Write();
client.LivraisonPrincipal.Write();
Logger.LogInformation("Compte client et adresse de livraison principale mis à jour.");

Si tout se passe bien, on se retrouve avec un bon de commande dans Sage que l'on peut ensuite passer en facture comptabilisée à la main.

Conclusion

L'autre API dont je n'ai pas parlé, notre module d'intranet, permet de récupérer les infos d'adhésion depuis HelloAsso et de les traiter avec une file de messages, avec une interface graphique sympa, une base de données PostgreSQL, et tout un tas de choses que je maîtrise bien mieux qu'une API en C# sur un serveur Windows derrière N firewalls et un VPN. C'est vachement plus simple de gérer les notifications en provenance de HelloAsso depuis ce module que depuis Passage, qui peut donc ne contenir que du code en rapport avec la base SQL ou les objets métiers et des contrôleurs les plus légers possible.

J'espère que cet article sera utile parce que j'ai passé pas mal de temps à m'y retrouver dans cet écosystème hostile. N'hésitez pas à m'écrire si vous avez besoin d'un coup de main, je ne garantis pas de pouvoir répondre rapidement mais j'essayerai et je pourrai compléter cet article en conséquence.

#backfromthecodemines