Le paramétrage proposé ici permet à un utilisateur de photographier une note de frais avec son téléphone portable dans l'application MercatorPenguin, et de la transmettre à la comptabilité via une tâche du CRM de Mercator. La pièce justificative est reconnue par l'IA, qui est capable de déterminer s'il s'agit réellement d'une photo correspondant à un ticket de dépense, d'y lire le total et la devise, ainsi que de déterminer la nature de la dépense répartie (pour l'exemple) comme ceci :
- frais de restaurant
- autres frais
Ce paramétrage est totalement open source. Il peut donc être adapté à l'infini.
Il requiert l'ajout de ces colonnes dans la table ACTIONS :
alter table ACTIONS add TOTAL float not null default 0
alter table ACTIONS add IS_RESTO bit not null default 0
alter table ACTIONS add DEV char(10) not null default ''
alter table ACTIONS add IMG_BIN varbinary(MAX) null
Ce développement utilise l'API de OpenAI pour la reconnaissance du contenu du ticket. Ceci requiert l'installation du plugin MercatorAI.OpenAI et ne fonctionne qu'avec une version Core de Mercator et de MercatorPenguinServer. Le Mercator auquel se lie MercatorPenguinServer doit disposer du plugin OpenAI.
1. Dans MercatorPenguin
Ce bundle doit être installé : le fichier Note de frais.pngb peut être lu via le bouton "Ouvrir" situé dans le bas de l'écran de paramétrage.
Il contient un bouton permettant à l'utilisateur de créer une nouvelle action de type "Note de frais". Elle se présente comme ceci et permet de :
- saisir un mémo explicatif,
- photographie la pièce justificative.

La reconnaissance par l'IA est effectuée par MercatorPenguinServer lors de la sauvegarde de l'action depuis MercatorPenguin. Si la pièce justificative n'est pas reconnue comme telle, la sauvegarde n'est pas possible.
L'écran présenté ci-dessus correspond au paramétrage XAML suivant. Il doit être lié à l'action décrite ci-dessous.
<Grid Margin="10, 10, 10, 10" RowSpacing="6" RowDefinitions="Auto,120,Auto,250">
<Label Text="Note" Font="Medium" Margin="10,5,0,0" Grid.Row="0" />
<m:EditEditor Source="note" HorizontalOptions="FillAndExpand" AutoSize="TextChanges" HeightRequest="120" Grid.Row="1" />
<Label Text="Piece justificative" Font="Medium" Margin="10,5,0,0" Grid.Row="2" />
<m:EditImage Source="img_bin" HeightRequest="250" Aspect="AspectFill" PhotoAction="TakePhoto" ButtonText="Prendre photo" MaxWidth="1200" MaxHeight="1200" Grid.Row="3" />
</Grid>
💡 Si les pièces justificatives utilisées sont de grande taille (ticket long par exemple), il peut être nécessaire d'augmenter la taille de redimensionnement de la photo en indiquant une valeur plus élevée dans les propriétés MaxWidth et MaxHeight.
2. Dans Mercator desktop
Il s'agit d'une action de type "tâche" dans Mercator desktop. Les droits "pour" devront être fixés de telle sorte que ces actions soient assignées au département "comptabilité". Cette action contient un bouton qui permet de créer automatique l'écriture comptable. La pièce justificative est visible en tant qu'image dans le second onglet. L'écriture comptable est présentée en mode "écriture non déjà sauvegardée". Lors de la sauvegarde de l'écriture comptable, la tâche est automatiquement marquée comme faite. La pièce justificative est transférée dans l'écriture comptable en tant que fichier SQL. Tous les paramètres de la génération de l'écriture comptable sont modifiables dans le code C# du bouton (journal, fournisseur, compte général, ...).

Le fichier zip ci-joint contient le modèle d'action. Plus d'information pour une installation rapide.
L'interaction avec l'IA s'effectue dans le customizer lié à l'action, dont voici le code :
using System;
using System.Collections.Generic;
using System.Text;
using System.Data;
using System.Linq;
using System.Windows.Forms;
using MercatorApi;
using MercatorUi;
using MercatorExtensions;
using MercatorDatabase;
using MercatorAI;
using MercatorExtensions;
namespace Action
{
public class Customizer : MercatorUi.ICustomizers.IActionEngineCreated, MercatorUi.ICustomizers.IActionEngineClosed
{
public void ActionEngineCreated(MercatorUi.Engine.Crm.ActionEngine actionEngine)
{
actionEngine.BeforeSave += actionEngine_BeforeSave;
}
public void ActionEngineClosed(MercatorUi.Engine.Crm.ActionEngine actionEngine)
{
actionEngine.BeforeSave -= actionEngine_BeforeSave;
}
void actionEngine_BeforeSave(object sender, MercatorUi.Engine.Crm.ActionEngine.BeforeSaveEventArgs e)
{
MercatorUi.Engine.Crm.ActionEngine actionEngine = (MercatorUi.Engine.Crm.ActionEngine)sender;
if (MercatorUi.Globals.IsMercatorPenguinServer)
{
if (string.IsNullOrWhiteSpace(actionEngine.ActionsRecord.NOTE))
{
actionEngine.LastError = _Divers.Iif_langue(Globals.Langue, "Erläuterung fehlt!", "Missing explanatory note!", "De verklarende notitie ontbreekt!", "Note explicative manquante !");
e.CancelSave = true;
return;
}
if ((actionEngine.ActionsRecord.IMG_BIN == null) || (actionEngine.ActionsRecord.IMG_BIN.Length == 0))
{
actionEngine.LastError = _Divers.Iif_langue(Globals.Langue, "Fehlender Beleg!", "Missing supporting document!", "Ontbrekend bewijsstuk!", "Pièce justificative manquante !");
e.CancelSave = true;
return;
}
MercatorAI.FactoryOpenAI factoryOpenAI = new MercatorAI.FactoryOpenAI();
factoryOpenAI.PluginFullPath = MercatorUi.Globals.MainDir + "PluginOpenAI\\\cf5 MercatorAI.OpenAI.dll";
MercatorAI.Interfaces.IOpenAiResponse openAiResponse = factoryOpenAI.CreateOpenAiResponse("gpt-5.1", true, out string error);
if (!string.IsNullOrEmpty(error))
{
actionEngine.LastError = "Impossible d'initialiser le chat d'OpenAI : " + error;
Globals.ApiLogDelegate.Invoke(actionEngine.LastError);
e.CancelSave = true;
return;
}
string systemMessage = _Divers.Iif_langue(Globals.Langue,
"Du bist ein Assistent, der Spesenbelege für die Buchhaltung unseres Unternehmens liest. Du hältst dich strikt an den ISO-Währungscode. Wenn die Währung nicht erkannt werden kann, gibst du EUR an.",
"You are an assistant that reads expense receipts for our company’s accounting. You strictly follow the ISO currency code. If the currency cannot be recognized, you indicate EUR.",
"Je bent een assistent die onkostennota’s leest voor de boekhouding van ons bedrijf. Je respecteert strikt de ISO-code van de munteenheid. Als de munteenheid niet kan worden herkend, geef je EUR aan.",
"Tu es un assistant qui lit des pièces justificatives de frais pour la comptabililité de notre société. Tu respectes strictement le code ISO de la devise. Si la devise ne peut être reconnue, tu indiques EUR.");
string question = _Divers.Iif_langue(Globals.Langue,
"Welchen Gesamtbetrag und welche Währung erkennst du auf dem beigefügten Bild? Wenn das nicht möglich ist, sag es deutlich.",
"What is the total amount and the currency you recognize in the attached image? If that’s not possible, say so clearly.",
"Wat is het totaalbedrag en de valuta die je herkent op de bijgevoegde afbeelding? Als dat niet mogelijk is, zeg dat dan duidelijk.",
"Quel est le total et la devise que tu reconnais dans l'image jointe. Si ce n'est pas possible, tu le dis clairement.");
FactoryOpenAI.InputDescriptor[] inputs =
[
new FactoryOpenAI.InputDescriptor(FactoryOpenAI.InputTypesEnum.Text, question),
new FactoryOpenAI.InputDescriptor(FactoryOpenAI.InputTypesEnum.ImagePngBytes, actionEngine.ActionsRecord.IMG_BIN)
];
NoteFraisDescriptor r;
try
{
r = openAiResponse.Ask<NoteFraisDescriptor>(inputs, "NoteFraisDescriptor", Globals.Langue, systemMessage: systemMessage);
}
catch (Exception ex)
{
actionEngine.LastError = "OpenAI : " + ex.Message;
Globals.ApiLogDelegate.Invoke(actionEngine.LastError);
e.CancelSave = true;
return;
}
if (r.IsRejected)
{
actionEngine.LastError = _Divers.Iif_langue(Globals.Langue, "Der Anhang scheint kein Beleg zu sein!", "The attachment does not appear to be a supporting document!", "De bijlage lijkt geen bewijsstuk te zijn!", "La pièce jointe ne semble pas être une pièce justificative !"
+ (!string.IsNullOrEmpty(r.Comment) ? "\r\n" + r.Comment : ""));
e.CancelSave = true;
return;
}
if (r.Date == null)
{
actionEngine.LastError = _Divers.Iif_langue(Globals.Langue, "Datum kann nicht bestimmt werden!", "Unable to determine the date!", "Datum kan niet worden bepaald!", "Impossible de déterminer la date !"
+ (!string.IsNullOrEmpty(r.Comment) ? "\r\n" + r.Comment : ""));
e.CancelSave = true;
return;
}
actionEngine.ActionsRecord.OBJET = _Divers.Iif_langue(Globals.Langue, "Spesenabrechnung", "Expense report", "Onkostennota", "Note de frais") + " " + Globals.CurrentUserRecord.NOM + " " + DateTime.Now.ToShortDateShortTimeString();
actionEngine.ActionsRecord.RESULTAT = r.Comment ?? "";
actionEngine.ActionsRecord.DATE = r.Date.Value.Date;
actionEngine.ActionsRecord.MOMENT_1 = DateTime.Now;
actionEngine.ActionsRecord.TOTAL = r.Total;
actionEngine.ActionsRecord.IS_RESTO = r.IsResto;
actionEngine.ActionsRecord.DEV = r.Dev;
}
}
}
public class NoteFraisDescriptor
{
[FactoryOpenAI.ChatJsonFormat(DescriptionF = "Rejeté car n'est pas une pièce justificative ?", DescriptionN = "Afgewezen omdat het geen ondersteunend document is?", DescriptionE = "Rejected because it is not a supporting document?", DescriptionD = "Abgelehnt, da es sich nicht um ein Belegdokument handelt?", IsRequired = true)]
public bool IsRejected { get; set; }
[FactoryOpenAI.ChatJsonFormat(DescriptionF = "Total du ticket", DescriptionN = "Totaal van het ticket", DescriptionE = "Total of the receipt", DescriptionD = "Gesamtbetrag des Kassenbons", IsRequired = true)]
public double Total { get; set; }
[FactoryOpenAI.ChatJsonFormat(DescriptionF = "Devise (code ISO)", DescriptionN = "Munteenheid (ISO-code)", DescriptionE = "Currency (ISO code)", DescriptionD = "Währung (ISO-Code)", IsRequired = true)]
public string Dev { get; set; }
[FactoryOpenAI.ChatJsonFormat(DescriptionF = "Date du ticket (format compatible DateTime? .NET - null, wenn nicht bestimmt)", DescriptionN = "Datum van het ticket (formaat compatibel met DateTime? .NET - null if not determined)", DescriptionE = "Ticket date (format compatible with .NET DateTime? - null indien niet bepaald)", DescriptionD = "Datum des Belegs (Format kompatibel mit .NET DateTime? - null si non déterminé)", IsRequired = true)]
public DateTime? Date { get; set; }
[FactoryOpenAI.ChatJsonFormat(DescriptionF = "Frais de restaurant ?", DescriptionN = "Restaurantkosten?", DescriptionE = "Restaurant expenses?", DescriptionD = "Restaurantkosten?", IsRequired = true)]
public bool IsResto { get; set; }
[FactoryOpenAI.ChatJsonFormat(DescriptionF = "Commentaire éventuel, sinon simplement un espace", DescriptionN = "Eventuele opmerking, anders gewoon een ruimte", DescriptionE = "Optional comment, otherwise simply a space", DescriptionD = "Optionaler Kommentar, ansonsten einfach ein Leerzeichen", IsRequired = true)]
public string Comment { get; set; }
}
}
La photo de la pièce justificative est envoyée à un tiers (OpenAI). Il ne faut donc en aucun cas transmettre des données revêtant un caractère confidentiel.
Les réponses composant l'écriture sont générées automatiquement à l’aide d’un modèle d’intelligence artificielle (IA). Bien que conçu pour fournir des réponses utiles et pertinentes, cette reconnaissance peut être incomplète, inexacte, partiellement ou totalement.
L’utilisateur reste seul responsable de l'évaluation de la qualité de l'écriture comptable suggérée. En aucun cas Mercator ou le fournisseur du service IA ne saurait être tenu responsable des conséquences qui découleraient de cet encodage.
L'usage de ce service doit être rendu compatible avec les normes relatives au RGPD qui sont en place dans l'entreprise.
A télécharger :
0000003350.zip (18 Kb - 17/11/2025)