From 2f5446ccf0494aa25a4cc27a0239fd9c77be5fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 24 Jun 2026 15:04:48 +0200 Subject: [PATCH] Fix MLLM decimal parsing for locale-formatted invoices (#8310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Root cause**: The UBL schema template used string `"0"` placeholders for numeric fields, causing the MLLM to return locale-formatted strings (e.g. Swedish `"2,34"`) instead of JSON numbers. AL's `AsDecimal()` then stripped the comma, turning `"2,34"` into `234`. - **Fix 1** (`ubl_example.json`): Changed all numeric string `"0"` placeholders to `0` (JSON numbers), teaching the model to return proper numeric values. - **Fix 2** (`EDocMLLMExtraction-SystemPrompt.md`): Added explicit rule requiring XML decimal format (period as decimal separator, no thousands separators). - **Fix 3** (`EDocMLLMSchemaHelper.Codeunit.al`): Replaced `AsDecimal()` with `Evaluate(…, AsText(), 9)` in `GetDecimal()`, which parses XML-format decimals locale-independently — consistent with how `GetDate()` already handles date strings. [AB#640342](https://dynamicssmb2.visualstudio.com/1fcb79e7-ab07-432a-a3c6-6cf5a88ba4a5/_workitems/edit/640342) --------- Co-authored-by: Magnus Hartvig Grønbech Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit bbad0a884e1e61328a88ef33fa2879f161032b4f) --- .../App/.resources/AITools/ubl_example.json | 37 ++++++++++--------- .../EDocMLLMExtraction-SystemPrompt.md | 3 +- .../EDocMLLMSchemaHelper.Codeunit.al | 24 ++++++++++-- .../EDocumentMLLMHandler.Codeunit.al | 2 +- .../src/Processing/EDocMLLMTests.Codeunit.al | 6 +-- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json b/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json index d96c56d613..a1122ce1da 100644 --- a/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json +++ b/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json @@ -106,11 +106,11 @@ "allowance_charge_reason": "", "amount": { "currency_id": "", - "value": "0" + "value": 0 }, "tax_category": { "id": "", - "percent": "0", + "percent": 0, "tax_scheme": { "id": "" } @@ -118,14 +118,14 @@ } ], "tax_total": { - "tax_amount": "0", + "tax_amount": 0, "tax_subtotal": [ { - "taxable_amount": "0", - "tax_amount": "0", + "taxable_amount": 0, + "tax_amount": 0, "tax_category": { "id": "", - "percent": "0", + "percent": 0, "tax_scheme": { "id": "" } @@ -134,32 +134,33 @@ ] }, "legal_monetary_total": { - "line_extension_amount": "0", - "tax_exclusive_amount": "0", - "tax_inclusive_amount": "0", - "allowance_total_amount": "0", - "charge_total_amount": "0", - "payable_amount": "0" + "line_extension_amount": 0, + "tax_exclusive_amount": 0, + "tax_inclusive_amount": 0, + "allowance_total_amount": 0, + "charge_total_amount": 0, + "payable_amount": 0 }, "invoice_line": [ { "id": "", "invoiced_quantity": { "unit_code": "", - "value": "0" + "value": 0 }, - "line_extension_amount": "0", + "line_extension_amount": 0, "allowance_charge": { "charge_indicator": false, "allowance_charge_reason_code": 0, "allowance_charge_reason": "", + "percent": 0, "amount": { "currency_id": "", - "value": "0" + "value": 0 }, "tax_category": { "id": "", - "percent": "0", + "percent": 0, "tax_scheme": { "id": "" } @@ -172,14 +173,14 @@ }, "classified_tax_category": { "id": "", - "percent": "0", + "percent": 0, "tax_scheme": { "id": "" } } }, "price": { - "price_amount": "0" + "price_amount": 0 } } ] diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md index c8ae18e094..b4ef101b80 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md @@ -18,7 +18,8 @@ CRITICAL FORMAT RULES: - Tax scheme ID: Always use "VAT" - Tax category ID: Use standard codes: S=Standard rate, Z=Zero rate, E=Exempt, AE=Reverse charge - Unit codes: Use UN/ECE codes -- Allowance Charge: Leave allowance_charge section empty if no discount/charge exists on the document +- Allowance Charge: Leave allowance_charge section empty if no discount/charge exists on the document. Use allowance_charge.percent (0-100) when the invoice shows a discount percentage AND price_amount is the PRE-discount unit price; use allowance_charge.amount.value when the invoice shows a monetary discount amount. If the invoice shows a post-discount unit price (e.g. "Pris efter rab.", "Net price"), use it as price_amount and leave allowance_charge empty — do NOT combine a post-discount price with a percentage +- Numbers: Use XML decimal format — period (.) as decimal separator, no thousands separators (e.g., 1083 not "1 083", 2.34 not "2,34") Output ONLY valid JSON. No markdown, no explanation. \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMSchemaHelper.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMSchemaHelper.Codeunit.al index 93468d3d62..6687859f20 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMSchemaHelper.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMSchemaHelper.Codeunit.al @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Foundation.Company; @@ -126,16 +127,21 @@ codeunit 6232 "E-Doc. MLLM Schema Helper" end; end; - procedure MapLinesFromJson(LinesArray: JsonArray; EDocEntryNo: Integer; var TempLine: Record "E-Document Purchase Line" temporary) + procedure MapLinesFromJson(LinesArray: JsonArray; EDocEntryNo: Integer; var TempLine: Record "E-Document Purchase Line" temporary; CurrencyCode: Code[10]) var + EDocumentImportHelper: Codeunit "E-Document Import Helper"; LineToken: JsonToken; LineObj: JsonObject; NestedObj: JsonObject; NestedObj2: JsonObject; LineNumber: Integer; + DiscountPct: Decimal; + RoundingPrecision: Decimal; begin TempLine.DeleteAll(); + RoundingPrecision := EDocumentImportHelper.GetCurrencyRoundingPrecision(CurrencyCode); + for LineNumber := 0 to LinesArray.Count() - 1 do if LinesArray.Get(LineNumber, LineToken) then begin Clear(TempLine); @@ -164,9 +170,16 @@ codeunit 6232 "E-Doc. MLLM Schema Helper" GetDecimal(LineObj, 'line_extension_amount', TempLine."Sub Total"); - if GetNestedObject(LineObj, 'allowance_charge', NestedObj) then + if GetNestedObject(LineObj, 'allowance_charge', NestedObj) then begin if GetNestedObject(NestedObj, 'amount', NestedObj2) then GetDecimal(NestedObj2, 'value', TempLine."Total Discount"); + if TempLine."Total Discount" = 0 then begin + DiscountPct := 0; + GetDecimal(NestedObj, 'percent', DiscountPct); + if DiscountPct <> 0 then + TempLine."Total Discount" := Round(TempLine."Unit Price" * TempLine.Quantity * DiscountPct / 100, RoundingPrecision); + end; + end; TempLine.Insert(); end; @@ -208,12 +221,17 @@ codeunit 6232 "E-Doc. MLLM Schema Helper" local procedure GetDecimal(JsonObj: JsonObject; PropertyName: Text; var FieldValue: Decimal) var JsonToken: JsonToken; + DecimalValue: Decimal; + DecimalParseFailedLbl: Label 'Could not parse decimal value returned by the model for property %1.', Comment = '%1 = JSON property name'; begin if not JsonObj.Get(PropertyName, JsonToken) then exit; if JsonToken.AsValue().IsNull() then exit; - FieldValue := JsonToken.AsValue().AsDecimal(); + if Evaluate(DecimalValue, JsonToken.AsValue().AsText(), 9) then + FieldValue := DecimalValue + else + Session.LogMessage('0000UAR', StrSubstNo(DecimalParseFailedLbl, PropertyName), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); end; local procedure GetNestedObject(JsonObj: JsonObject; PropertyName: Text; var NestedObj: JsonObject): Boolean diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandler.Codeunit.al index e9c2df5e13..5c991e27bc 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandler.Codeunit.al @@ -298,7 +298,7 @@ codeunit 6231 "E-Document MLLM Handler" implements IStructureReceivedEDocument, if SourceJsonObject.Get('invoice_line', LinesToken) then if LinesToken.IsArray() then begin LinesArray := LinesToken.AsArray(); - EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, EDocument."Entry No", TempEDocPurchaseLine); + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, EDocument."Entry No", TempEDocPurchaseLine, TempEDocPurchaseHeader."Currency Code"); end; end; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al index f3daf6bae7..5eb6343ee9 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al @@ -126,7 +126,7 @@ codeunit 135647 "EDoc MLLM Tests" LinesArray := BuildThreeLineArray(); - EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine); + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine, ''); TempLine.FindSet(); Assert.AreEqual(10000, TempLine."Line No.", 'First line number'); @@ -180,7 +180,7 @@ codeunit 135647 "EDoc MLLM Tests" LineObj.Add('invoiced_quantity', QuantityObj); LinesArray.Add(LineObj); - EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine); + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine, ''); TempLine.FindFirst(); Assert.AreEqual(1, TempLine.Quantity, 'Zero quantity should default to 1'); @@ -197,7 +197,7 @@ codeunit 135647 "EDoc MLLM Tests" // [SCENARIO] Empty lines array produces no line records LibraryLowerPermission.SetOutsideO365Scope(); - EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine); + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine, ''); Assert.IsTrue(TempLine.IsEmpty(), 'No lines should be inserted for empty array'); end;