De hier voorgestelde configuratie laat een gebruiker toe om met zijn mobiele telefoon een foto te nemen van een onkostennota in de MercatorPenguin-app en deze via een taak in de Mercator-CRM door te sturen naar de boekhouding. Het bewijsstuk wordt herkend door AI, die kan bepalen of het werkelijk een foto van een uitgaventicket is, het totaalbedrag en de valuta kan uitlezen, en de aard van de uitgave kan bepalen, onderverdeeld (in dit voorbeeld) als volgt:
- restaurantkosten
- overige kosten
Deze configuratie is volledig open source en kan dus onbeperkt worden aangepast.
Er moeten volgende kolommen worden toegevoegd aan de tabel 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
Deze ontwikkeling maakt gebruik van de OpenAI-API voor de herkenning van de inhoud van het ticket. Dit vereist de installatie van de plugin MercatorAI.OpenAI en werkt enkel met een Core-versie van Mercator en MercatorPenguinServer. Het Mercator-pakket dat is gekoppeld aan MercatorPenguinServer moet beschikken over de OpenAI-plugin.
1. In MercatorPenguin
Deze bundel moet worden geïnstalleerd: het bestand Note de frais.pngb kan worden geopend via de knop "Openen" onderaan het instellingenvenster.
Het bevat een knop waarmee de gebruiker een nieuwe actie van het type "Onkostennota" kan aanmaken. Deze ziet er als volgt uit en laat toe om:
- een verklarende memo in te voeren,
- een foto te nemen van het bewijsstuk.

De AI-herkenning wordt uitgevoerd door MercatorPenguinServer bij het opslaan van de actie vanuit MercatorPenguin. Als het bewijsstuk niet als zodanig wordt herkend, kan het niet worden opgeslagen.
Het hierboven getoonde scherm komt overeen met de volgende XAML-configuratie, die gekoppeld moet zijn aan de hieronder beschreven actie.
<Grid Margin="10, 10, 10, 10" RowSpacing="6" RowDefinitions="Auto,120,Auto,250">
<Label Text="Nota" 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="Bewijsstuk" Font="Medium" Margin="10,5,0,0" Grid.Row="2" />
<m:EditImage Source="img_bin" HeightRequest="250" Aspect="AspectFill" PhotoAction="TakePhoto" ButtonText="Foto maken" MaxWidth="1200" MaxHeight="1200" Grid.Row="3" />
</Grid>
💡 Als de gebruikte bewijsstukken groot van formaat zijn (bijvoorbeeld een lang ticket), kan het nodig zijn de grootte van de fotoherdimensionering te verhogen door een hogere waarde op te geven in de eigenschappen MaxWidth en MaxHeight.
2. In Mercator Desktop
Het betreft hier een actie van het type "taak" in Mercator Desktop. De rechten "voor" moeten zo worden ingesteld dat deze acties worden toegewezen aan de afdeling "boekhouding". Deze actie bevat een knop waarmee automatisch de boeking kan worden aangemaakt. Het bewijsstuk is zichtbaar als afbeelding op het tweede tabblad. De boeking wordt weergegeven in de modus "nog niet opgeslagen boeking". Bij het opslaan van de boeking wordt de taak automatisch als voltooid gemarkeerd. Het bewijsstuk wordt overgedragen naar de boeking als SQL-bestand. Alle parameters voor het genereren van de boeking (journaal, leverancier, grootboekrekening, enz.) kunnen worden gewijzigd in de C#-code van de knop.

Het bijgevoegde zip-bestand bevat het actiemodel en biedt meer informatie voor een snelle installatie.
De interactie met de AI gebeurt in de customizer die aan de actie is gekoppeld, waarvan hier de code te vinden is:
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; }
}
}
De foto van het bewijsstuk wordt verzonden naar een derde partij (OpenAI). Er mogen dus in geen geval vertrouwelijke gegevens worden doorgestuurd.
De antwoorden die de boeking vormen, worden automatisch gegenereerd met behulp van een kunstmatig intelligentiemodel (AI). Hoewel ontworpen om nuttige en relevante antwoorden te bieden, kan deze herkenning onvolledig, onnauwkeurig of gedeeltelijk onjuist zijn.
De gebruiker blijft als enige verantwoordelijk voor de beoordeling van de kwaliteit van de voorgestelde boeking. Mercator noch de leverancier van de AI-dienst kan in geen enkel geval aansprakelijk worden gesteld voor de gevolgen die voortvloeien uit deze codering.
Het gebruik van deze dienst moet in overeenstemming worden gebracht met de GDPR-regelgeving die binnen het bedrijf van kracht is.
Te laden :
0000003350.zip (18 Kb - 17-11-2025)