From 5f1b4d06ec468b7b55e22928c78a2fb4cbb348ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 08:49:19 +0200 Subject: [PATCH 01/85] [E-Document] Add Purchase Credit Memo support for PEPPOL CreditNote import Add full credit memo pipeline: PEPPOL CreditNote XML parsing, per-type Process Draft enum dispatch, shared PrepareDraft helper, FinishDraft credit memo creation with ISV extension interface. - Parse CreditNote XML with BillingReference extraction (warning on empty) - Add enum values "Purchase Invoice" (1) and "Purchase Credit Memo" (2), obsolete "Purchase Document" (0) with Pending tag 29.0 - Extract shared PrepareDraft logic into E-Doc. Prepare Draft Helper (6406) - Create Prepare Purch. Cr. Memo Draft (6403) returning correct E-Document Type - Create E-Doc. Create Purch. Cr. Memo (6404) with IEDocumentCreatePurchaseCreditMemo interface (6405) for ISV customization - Wire E-Document Type value 10 to new FinishDraft implementation - Add field 39 "Applies-to Doc. No." to staging header table - Add PEPPOL CreditNote test XML and 5 new tests covering parsing, enum routing, E2E pipeline, FinishDraft undo, and invoice regression Co-Authored-By: Claude Opus 4.6 (1M context) --- .../App/src/Document/EDocumentType.Enum.al | 1 + .../Import/EDocProcessDraft.Enum.al | 15 +- .../EDocCreatePurchCrMemo.Codeunit.al | 144 ++++++++++++++ .../EDocPrepareDraftHelper.Codeunit.al | 175 ++++++++++++++++++ .../EDocProcCustomizations.Enum.al | 8 +- .../PreparePurchCrMemoDraft.Codeunit.al | 38 ++++ .../PreparePurchaseEDocDraft.Codeunit.al | 157 +--------------- .../Purchase/EDocumentPurchaseHeader.Table.al | 5 + .../EDocumentADIHandler.Codeunit.al | 2 +- .../EDocumentMLLMHandler.Codeunit.al | 2 +- .../EDocumentPEPPOLHandler.Codeunit.al | 141 ++++++++------ .../IEDocumentCreatePurchCrMemo.Interface.al | 21 +++ .../ContosoInbInvHandler.Codeunit.al | 2 +- .../.resources/peppol/peppol-creditnote-0.xml | 130 +++++++++++++ .../EDocMockCustomizations.EnumExt.al | 3 +- .../src/Processing/EDocPDFMock.Codeunit.al | 2 +- .../Processing/EDocProcessTest.Codeunit.al | 122 ++++++++++++ .../EDocProcessingMocks.Codeunit.al | 9 +- .../EDocStructuredValidations.Codeunit.al | 35 ++++ .../EDocumentStructuredTests.Codeunit.al | 34 ++++ 20 files changed, 832 insertions(+), 214 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchCrMemo.Interface.al create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml diff --git a/src/Apps/W1/EDocument/App/src/Document/EDocumentType.Enum.al b/src/Apps/W1/EDocument/App/src/Document/EDocumentType.Enum.al index 08b282174e..72a79fb517 100644 --- a/src/Apps/W1/EDocument/App/src/Document/EDocumentType.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Document/EDocumentType.Enum.al @@ -56,6 +56,7 @@ enum 6121 "E-Document Type" implements IEDocumentFinishDraft value(10; "Purchase Credit Memo") { Caption = 'Purchase Credit Memo'; + Implementation = IEDocumentFinishDraft = "E-Doc. Create Purch. Cr. Memo"; } value(11; "Service Order") { diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al index 3240293b1d..be5b7851ab 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al @@ -17,5 +17,18 @@ enum 6107 "E-Doc. Process Draft" implements IProcessStructuredData { Caption = 'Purchase Document'; Implementation = IProcessStructuredData = "Prepare Purchase E-Doc. Draft"; + ObsoleteState = Pending; + ObsoleteReason = 'Use "Purchase Invoice" or "Purchase Credit Memo" instead.'; + ObsoleteTag = '29.0'; } -} \ No newline at end of file + value(1; "Purchase Invoice") + { + Caption = 'Purchase Invoice'; + Implementation = IProcessStructuredData = "Prepare Purchase E-Doc. Draft"; + } + value(2; "Purchase Credit Memo") + { + Caption = 'Purchase Credit Memo'; + Implementation = IProcessStructuredData = "Prepare Purch. Cr. Memo Draft"; + } +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al new file mode 100644 index 0000000000..79171e9bdf --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.eServices.EDocument.Processing.Interfaces; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Foundation.Attachment; +using Microsoft.Purchases.Document; +using Microsoft.Purchases.Payables; +using System.Telemetry; + +/// +/// Dealing with the creation of the purchase credit memo after the draft has been populated. +/// +codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, IEDocumentCreatePurchaseCreditMemo +{ + Access = Internal; + + var + Telemetry: Codeunit "Telemetry"; + EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; + CrMemoAlreadyExistsErr: Label 'A purchase credit memo with external document number %1 already exists for vendor %2.', Comment = '%1 = Vendor Cr. Memo No., %2 = Vendor No.'; + DraftLineDoesNotContainTypeAndNumberErr: Label 'One of the draft lines do not contain the type and number. Please, specify these fields manually.'; + + procedure ApplyDraftToBC(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): RecordId + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + PurchaseHeader: Record "Purchase Header"; + EDocPurchaseDocumentHelper: Codeunit "E-Doc. Purch. Doc. Helper"; + DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + EmptyRecordId: RecordId; + IEDocumentFinishPurchaseCrMemo: Interface IEDocumentCreatePurchaseCreditMemo; + begin + EDocumentPurchaseHeader.GetFromEDocument(EDocument); + + IEDocumentFinishPurchaseCrMemo := EDocImportParameters."Processing Customizations"; + if EDocImportParameters."Existing Doc. RecordId" <> EmptyRecordId then begin + EDocImpSessionTelemetry.SetBool('LinkedToExisting', true); + PurchaseHeader.Get(EDocImportParameters."Existing Doc. RecordId"); + end else + PurchaseHeader := IEDocumentFinishPurchaseCrMemo.CreatePurchaseCreditMemo(EDocument); + + PurchaseHeader.SetRecFilter(); + PurchaseHeader.FindFirst(); + PurchaseHeader."Doc. Amount Incl. VAT" := EDocumentPurchaseHeader.Total; + PurchaseHeader."Doc. Amount VAT" := EDocumentPurchaseHeader."Total VAT"; + PurchaseHeader.TestField("No."); + PurchaseHeader."E-Document Link" := EDocument.SystemId; + PurchaseHeader.Modify(); + + DocumentAttachmentMgt.CopyAttachments(EDocument, PurchaseHeader); + DocumentAttachmentMgt.DeleteAttachedDocuments(EDocument); + + EDocImpSessionTelemetry.SetBool('Totals Validation', EDocPurchaseDocumentHelper.TryValidateDocumentTotals(PurchaseHeader)); + + exit(PurchaseHeader.RecordId); + end; + + procedure RevertDraftActions(EDocument: Record "E-Document") + var + PurchaseHeader: Record "Purchase Header"; + DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + begin + PurchaseHeader.SetRange("E-Document Link", EDocument.SystemId); + if not PurchaseHeader.FindFirst() then + exit; + + DocumentAttachmentMgt.CopyAttachments(PurchaseHeader, EDocument); + DocumentAttachmentMgt.DeleteAttachedDocuments(PurchaseHeader); + + PurchaseHeader.TestField("Document Type", "Purchase Document Type"::"Credit Memo"); + Clear(PurchaseHeader."E-Document Link"); + PurchaseHeader.Modify(); + end; + + procedure CreatePurchaseCreditMemo(EDocument: Record "E-Document"): Record "Purchase Header" + var + PurchaseHeader: Record "Purchase Header"; + GLSetup: Record "General Ledger Setup"; + VendorLedgerEntry: Record "Vendor Ledger Entry"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocRecordLink: Record "E-Doc. Record Link"; + EDocPurchaseDocumentHelper: Codeunit "E-Doc. Purch. Doc. Helper"; + PurchCalcDiscByType: Codeunit "Purch - Calc Disc. By Type"; + StopCreatingCreditMemo: Boolean; + VendorCrMemoNo: Code[35]; + PurchaseLineNo: Integer; + begin + EDocumentPurchaseHeader.GetFromEDocument(EDocument); + if not EDocPurchaseDocumentHelper.AllDraftLinesHaveTypeAndNumber(EDocumentPurchaseHeader) then begin + Telemetry.LogMessage('0000PRG', 'Draft line does not contain type or number', Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All); + Error(DraftLineDoesNotContainTypeAndNumberErr); + end; + EDocumentPurchaseHeader.TestField("E-Document Entry No."); + PurchaseHeader.SetRange("Buy-from Vendor No.", EDocumentPurchaseHeader."[BC] Vendor No."); + PurchaseHeader."Document Type" := "Purchase Document Type"::"Credit Memo"; + PurchaseHeader."Pay-to Vendor No." := EDocumentPurchaseHeader."[BC] Vendor No."; + PurchaseHeader."Posting Description" := EDocumentPurchaseHeader."Posting Description"; + if EDocumentPurchaseHeader."Document Date" <> 0D then + PurchaseHeader.Validate("Document Date", EDocumentPurchaseHeader."Document Date"); + if EDocumentPurchaseHeader."Due Date" <> 0D then + PurchaseHeader.Validate("Due Date", EDocumentPurchaseHeader."Due Date"); + + VendorCrMemoNo := CopyStr(EDocumentPurchaseHeader."Sales Invoice No.", 1, MaxStrLen(PurchaseHeader."Vendor Cr. Memo No.")); + VendorLedgerEntry.SetLoadFields("Entry No."); + VendorLedgerEntry.ReadIsolation := VendorLedgerEntry.ReadIsolation::ReadUncommitted; + StopCreatingCreditMemo := PurchaseHeader.FindPostedDocumentWithSameExternalDocNo(VendorLedgerEntry, VendorCrMemoNo); + if StopCreatingCreditMemo then begin + Telemetry.LogMessage('0000PRH', CrMemoAlreadyExistsErr, Verbosity::Error, DataClassification::OrganizationIdentifiableInformation, TelemetryScope::All); + Error(CrMemoAlreadyExistsErr, VendorCrMemoNo, EDocumentPurchaseHeader."[BC] Vendor No."); + end; + + PurchaseHeader.Validate("Vendor Cr. Memo No.", VendorCrMemoNo); + PurchaseHeader.Insert(true); + PurchaseHeader.Modify(); + + // Validate currency after insert + GLSetup.GetRecordOnce(); + if EDocumentPurchaseHeader."Currency Code" <> GLSetup.GetCurrencyCode('') then begin + PurchaseHeader.Validate("Currency Code", EDocumentPurchaseHeader."Currency Code"); + PurchaseHeader.Modify(); + end; + + EDocRecordLink.InsertEDocumentHeaderLink(EDocumentPurchaseHeader, PurchaseHeader); + + PurchaseLineNo := EDocPurchaseDocumentHelper.GetLastPurchaseLineNo("Purchase Document Type"::"Credit Memo", PurchaseHeader."No."); + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); + if EDocumentPurchaseLine.FindSet() then + repeat + PurchaseLineNo += 10000; + EDocPurchaseDocumentHelper.CreatePurchaseLineFromDraft(PurchaseHeader, EDocumentPurchaseLine, EDocumentPurchaseHeader."Total Discount" > 0, PurchaseLineNo); + until EDocumentPurchaseLine.Next() = 0; + + PurchaseHeader.Modify(); + PurchCalcDiscByType.ApplyInvDiscBasedOnAmt(EDocumentPurchaseHeader."Total Discount", PurchaseHeader); + exit(PurchaseHeader); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al new file mode 100644 index 0000000000..943bea9235 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al @@ -0,0 +1,175 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.AI; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.eServices.EDocument.Processing.Interfaces; +using Microsoft.Foundation.UOM; +using Microsoft.Purchases.Document; +using Microsoft.Purchases.Vendor; +using System.Log; + +/// +/// Shared logic for preparing purchase document drafts (invoices and credit memos). +/// +codeunit 6406 "E-Doc. Prepare Draft Helper" +{ + Access = Internal; + + var + EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; + + procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters") + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + UnitOfMeasure: Record "Unit of Measure"; + Vendor: Record Vendor; + PurchaseOrder: Record "Purchase Header"; + EDocVendorAssignmentHistory: Record "E-Doc. Vendor Assign. History"; + EDocPurchaseHistMapping: Codeunit "E-Doc. Purchase Hist. Mapping"; + EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session"; + IUnitOfMeasureProvider: Interface IUnitOfMeasureProvider; + IPurchaseLineProvider: Interface IPurchaseLineProvider; + IPurchaseOrderProvider: Interface IPurchaseOrderProvider; + begin + IUnitOfMeasureProvider := EDocImportParameters."Processing Customizations"; + IPurchaseLineProvider := EDocImportParameters."Processing Customizations"; + IPurchaseOrderProvider := EDocImportParameters."Processing Customizations"; + + if EDocActivityLogSession.CreateSession() then; + + EDocumentPurchaseHeader.GetFromEDocument(EDocument); + EDocumentPurchaseHeader.TestField("E-Document Entry No."); + if EDocumentPurchaseHeader."[BC] Vendor No." = '' then begin + Vendor := GetVendor(EDocument, EDocImportParameters."Processing Customizations"); + EDocumentPurchaseHeader."[BC] Vendor No." := Vendor."No."; + end; + + PurchaseOrder := IPurchaseOrderProvider.GetPurchaseOrder(EDocumentPurchaseHeader); + if PurchaseOrder."No." <> '' then begin + EDocumentPurchaseHeader."[BC] Purchase Order No." := PurchaseOrder."No."; + EDocumentPurchaseHeader.Modify(); + end; + if EDocPurchaseHistMapping.FindRelatedPurchaseHeaderInHistory(EDocument, EDocVendorAssignmentHistory) then + EDocPurchaseHistMapping.UpdateMissingHeaderValuesFromHistory(EDocVendorAssignmentHistory, EDocumentPurchaseHeader); + EDocumentPurchaseHeader.Modify(); + + EDocImpSessionTelemetry.SetBool('Vendor', EDocumentPurchaseHeader."[BC] Vendor No." <> ''); + if EDocumentPurchaseHeader."[BC] Vendor No." <> '' then begin + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); + + if EDocumentPurchaseLine.FindSet() then + repeat + UnitOfMeasure := IUnitOfMeasureProvider.GetUnitOfMeasure(EDocument, EDocumentPurchaseLine."Line No.", EDocumentPurchaseLine."Unit of Measure"); + EDocumentPurchaseLine."[BC] Unit of Measure" := UnitOfMeasure.Code; + IPurchaseLineProvider.GetPurchaseLine(EDocumentPurchaseLine); + EDocumentPurchaseLine.Modify(); + until EDocumentPurchaseLine.Next() = 0; + + CopilotLineMatching(EDocument."Entry No"); + end; + + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); + if EDocumentPurchaseLine.FindSet() then + repeat + EDocImpSessionTelemetry.SetLine(EDocumentPurchaseLine.SystemId); + until EDocumentPurchaseLine.Next() = 0; + + LogAllActivitySessionChanges(EDocActivityLogSession); + + if EDocActivityLogSession.EndSession() then; + end; + + procedure GetVendor(EDocument: Record "E-Document"; Customizations: Enum "E-Doc. Proc. Customizations") Vendor: Record Vendor + var + IVendorProvider: Interface IVendorProvider; + begin + IVendorProvider := Customizations; + Vendor := IVendorProvider.GetVendor(EDocument); + end; + + procedure OpenDraftPage(var EDocument: Record "E-Document") + var + EDocumentPurchaseDraft: Page "E-Document Purchase Draft"; + begin + EDocumentPurchaseDraft.Editable(true); + EDocumentPurchaseDraft.SetRecord(EDocument); + EDocumentPurchaseDraft.Run(); + end; + + procedure CleanUpDraft(EDocument: Record "E-Document") + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.SetRange("E-Document Entry No.", EDocument."Entry No"); + if not EDocumentPurchaseHeader.IsEmpty() then + EDocumentPurchaseHeader.DeleteAll(true); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); + if not EDocumentPurchaseLine.IsEmpty() then + EDocumentPurchaseLine.DeleteAll(true); + end; + + local procedure LogAllActivitySessionChanges(EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session") + begin + Log(EDocActivityLogSession, EDocActivityLogSession.AccountNumberTok()); + Log(EDocActivityLogSession, EDocActivityLogSession.DeferralTok()); + Log(EDocActivityLogSession, EDocActivityLogSession.ItemRefTok()); + Log(EDocActivityLogSession, EDocActivityLogSession.TextToAccountMappingTok()); + end; + + local procedure Log(EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session"; ActivityLogName: Text) + var + ActivityLog: Codeunit "Activity Log Builder"; + ActivityLogList: List of [Codeunit "Activity Log Builder"]; + Found: Boolean; + begin + Clear(ActivityLogList); + EDocActivityLogSession.GetAll(ActivityLogName, ActivityLogList, Found); + foreach ActivityLog in ActivityLogList do + ActivityLog.Log(); + end; + + local procedure CopilotLineMatching(EDocumentEntryNo: Integer) + var + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseLine.SetLoadFields("E-Document Entry No.", "[BC] Purchase Type No.", "[BC] Deferral Code"); + EDocumentPurchaseLine.ReadIsolation(IsolationLevel::ReadCommitted); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.SetRange("[BC] Purchase Type No.", ''); + EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); + + if not EDocumentPurchaseLine.IsEmpty() then begin + Commit(); + Codeunit.Run(Codeunit::"E-Doc. Historical Matching", EDocumentPurchaseLine); + end; + + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.SetRange("[BC] Purchase Type No.", ''); + EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); + if not EDocumentPurchaseLine.IsEmpty() then begin + Commit(); + Codeunit.Run(Codeunit::"E-Doc. GL Account Matching", EDocumentPurchaseLine); + end; + + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.SetRange("[BC] Deferral Code", ''); + EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); + if not EDocumentPurchaseLine.IsEmpty() then begin + Commit(); + if Codeunit.Run(Codeunit::"E-Doc. Deferral Matching", EDocumentPurchaseLine) then; + end; + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocProcCustomizations.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocProcCustomizations.Enum.al index e533729eed..6120760440 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocProcCustomizations.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocProcCustomizations.Enum.al @@ -11,16 +11,18 @@ enum 6110 "E-Doc. Proc. Customizations" implements IPurchaseOrderProvider, IPurchaseLineProvider, IUnitOfMeasureProvider, - IEDocumentCreatePurchaseInvoice + IEDocumentCreatePurchaseInvoice, + IEDocumentCreatePurchaseCreditMemo { Extensible = true; DefaultImplementation = IVendorProvider = "E-Doc. Providers", IPurchaseOrderProvider = "E-Doc. Providers", IPurchaseLineProvider = "E-Doc. Providers", IUnitOfMeasureProvider = "E-Doc. Providers", - IEDocumentCreatePurchaseInvoice = "E-Doc. Create Purchase Invoice"; + IEDocumentCreatePurchaseInvoice = "E-Doc. Create Purchase Invoice", + IEDocumentCreatePurchaseCreditMemo = "E-Doc. Create Purch. Cr. Memo"; value(0; Default) { } -} \ No newline at end of file +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al new file mode 100644 index 0000000000..363aa10fcd --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Interfaces; +using Microsoft.Purchases.Vendor; + +codeunit 6403 "Prepare Purch. Cr. Memo Draft" implements IProcessStructuredData +{ + Access = Internal; + + var + PrepareDraftHelper: Codeunit "E-Doc. Prepare Draft Helper"; + + procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): Enum "E-Document Type" + begin + PrepareDraftHelper.PrepareDraft(EDocument, EDocImportParameters); + exit("E-Document Type"::"Purchase Credit Memo"); + end; + + procedure OpenDraftPage(var EDocument: Record "E-Document") + begin + PrepareDraftHelper.OpenDraftPage(EDocument); + end; + + procedure CleanUpDraft(EDocument: Record "E-Document") + begin + PrepareDraftHelper.CleanUpDraft(EDocument); + end; + + procedure GetVendor(EDocument: Record "E-Document"; Customizations: Enum "E-Doc. Proc. Customizations") Vendor: Record Vendor + begin + Vendor := PrepareDraftHelper.GetVendor(EDocument, Customizations); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al index 7997ca6d44..b59b6a4076 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al @@ -5,179 +5,34 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument; -using Microsoft.eServices.EDocument.Processing.AI; -using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; -using Microsoft.Foundation.UOM; -using Microsoft.Purchases.Document; using Microsoft.Purchases.Vendor; -using System.Log; codeunit 6125 "Prepare Purchase E-Doc. Draft" implements IProcessStructuredData { Access = Internal; var - EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; + PrepareDraftHelper: Codeunit "E-Doc. Prepare Draft Helper"; procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): Enum "E-Document Type" - var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - UnitOfMeasure: Record "Unit of Measure"; - Vendor: Record Vendor; - PurchaseOrder: Record "Purchase Header"; - EDocVendorAssignmentHistory: Record "E-Doc. Vendor Assign. History"; - EDocPurchaseHistMapping: Codeunit "E-Doc. Purchase Hist. Mapping"; - EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session"; - IUnitOfMeasureProvider: Interface IUnitOfMeasureProvider; - IPurchaseLineProvider: Interface IPurchaseLineProvider; - IPurchaseOrderProvider: Interface IPurchaseOrderProvider; begin - IUnitOfMeasureProvider := EDocImportParameters."Processing Customizations"; - IPurchaseLineProvider := EDocImportParameters."Processing Customizations"; - IPurchaseOrderProvider := EDocImportParameters."Processing Customizations"; - - if EDocActivityLogSession.CreateSession() then; - - EDocumentPurchaseHeader.GetFromEDocument(EDocument); - EDocumentPurchaseHeader.TestField("E-Document Entry No."); - if EDocumentPurchaseHeader."[BC] Vendor No." = '' then begin - Vendor := GetVendor(EDocument, EDocImportParameters."Processing Customizations"); - EDocumentPurchaseHeader."[BC] Vendor No." := Vendor."No."; - end; - - PurchaseOrder := IPurchaseOrderProvider.GetPurchaseOrder(EDocumentPurchaseHeader); - if PurchaseOrder."No." <> '' then begin - // Matching purchase order specified in the E-Document - EDocumentPurchaseHeader."[BC] Purchase Order No." := PurchaseOrder."No."; - EDocumentPurchaseHeader.Modify(); - end; - if EDocPurchaseHistMapping.FindRelatedPurchaseHeaderInHistory(EDocument, EDocVendorAssignmentHistory) then - EDocPurchaseHistMapping.UpdateMissingHeaderValuesFromHistory(EDocVendorAssignmentHistory, EDocumentPurchaseHeader); - EDocumentPurchaseHeader.Modify(); - - // If we can't find a vendor - EDocImpSessionTelemetry.SetBool('Vendor', EDocumentPurchaseHeader."[BC] Vendor No." <> ''); - if EDocumentPurchaseHeader."[BC] Vendor No." <> '' then begin - - // Get all purchase lines for the document - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - - // Apply basic unit of measure and text-to-account resolution first - if EDocumentPurchaseLine.FindSet() then - repeat - UnitOfMeasure := IUnitOfMeasureProvider.GetUnitOfMeasure(EDocument, EDocumentPurchaseLine."Line No.", EDocumentPurchaseLine."Unit of Measure"); - EDocumentPurchaseLine."[BC] Unit of Measure" := UnitOfMeasure.Code; - IPurchaseLineProvider.GetPurchaseLine(EDocumentPurchaseLine); - EDocumentPurchaseLine.Modify(); - until EDocumentPurchaseLine.Next() = 0; - - // Apply all Copilot-powered matching techniques to the lines - CopilotLineMatching(EDocument."Entry No"); - end; - - // Log telemetry and activity sessions - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - if EDocumentPurchaseLine.FindSet() then - repeat - EDocImpSessionTelemetry.SetLine(EDocumentPurchaseLine.SystemId); - until EDocumentPurchaseLine.Next() = 0; - - // Log all accumulated activity session changes at the end - LogAllActivitySessionChanges(EDocActivityLogSession); - - if EDocActivityLogSession.EndSession() then; + PrepareDraftHelper.PrepareDraft(EDocument, EDocImportParameters); exit("E-Document Type"::"Purchase Invoice"); end; - local procedure LogAllActivitySessionChanges(EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session") - begin - Log(EDocActivityLogSession, EDocActivityLogSession.AccountNumberTok()); - Log(EDocActivityLogSession, EDocActivityLogSession.DeferralTok()); - Log(EDocActivityLogSession, EDocActivityLogSession.ItemRefTok()); - Log(EDocActivityLogSession, EDocActivityLogSession.TextToAccountMappingTok()); - end; - - local procedure Log(EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session"; ActivityLogName: Text) - var - ActivityLog: Codeunit "Activity Log Builder"; - ActivityLogList: List of [Codeunit "Activity Log Builder"]; - Found: Boolean; - begin - Clear(ActivityLogList); - EDocActivityLogSession.GetAll(ActivityLogName, ActivityLogList, Found); - foreach ActivityLog in ActivityLogList do - ActivityLog.Log(); - end; - - local procedure CopilotLineMatching(EDocumentEntryNo: Integer) - var - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - begin - EDocumentPurchaseLine.SetLoadFields("E-Document Entry No.", "[BC] Purchase Type No.", "[BC] Deferral Code"); - EDocumentPurchaseLine.ReadIsolation(IsolationLevel::ReadCommitted); - - // Step 1: Apply historical pattern matching - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.SetRange("[BC] Purchase Type No.", ''); - EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); - - if not EDocumentPurchaseLine.IsEmpty() then begin - Commit(); - Codeunit.Run(Codeunit::"E-Doc. Historical Matching", EDocumentPurchaseLine); - end; - - // Step 2: Apply line-to-account matching for remaining lines with no purchase type - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.SetRange("[BC] Purchase Type No.", ''); - EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); - if not EDocumentPurchaseLine.IsEmpty() then begin - Commit(); - Codeunit.Run(Codeunit::"E-Doc. GL Account Matching", EDocumentPurchaseLine); - end; - - // Step 3: Apply deferral matching for lines with a purchase type but no deferral code - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.SetRange("[BC] Deferral Code", ''); - EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); - if not EDocumentPurchaseLine.IsEmpty() then begin - Commit(); - if Codeunit.Run(Codeunit::"E-Doc. Deferral Matching", EDocumentPurchaseLine) then; - end; - end; - procedure OpenDraftPage(var EDocument: Record "E-Document") - var - EDocumentPurchaseDraft: Page "E-Document Purchase Draft"; begin - EDocumentPurchaseDraft.Editable(true); - EDocumentPurchaseDraft.SetRecord(EDocument); - EDocumentPurchaseDraft.Run(); + PrepareDraftHelper.OpenDraftPage(EDocument); end; procedure CleanUpDraft(EDocument: Record "E-Document") - var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; begin - EDocumentPurchaseHeader.SetRange("E-Document Entry No.", EDocument."Entry No"); - if not EDocumentPurchaseHeader.IsEmpty() then - EDocumentPurchaseHeader.DeleteAll(true); - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - if not EDocumentPurchaseLine.IsEmpty() then - EDocumentPurchaseLine.DeleteAll(true); + PrepareDraftHelper.CleanUpDraft(EDocument); end; procedure GetVendor(EDocument: Record "E-Document"; Customizations: Enum "E-Doc. Proc. Customizations") Vendor: Record Vendor - var - IVendorProvider: Interface IVendorProvider; begin - IVendorProvider := Customizations; - Vendor := IVendorProvider.GetVendor(EDocument); + Vendor := PrepareDraftHelper.GetVendor(EDocument, Customizations); end; -} \ No newline at end of file +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al index 335ddc1bae..6f1667d071 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al @@ -224,6 +224,11 @@ table 6100 "E-Document Purchase Header" Caption = 'Posting Description'; DataClassification = CustomerContent; } + field(39; "Applies-to Doc. No."; Text[100]) + { + Caption = 'Applies-to Doc. No.'; + DataClassification = CustomerContent; + } #endregion Purchase fields #region Business Central Data - Validated fields [101-200] diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentADIHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentADIHandler.Codeunit.al index 1e2c79bca8..52ca55a954 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentADIHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentADIHandler.Codeunit.al @@ -79,7 +79,7 @@ codeunit 6174 "E-Document ADI Handler" implements IStructureReceivedEDocument, I begin ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); EDocPurchaseDraftUtility.PersistDraft(EDocument, TempEDocPurchaseHeader, TempEDocPurchaseLine); - exit(Enum::"E-Doc. Process Draft"::"Purchase Document"); + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); end; local procedure ReadIntoBuffer( 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 613913c963..58003701f8 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 @@ -235,7 +235,7 @@ codeunit 6231 "E-Document MLLM Handler" implements IStructureReceivedEDocument, begin ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); EDocPurchaseDraftUtility.PersistDraft(EDocument, TempEDocPurchaseHeader, TempEDocPurchaseLine); - exit(Enum::"E-Doc. Process Draft"::"Purchase Document"); + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); end; local procedure ReadIntoBuffer( diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index 890b2fe482..bb56d805a2 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -27,7 +27,6 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootElement: XmlElement; - CreditNoteNotSupportedLbl: Label 'Credit notes are not supported'; begin EDocumentPurchaseHeader.InsertForEDocument(EDocument); @@ -40,26 +39,46 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader 'INVOICE': begin PopulatePurchaseInvoiceHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); - InsertPurchaseInvoiceLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No."); + InsertPurchaseLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/inv:Invoice/cac:InvoiceLine', 'cac:InvoiceLine', 'cbc:InvoicedQuantity'); end; 'CREDITNOTE': - Error(CreditNoteNotSupportedLbl); + begin + PopulatePurchaseCreditMemoHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); + InsertPurchaseLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/cre:CreditNote/cac:CreditNoteLine', 'cac:CreditNoteLine', 'cbc:CreditedQuantity'); + end; end; EDocumentPurchaseHeader.Modify(); EDocument.Direction := EDocument.Direction::Incoming; - exit(Enum::"E-Doc. Process Draft"::"Purchase Document"); + + case UpperCase(RootElement.LocalName()) of + 'INVOICE': + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); + 'CREDITNOTE': + exit(Enum::"E-Doc. Process Draft"::"Purchase Credit Memo"); + else + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); + end; end; local procedure PopulatePurchaseInvoiceHeader(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") begin - PopulateDocumentInfo(PeppolXML, XmlNamespaces, Header); - PopulateSupplierInfo(PeppolXML, XmlNamespaces, Header); - PopulateCustomerInfo(PeppolXML, XmlNamespaces, Header); - PopulateAmountsAndDates(PeppolXML, XmlNamespaces, Header); - PopulateCurrency(PeppolXML, XmlNamespaces, Header); + PopulateInvoiceDocumentInfo(PeppolXML, XmlNamespaces, Header); + PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + PopulateCurrency(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + end; + + local procedure PopulatePurchaseCreditMemoHeader(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + begin + PopulateCreditNoteDocumentInfo(PeppolXML, XmlNamespaces, Header); + PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + PopulateCurrency(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); end; - local procedure PopulateDocumentInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + local procedure PopulateInvoiceDocumentInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") var Value: Text; begin @@ -69,79 +88,92 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader Header."Purchase Order No." := CopyStr(Value, 1, MaxStrLen(Header."Purchase Order No.")); end; - local procedure PopulateSupplierInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + local procedure PopulateCreditNoteDocumentInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + var + Value: Text; + begin + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cbc:ID', Value) then + Header."Sales Invoice No." := CopyStr(Value, 1, MaxStrLen(Header."Sales Invoice No.")); + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:OrderReference/cbc:ID', Value) then + Header."Purchase Order No." := CopyStr(Value, 1, MaxStrLen(Header."Purchase Order No.")); + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID', Value) then + Header."Applies-to Doc. No." := CopyStr(Value, 1, MaxStrLen(Header."Applies-to Doc. No.")); + if Header."Applies-to Doc. No." = '' then + Session.LogMessage('0000PRF', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); + end; + + local procedure PopulateSupplierInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") var XmlNode: XmlNode; Value: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); // PayeeParty is used when the Payee is different from the Seller. Otherwise, it will not be shown in the XML. - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name', Value) then Header."Vendor Contact Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Contact Name")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then Header."Vendor Address" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Address")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID', Value) then Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); // Vendor GLN: only populate when EndpointID schemeID = 0088 - if PeppolXML.SelectSingleNode('/inv:Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then + if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then if XmlNode.AsXmlAttribute().Value() = '0088' then - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID', Value) then Header."Vendor GLN" := CopyStr(Value, 1, MaxStrLen(Header."Vendor GLN")); end; - local procedure PopulateCustomerInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + local procedure PopulateCustomerInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") var Value: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then Header."Customer Address" := CopyStr(Value, 1, MaxStrLen(Header."Customer Address")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID', Value) then Header."Customer GLN" := CopyStr(Value, 1, MaxStrLen(Header."Customer GLN")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID/@schemeID', Value) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID/@schemeID', Value) then Header."Customer Company Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Id")); end; - local procedure PopulateAmountsAndDates(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + local procedure PopulateAmountsAndDates(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") begin - XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:LegalMonetaryTotal/cbc:PayableAmount', Header.Total); - XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', Header."Sub Total"); - XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', Header."Total Discount"); + XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:PayableAmount', Header.Total); + XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', Header."Sub Total"); + XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', Header."Total Discount"); Header."Total VAT" := Header."Total" - Header."Sub Total" - Header."Total Discount"; - XmlHelper.SetDateValueInField(PeppolXML, XmlNamespaces, '/inv:Invoice/cbc:DueDate', Header."Due Date"); - XmlHelper.SetDateValueInField(PeppolXML, XmlNamespaces, '/inv:Invoice/cbc:IssueDate', Header."Document Date"); + XmlHelper.SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:DueDate', Header."Due Date"); + XmlHelper.SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:IssueDate', Header."Document Date"); end; - local procedure PopulateCurrency(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + local procedure PopulateCurrency(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") var DocumentCurrencyCode: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cbc:DocumentCurrencyCode', DocumentCurrencyCode) then + if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cbc:DocumentCurrencyCode', DocumentCurrencyCode) then SetCurrencyIfForeign(DocumentCurrencyCode, Header."Currency Code"); end; - local procedure InsertPurchaseInvoiceLines(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer) + local procedure InsertPurchaseLines(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer; LineXPath: Text; LineElementName: Text; QuantityElementName: Text) var EDocumentPurchaseLine: Record "E-Document Purchase Line"; NewLineXML: XmlDocument; LineXMLList: XmlNodeList; LineXMLNode: XmlNode; i: Integer; - InvoiceLinePathLbl: Label '/inv:Invoice/cac:InvoiceLine'; begin - if not PeppolXML.SelectNodes(InvoiceLinePathLbl, XmlNamespaces, LineXMLList) then + if not PeppolXML.SelectNodes(LineXPath, XmlNamespaces, LineXMLList) then exit; for i := 1 to LineXMLList.Count do begin @@ -150,40 +182,40 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocumentEntryNo); LineXMLList.Get(i, LineXMLNode); NewLineXML.ReplaceNodes(LineXMLNode); - PopulateEDocumentPurchaseLine(NewLineXML, XmlNamespaces, EDocumentPurchaseLine); + PopulateEDocumentPurchaseLine(NewLineXML, XmlNamespaces, EDocumentPurchaseLine, LineElementName, QuantityElementName); EDocumentPurchaseLine.Insert(); end; end; - local procedure PopulateEDocumentPurchaseLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line") + local procedure PopulateEDocumentPurchaseLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line"; LineElementName: Text; QuantityElementName: Text) var Value: Text; begin - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, 'cac:InvoiceLine/cbc:InvoicedQuantity', Line.Quantity); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cbc:InvoicedQuantity/@unitCode', Value) then + XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName, Line.Quantity); + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName + '/@unitCode', Value) then Line."Unit of Measure" := CopyStr(Value, 1, MaxStrLen(Line."Unit of Measure")); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, 'cac:InvoiceLine/cbc:LineExtensionAmount', Line."Sub Total"); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:AllowanceCharge/cbc:Amount', Line."Total Discount"); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cbc:Note', Value) then + XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount', Line."Sub Total"); + XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:AllowanceCharge/cbc:Amount', Line."Total Discount"); + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:Note', Value) then Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:Item/cbc:Name', Value) then + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Name', Value) then Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:Item/cbc:Description', Value) then + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Description', Value) then Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:Item/cac:SellersItemIdentification/cbc:ID', Value) then + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:SellersItemIdentification/cbc:ID', Value) then Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:Item/cac:StandardItemIdentification/cbc:ID', Value) then + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:StandardItemIdentification/cbc:ID', Value) then Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', Line."VAT Rate"); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, 'cac:InvoiceLine/cac:Price/cbc:PriceAmount', Line."Unit Price"); - PopulateCurrencyForLine(LineXML, XmlNamespaces, Line); + XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', Line."VAT Rate"); + XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Price/cbc:PriceAmount', Line."Unit Price"); + PopulateCurrencyForLine(LineXML, XmlNamespaces, Line, LineElementName); end; - local procedure PopulateCurrencyForLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line") + local procedure PopulateCurrencyForLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line"; LineElementName: Text) var LineCurrencyCode: Text; begin - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, 'cac:InvoiceLine/cbc:LineExtensionAmount/@currencyID', LineCurrencyCode) then + if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount/@currencyID', LineCurrencyCode) then SetCurrencyIfForeign(LineCurrencyCode, Line."Currency Code"); end; @@ -209,4 +241,7 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader begin Error('A view is not implemented for this handler.'); end; + + var + BillingReferenceEmptyTelemetryTxt: Label 'CreditNote BillingReference is empty - no originating invoice reference found.', Locked = true; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchCrMemo.Interface.al b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchCrMemo.Interface.al new file mode 100644 index 0000000000..ee78778410 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchCrMemo.Interface.al @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Interfaces; + +using Microsoft.eServices.EDocument; +using Microsoft.Purchases.Document; + +/// +/// Interface for changing the way that purchase credit memos get created from an E-Document. +/// +interface IEDocumentCreatePurchaseCreditMemo +{ + /// + /// Creates a purchase credit memo from an E-Document with a draft ready. + /// + /// The E-Document to create the credit memo from. + /// The created Purchase Header record. + procedure CreatePurchaseCreditMemo(EDocument: Record "E-Document"): Record "Purchase Header"; +} diff --git a/src/Apps/W1/EDocument/Demo Data/EDocumentInvoices/ContosoInbInvHandler.Codeunit.al b/src/Apps/W1/EDocument/Demo Data/EDocumentInvoices/ContosoInbInvHandler.Codeunit.al index c811e2211f..02e9101a50 100644 --- a/src/Apps/W1/EDocument/Demo Data/EDocumentInvoices/ContosoInbInvHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/Demo Data/EDocumentInvoices/ContosoInbInvHandler.Codeunit.al @@ -132,7 +132,7 @@ codeunit 5392 "Contoso Inb.Inv. Handler" implements IStructureReceivedEDocument, local procedure GetDefaultIProcessStructuredDataImplementation(): Interface IProcessStructuredData begin - exit(Enum::"E-Doc. Process Draft"::"Purchase Document"); + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); end; } diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml new file mode 100644 index 0000000000..82e782fd4f --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml @@ -0,0 +1,130 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + CN-5001 + 2026-02-15 + 2026-03-15 + 381 + XYZ + 1 + + 5 + + + + 103033 + 2026-01-22 + + + + + 1234567890128 + + CRONUS International + + + Main Street, 14 + Birmingham + B27 4KT + + GB + + + + GB123456789 + + VAT + + + + CRONUS International + 123456789 + + + Jim Olive + JO@contoso.com + + + + + + 789456278 + + 8712345000004 + + + The Cannon Group PLC + + + 192 Market Square + Birmingham + B27 4KT + + GB + + + + GB789456278 + + VAT + + + + The Cannon Group PLC + 789456278 + + + Mr. Andy Teal + + + + + 500 + + 2000 + 500 + + S + 25 + + VAT + + + + + + 2000 + 2000 + 2500 + 0 + 2500 + + + 10000 + 1 + 2000 + + Bicycle - Return + + 1000 + + + S + 25 + + VAT + + + + + 2000.00 + 1 + + + diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMockCustomizations.EnumExt.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMockCustomizations.EnumExt.al index 35eab277e6..d55253dc74 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMockCustomizations.EnumExt.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMockCustomizations.EnumExt.al @@ -11,6 +11,7 @@ enumextension 133501 "E-Doc. Mock Customizations" extends "E-Doc. Proc. Customiz { value(133501; "Mock Create Purchase Invoice") { - Implementation = IEDocumentCreatePurchaseInvoice = "E-Doc. Processing Mocks"; + Implementation = IEDocumentCreatePurchaseInvoice = "E-Doc. Processing Mocks", + IEDocumentCreatePurchaseCreditMemo = "E-Doc. Processing Mocks"; } } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocPDFMock.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocPDFMock.Codeunit.al index c1adc40b13..6c7376fd77 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocPDFMock.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocPDFMock.Codeunit.al @@ -22,7 +22,7 @@ codeunit 139782 "E-Doc PDF Mock" implements IStructureReceivedEDocument, IStruct EDocumentPurchaseHeader."Vendor VAT Id" := '1111111111234'; EDocumentPurchaseHeader.Insert(); end; - exit(Enum::"E-Doc. Process Draft"::"Purchase Document"); + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); end; procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al index c5b2113089..03d6398620 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al @@ -703,6 +703,128 @@ codeunit 139883 "E-Doc Process Test" if ItemReference.Delete() then; end; + [Test] + procedure FinishDraftCreditMemoCanBeUndone() + var + EDocument: Record "E-Document"; + TempEDocImportParameters: Record "E-Doc. Import Parameters"; + PurchaseHeader: Record "Purchase Header"; + EDocLogRecord: Record "E-Document Log"; + EDocImport: Codeunit "E-Doc. Import"; + EDocumentLog: Codeunit "E-Document Log"; + EDocumentProcessing: Codeunit "E-Document Processing"; + begin + // [SCENARIO] A credit memo created via FinishDraft can be reverted + Initialize(Enum::"Service Integration"::"Mock"); + LibraryEDoc.CreateInboundEDocument(EDocument, EDocumentService); + EDocument."Document Type" := "E-Document Type"::"Purchase Credit Memo"; + EDocument.Modify(); + EDocumentService."Import Process" := "E-Document Import Process"::"Version 2.0"; + EDocumentService.Modify(); + + EDocumentLog.SetBlob('Test', Enum::"E-Doc. File Format"::XML, 'Data'); + EDocumentLog.SetFields(EDocument, EDocumentService); + EDocLogRecord := EDocumentLog.InsertLog(Enum::"E-Document Service Status"::Imported, Enum::"Import E-Doc. Proc. Status"::Readable); + + EDocument."Structured Data Entry No." := EDocLogRecord."E-Doc. Data Storage Entry No."; + EDocument.Modify(); + + // [GIVEN] A credit memo is created via FinishDraft + EDocumentProcessing.ModifyEDocumentProcessingStatus(EDocument, "Import E-Doc. Proc. Status"::"Draft Ready"); + TempEDocImportParameters."Step to Run" := "Import E-Document Steps"::"Finish draft"; + TempEDocImportParameters."Processing Customizations" := "E-Doc. Proc. Customizations"::"Mock Create Purchase Invoice"; + EDocImport.ProcessIncomingEDocument(EDocument, TempEDocImportParameters); + + PurchaseHeader.SetRange("E-Document Link", EDocument.SystemId); + PurchaseHeader.FindFirst(); + Assert.AreEqual("Purchase Document Type"::"Credit Memo", PurchaseHeader."Document Type", 'The document type should be Credit Memo.'); + + // [WHEN] Undo is performed + TempEDocImportParameters."Step to Run" := "Import E-Document Steps"::"Structure received data"; + EDocImport.ProcessIncomingEDocument(EDocument, TempEDocImportParameters); + + // [THEN] The credit memo is removed + Assert.RecordIsEmpty(PurchaseHeader); + end; + + [Test] + procedure ProcessingInboundCreditNoteCreatesCorrectDocumentType() + var + EDocument: Record "E-Document"; + TempEDocImportParams: Record "E-Doc. Import Parameters"; + PurchaseHeader: Record "Purchase Header"; + PurchaseLine: Record "Purchase Line"; + EDocRecordLink: Record "E-Doc. Record Link"; + begin + // [SCENARIO] A PEPPOL CreditNote processed through the full pipeline creates a Purchase Credit Memo with correct content + Initialize(Enum::"Service Integration"::"Mock"); + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::PEPPOL; + EDocumentService.Modify(); + + EDocRecordLink.DeleteAll(); + + // [GIVEN] An inbound credit note e-document is received and fully processed + TempEDocImportParams."Step to Run" := "Import E-Document Steps"::"Finish draft"; + WorkDate(DMY2Date(1, 1, 2027)); + Assert.IsTrue(LibraryEDoc.CreateInboundPEPPOLDocumentToState(EDocument, EDocumentService, 'peppol/peppol-creditnote-0.xml', TempEDocImportParams), 'The credit note e-document should be processed'); + + // [THEN] The E-Document type is Purchase Credit Memo + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual("E-Document Type"::"Purchase Credit Memo", EDocument."Document Type", 'The document type should be Purchase Credit Memo.'); + + // [THEN] A Purchase Credit Memo header is created with correct fields + PurchaseHeader.Get(EDocument."Document Record ID"); + Assert.AreEqual("Purchase Document Type"::"Credit Memo", PurchaseHeader."Document Type", 'The purchase header document type should be Credit Memo.'); + Assert.AreEqual(EDocument.SystemId, PurchaseHeader."E-Document Link", 'The E-Document link should be set on the purchase header.'); + Assert.AreEqual('CN-5001', PurchaseHeader."Vendor Cr. Memo No.", 'The vendor credit memo number should match the CreditNote ID.'); + Assert.AreEqual(Vendor."No.", PurchaseHeader."Buy-from Vendor No.", 'The vendor should be resolved from the CreditNote.'); + Assert.AreEqual(2500, PurchaseHeader."Doc. Amount Incl. VAT", 'The document amount incl. VAT should match the CreditNote total.'); + + // [THEN] The purchase credit memo has the correct number of lines + PurchaseLine.SetRange("Document Type", PurchaseHeader."Document Type"); + PurchaseLine.SetRange("Document No.", PurchaseHeader."No."); + Assert.RecordCount(PurchaseLine, 1); + + // [THEN] Links are created between e-document and purchase records + EDocRecordLink.SetRange("Target Table No.", Database::"Purchase Header"); + EDocRecordLink.SetRange("Target SystemId", PurchaseHeader.SystemId); + Assert.RecordCount(EDocRecordLink, 1); + end; + + [Test] + procedure ProcessingInboundInvoiceStillCreatesCorrectDocumentType() + var + EDocument: Record "E-Document"; + TempEDocImportParams: Record "E-Doc. Import Parameters"; + PurchaseHeader: Record "Purchase Header"; + PurchaseLine: Record "Purchase Line"; + begin + // [SCENARIO] After the refactoring, a PEPPOL Invoice still creates a Purchase Invoice with correct content (regression check) + Initialize(Enum::"Service Integration"::"Mock"); + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::PEPPOL; + EDocumentService.Modify(); + + TempEDocImportParams."Step to Run" := "Import E-Document Steps"::"Finish draft"; + WorkDate(DMY2Date(1, 1, 2027)); + Assert.IsTrue(LibraryEDoc.CreateInboundPEPPOLDocumentToState(EDocument, EDocumentService, 'peppol/peppol-invoice-0.xml', TempEDocImportParams), 'The invoice e-document should be processed'); + + // [THEN] The E-Document type is Purchase Invoice + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual("E-Document Type"::"Purchase Invoice", EDocument."Document Type", 'The document type should be Purchase Invoice.'); + + // [THEN] A Purchase Invoice header is created with correct fields + PurchaseHeader.Get(EDocument."Document Record ID"); + Assert.AreEqual("Purchase Document Type"::Invoice, PurchaseHeader."Document Type", 'The purchase header document type should be Invoice.'); + Assert.AreEqual('103033', PurchaseHeader."Vendor Invoice No.", 'The vendor invoice number should match the Invoice ID.'); + Assert.AreEqual(Vendor."No.", PurchaseHeader."Buy-from Vendor No.", 'The vendor should be resolved from the Invoice.'); + Assert.AreEqual(14140, PurchaseHeader."Doc. Amount Incl. VAT", 'The document amount incl. VAT should match the Invoice total.'); + + // [THEN] The purchase invoice has the correct number of lines (2 from peppol-invoice-0.xml) + PurchaseLine.SetRange("Document Type", PurchaseHeader."Document Type"); + PurchaseLine.SetRange("Document No.", PurchaseHeader."No."); + Assert.RecordCount(PurchaseLine, 2); + end; + local procedure Initialize(Integration: Enum "Service Integration") var TransformationRule: Record "Transformation Rule"; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessingMocks.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessingMocks.Codeunit.al index fbffe590c3..7c6524e113 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessingMocks.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessingMocks.Codeunit.al @@ -8,7 +8,7 @@ using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Purchases.Document; -codeunit 133503 "E-Doc. Processing Mocks" implements IEDocumentCreatePurchaseInvoice +codeunit 133503 "E-Doc. Processing Mocks" implements IEDocumentCreatePurchaseInvoice, IEDocumentCreatePurchaseCreditMemo { procedure CreatePurchaseInvoice(EDocument: Record "E-Document") PurchaseHeader: Record "Purchase Header" @@ -18,4 +18,11 @@ codeunit 133503 "E-Doc. Processing Mocks" implements IEDocumentCreatePurchaseInv PurchaseHeader.Insert(); end; + procedure CreatePurchaseCreditMemo(EDocument: Record "E-Document") PurchaseHeader: Record "Purchase Header" + begin + PurchaseHeader."No." := 'CM-' + Format(EDocument."Entry No"); + PurchaseHeader."Document Type" := "Purchase Document Type"::"Credit Memo"; + PurchaseHeader.Insert(); + end; + } diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al index 076298e65c..61ebf85b95 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al @@ -154,6 +154,41 @@ codeunit 139894 "EDoc Structured Validations" Assert.AreEqual(5000, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); end; + internal procedure AssertFullPEPPOLCreditNoteExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('CN-5001', EDocumentPurchaseHeader."Sales Invoice No.", 'The credit note number does not match the mock data.'); + Assert.AreEqual(DMY2Date(15, 02, 2026), EDocumentPurchaseHeader."Document Date", 'The document date does not match the mock data.'); + Assert.AreEqual(DMY2Date(15, 03, 2026), EDocumentPurchaseHeader."Due Date", 'The due date does not match the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not match the mock data.'); + Assert.AreEqual('5', EDocumentPurchaseHeader."Purchase Order No.", 'The order reference does not match the mock data.'); + Assert.AreEqual('103033', EDocumentPurchaseHeader."Applies-to Doc. No.", 'The billing reference (applies-to doc. no.) does not match the mock data.'); + Assert.AreEqual('CRONUS International', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match the mock data.'); + Assert.AreEqual('Main Street, 14', EDocumentPurchaseHeader."Vendor Address", 'The vendor street does not match the mock data.'); + Assert.AreEqual('GB123456789', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match the mock data.'); + Assert.AreEqual('Jim Olive', EDocumentPurchaseHeader."Vendor Contact Name", 'The vendor contact name does not match the mock data.'); + Assert.AreEqual('The Cannon Group PLC', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not match the mock data.'); + Assert.AreEqual('GB789456278', EDocumentPurchaseHeader."Customer VAT Id", 'The customer VAT id does not match the mock data.'); + Assert.AreEqual('192 Market Square', EDocumentPurchaseHeader."Customer Address", 'The customer address does not match the mock data.'); + Assert.AreEqual(2500, EDocumentPurchaseHeader.Total, 'The total does not match the mock data.'); + Assert.AreEqual(2000, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match the mock data.'); + Assert.AreEqual(0, EDocumentPurchaseHeader."Total Discount", 'The total discount does not match the mock data.'); + Assert.AreEqual(500, EDocumentPurchaseHeader."Total VAT", 'The total VAT does not match the mock data.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.FindSet(); + Assert.AreEqual(1, EDocumentPurchaseLine."Quantity", 'The quantity in the credit note line does not match the mock data.'); + Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the credit note line does not match the mock data.'); + Assert.AreEqual(2000, EDocumentPurchaseLine."Sub Total", 'The line extension amount does not match the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the credit note line does not match the mock data.'); + Assert.AreEqual('Bicycle - Return', EDocumentPurchaseLine.Description, 'The description in the credit note line does not match the mock data.'); + Assert.AreEqual('1000', EDocumentPurchaseLine."Product Code", 'The product code in the credit note line does not match the mock data.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in the credit note line does not match the mock data.'); + Assert.AreEqual(2000, EDocumentPurchaseLine."Unit Price", 'The unit price in the credit note line does not match the mock data.'); + end; #endregion #region MLLM diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al index 56bb815791..f7d9f97e85 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al @@ -98,6 +98,40 @@ codeunit 139891 "E-Document Structured Tests" else Assert.Fail(EDocumentStatusNotUpdatedErr); end; + [Test] + procedure TestPEPPOLCreditNote_ValidDocument() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] A valid PEPPOL CreditNote XML is parsed into the staging tables with correct header, lines, and BillingReference + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-creditnote-0.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + StructuredValidations.AssertFullPEPPOLCreditNoteExtracted(EDocument."Entry No"); + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual(Enum::"E-Doc. Process Draft"::"Purchase Credit Memo", EDocument."Process Draft Impl.", 'The process draft implementation should be set to Purchase Credit Memo for credit notes.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_ReturnsInvoiceProcessDraftImpl() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] After parsing a PEPPOL Invoice, the Process Draft Impl. is set to "Purchase Invoice" (not the obsoleted "Purchase Document") + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-0.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual(Enum::"E-Doc. Process Draft"::"Purchase Invoice", EDocument."Process Draft Impl.", 'The process draft implementation should be set to Purchase Invoice for invoices.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; #endregion #region MLLM JSON From 92d6468cb2fe1287eaad8edd13d307a60221d741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 09:51:54 +0200 Subject: [PATCH 02/85] [E-Document] Address PR review: extract shared FinishDraft logic, fix naming and tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared ApplyDraftToBC logic (amounts, E-Document Link, attachments, totals validation) into FinalizeCreatedDocument/RevertCreatedDocument on E-Doc. Purch. Doc. Helper — both invoice and credit memo codeunits now delegate to it, keeping only type-specific dispatch - Rename "E-Doc. Prepare Draft Helper" to "Prepare Purchase Draft" - Add #if not CLEAN29 tags around obsoleted enum value 0 "Purchase Document" - Fix telemetry tag to empty string per convention Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Import/EDocProcessDraft.Enum.al | 2 + .../EDocCreatePurchCrMemo.Codeunit.al | 33 +++------------ .../EDocCreatePurchaseInvoice.Codeunit.al | 27 ++----------- .../EDocPurchDocHelper.Codeunit.al | 40 +++++++++++++++++++ .../PreparePurchCrMemoDraft.Codeunit.al | 2 +- ...it.al => PreparePurchaseDraft.Codeunit.al} | 2 +- .../PreparePurchaseEDocDraft.Codeunit.al | 2 +- .../EDocumentPEPPOLHandler.Codeunit.al | 2 +- ...mentCreatePurchaseCreditMemo.Interface.al} | 0 9 files changed, 56 insertions(+), 54 deletions(-) rename src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/{EDocPrepareDraftHelper.Codeunit.al => PreparePurchaseDraft.Codeunit.al} (99%) rename src/Apps/W1/EDocument/App/src/Processing/Interfaces/{IEDocumentCreatePurchCrMemo.Interface.al => IEDocumentCreatePurchaseCreditMemo.Interface.al} (100%) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al index be5b7851ab..2327f2001a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocProcessDraft.Enum.al @@ -13,6 +13,7 @@ enum 6107 "E-Doc. Process Draft" implements IProcessStructuredData { Extensible = true; +#if not CLEAN29 value(0; "Purchase Document") { Caption = 'Purchase Document'; @@ -21,6 +22,7 @@ enum 6107 "E-Doc. Process Draft" implements IProcessStructuredData ObsoleteReason = 'Use "Purchase Invoice" or "Purchase Credit Memo" instead.'; ObsoleteTag = '29.0'; } +#endif value(1; "Purchase Invoice") { Caption = 'Purchase Invoice'; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al index 79171e9bdf..ea662ad5a7 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al @@ -9,7 +9,6 @@ using Microsoft.eServices.EDocument.Processing; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; -using Microsoft.Foundation.Attachment; using Microsoft.Purchases.Document; using Microsoft.Purchases.Payables; using System.Telemetry; @@ -23,21 +22,17 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, var Telemetry: Codeunit "Telemetry"; - EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; CrMemoAlreadyExistsErr: Label 'A purchase credit memo with external document number %1 already exists for vendor %2.', Comment = '%1 = Vendor Cr. Memo No., %2 = Vendor No.'; DraftLineDoesNotContainTypeAndNumberErr: Label 'One of the draft lines do not contain the type and number. Please, specify these fields manually.'; procedure ApplyDraftToBC(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): RecordId var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; PurchaseHeader: Record "Purchase Header"; EDocPurchaseDocumentHelper: Codeunit "E-Doc. Purch. Doc. Helper"; - DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; EmptyRecordId: RecordId; IEDocumentFinishPurchaseCrMemo: Interface IEDocumentCreatePurchaseCreditMemo; begin - EDocumentPurchaseHeader.GetFromEDocument(EDocument); - IEDocumentFinishPurchaseCrMemo := EDocImportParameters."Processing Customizations"; if EDocImportParameters."Existing Doc. RecordId" <> EmptyRecordId then begin EDocImpSessionTelemetry.SetBool('LinkedToExisting', true); @@ -45,18 +40,7 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, end else PurchaseHeader := IEDocumentFinishPurchaseCrMemo.CreatePurchaseCreditMemo(EDocument); - PurchaseHeader.SetRecFilter(); - PurchaseHeader.FindFirst(); - PurchaseHeader."Doc. Amount Incl. VAT" := EDocumentPurchaseHeader.Total; - PurchaseHeader."Doc. Amount VAT" := EDocumentPurchaseHeader."Total VAT"; - PurchaseHeader.TestField("No."); - PurchaseHeader."E-Document Link" := EDocument.SystemId; - PurchaseHeader.Modify(); - - DocumentAttachmentMgt.CopyAttachments(EDocument, PurchaseHeader); - DocumentAttachmentMgt.DeleteAttachedDocuments(EDocument); - - EDocImpSessionTelemetry.SetBool('Totals Validation', EDocPurchaseDocumentHelper.TryValidateDocumentTotals(PurchaseHeader)); + EDocPurchaseDocumentHelper.FinalizeCreatedDocument(EDocument, PurchaseHeader); exit(PurchaseHeader.RecordId); end; @@ -64,18 +48,14 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, procedure RevertDraftActions(EDocument: Record "E-Document") var PurchaseHeader: Record "Purchase Header"; - DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + EDocPurchaseDocumentHelper: Codeunit "E-Doc. Purch. Doc. Helper"; begin PurchaseHeader.SetRange("E-Document Link", EDocument.SystemId); if not PurchaseHeader.FindFirst() then exit; - DocumentAttachmentMgt.CopyAttachments(PurchaseHeader, EDocument); - DocumentAttachmentMgt.DeleteAttachedDocuments(PurchaseHeader); - PurchaseHeader.TestField("Document Type", "Purchase Document Type"::"Credit Memo"); - Clear(PurchaseHeader."E-Document Link"); - PurchaseHeader.Modify(); + EDocPurchaseDocumentHelper.RevertCreatedDocument(EDocument); end; procedure CreatePurchaseCreditMemo(EDocument: Record "E-Document"): Record "Purchase Header" @@ -94,7 +74,7 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, begin EDocumentPurchaseHeader.GetFromEDocument(EDocument); if not EDocPurchaseDocumentHelper.AllDraftLinesHaveTypeAndNumber(EDocumentPurchaseHeader) then begin - Telemetry.LogMessage('0000PRG', 'Draft line does not contain type or number', Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All); + Telemetry.LogMessage('', 'Draft line does not contain type or number', Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All); Error(DraftLineDoesNotContainTypeAndNumberErr); end; EDocumentPurchaseHeader.TestField("E-Document Entry No."); @@ -112,7 +92,7 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, VendorLedgerEntry.ReadIsolation := VendorLedgerEntry.ReadIsolation::ReadUncommitted; StopCreatingCreditMemo := PurchaseHeader.FindPostedDocumentWithSameExternalDocNo(VendorLedgerEntry, VendorCrMemoNo); if StopCreatingCreditMemo then begin - Telemetry.LogMessage('0000PRH', CrMemoAlreadyExistsErr, Verbosity::Error, DataClassification::OrganizationIdentifiableInformation, TelemetryScope::All); + Telemetry.LogMessage('', CrMemoAlreadyExistsErr, Verbosity::Error, DataClassification::OrganizationIdentifiableInformation, TelemetryScope::All); Error(CrMemoAlreadyExistsErr, VendorCrMemoNo, EDocumentPurchaseHeader."[BC] Vendor No."); end; @@ -120,7 +100,6 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, PurchaseHeader.Insert(true); PurchaseHeader.Modify(); - // Validate currency after insert GLSetup.GetRecordOnce(); if EDocumentPurchaseHeader."Currency Code" <> GLSetup.GetCurrencyCode('') then begin PurchaseHeader.Validate("Currency Code", EDocumentPurchaseHeader."Currency Code"); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchaseInvoice.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchaseInvoice.Codeunit.al index da4aefa5d3..9585a19b8f 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchaseInvoice.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchaseInvoice.Codeunit.al @@ -9,7 +9,6 @@ using Microsoft.eServices.EDocument.Processing; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; -using Microsoft.Foundation.Attachment; using Microsoft.Purchases.Document; using Microsoft.Purchases.Payables; using System.Telemetry; @@ -23,7 +22,6 @@ codeunit 6117 "E-Doc. Create Purchase Invoice" implements IEDocumentFinishDraft, var Telemetry: Codeunit "Telemetry"; - EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; InvoiceAlreadyExistsErr: Label 'A purchase invoice with external document number %1 already exists for vendor %2.', Comment = '%1 = Vendor Invoice No., %2 = Vendor No.'; DraftLineDoesNotConstantTypeAndNumberErr: Label 'One of the draft lines do not contain the type and number. Please, specify these fields manually.'; @@ -34,7 +32,7 @@ codeunit 6117 "E-Doc. Create Purchase Invoice" implements IEDocumentFinishDraft, TempPOMatchWarnings: Record "E-Doc PO Match Warning" temporary; EDocPOMatching: Codeunit "E-Doc. PO Matching"; EDocPurchaseDocumentHelper: Codeunit "E-Doc. Purch. Doc. Helper"; - DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; EmptyRecordId: RecordId; IEDocumentFinishPurchaseDraft: Interface IEDocumentCreatePurchaseInvoice; YourMatchedLinesAreNotValidErr: Label 'The purchase invoice cannot be created because one or more of its matched lines are not valid matches. Review if your configuration allows for receiving at invoice.'; @@ -63,20 +61,7 @@ codeunit 6117 "E-Doc. Create Purchase Invoice" implements IEDocumentFinishDraft, PurchaseHeader := IEDocumentFinishPurchaseDraft.CreatePurchaseInvoice(EDocument); EDocPOMatching.TransferPOMatchesFromEDocumentToInvoice(EDocument); - PurchaseHeader.SetRecFilter(); - PurchaseHeader.FindFirst(); - PurchaseHeader."Doc. Amount Incl. VAT" := EDocumentPurchaseHeader.Total; - PurchaseHeader."Doc. Amount VAT" := EDocumentPurchaseHeader."Total VAT"; - PurchaseHeader.TestField("No."); - PurchaseHeader."E-Document Link" := EDocument.SystemId; - PurchaseHeader.Modify(); - - // Post document creation - DocumentAttachmentMgt.CopyAttachments(EDocument, PurchaseHeader); - DocumentAttachmentMgt.DeleteAttachedDocuments(EDocument); - - // Post document validation - Silently emit telemetry - EDocImpSessionTelemetry.SetBool('Totals Validation', EDocPurchaseDocumentHelper.TryValidateDocumentTotals(PurchaseHeader)); + EDocPurchaseDocumentHelper.FinalizeCreatedDocument(EDocument, PurchaseHeader); exit(PurchaseHeader.RecordId); end; @@ -85,19 +70,15 @@ codeunit 6117 "E-Doc. Create Purchase Invoice" implements IEDocumentFinishDraft, var PurchaseHeader: Record "Purchase Header"; EDocPOMatching: Codeunit "E-Doc. PO Matching"; - DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + EDocPurchaseDocumentHelper: Codeunit "E-Doc. Purch. Doc. Helper"; begin PurchaseHeader.SetRange("E-Document Link", EDocument.SystemId); if not PurchaseHeader.FindFirst() then exit; EDocPOMatching.TransferPOMatchesFromInvoiceToEDocument(PurchaseHeader); - DocumentAttachmentMgt.CopyAttachments(PurchaseHeader, EDocument); - DocumentAttachmentMgt.DeleteAttachedDocuments(PurchaseHeader); - PurchaseHeader.TestField("Document Type", "Purchase Document Type"::Invoice); - Clear(PurchaseHeader."E-Document Link"); - PurchaseHeader.Modify(); + EDocPurchaseDocumentHelper.RevertCreatedDocument(EDocument); end; procedure CreatePurchaseInvoice(EDocument: Record "E-Document"): Record "Purchase Header" diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocPurchDocHelper.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocPurchDocHelper.Codeunit.al index d1e87d8dd2..dd97e61275 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocPurchDocHelper.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocPurchDocHelper.Codeunit.al @@ -4,9 +4,11 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.Finance.Dimension; +using Microsoft.Foundation.Attachment; using Microsoft.Purchases.Document; using Microsoft.Purchases.Posting; @@ -97,4 +99,42 @@ codeunit 6402 "E-Doc. Purch. Doc. Helper" if PurchaseLine.FindLast() then exit(PurchaseLine."Line No."); end; + + procedure FinalizeCreatedDocument(EDocument: Record "E-Document"; var PurchaseHeader: Record "Purchase Header") + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; + begin + EDocumentPurchaseHeader.GetFromEDocument(EDocument); + + PurchaseHeader.SetRecFilter(); + PurchaseHeader.FindFirst(); + PurchaseHeader."Doc. Amount Incl. VAT" := EDocumentPurchaseHeader.Total; + PurchaseHeader."Doc. Amount VAT" := EDocumentPurchaseHeader."Total VAT"; + PurchaseHeader.TestField("No."); + PurchaseHeader."E-Document Link" := EDocument.SystemId; + PurchaseHeader.Modify(); + + DocumentAttachmentMgt.CopyAttachments(EDocument, PurchaseHeader); + DocumentAttachmentMgt.DeleteAttachedDocuments(EDocument); + + EDocImpSessionTelemetry.SetBool('Totals Validation', TryValidateDocumentTotals(PurchaseHeader)); + end; + + procedure RevertCreatedDocument(EDocument: Record "E-Document") + var + PurchaseHeader: Record "Purchase Header"; + DocumentAttachmentMgt: Codeunit "Document Attachment Mgmt"; + begin + PurchaseHeader.SetRange("E-Document Link", EDocument.SystemId); + if not PurchaseHeader.FindFirst() then + exit; + + DocumentAttachmentMgt.CopyAttachments(PurchaseHeader, EDocument); + DocumentAttachmentMgt.DeleteAttachedDocuments(PurchaseHeader); + + Clear(PurchaseHeader."E-Document Link"); + PurchaseHeader.Modify(); + end; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al index 363aa10fcd..77c71f0f43 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al @@ -13,7 +13,7 @@ codeunit 6403 "Prepare Purch. Cr. Memo Draft" implements IProcessStructuredData Access = Internal; var - PrepareDraftHelper: Codeunit "E-Doc. Prepare Draft Helper"; + PrepareDraftHelper: Codeunit "Prepare Purchase Draft"; procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): Enum "E-Document Type" begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al similarity index 99% rename from src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al index 943bea9235..239f57ebcc 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/EDocPrepareDraftHelper.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al @@ -16,7 +16,7 @@ using System.Log; /// /// Shared logic for preparing purchase document drafts (invoices and credit memos). /// -codeunit 6406 "E-Doc. Prepare Draft Helper" +codeunit 6406 "Prepare Purchase Draft" { Access = Internal; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al index b59b6a4076..335b4e979f 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseEDocDraft.Codeunit.al @@ -13,7 +13,7 @@ codeunit 6125 "Prepare Purchase E-Doc. Draft" implements IProcessStructuredData Access = Internal; var - PrepareDraftHelper: Codeunit "E-Doc. Prepare Draft Helper"; + PrepareDraftHelper: Codeunit "Prepare Purchase Draft"; procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): Enum "E-Document Type" begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index bb56d805a2..4131e53447 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -99,7 +99,7 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID', Value) then Header."Applies-to Doc. No." := CopyStr(Value, 1, MaxStrLen(Header."Applies-to Doc. No.")); if Header."Applies-to Doc. No." = '' then - Session.LogMessage('0000PRF', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); + Session.LogMessage('', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); end; local procedure PopulateSupplierInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") diff --git a/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchCrMemo.Interface.al b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchaseCreditMemo.Interface.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchCrMemo.Interface.al rename to src/Apps/W1/EDocument/App/src/Processing/Interfaces/IEDocumentCreatePurchaseCreditMemo.Interface.al From 29d7ff6e2c95e36b2025785b9c86e4428ed4f15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 12:21:44 +0200 Subject: [PATCH 03/85] [E-Document] Complete PEPPOL handler: fix CreditNote DueDate, add attachments and charges - Fix CreditNote DueDate XPath: use PaymentMeans/PaymentDueDate per PEPPOL BIS 3.0 spec (Invoice uses top-level cbc:DueDate, CreditNote does not) - Add document attachment extraction from AdditionalDocumentReference with embedded base64 binary objects - Add document-level AllowanceCharge line creation for charges (ChargeIndicator=true), matching V1 behavior - Fix Customer EndpointID: only set GLN when schemeID=0088, store full schemeID:value in Customer Company Id - Fix Description priority: use mandatory Item Name as primary, fallback to Description only if Name absent - Rename XML Utility to PEPPOL Utility (codeunit 6401) - Add PEPPOL BIS 3.0 spec reference comments throughout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocumentPEPPOLHandler.Codeunit.al | 291 +++++++++++++++--- ....al => EDocumentPEPPOLUtility.Codeunit.al} | 4 +- 2 files changed, 244 insertions(+), 51 deletions(-) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocumentXMLHelper.Codeunit.al => EDocumentPEPPOLUtility.Codeunit.al} (96%) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index 4131e53447..b56baeaf84 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -9,8 +9,14 @@ using Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; +using System.Text; using System.Utilities; +/// +/// Reads PEPPOL BIS 3.0 Invoice and CreditNote XML into v2 import draft staging tables. +/// Spec reference: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/tree/ +/// https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-creditnote/tree/ +/// codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader { Access = Internal; @@ -18,7 +24,7 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader InherentPermissions = X; var - XmlHelper: Codeunit "E-Document XML Helper"; + PeppolUtility: Codeunit "E-Document PEPPOL Utility"; procedure ReadIntoDraft(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob"): Enum "E-Doc. Process Draft" var @@ -32,7 +38,7 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader TempBlob.CreateInStream(DocStream, TextEncoding::UTF8); XmlDocument.ReadFrom(DocStream, PeppolXML); - XmlHelper.InitializePEPPOLNamespaces(XmlNamespaces); + PeppolUtility.InitializePEPPOL3Namespaces(XmlNamespaces); PeppolXML.GetRoot(RootElement); case UpperCase(RootElement.LocalName()) of @@ -40,11 +46,15 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader begin PopulatePurchaseInvoiceHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); InsertPurchaseLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/inv:Invoice/cac:InvoiceLine', 'cac:InvoiceLine', 'cbc:InvoicedQuantity'); + InsertAllowanceChargeLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/inv:Invoice'); + InsertDocumentAttachments(EDocument, PeppolXML, XmlNamespaces, '/inv:Invoice'); end; 'CREDITNOTE': begin PopulatePurchaseCreditMemoHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); InsertPurchaseLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/cre:CreditNote/cac:CreditNoteLine', 'cac:CreditNoteLine', 'cbc:CreditedQuantity'); + InsertAllowanceChargeLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/cre:CreditNote'); + InsertDocumentAttachments(EDocument, PeppolXML, XmlNamespaces, '/cre:CreditNote'); end; end; EDocumentPurchaseHeader.Modify(); @@ -65,7 +75,8 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader PopulateInvoiceDocumentInfo(PeppolXML, XmlNamespaces, Header); PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); - PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + // Per PEPPOL BIS 3.0: Invoice has DueDate as a direct child element + PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/inv:Invoice', '/inv:Invoice/cbc:DueDate', Header); PopulateCurrency(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); end; @@ -74,7 +85,9 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader PopulateCreditNoteDocumentInfo(PeppolXML, XmlNamespaces, Header); PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); - PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + // Per PEPPOL BIS 3.0: CreditNote has no top-level DueDate; it is under PaymentMeans. + // Spec ref: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-creditnote/cac-PaymentMeans/cbc-PaymentDueDate/ + PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/cre:CreditNote', '/cre:CreditNote/cac:PaymentMeans/cbc:PaymentDueDate', Header); PopulateCurrency(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); end; @@ -82,9 +95,9 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader var Value: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cbc:ID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cbc:ID', Value) then Header."Sales Invoice No." := CopyStr(Value, 1, MaxStrLen(Header."Sales Invoice No.")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:OrderReference/cbc:ID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/inv:Invoice/cac:OrderReference/cbc:ID', Value) then Header."Purchase Order No." := CopyStr(Value, 1, MaxStrLen(Header."Purchase Order No.")); end; @@ -92,11 +105,11 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader var Value: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cbc:ID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cbc:ID', Value) then Header."Sales Invoice No." := CopyStr(Value, 1, MaxStrLen(Header."Sales Invoice No.")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:OrderReference/cbc:ID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:OrderReference/cbc:ID', Value) then Header."Purchase Order No." := CopyStr(Value, 1, MaxStrLen(Header."Purchase Order No.")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID', Value) then Header."Applies-to Doc. No." := CopyStr(Value, 1, MaxStrLen(Header."Applies-to Doc. No.")); if Header."Applies-to Doc. No." = '' then Session.LogMessage('', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); @@ -107,64 +120,80 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader XmlNode: XmlNode; Value: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); - // PayeeParty is used when the Payee is different from the Seller. Otherwise, it will not be shown in the XML. - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then + // Per PEPPOL BIS 3.0: PayeeParty is used when the Payee is different from the Seller. + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name', Value) then Header."Vendor Contact Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Contact Name")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then Header."Vendor Address" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Address")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID', Value) then + // Per PEPPOL BIS 3.0: PayeeParty/PartyLegalEntity/CompanyID is the Payee legal registration identifier. + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID', Value) then Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); - // Vendor GLN: only populate when EndpointID schemeID = 0088 + // Per PEPPOL BIS 3.0: EndpointID/@schemeID uses the EAS code list. + // SchemeID 0088 = EAN Location Code (GLN). Only populate GLN for this scheme. if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then if XmlNode.AsXmlAttribute().Value() = '0088' then - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID', Value) then Header."Vendor GLN" := CopyStr(Value, 1, MaxStrLen(Header."Vendor GLN")); end; local procedure PopulateCustomerInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") var + XmlNode: XmlNode; + SchemeID: Text; + EndpointValue: Text; Value: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then Header."Customer Address" := CopyStr(Value, 1, MaxStrLen(Header."Customer Address")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID', Value) then - Header."Customer GLN" := CopyStr(Value, 1, MaxStrLen(Header."Customer GLN")); - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID/@schemeID', Value) then - Header."Customer Company Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Id")); + + // Per PEPPOL BIS 3.0: EndpointID/@schemeID uses the EAS code list. + // SchemeID 0088 = EAN Location Code (GLN). Only populate GLN for this scheme. + // Customer Company Id stores the full electronic address identifier (schemeID:value). + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID', EndpointValue) then begin + if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then + SchemeID := XmlNode.AsXmlAttribute().Value(); + + if SchemeID = '0088' then + Header."Customer GLN" := CopyStr(EndpointValue, 1, MaxStrLen(Header."Customer GLN")); + + Header."Customer Company Id" := CopyStr(SchemeID + ':' + EndpointValue, 1, MaxStrLen(Header."Customer Company Id")); + end; end; - local procedure PopulateAmountsAndDates(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") + local procedure PopulateAmountsAndDates(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; DueDatePath: Text; var Header: Record "E-Document Purchase Header") begin - XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:PayableAmount', Header.Total); - XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', Header."Sub Total"); - XmlHelper.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', Header."Total Discount"); + PeppolUtility.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:PayableAmount', Header.Total); + PeppolUtility.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', Header."Sub Total"); + PeppolUtility.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', Header."Total Discount"); Header."Total VAT" := Header."Total" - Header."Sub Total" - Header."Total Discount"; - XmlHelper.SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:DueDate', Header."Due Date"); - XmlHelper.SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:IssueDate', Header."Document Date"); + PeppolUtility.SetDateValueInField(PeppolXML, XmlNamespaces, DueDatePath, Header."Due Date"); + PeppolUtility.SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:IssueDate', Header."Document Date"); end; local procedure PopulateCurrency(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") var DocumentCurrencyCode: Text; begin - if XmlHelper.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cbc:DocumentCurrencyCode', DocumentCurrencyCode) then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cbc:DocumentCurrencyCode', DocumentCurrencyCode) then SetCurrencyIfForeign(DocumentCurrencyCode, Header."Currency Code"); end; + #region Purchase Lines + local procedure InsertPurchaseLines(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer; LineXPath: Text; LineElementName: Text; QuantityElementName: Text) var EDocumentPurchaseLine: Record "E-Document Purchase Line"; @@ -191,23 +220,34 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader var Value: Text; begin - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName, Line.Quantity); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName + '/@unitCode', Value) then + PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName, Line.Quantity); + if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName + '/@unitCode', Value) then Line."Unit of Measure" := CopyStr(Value, 1, MaxStrLen(Line."Unit of Measure")); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount', Line."Sub Total"); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:AllowanceCharge/cbc:Amount', Line."Total Discount"); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:Note', Value) then - Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Name', Value) then - Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Description', Value) then + PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount', Line."Sub Total"); + PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:AllowanceCharge/cbc:Amount', Line."Total Discount"); + + // Per PEPPOL BIS 3.0: Item Name (1..1, mandatory) is the primary short product description. + // Item Description (0..1) is an optional longer description that may exceed field capacity. + // Line Note (0..1) is operational info, not a product description. + // Priority: Name (always present per spec), fallback to Description if Name is absent. + if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Name', Value) then Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:SellersItemIdentification/cbc:ID', Value) then - Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:StandardItemIdentification/cbc:ID', Value) then - Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', Line."VAT Rate"); - XmlHelper.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Price/cbc:PriceAmount', Line."Unit Price"); + if Line.Description = '' then + if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Description', Value) then + Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); + + // Per PEPPOL BIS 3.0: SellersItemIdentification is the seller's internal product code. + // StandardItemIdentification is a registered standard (e.g., GTIN via schemeID 0160). + // StandardItemIdentification takes priority as the more universally recognized identifier. + if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:SellersItemIdentification/cbc:ID', Value) then + if Value <> '' then + Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); + if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:StandardItemIdentification/cbc:ID', Value) then + if Value <> '' then + Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); + + PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', Line."VAT Rate"); + PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Price/cbc:PriceAmount', Line."Unit Price"); PopulateCurrencyForLine(LineXML, XmlNamespaces, Line, LineElementName); end; @@ -215,10 +255,163 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader var LineCurrencyCode: Text; begin - if XmlHelper.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount/@currencyID', LineCurrencyCode) then + if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount/@currencyID', LineCurrencyCode) then SetCurrencyIfForeign(LineCurrencyCode, Line."Currency Code"); end; + #endregion Purchase Lines + + #region Document-Level Allowance/Charge Lines + + /// + /// Per PEPPOL BIS 3.0: Document-level AllowanceCharge (0..n) represents surcharges and allowances + /// that apply to the entire document (e.g., shipping fees, early payment discounts). + /// ChargeIndicator = true means a charge (surcharge/fee); false means an allowance (discount). + /// Allowances are already captured in the header-level AllowanceTotalAmount. + /// This method creates separate purchase lines for charges only. + /// Spec ref: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-AllowanceCharge/ + /// + local procedure InsertAllowanceChargeLines(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer; RootPath: Text) + var + ChargeXML: XmlDocument; + ChargeNodes: XmlNodeList; + ChargeNode: XmlNode; + ChargeIndicator: Text; + i: Integer; + begin + if not PeppolXML.SelectNodes(RootPath + '/cac:AllowanceCharge', XmlNamespaces, ChargeNodes) then + exit; + + for i := 1 to ChargeNodes.Count do begin + ChargeNodes.Get(i, ChargeNode); + ChargeXML.ReplaceNodes(ChargeNode); + + if PeppolUtility.TryGetStringValue(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cbc:ChargeIndicator', ChargeIndicator) then + if UpperCase(ChargeIndicator) = 'TRUE' then + InsertSingleChargeLine(ChargeXML, XmlNamespaces, EDocumentEntryNo); + end; + end; + + local procedure InsertSingleChargeLine(ChargeXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer) + var + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + ChargeAmount: Decimal; + Value: Text; + CurrencyCode: Text; + begin + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine.Validate("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocumentEntryNo); + EDocumentPurchaseLine.Quantity := 1; + + PeppolUtility.SetNumberValueInField(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cbc:Amount', ChargeAmount); + EDocumentPurchaseLine."Unit Price" := ChargeAmount; + EDocumentPurchaseLine."Sub Total" := ChargeAmount; + + if PeppolUtility.TryGetStringValue(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cbc:AllowanceChargeReason', Value) then + EDocumentPurchaseLine.Description := CopyStr(Value, 1, MaxStrLen(EDocumentPurchaseLine.Description)); + + PeppolUtility.SetNumberValueInField(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cac:TaxCategory/cbc:Percent', EDocumentPurchaseLine."VAT Rate"); + + if PeppolUtility.TryGetStringValue(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cbc:Amount/@currencyID', CurrencyCode) then + SetCurrencyIfForeign(CurrencyCode, EDocumentPurchaseLine."Currency Code"); + + EDocumentPurchaseLine.Insert(); + end; + + #endregion Document-Level Allowance/Charge Lines + + #region Document Attachments + + /// + /// Per PEPPOL BIS 3.0: AdditionalDocumentReference (0..n) can contain embedded binary attachments + /// (e.g., PDF copies, timesheets, delivery notes) encoded as base64 in EmbeddedDocumentBinaryObject. + /// Spec ref: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-AdditionalDocumentReference/ + /// + local procedure InsertDocumentAttachments(EDocument: Record "E-Document"; PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text) + var + AttachmentNodes: XmlNodeList; + AttachmentNode: XmlNode; + AttachmentXML: XmlDocument; + i: Integer; + begin + if not PeppolXML.SelectNodes(RootPath + '/cac:AdditionalDocumentReference', XmlNamespaces, AttachmentNodes) then + exit; + + for i := 1 to AttachmentNodes.Count do begin + AttachmentNodes.Get(i, AttachmentNode); + AttachmentXML.ReplaceNodes(AttachmentNode); + InsertSingleAttachment(EDocument, AttachmentXML, XmlNamespaces); + end; + end; + + local procedure InsertSingleAttachment(EDocument: Record "E-Document"; AttachmentXML: XmlDocument; XmlNamespaces: XmlNamespaceManager) + var + EDocAttachmentProcessor: Codeunit "E-Doc. Attachment Processor"; + Base64Convert: Codeunit "Base64 Convert"; + AttachmentBlob: Codeunit "Temp Blob"; + InStream: InStream; + OutStream: OutStream; + Base64Content: Text; + FileName: Text; + MimeCode: Text; + FileExtension: Text; + ElementName: Text; + begin + ElementName := 'cac:AdditionalDocumentReference'; + + // Only process references with embedded binary content; skip external URI references + if not PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject', Base64Content) then + exit; + + if Base64Content = '' then + exit; + + // Per PEPPOL BIS 3.0: @filename is mandatory on EmbeddedDocumentBinaryObject + if not PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject/@filename', FileName) then + // Fallback to document reference ID if filename attribute is missing + PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cbc:ID', FileName); + + if FileName = '' then + exit; + + // If filename has no extension, derive one from the mandatory @mimeCode attribute + if not FileName.Contains('.') then + if PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject/@mimeCode', MimeCode) then begin + FileExtension := DetermineFileExtension(MimeCode); + if FileExtension <> '' then + FileName := FileName + '.' + FileExtension; + end; + + // Decode base64 content and save as attachment on the E-Document + AttachmentBlob.CreateOutStream(OutStream); + Base64Convert.FromBase64(Base64Content, OutStream); + AttachmentBlob.CreateInStream(InStream); + EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); + end; + + local procedure DetermineFileExtension(MimeCode: Text): Text + begin + case MimeCode of + 'image/jpeg': + exit('jpeg'); + 'image/png': + exit('png'); + 'application/pdf': + exit('pdf'); + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + exit('xlsx'); + 'application/vnd.oasis.opendocument.spreadsheet': + exit('ods'); + 'text/csv': + exit('csv'); + else + exit(''); + end; + end; + + #endregion Document Attachments + /// /// BC convention: blank Currency Code means LCY. Sets the field to the currency code /// only if it differs from LCY. Explicitly blanks the field when it matches LCY. diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentXMLHelper.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al similarity index 96% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentXMLHelper.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al index 127a3e31a5..769b5f5685 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentXMLHelper.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al @@ -4,13 +4,13 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; -codeunit 6401 "E-Document XML Helper" +codeunit 6401 "E-Document PEPPOL Utility" { Access = Public; InherentEntitlements = X; InherentPermissions = X; - procedure InitializePEPPOLNamespaces(var XmlNamespaces: XmlNamespaceManager) + procedure InitializePEPPOL3Namespaces(var XmlNamespaces: XmlNamespaceManager) var CommonAggregateComponentsLbl: Label 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'; CommonBasicComponentsLbl: Label 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'; From 0b4eddacee70c8ee60a0075a0ba9e6fa73e335d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 14:09:49 +0200 Subject: [PATCH 04/85] [E-Document] Add PEPPOL handler test coverage for completeness items Add 10 new test cases covering all completeness document items: - Document-level charges/allowances (charge creates line, allowance does not) - Embedded document attachments (base64 extraction, external URI skip) - CreditNote without DueDate (PaymentMeans/PaymentDueDate absent) - Description cascade (Name priority, Description fallback) - PayeeParty override (vendor name and VAT ID) - StandardItemIdentification priority over SellersItemIdentification - Customer endpoint schemeID logic (GLN only for 0088) - Multiple VAT rates and zero-rated VAT category Z Co-Authored-By: Claude Opus 4.6 (1M context) --- .../peppol/peppol-creditnote-no-duedate.xml | 215 ++++++++++ .../peppol/peppol-invoice-allowance.xml | 370 ++++++++++++++++++ .../peppol/peppol-invoice-attachment.xml | 132 +++++++ .../peppol/peppol-invoice-basic.xml | 210 ++++++++++ .../peppol/peppol-invoice-charges.xml | 147 +++++++ .../peppol-invoice-description-fallback.xml | 125 ++++++ .../peppol/peppol-invoice-payee-party.xml | 94 +++++ .../peppol/peppol-invoice-vat-category-s.xml | 297 ++++++++++++++ .../peppol/peppol-invoice-vat-category-z.xml | 113 ++++++ .../EDocStructuredValidations.Codeunit.al | 341 ++++++++++++++++ .../EDocumentStructuredTests.Codeunit.al | 152 +++++++ 11 files changed, 2196 insertions(+) create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-no-duedate.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-allowance.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-charges.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-description-fallback.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-payee-party.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-s.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-no-duedate.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-no-duedate.xml new file mode 100644 index 0000000000..ba95607394 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-no-duedate.xml @@ -0,0 +1,215 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Snippet1 + 2017-11-13 + 381 + Please note we have a new phone number: 22 22 22 22 + EUR + 4025:123:4343 + 0150abc + + + Snippet1 + + + + + 9482348239847239874 + + 99887766 + + + SupplierTradingName Ltd. + + + Main street 1 + Postbox 123 + London + GB 123 EW + + GB + + + + GB1232434 + + VAT + + + + SupplierOfficialName Ltd + GB983294 + + + + + + FR23342 + + FR23342 + + + BuyerTradingName AS + + + Hovedgatan 32 + Po box 878 + Stockholm + 456 34 + + SE + + + + SE4598375937 + + VAT + + + + Buyer Official Name + 39937423947 + + + Lisa Johnson + 23434234 + lj@buyer.se + + + + + 2017-11-01 + + 9483759475923478 + + Delivery street 2 + Building 56 + Stockholm + 21234 + + SE + + + + + + Delivery party Name + + + + + 30 + Snippet1 + + IBAN32423940 + AccountName + + BIC324098 + + + + + Payment within 10 days, 2% discount + + + true + Insurance + 25 + + S + 25.0 + + VAT + + + + + 331.25 + + 1325 + 331.25 + + S + 25.0 + + VAT + + + + + + 1300 + 1325 + 1656.25 + 25 + 1656.25 + + + + 1 + 7 + 2800 + Konteringsstreng + + 123 + + + Description of item + item name + + 21382183120983 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + 400 + + + + 2 + -3 + -1500 + + 123 + + + Description 2 + item name 2 + + 21382183120983 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + 500 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-allowance.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-allowance.xml new file mode 100644 index 0000000000..2632750efd --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-allowance.xml @@ -0,0 +1,370 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Snippet1 + 2017-11-13 + 2017-12-01 + 380 + Please note we have a new phone number: 22 22 22 22 + 2017-12-01 + EUR + SEK + 4025:123:4343 + 0150abc + + 2017-12-01 + 2017-12-31 + + + framework no 1 + + + DR35141 + 130 + + + ts12345 + Technical specification + + + www.techspec.no + + + + + + 7300010000001 + + 99887766 + + + SupplierTradingName Ltd. + + + Main street 1 + Postbox 123 + London + GB 123 EW + + GB + + + + GB1232434 + + VAT + + + + + SupplierOfficialName Ltd + GB983294 + AdditionalLegalInformation + + + + + + + + 4598375937 + + 4598375937 + + + BuyerTradingName AS + + + Hovedgatan 32 + Po box 878 + Stockholm + 456 34 + Södermalm + + + SE + + + + SE4598375937 + + VAT + + + + Buyer Official Name + 39937423947 + + + Lisa Johnson + 23434234 + lj@buyer.se + + + + + 2017-11-01 + + 7300010000001 + + Delivery street 2 + Building 56 + Stockholm + 21234 + Södermalm + + Gate 15 + + + SE + + + + + + Delivery party Name + + + + + 30 + Snippet1 + + IBAN32423940 + AccountName + + BIC324098 + + + + + Payment within 10 days, 2% discount + + + + true + CG + Cleaning + 20 + 200 + 1000 + + S + 25 + + VAT + + + + + + false + 95 + Discount + 200 + + S + 25 + + VAT + + + + + + 1225.00 + + 4900.0 + 1225 + + S + 25 + + VAT + + + + + 1000.0 + 0 + + E + 0 + Reason for tax exempt + + VAT + + + + + + 9324.00 + + + 5900 + 5900 + 7125 + 200 + 200 + 1000 + 6125.00 + + + 1 + Testing note on line level + 10 + 4000.00 + Konteringsstreng + + true + CG + Cleaning + 1 + 1 + 100 + + + false + 95 + Discount + 101 + + + Description of item + item name + + + 97iugug876 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + + + 410 + 1 + + false + 40 + 450 + + + + + + 2 + Testing note on line level + + 10 + 1000.00 + + Konteringsstreng + + 2017-12-01 + 2017-12-05 + + + 124 + + + + Description of item + item name + + 97iugug876 + + + 86776 + + + E + 0.0 + + VAT + + + + AdditionalItemName + AdditionalItemValue + + + + 200 + 2 + + + + 3 + Testing note on line level + 10 + 900.00 + Konteringsstreng + + 2017-12-01 + 2017-12-05 + + + 124 + + + + true + CG + Charge + 1 + 1 + 100 + + + false + 95 + Discount + 101 + + + + Description of item + item name + + 97iugug876 + + + + 86776 + + + S + 25.0 + + VAT + + + + AdditionalItemName + AdditionalItemValue + + + + + 100 + + + + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml new file mode 100644 index 0000000000..c4a620a51b --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml @@ -0,0 +1,132 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV-ATT-001 + 2026-03-01 + 2026-04-01 + 380 + XYZ + 1 + + + att-001 + Invoice PDF copy + + SGVsbG8gV29ybGQ= + + + + + att-002 + Photo evidence + + AQID + + + + + ext-001 + External spec + + + https://example.com/spec.pdf + + + + + + DR99999 + 130 + + + + 1234567890128 + + Attachment Supplier Ltd. + + + Test Street 1 + London + EC1A 1BB + + GB + + + + GB111222333 + + VAT + + + + Attachment Supplier Ltd. + + + + + + 8712345000004 + + Test Buyer Corp + + + Buyer Street 2 + Birmingham + B27 4KT + + GB + + + + GB444555666 + + VAT + + + + Test Buyer Corp + + + + + 125 + + 500 + 125 + + S + 25 + + VAT + + + + + + 500 + 500 + 625 + 625 + + + 1 + 5 + 500 + + Test Item + + S + 25 + + VAT + + + + + 100.00 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml new file mode 100644 index 0000000000..bc5930214d --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml @@ -0,0 +1,210 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Snippet1 + 2017-11-13 + 2017-12-01 + 380 + EUR + 4025:123:4343 + 0150abc + + + 9482348239847239874 + + 99887766 + + + SupplierTradingName Ltd. + + + Main street 1 + Postbox 123 + London + GB 123 EW + + GB + + + + GB1232434 + + VAT + + + + SupplierOfficialName Ltd + GB983294 + + + + + + FR23342 + + FR23342 + + + BuyerTradingName AS + + + Hovedgatan 32 + Po box 878 + Stockholm + 456 34 + + SE + + + + SE4598375937 + + VAT + + + + Buyer Official Name + 39937423947 + + + Lisa Johnson + 23434234 + lj@buyer.se + + + + + 2017-11-01 + + 9483759475923478 + + Delivery street 2 + Building 56 + Stockholm + 21234 + + SE + + + + + + Delivery party Name + + + + + 30 + Snippet1 + + IBAN32423940 + AccountName + + BIC324098 + + + + + Payment within 10 days, 2% discount + + + true + Insurance + 25 + + S + 25.0 + + VAT + + + + + 331.25 + + 1325 + 331.25 + + S + 25.0 + + VAT + + + + + + 1300 + 1325 + 1656.25 + 25 + 1656.25 + + + + 1 + 7 + 2800 + Konteringsstreng + + 123 + + + Description of item + item name + + 21382183120983 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + 400 + + + + 2 + -3 + -1500 + + 123 + + + Description 2 + item name 2 + + 21382183120983 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + 500 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-charges.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-charges.xml new file mode 100644 index 0000000000..e9063f5f9b --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-charges.xml @@ -0,0 +1,147 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV-CHARGE-001 + 2026-03-01 + 2026-04-01 + 380 + XYZ + 1 + + PO-100 + + + + 1234567890128 + + CRONUS International + + + Main Street, 14 + Birmingham + B27 4KT + + GB + + + + GB123456789 + + VAT + + + + CRONUS International + 123456789 + + + Jim Olive + + + + + + 8712345000004 + + The Cannon Group PLC + + + 192 Market Square + Birmingham + B27 4KT + + GB + + + + GB789456278 + + VAT + + + + The Cannon Group PLC + + + + + + false + 95 + Early Payment Discount + 200 + + S + 25 + + VAT + + + + + + true + FC + Freight charge + 150 + + S + 25 + + VAT + + + + + 250 + + 1000 + 250 + + S + 25 + + VAT + + + + + + 1000 + 950 + 1200 + 200 + 150 + 1200 + + + 10000 + 2 + 1000 + + Widget + + WIDGET-001 + + + 7350053850019 + + + S + 25 + + VAT + + + + + 500.00 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-description-fallback.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-description-fallback.xml new file mode 100644 index 0000000000..2cb5af0543 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-description-fallback.xml @@ -0,0 +1,125 @@ + + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV-DESC-001 + 2026-03-01 + 2026-04-01 + 380 + XYZ + 1 + + + 1234567890128 + + Description Test Supplier + + + Test Street 1 + London + EC1A 1BB + + GB + + + + GB111222333 + + VAT + + + + Description Test Supplier + + + + + + 8712345000004 + + Test Buyer Corp + + + Buyer Street 2 + Birmingham + B27 4KT + + GB + + + + Test Buyer Corp + + + + + 75 + + + 300 + 300 + 375 + 375 + + + + 1 + 1 + 100 + + Widget Alpha + + S + 25 + + VAT + + + + + 100.00 + + + + + 2 + 1 + 100 + + Detailed description of Widget Beta for testing fallback + + S + 25 + + VAT + + + + + 100.00 + + + + + 3 + 1 + 100 + + This longer description should NOT be used when Name is present + Widget Gamma + + S + 25 + + VAT + + + + + 100.00 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-payee-party.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-payee-party.xml new file mode 100644 index 0000000000..44aa3abada --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-payee-party.xml @@ -0,0 +1,94 @@ + + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV-PAYEE-001 + 2026-03-01 + 2026-04-01 + 380 + XYZ + 1 + + + 1234567890128 + + Original Supplier Name + + + Supplier Street 1 + London + EC1A 1BB + + GB + + + + GB111222333 + + VAT + + + + Original Supplier Name + + + + + + + Factoring Company GmbH + + + DE999888777 + + + + + 8712345000004 + + Test Buyer Corp + + + Buyer Street 2 + Birmingham + B27 4KT + + GB + + + + Test Buyer Corp + + + + + 50 + + + 200 + 200 + 250 + 250 + + + 1 + 2 + 200 + + Test Item + + S + 25 + + VAT + + + + + 100.00 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-s.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-s.xml new file mode 100644 index 0000000000..122be59628 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-s.xml @@ -0,0 +1,297 @@ + + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Snippet1 + 2017-11-13 + 2017-12-01 + 380 + EUR + 4025:123:4343 + 0150abc + + + 7300010000001 + + 99887766 + + + SupplierTradingName Ltd. + + + Main street 1 + Postbox 123 + London + GB 123 EW + + GB + + + + GB1232434 + + VAT + + + + SupplierOfficialName Ltd + GB983294 + AdditionalLegalInformation + + + John Doe + 9384203984 + john.doe@foo.bar + + + + + + + FR23342 + + FR23342 + + + BuyerTradingName AS + + + Hovedgatan 32 + Po box 878 + Stockholm + 456 34 + + SE + + + + SE4598375937 + + VAT + + + + Buyer Official Name + 39937423947 + + + + + 2017-11-01 + + 7300010000001 + + Delivery street 2 + Building 56 + Stockholm + 21234 + Södermalm + + Gate 15 + + + SE + + + + + + Delivery party Name + + + + + 30 + Snippet1 + + IBAN32423940 + AccountName + + BIC324098 + + + + + Payment within 10 days, 2% discount + + + + true + Cleaning + 200 + + S + 25 + + VAT + + + + + + false + Discount + 100 + + S + 25 + + VAT + + + + + + 1550.00 + + + 5000.0 + 1250 + + S + 25 + + VAT + + + + + + 2000.0 + 300 + + S + 15 + + VAT + + + + + + + 6900 + 7000 + 8550 + 100 + 200 + 8550 + + + + + 1 + Testing note on line level + 10 + 4000.00 + + Konteringsstreng + + 2017-12-01 + 2017-12-05 + + + 123 + + + Description of item + item name + + 97iugug876 + + + 7300010000001 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + + 400 + + + + + 2 + 10 + 2000.00 + + Konteringsstreng + + Description of item + item name + + 97iugug876 + + + 7300010000001 + + + 86776 + + + + S + 15.0 + + VAT + + + + + 200 + + + + + 3 + 10 + 900.00 + + Konteringsstreng + + Description of item + item name + + 97iugug876 + + + 873649827489 + + + 86776 + + + S + 25.0 + + VAT + + + + AdditionalItemName + AdditionalItemValue + + + + 90 + + + + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml new file mode 100644 index 0000000000..d9b8a0adc7 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml @@ -0,0 +1,113 @@ + + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Vat-Z + 2018-08-30 + 380 + GBP + test reference + + + 7300010000001 + + 7300010000001 + + + Main street 2, Building 4 + Big city + 54321 + + GB + + + + GB928741974 + + VAT + + + + The Sellercompany Incorporated + + + + + + DK12345678 + + Anystreet 8 + Back door + Anytown + 101 + RegionB + + DK + + + + The Buyercompany + + + + + 30 + + SE1212341234123412 + + SEXDABCD + + + + + Payment within 30 days + + + 0.00 + + 1200.00 + 0.00 + + Z + 0 + + VAT + + + + + + 1200.00 + 1200.00 + 1200.00 + 1200.00 + + + 1 + 10 + 1200.00 + + 1 + + + Test item, category Z + + 192387129837129873 + + + Z + 0 + + VAT + + + + + 120.00 + + + + diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al index 61ebf85b95..90d38e3f06 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al @@ -191,6 +191,340 @@ codeunit 139894 "EDoc Structured Validations" end; #endregion + internal procedure AssertPEPPOLBaseExampleExtracted(EDocumentEntryNo: Integer) + var + GLSetup: Record "General Ledger Setup"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + GLSetup.Get(); + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + Assert.AreEqual(DMY2Date(13, 11, 2017), EDocumentPurchaseHeader."Document Date", 'The document date does not match.'); + Assert.AreEqual(DMY2Date(01, 12, 2017), EDocumentPurchaseHeader."Due Date", 'The due date does not match.'); + Assert.AreEqual(ExpectedCurrencyCode('EUR', GLSetup."LCY Code"), EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('SupplierTradingName Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual('Main street 1', EDocumentPurchaseHeader."Vendor Address", 'The vendor address does not match.'); + Assert.AreEqual('GB1232434', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match.'); + Assert.AreEqual('9482348239847239874', EDocumentPurchaseHeader."Vendor GLN", 'The vendor GLN should be populated for schemeID=0088.'); + Assert.AreEqual('BuyerTradingName AS', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not match.'); + Assert.AreEqual('SE4598375937', EDocumentPurchaseHeader."Customer VAT Id", 'The customer VAT id does not match.'); + Assert.AreEqual('Hovedgatan 32', EDocumentPurchaseHeader."Customer Address", 'The customer address does not match.'); + Assert.AreEqual('', EDocumentPurchaseHeader."Customer GLN", 'Customer GLN should be empty for schemeID=0002.'); + Assert.AreEqual('0002:FR23342', EDocumentPurchaseHeader."Customer Company Id", 'Customer Company Id should be schemeID:value.'); + Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'The total does not match.'); + Assert.AreEqual(1325, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + Assert.AreEqual(3, EDocumentPurchaseLine.Count(), 'Expected 2 invoice lines + 1 charge line = 3 lines.'); + + EDocumentPurchaseLine.FindSet(); + // Line 1: 7 x 400 EUR + Assert.AreEqual(7, EDocumentPurchaseLine.Quantity, 'Line 1 quantity does not match.'); + Assert.AreEqual('DAY', EDocumentPurchaseLine."Unit of Measure", 'Line 1 unit of measure does not match.'); + Assert.AreEqual(2800, EDocumentPurchaseLine."Sub Total", 'Line 1 sub total does not match.'); + Assert.AreEqual('item name', EDocumentPurchaseLine.Description, 'Line 1 description does not match.'); + Assert.AreEqual('21382183120983', EDocumentPurchaseLine."Product Code", 'Line 1 product code should be StandardItemIdentification.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Line 1 VAT rate does not match.'); + Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'Line 1 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Line 2: -3 x 500 EUR (negative quantity) + Assert.AreEqual(-3, EDocumentPurchaseLine.Quantity, 'Line 2 quantity does not match (should be negative).'); + Assert.AreEqual(-1500, EDocumentPurchaseLine."Sub Total", 'Line 2 sub total does not match.'); + Assert.AreEqual('item name 2', EDocumentPurchaseLine.Description, 'Line 2 description does not match.'); + Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Line 2 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Charge line: Insurance, 25 EUR, VAT 25% + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'Charge line quantity should be 1.'); + Assert.AreEqual(25, EDocumentPurchaseLine."Unit Price", 'Charge line unit price does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."Sub Total", 'Charge line sub total does not match.'); + Assert.AreEqual('Insurance', EDocumentPurchaseLine.Description, 'Charge line description does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Charge line VAT rate does not match.'); + end; + + internal procedure AssertPEPPOLInvoiceWithChargesExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('INV-CHARGE-001', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + Assert.AreEqual(DMY2Date(01, 03, 2026), EDocumentPurchaseHeader."Document Date", 'The document date does not match.'); + Assert.AreEqual(DMY2Date(01, 04, 2026), EDocumentPurchaseHeader."Due Date", 'The due date does not match.'); + Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('PO-100', EDocumentPurchaseHeader."Purchase Order No.", 'The purchase order number does not match.'); + Assert.AreEqual('CRONUS International', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual(1200, EDocumentPurchaseHeader.Total, 'The total does not match.'); + Assert.AreEqual(950, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match.'); + Assert.AreEqual(200, EDocumentPurchaseHeader."Total Discount", 'The total discount (allowance) does not match.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + Assert.AreEqual(2, EDocumentPurchaseLine.Count(), 'Expected 1 invoice line + 1 charge line (allowance should NOT create a line).'); + + EDocumentPurchaseLine.FindSet(); + // Invoice line: Widget, 2 x 500 XYZ + Assert.AreEqual(2, EDocumentPurchaseLine.Quantity, 'Invoice line quantity does not match.'); + Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'Invoice line unit of measure does not match.'); + Assert.AreEqual(1000, EDocumentPurchaseLine."Sub Total", 'Invoice line sub total does not match.'); + Assert.AreEqual('Widget', EDocumentPurchaseLine.Description, 'Invoice line description does not match.'); + // StandardItemIdentification (7350053850019) should override SellersItemIdentification (WIDGET-001) + Assert.AreEqual('7350053850019', EDocumentPurchaseLine."Product Code", 'Product code should be StandardItemIdentification, not SellersItemIdentification.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Invoice line VAT rate does not match.'); + Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Invoice line unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Charge line: Freight charge, 150 XYZ, VAT 25% + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'Charge line quantity should be 1.'); + Assert.AreEqual(150, EDocumentPurchaseLine."Unit Price", 'Charge line unit price does not match.'); + Assert.AreEqual(150, EDocumentPurchaseLine."Sub Total", 'Charge line sub total does not match.'); + Assert.AreEqual('Freight charge', EDocumentPurchaseLine.Description, 'Charge line description does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Charge line VAT rate does not match.'); + Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'Charge line currency code does not match.'); + end; + + internal procedure AssertPEPPOLVatCategorySExtracted(EDocumentEntryNo: Integer) + var + GLSetup: Record "General Ledger Setup"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + GLSetup.Get(); + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + Assert.AreEqual(DMY2Date(13, 11, 2017), EDocumentPurchaseHeader."Document Date", 'The document date does not match.'); + Assert.AreEqual(DMY2Date(01, 12, 2017), EDocumentPurchaseHeader."Due Date", 'The due date does not match.'); + Assert.AreEqual(ExpectedCurrencyCode('EUR', GLSetup."LCY Code"), EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('SupplierTradingName Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual('John Doe', EDocumentPurchaseHeader."Vendor Contact Name", 'The vendor contact name does not match.'); + Assert.AreEqual('7300010000001', EDocumentPurchaseHeader."Vendor GLN", 'The vendor GLN does not match for schemeID=0088.'); + Assert.AreEqual(8550, EDocumentPurchaseHeader.Total, 'The total does not match.'); + Assert.AreEqual(7000, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match.'); + Assert.AreEqual(100, EDocumentPurchaseHeader."Total Discount", 'The total discount does not match.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + Assert.AreEqual(4, EDocumentPurchaseLine.Count(), 'Expected 3 invoice lines + 1 charge line.'); + + EDocumentPurchaseLine.FindSet(); + // Line 1: 10 x 400, VAT 25%, StandardItemIdentification overrides SellersItemIdentification + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line 1 quantity does not match.'); + Assert.AreEqual(4000, EDocumentPurchaseLine."Sub Total", 'Line 1 sub total does not match.'); + Assert.AreEqual('item name', EDocumentPurchaseLine.Description, 'Line 1 description does not match.'); + Assert.AreEqual('7300010000001', EDocumentPurchaseLine."Product Code", 'Line 1 product code should be StandardItemIdentification.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Line 1 VAT rate does not match.'); + Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'Line 1 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Line 2: 10 x 200, VAT 15% (different rate) + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line 2 quantity does not match.'); + Assert.AreEqual(2000, EDocumentPurchaseLine."Sub Total", 'Line 2 sub total does not match.'); + Assert.AreEqual(15, EDocumentPurchaseLine."VAT Rate", 'Line 2 VAT rate should be 15% (different from line 1).'); + Assert.AreEqual(200, EDocumentPurchaseLine."Unit Price", 'Line 2 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Line 3: 10 x 90, VAT 25%, StandardItemIdentification with different schemeID (0160) + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line 3 quantity does not match.'); + Assert.AreEqual(900, EDocumentPurchaseLine."Sub Total", 'Line 3 sub total does not match.'); + Assert.AreEqual('873649827489', EDocumentPurchaseLine."Product Code", 'Line 3 product code should be StandardItemIdentification with schemeID=0160.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Line 3 VAT rate does not match.'); + Assert.AreEqual(90, EDocumentPurchaseLine."Unit Price", 'Line 3 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Charge line: Cleaning, 200 EUR, VAT 25% + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'Charge line quantity should be 1.'); + Assert.AreEqual(200, EDocumentPurchaseLine."Unit Price", 'Charge line unit price does not match.'); + Assert.AreEqual('Cleaning', EDocumentPurchaseLine.Description, 'Charge line description does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Charge line VAT rate does not match.'); + end; + + internal procedure AssertPEPPOLVatCategoryZExtracted(EDocumentEntryNo: Integer) + var + GLSetup: Record "General Ledger Setup"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + GLSetup.Get(); + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('Vat-Z', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + Assert.AreEqual(DMY2Date(30, 08, 2018), EDocumentPurchaseHeader."Document Date", 'The document date does not match.'); + Assert.AreEqual(0D, EDocumentPurchaseHeader."Due Date", 'Due Date should be blank when not present in the XML.'); + Assert.AreEqual(ExpectedCurrencyCode('GBP', GLSetup."LCY Code"), EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('The Sellercompany Incorporated', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual('GB928741974', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match.'); + Assert.AreEqual('7300010000001', EDocumentPurchaseHeader."Vendor GLN", 'The vendor GLN does not match for schemeID=0088.'); + Assert.AreEqual('The Buyercompany', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not match.'); + Assert.AreEqual('', EDocumentPurchaseHeader."Customer GLN", 'Customer GLN should be empty for schemeID=0184.'); + Assert.AreEqual('0184:DK12345678', EDocumentPurchaseHeader."Customer Company Id", 'Customer Company Id should be schemeID:value.'); + Assert.AreEqual(1200, EDocumentPurchaseHeader.Total, 'The total does not match.'); + Assert.AreEqual(1200, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match.'); + Assert.AreEqual(0, EDocumentPurchaseHeader."Total VAT", 'The total VAT should be 0 for zero-rated goods.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + Assert.AreEqual(1, EDocumentPurchaseLine.Count(), 'Expected 1 invoice line.'); + + EDocumentPurchaseLine.FindSet(); + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line quantity does not match.'); + Assert.AreEqual('EA', EDocumentPurchaseLine."Unit of Measure", 'Line unit of measure does not match.'); + Assert.AreEqual(1200, EDocumentPurchaseLine."Sub Total", 'Line sub total does not match.'); + Assert.AreEqual('Test item, category Z', EDocumentPurchaseLine.Description, 'Line description does not match.'); + Assert.AreEqual('192387129837129873', EDocumentPurchaseLine."Product Code", 'Line product code does not match.'); + Assert.AreEqual(0, EDocumentPurchaseLine."VAT Rate", 'Line VAT rate should be 0 for category Z.'); + Assert.AreEqual(120, EDocumentPurchaseLine."Unit Price", 'Line unit price does not match.'); + end; + + internal procedure AssertPEPPOLAllowanceExampleExtracted(EDocumentEntryNo: Integer) + var + GLSetup: Record "General Ledger Setup"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + GLSetup.Get(); + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + Assert.AreEqual(DMY2Date(13, 11, 2017), EDocumentPurchaseHeader."Document Date", 'The document date does not match.'); + Assert.AreEqual(DMY2Date(01, 12, 2017), EDocumentPurchaseHeader."Due Date", 'The due date does not match.'); + Assert.AreEqual(ExpectedCurrencyCode('EUR', GLSetup."LCY Code"), EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('SupplierTradingName Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual('7300010000001', EDocumentPurchaseHeader."Vendor GLN", 'The vendor GLN does not match for schemeID=0088.'); + Assert.AreEqual(6125, EDocumentPurchaseHeader.Total, 'The total (PayableAmount) does not match.'); + Assert.AreEqual(5900, EDocumentPurchaseHeader."Sub Total", 'The sub total (TaxExclusiveAmount) does not match.'); + Assert.AreEqual(200, EDocumentPurchaseHeader."Total Discount", 'The total discount (AllowanceTotalAmount) does not match.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + // 3 invoice lines + 1 charge line (Cleaning 200 EUR); the allowance (Discount 200 EUR) should NOT create a line + Assert.AreEqual(4, EDocumentPurchaseLine.Count(), 'Expected 3 invoice lines + 1 charge line (allowance should NOT create a line).'); + + EDocumentPurchaseLine.FindSet(); + // Line 1: 10 x 410, only SellersItemIdentification (no StandardItemIdentification) + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line 1 quantity does not match.'); + Assert.AreEqual(4000, EDocumentPurchaseLine."Sub Total", 'Line 1 sub total does not match.'); + Assert.AreEqual('item name', EDocumentPurchaseLine.Description, 'Line 1 description does not match (Name takes priority).'); + Assert.AreEqual('97iugug876', EDocumentPurchaseLine."Product Code", 'Line 1 product code should be SellersItemIdentification when no StandardItemIdentification exists.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Line 1 VAT rate does not match.'); + Assert.AreEqual(410, EDocumentPurchaseLine."Unit Price", 'Line 1 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Line 2: 10 x 200, VAT E (0%), SellersItemIdentification only + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line 2 quantity does not match.'); + Assert.AreEqual(1000, EDocumentPurchaseLine."Sub Total", 'Line 2 sub total does not match.'); + Assert.AreEqual(0, EDocumentPurchaseLine."VAT Rate", 'Line 2 VAT rate should be 0% for category E.'); + Assert.AreEqual(200, EDocumentPurchaseLine."Unit Price", 'Line 2 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Line 3: 10 x 100, VAT 25%, SellersItemIdentification only + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line 3 quantity does not match.'); + Assert.AreEqual(900, EDocumentPurchaseLine."Sub Total", 'Line 3 sub total does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Line 3 VAT rate does not match.'); + Assert.AreEqual(100, EDocumentPurchaseLine."Unit Price", 'Line 3 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Charge line: Cleaning, 200 EUR, VAT 25% + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'Charge line quantity should be 1.'); + Assert.AreEqual(200, EDocumentPurchaseLine."Unit Price", 'Charge line unit price does not match.'); + Assert.AreEqual(200, EDocumentPurchaseLine."Sub Total", 'Charge line sub total does not match.'); + Assert.AreEqual('Cleaning', EDocumentPurchaseLine.Description, 'Charge line description does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Charge line VAT rate does not match.'); + end; + + internal procedure AssertPEPPOLCreditNoteCorrectionExtracted(EDocumentEntryNo: Integer) + var + GLSetup: Record "General Ledger Setup"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + GLSetup.Get(); + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Sales Invoice No.", 'The credit note ID does not match.'); + Assert.AreEqual(DMY2Date(13, 11, 2017), EDocumentPurchaseHeader."Document Date", 'The document date does not match.'); + // CreditNote without PaymentMeans/PaymentDueDate: DueDate should be blank + Assert.AreEqual(0D, EDocumentPurchaseHeader."Due Date", 'Due Date should be blank when CreditNote has no PaymentMeans/PaymentDueDate.'); + Assert.AreEqual(ExpectedCurrencyCode('EUR', GLSetup."LCY Code"), EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Applies-to Doc. No.", 'The BillingReference (Applies-to Doc. No.) does not match.'); + Assert.AreEqual('SupplierTradingName Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual('GB1232434', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match.'); + Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'The total does not match.'); + Assert.AreEqual(1325, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + Assert.AreEqual(3, EDocumentPurchaseLine.Count(), 'Expected 2 credit note lines + 1 charge line.'); + + EDocumentPurchaseLine.FindSet(); + // CreditNoteLine 1: 7 x 400 + Assert.AreEqual(7, EDocumentPurchaseLine.Quantity, 'Line 1 quantity does not match.'); + Assert.AreEqual('DAY', EDocumentPurchaseLine."Unit of Measure", 'Line 1 unit of measure does not match.'); + Assert.AreEqual(2800, EDocumentPurchaseLine."Sub Total", 'Line 1 sub total does not match.'); + Assert.AreEqual('item name', EDocumentPurchaseLine.Description, 'Line 1 description does not match.'); + Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'Line 1 unit price does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Line 1 VAT rate does not match.'); + + EDocumentPurchaseLine.Next(); + // CreditNoteLine 2: -3 x 500 (negative quantity) + Assert.AreEqual(-3, EDocumentPurchaseLine.Quantity, 'Line 2 quantity does not match (should be negative).'); + Assert.AreEqual(-1500, EDocumentPurchaseLine."Sub Total", 'Line 2 sub total does not match.'); + Assert.AreEqual('item name 2', EDocumentPurchaseLine.Description, 'Line 2 description does not match.'); + Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Line 2 unit price does not match.'); + + EDocumentPurchaseLine.Next(); + // Charge line: Insurance, 25 EUR, VAT 25% + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'Charge line quantity should be 1.'); + Assert.AreEqual(25, EDocumentPurchaseLine."Unit Price", 'Charge line unit price does not match.'); + Assert.AreEqual('Insurance', EDocumentPurchaseLine.Description, 'Charge line description does not match.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'Charge line VAT rate does not match.'); + end; + + internal procedure AssertPEPPOLAttachmentHeaderExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('INV-ATT-001', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); + Assert.AreEqual('Attachment Supplier Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); + Assert.AreEqual(625, EDocumentPurchaseHeader.Total, 'The total does not match.'); + end; + + internal procedure AssertPEPPOLDescriptionFallbackExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('INV-DESC-001', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + Assert.AreEqual(3, EDocumentPurchaseLine.Count(), 'Expected 3 invoice lines.'); + + EDocumentPurchaseLine.FindSet(); + // Line 1: Name only - should use Name + Assert.AreEqual('Widget Alpha', EDocumentPurchaseLine.Description, 'Line 1: Name should be used as description.'); + + EDocumentPurchaseLine.Next(); + // Line 2: Description only, no Name - should fall back to Description + Assert.AreEqual('Detailed description of Widget Beta for testing fallback', EDocumentPurchaseLine.Description, 'Line 2: Description should be used as fallback when Name is absent.'); + + EDocumentPurchaseLine.Next(); + // Line 3: Both Name and Description - Name takes priority + Assert.AreEqual('Widget Gamma', EDocumentPurchaseLine.Description, 'Line 3: Name should take priority over Description.'); + end; + + internal procedure AssertPEPPOLPayeePartyOverrideExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + Assert.AreEqual('INV-PAYEE-001', EDocumentPurchaseHeader."Sales Invoice No.", 'The invoice ID does not match.'); + // PayeeParty overrides AccountingSupplierParty + Assert.AreEqual('Factoring Company GmbH', EDocumentPurchaseHeader."Vendor Company Name", 'Vendor name should be overridden by PayeeParty/PartyName.'); + Assert.AreEqual('DE999888777', EDocumentPurchaseHeader."Vendor VAT Id", 'Vendor VAT Id should be overridden by PayeeParty/PartyLegalEntity/CompanyID.'); + // Address comes from AccountingSupplierParty (PayeeParty has no address) + Assert.AreEqual('Supplier Street 1', EDocumentPurchaseHeader."Vendor Address", 'Vendor address should still come from AccountingSupplierParty.'); + // GLN comes from AccountingSupplierParty endpoint + Assert.AreEqual('1234567890128', EDocumentPurchaseHeader."Vendor GLN", 'Vendor GLN should still come from AccountingSupplierParty endpoint.'); + Assert.AreEqual(250, EDocumentPurchaseHeader.Total, 'The total does not match.'); + end; + #endregion + #region MLLM internal procedure AssertFullMLLMDocumentExtracted(EDocumentEntryNo: Integer) var @@ -254,4 +588,11 @@ codeunit 139894 "EDoc Structured Validations" end; #endregion + local procedure ExpectedCurrencyCode(DocumentCurrency: Code[10]; LCYCode: Code[10]): Code[10] + begin + if DocumentCurrency = LCYCode then + exit(''); + exit(DocumentCurrency); + end; + } diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al index f7d9f97e85..66d047af7d 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al @@ -132,6 +132,158 @@ codeunit 139891 "E-Document Structured Tests" else Assert.Fail(EDocumentStatusNotUpdatedErr); end; + + [Test] + procedure TestPEPPOLInvoice_BaseExample() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] A basic PEPPOL invoice with 2 lines and a document-level charge is parsed correctly. + // Covers: vendor GLN (schemeID=0088), customer non-0088 endpoint (no GLN), charge line creation. + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-basic.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLBaseExampleExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_WithCharges() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Document-level charges (ChargeIndicator=true) create purchase lines; allowances (ChargeIndicator=false) do not. + // Covers: completeness item "Document-level AllowanceCharge lines not created" + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-charges.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLInvoiceWithChargesExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_VatCategoryS() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Invoice with multiple VAT rates (25% and 15%), StandardItemIdentification priority over SellersItemIdentification. + // Covers: completeness item "SellersItemIdentification vs StandardItemIdentification merged" + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-vat-category-s.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLVatCategorySExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_VatCategoryZ() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Invoice with zero-rated VAT (category Z), no DueDate, GBP currency. + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-vat-category-z.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLVatCategoryZExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_AllowanceExample() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Full PEPPOL example with both charge and allowance at document level, SellersItemIdentification only, 3 invoice lines. + // Covers: allowance does NOT create line, charge DOES, SellersItemIdentification as product code fallback. + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-allowance.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLAllowanceExampleExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLCreditNote_CorrectionNoDueDate() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + // [SCENARIO] CreditNote without PaymentMeans/PaymentDueDate results in blank Due Date. + // Covers: completeness item "CreditNote DueDate uses wrong XPath" (negative case - no DueDate at all) + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-creditnote-no-duedate.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + StructuredValidations.AssertPEPPOLCreditNoteCorrectionExtracted(EDocument."Entry No"); + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual(Enum::"E-Doc. Process Draft"::"Purchase Credit Memo", EDocument."Process Draft Impl.", 'The process draft implementation should be set to Purchase Credit Memo for credit notes.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_EmbeddedAttachments() + var + EDocument: Record "E-Document"; + DocumentAttachment: Record "Document Attachment"; + begin + // [SCENARIO] Embedded base64 attachments are extracted; external URI and bare references are skipped. + // Covers: completeness item "Document attachments not extracted" + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-attachment.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + StructuredValidations.AssertPEPPOLAttachmentHeaderExtracted(EDocument."Entry No"); + // Verify exactly 2 attachments were created (embedded PDF + embedded PNG); external URI and bare ref skipped + DocumentAttachment.SetRange("E-Document Entry No.", EDocument."Entry No"); + Assert.AreEqual(2, DocumentAttachment.Count(), 'Expected 2 embedded attachments (PDF + PNG). External URI and bare references should be skipped.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_DescriptionFallback() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] When Item/Name is absent, Description is used as fallback. When both exist, Name takes priority. + // Covers: completeness item "Description cascade vs separate fields" + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-description-fallback.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLDescriptionFallbackExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure TestPEPPOLInvoice_PayeePartyOverride() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] When PayeeParty is present, it overrides vendor company name and VAT ID from AccountingSupplierParty. + // Covers: completeness item "PayeeParty/PartyIdentification fallback missing" + Initialize(Enum::"Service Integration"::"Mock"); + SetupPEPPOLEDocumentService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-payee-party.xml'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLPayeePartyOverrideExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; #endregion #region MLLM JSON From 0b7c951543df19fc212554fd82a9f4d4ed671564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 17:53:49 +0200 Subject: [PATCH 05/85] [E-Document] Fix PEPPOL handler: RegistrationName fallback, GLN test data, valid attachment content - Add RegistrationName fallback for vendor and customer name when PartyName/Name is absent (optional in PEPPOL BIS 3.0) - Fix test GLN to valid 13-digit value matching Text[13] field - Use valid PDF and PNG content in attachment test XML Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocumentPEPPOLHandler.Codeunit.al | 10 ++++++++++ .../.resources/peppol/peppol-invoice-attachment.xml | 4 ++-- .../Test/.resources/peppol/peppol-invoice-basic.xml | 2 +- .../Processing/EDocStructuredValidations.Codeunit.al | 7 +++---- .../Processing/EDocumentStructuredTests.Codeunit.al | 3 +-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index b56baeaf84..d5b2c0b962 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -122,6 +122,11 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader begin if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); + // Per PEPPOL BIS 3.0: PartyLegalEntity/RegistrationName is the seller's legal name (mandatory). + // Use it as fallback when PartyName/Name is absent (PartyName is optional in PEPPOL BIS 3.0). + if Header."Vendor Company Name" = '' then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName', Value) then + Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); // Per PEPPOL BIS 3.0: PayeeParty is used when the Payee is different from the Seller. if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); @@ -152,6 +157,11 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader begin if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); + // Per PEPPOL BIS 3.0: PartyLegalEntity/RegistrationName is the buyer's legal name (mandatory). + // Use it as fallback when PartyName/Name is absent (PartyName is optional in PEPPOL BIS 3.0). + if Header."Customer Company Name" = '' then + if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName', Value) then + Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml index c4a620a51b..2932a22371 100644 --- a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-attachment.xml @@ -15,7 +15,7 @@ att-001 Invoice PDF copy - SGVsbG8gV29ybGQ= + JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2Jq @@ -23,7 +23,7 @@ att-002 Photo evidence - AQID + iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml index bc5930214d..db325c80ca 100644 --- a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-basic.xml @@ -13,7 +13,7 @@ 0150abc - 9482348239847239874 + 9482348239847 99887766 diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al index 90d38e3f06..c667b2d6d0 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al @@ -179,7 +179,7 @@ codeunit 139894 "EDoc Structured Validations" Assert.AreEqual(500, EDocumentPurchaseHeader."Total VAT", 'The total VAT does not match the mock data.'); EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.FindSet(); + EDocumentPurchaseLine.FindFirst(); Assert.AreEqual(1, EDocumentPurchaseLine."Quantity", 'The quantity in the credit note line does not match the mock data.'); Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the credit note line does not match the mock data.'); Assert.AreEqual(2000, EDocumentPurchaseLine."Sub Total", 'The line extension amount does not match the mock data.'); @@ -189,7 +189,6 @@ codeunit 139894 "EDoc Structured Validations" Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in the credit note line does not match the mock data.'); Assert.AreEqual(2000, EDocumentPurchaseLine."Unit Price", 'The unit price in the credit note line does not match the mock data.'); end; - #endregion internal procedure AssertPEPPOLBaseExampleExtracted(EDocumentEntryNo: Integer) var @@ -206,7 +205,7 @@ codeunit 139894 "EDoc Structured Validations" Assert.AreEqual('SupplierTradingName Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); Assert.AreEqual('Main street 1', EDocumentPurchaseHeader."Vendor Address", 'The vendor address does not match.'); Assert.AreEqual('GB1232434', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match.'); - Assert.AreEqual('9482348239847239874', EDocumentPurchaseHeader."Vendor GLN", 'The vendor GLN should be populated for schemeID=0088.'); + Assert.AreEqual('9482348239847', EDocumentPurchaseHeader."Vendor GLN", 'The vendor GLN should be populated for schemeID=0088.'); Assert.AreEqual('BuyerTradingName AS', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not match.'); Assert.AreEqual('SE4598375937', EDocumentPurchaseHeader."Customer VAT Id", 'The customer VAT id does not match.'); Assert.AreEqual('Hovedgatan 32', EDocumentPurchaseHeader."Customer Address", 'The customer address does not match.'); @@ -363,7 +362,7 @@ codeunit 139894 "EDoc Structured Validations" EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); Assert.AreEqual(1, EDocumentPurchaseLine.Count(), 'Expected 1 invoice line.'); - EDocumentPurchaseLine.FindSet(); + EDocumentPurchaseLine.FindFirst(); Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'Line quantity does not match.'); Assert.AreEqual('EA', EDocumentPurchaseLine."Unit of Measure", 'Line unit of measure does not match.'); Assert.AreEqual(1200, EDocumentPurchaseLine."Sub Total", 'Line sub total does not match.'); diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al index 66d047af7d..a7e33f41c3 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al @@ -216,7 +216,6 @@ codeunit 139891 "E-Document Structured Tests" procedure TestPEPPOLCreditNote_CorrectionNoDueDate() var EDocument: Record "E-Document"; - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; begin // [SCENARIO] CreditNote without PaymentMeans/PaymentDueDate results in blank Due Date. // Covers: completeness item "CreditNote DueDate uses wrong XPath" (negative case - no DueDate at all) @@ -239,7 +238,7 @@ codeunit 139891 "E-Document Structured Tests" DocumentAttachment: Record "Document Attachment"; begin // [SCENARIO] Embedded base64 attachments are extracted; external URI and bare references are skipped. - // Covers: completeness item "Document attachments not extracted" + // Test XML: 1 valid PDF, 1 valid PNG, 1 external URI (no embedded content), 1 bare reference (no attachment node). Initialize(Enum::"Service Integration"::"Mock"); SetupPEPPOLEDocumentService(); CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-attachment.xml'); From a0793e72bb7c366b7a27181b67f3b1829ed868e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 17:56:03 +0200 Subject: [PATCH 06/85] [E-Document] Fix CreditNote test XML: move DueDate to PaymentMeans per PEPPOL BIS 3.0 CreditNote has no top-level cbc:DueDate. Per spec, the due date is at cac:PaymentMeans/cbc:PaymentDueDate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocument/Test/.resources/peppol/peppol-creditnote-0.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml index 82e782fd4f..357def3f92 100644 --- a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-creditnote-0.xml @@ -9,7 +9,6 @@ urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 CN-5001 2026-02-15 - 2026-03-15 381 XYZ 1 @@ -84,6 +83,10 @@ + + 30 + 2026-03-15 + 500 From c85f246e8179370dc41cb5fed63a997b85153134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 18:14:38 +0200 Subject: [PATCH 07/85] [E-Document] Set Applies-to Doc. No. on Purchase Credit Memo from BillingReference Populate the Applies-to Doc. No. field on the BC Purchase Credit Memo from the PEPPOL CreditNote BillingReference. Uses direct assignment instead of Validate to avoid triggering Vendor Ledger Entry lookups, since the BillingReference is the vendor's invoice ID, not a BC document number. Consolidates Modify calls in CreatePurchaseCreditMemo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al | 9 ++++++--- .../Test/src/Processing/EDocProcessTest.Codeunit.al | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al index ea662ad5a7..78a855b6a6 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al @@ -101,10 +101,13 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, PurchaseHeader.Modify(); GLSetup.GetRecordOnce(); - if EDocumentPurchaseHeader."Currency Code" <> GLSetup.GetCurrencyCode('') then begin + if EDocumentPurchaseHeader."Currency Code" <> GLSetup.GetCurrencyCode('') then PurchaseHeader.Validate("Currency Code", EDocumentPurchaseHeader."Currency Code"); - PurchaseHeader.Modify(); - end; + + if EDocumentPurchaseHeader."Applies-to Doc. No." <> '' then + PurchaseHeader."Applies-to Doc. No." := CopyStr(EDocumentPurchaseHeader."Applies-to Doc. No.", 1, MaxStrLen(PurchaseHeader."Applies-to Doc. No.")); + + PurchaseHeader.Modify(); EDocRecordLink.InsertEDocumentHeaderLink(EDocumentPurchaseHeader, PurchaseHeader); diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al index 03d6398620..e6444d3c95 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocProcessTest.Codeunit.al @@ -779,6 +779,7 @@ codeunit 139883 "E-Doc Process Test" Assert.AreEqual('CN-5001', PurchaseHeader."Vendor Cr. Memo No.", 'The vendor credit memo number should match the CreditNote ID.'); Assert.AreEqual(Vendor."No.", PurchaseHeader."Buy-from Vendor No.", 'The vendor should be resolved from the CreditNote.'); Assert.AreEqual(2500, PurchaseHeader."Doc. Amount Incl. VAT", 'The document amount incl. VAT should match the CreditNote total.'); + Assert.AreEqual('103033', PurchaseHeader."Applies-to Doc. No.", 'The Applies-to Doc. No. should match the BillingReference from the CreditNote.'); // [THEN] The purchase credit memo has the correct number of lines PurchaseLine.SetRange("Document Type", PurchaseHeader."Document Type"); From 71cdf00ba814d1901f97c577bf05c7f5577ba7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 31 Mar 2026 19:37:24 +0200 Subject: [PATCH 08/85] [E-Document] Refactor PEPPOL handler: move extraction logic to utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split responsibilities between handler and utility: - Handler (224 lines): orchestration only — what to parse, in what order, document-type-specific dispatch (Invoice vs CreditNote doc info) - Utility (339 lines): reusable PEPPOL extraction — party info, amounts, dates, currency, line fields, attachment decoding, MIME mapping Moved to utility: PopulateSupplierInfo, PopulateCustomerInfo, PopulateAmountsAndDates, PopulateCurrency, SetCurrencyIfForeign, PopulatePurchaseLine, ExtractAttachment, MimeToFileExtension. Methods taking internal table types use 'internal' access to satisfy AL0749 (public codeunit exposing internal parameter types). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocumentPEPPOLHandler.Codeunit.al | 274 ++---------------- .../EDocumentPEPPOLUtility.Codeunit.al | 259 +++++++++++++++++ 2 files changed, 283 insertions(+), 250 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index d5b2c0b962..31da68379c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -8,12 +8,12 @@ using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; -using Microsoft.Finance.GeneralLedger.Setup; -using System.Text; using System.Utilities; /// /// Reads PEPPOL BIS 3.0 Invoice and CreditNote XML into v2 import draft staging tables. +/// This codeunit orchestrates *what* to parse and in what order. +/// Reusable extraction logic lives in "E-Document PEPPOL Utility". /// Spec reference: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/tree/ /// https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-creditnote/tree/ /// @@ -44,14 +44,14 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader case UpperCase(RootElement.LocalName()) of 'INVOICE': begin - PopulatePurchaseInvoiceHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); + PopulateInvoiceHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); InsertPurchaseLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/inv:Invoice/cac:InvoiceLine', 'cac:InvoiceLine', 'cbc:InvoicedQuantity'); InsertAllowanceChargeLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/inv:Invoice'); InsertDocumentAttachments(EDocument, PeppolXML, XmlNamespaces, '/inv:Invoice'); end; 'CREDITNOTE': begin - PopulatePurchaseCreditMemoHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); + PopulateCreditMemoHeader(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader); InsertPurchaseLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/cre:CreditNote/cac:CreditNoteLine', 'cac:CreditNoteLine', 'cbc:CreditedQuantity'); InsertAllowanceChargeLines(PeppolXML, XmlNamespaces, EDocumentPurchaseHeader."E-Document Entry No.", '/cre:CreditNote'); InsertDocumentAttachments(EDocument, PeppolXML, XmlNamespaces, '/cre:CreditNote'); @@ -70,25 +70,27 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader end; end; - local procedure PopulatePurchaseInvoiceHeader(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + #region Header Orchestration + + local procedure PopulateInvoiceHeader(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") begin PopulateInvoiceDocumentInfo(PeppolXML, XmlNamespaces, Header); - PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); - PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + PeppolUtility.PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + PeppolUtility.PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); // Per PEPPOL BIS 3.0: Invoice has DueDate as a direct child element - PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/inv:Invoice', '/inv:Invoice/cbc:DueDate', Header); - PopulateCurrency(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); + PeppolUtility.PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/inv:Invoice', '/inv:Invoice/cbc:DueDate', Header); + PeppolUtility.PopulateCurrency(PeppolXML, XmlNamespaces, '/inv:Invoice', Header); end; - local procedure PopulatePurchaseCreditMemoHeader(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") + local procedure PopulateCreditMemoHeader(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") begin PopulateCreditNoteDocumentInfo(PeppolXML, XmlNamespaces, Header); - PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); - PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + PeppolUtility.PopulateSupplierInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + PeppolUtility.PopulateCustomerInfo(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); // Per PEPPOL BIS 3.0: CreditNote has no top-level DueDate; it is under PaymentMeans. // Spec ref: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-creditnote/cac-PaymentMeans/cbc-PaymentDueDate/ - PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/cre:CreditNote', '/cre:CreditNote/cac:PaymentMeans/cbc:PaymentDueDate', Header); - PopulateCurrency(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); + PeppolUtility.PopulateAmountsAndDates(PeppolXML, XmlNamespaces, '/cre:CreditNote', '/cre:CreditNote/cac:PaymentMeans/cbc:PaymentDueDate', Header); + PeppolUtility.PopulateCurrency(PeppolXML, XmlNamespaces, '/cre:CreditNote', Header); end; local procedure PopulateInvoiceDocumentInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Header: Record "E-Document Purchase Header") @@ -115,94 +117,9 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader Session.LogMessage('', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); end; - local procedure PopulateSupplierInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") - var - XmlNode: XmlNode; - Value: Text; - begin - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then - Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); - // Per PEPPOL BIS 3.0: PartyLegalEntity/RegistrationName is the seller's legal name (mandatory). - // Use it as fallback when PartyName/Name is absent (PartyName is optional in PEPPOL BIS 3.0). - if Header."Vendor Company Name" = '' then - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName', Value) then - Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); - // Per PEPPOL BIS 3.0: PayeeParty is used when the Payee is different from the Seller. - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then - Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name', Value) then - Header."Vendor Contact Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Contact Name")); - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then - Header."Vendor Address" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Address")); - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then - Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); - // Per PEPPOL BIS 3.0: PayeeParty/PartyLegalEntity/CompanyID is the Payee legal registration identifier. - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID', Value) then - Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); - - // Per PEPPOL BIS 3.0: EndpointID/@schemeID uses the EAS code list. - // SchemeID 0088 = EAN Location Code (GLN). Only populate GLN for this scheme. - if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then - if XmlNode.AsXmlAttribute().Value() = '0088' then - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID', Value) then - Header."Vendor GLN" := CopyStr(Value, 1, MaxStrLen(Header."Vendor GLN")); - end; - - local procedure PopulateCustomerInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") - var - XmlNode: XmlNode; - SchemeID: Text; - EndpointValue: Text; - Value: Text; - begin - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then - Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); - // Per PEPPOL BIS 3.0: PartyLegalEntity/RegistrationName is the buyer's legal name (mandatory). - // Use it as fallback when PartyName/Name is absent (PartyName is optional in PEPPOL BIS 3.0). - if Header."Customer Company Name" = '' then - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName', Value) then - Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then - Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then - Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then - Header."Customer Address" := CopyStr(Value, 1, MaxStrLen(Header."Customer Address")); - - // Per PEPPOL BIS 3.0: EndpointID/@schemeID uses the EAS code list. - // SchemeID 0088 = EAN Location Code (GLN). Only populate GLN for this scheme. - // Customer Company Id stores the full electronic address identifier (schemeID:value). - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID', EndpointValue) then begin - if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then - SchemeID := XmlNode.AsXmlAttribute().Value(); - - if SchemeID = '0088' then - Header."Customer GLN" := CopyStr(EndpointValue, 1, MaxStrLen(Header."Customer GLN")); - - Header."Customer Company Id" := CopyStr(SchemeID + ':' + EndpointValue, 1, MaxStrLen(Header."Customer Company Id")); - end; - end; - - local procedure PopulateAmountsAndDates(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; DueDatePath: Text; var Header: Record "E-Document Purchase Header") - begin - PeppolUtility.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:PayableAmount', Header.Total); - PeppolUtility.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', Header."Sub Total"); - PeppolUtility.SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', Header."Total Discount"); - Header."Total VAT" := Header."Total" - Header."Sub Total" - Header."Total Discount"; + #endregion Header Orchestration - PeppolUtility.SetDateValueInField(PeppolXML, XmlNamespaces, DueDatePath, Header."Due Date"); - PeppolUtility.SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:IssueDate', Header."Document Date"); - end; - - local procedure PopulateCurrency(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") - var - DocumentCurrencyCode: Text; - begin - if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cbc:DocumentCurrencyCode', DocumentCurrencyCode) then - SetCurrencyIfForeign(DocumentCurrencyCode, Header."Currency Code"); - end; - - #region Purchase Lines + #region Line Orchestration local procedure InsertPurchaseLines(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer; LineXPath: Text; LineElementName: Text; QuantityElementName: Text) var @@ -221,66 +138,11 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocumentEntryNo); LineXMLList.Get(i, LineXMLNode); NewLineXML.ReplaceNodes(LineXMLNode); - PopulateEDocumentPurchaseLine(NewLineXML, XmlNamespaces, EDocumentPurchaseLine, LineElementName, QuantityElementName); + PeppolUtility.PopulatePurchaseLine(NewLineXML, XmlNamespaces, EDocumentPurchaseLine, LineElementName, QuantityElementName); EDocumentPurchaseLine.Insert(); end; end; - local procedure PopulateEDocumentPurchaseLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line"; LineElementName: Text; QuantityElementName: Text) - var - Value: Text; - begin - PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName, Line.Quantity); - if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName + '/@unitCode', Value) then - Line."Unit of Measure" := CopyStr(Value, 1, MaxStrLen(Line."Unit of Measure")); - PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount', Line."Sub Total"); - PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:AllowanceCharge/cbc:Amount', Line."Total Discount"); - - // Per PEPPOL BIS 3.0: Item Name (1..1, mandatory) is the primary short product description. - // Item Description (0..1) is an optional longer description that may exceed field capacity. - // Line Note (0..1) is operational info, not a product description. - // Priority: Name (always present per spec), fallback to Description if Name is absent. - if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Name', Value) then - Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - if Line.Description = '' then - if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Description', Value) then - Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); - - // Per PEPPOL BIS 3.0: SellersItemIdentification is the seller's internal product code. - // StandardItemIdentification is a registered standard (e.g., GTIN via schemeID 0160). - // StandardItemIdentification takes priority as the more universally recognized identifier. - if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:SellersItemIdentification/cbc:ID', Value) then - if Value <> '' then - Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); - if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:StandardItemIdentification/cbc:ID', Value) then - if Value <> '' then - Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); - - PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', Line."VAT Rate"); - PeppolUtility.SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Price/cbc:PriceAmount', Line."Unit Price"); - PopulateCurrencyForLine(LineXML, XmlNamespaces, Line, LineElementName); - end; - - local procedure PopulateCurrencyForLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line"; LineElementName: Text) - var - LineCurrencyCode: Text; - begin - if PeppolUtility.TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount/@currencyID', LineCurrencyCode) then - SetCurrencyIfForeign(LineCurrencyCode, Line."Currency Code"); - end; - - #endregion Purchase Lines - - #region Document-Level Allowance/Charge Lines - - /// - /// Per PEPPOL BIS 3.0: Document-level AllowanceCharge (0..n) represents surcharges and allowances - /// that apply to the entire document (e.g., shipping fees, early payment discounts). - /// ChargeIndicator = true means a charge (surcharge/fee); false means an allowance (discount). - /// Allowances are already captured in the header-level AllowanceTotalAmount. - /// This method creates separate purchase lines for charges only. - /// Spec ref: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-AllowanceCharge/ - /// local procedure InsertAllowanceChargeLines(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; EDocumentEntryNo: Integer; RootPath: Text) var ChargeXML: XmlDocument; @@ -324,20 +186,15 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader PeppolUtility.SetNumberValueInField(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cac:TaxCategory/cbc:Percent', EDocumentPurchaseLine."VAT Rate"); if PeppolUtility.TryGetStringValue(ChargeXML, XmlNamespaces, 'cac:AllowanceCharge/cbc:Amount/@currencyID', CurrencyCode) then - SetCurrencyIfForeign(CurrencyCode, EDocumentPurchaseLine."Currency Code"); + PeppolUtility.SetCurrencyIfForeign(CurrencyCode, EDocumentPurchaseLine."Currency Code"); EDocumentPurchaseLine.Insert(); end; - #endregion Document-Level Allowance/Charge Lines + #endregion Line Orchestration - #region Document Attachments + #region Attachment Orchestration - /// - /// Per PEPPOL BIS 3.0: AdditionalDocumentReference (0..n) can contain embedded binary attachments - /// (e.g., PDF copies, timesheets, delivery notes) encoded as base64 in EmbeddedDocumentBinaryObject. - /// Spec ref: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-AdditionalDocumentReference/ - /// local procedure InsertDocumentAttachments(EDocument: Record "E-Document"; PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text) var AttachmentNodes: XmlNodeList; @@ -351,94 +208,11 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader for i := 1 to AttachmentNodes.Count do begin AttachmentNodes.Get(i, AttachmentNode); AttachmentXML.ReplaceNodes(AttachmentNode); - InsertSingleAttachment(EDocument, AttachmentXML, XmlNamespaces); - end; - end; - - local procedure InsertSingleAttachment(EDocument: Record "E-Document"; AttachmentXML: XmlDocument; XmlNamespaces: XmlNamespaceManager) - var - EDocAttachmentProcessor: Codeunit "E-Doc. Attachment Processor"; - Base64Convert: Codeunit "Base64 Convert"; - AttachmentBlob: Codeunit "Temp Blob"; - InStream: InStream; - OutStream: OutStream; - Base64Content: Text; - FileName: Text; - MimeCode: Text; - FileExtension: Text; - ElementName: Text; - begin - ElementName := 'cac:AdditionalDocumentReference'; - - // Only process references with embedded binary content; skip external URI references - if not PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject', Base64Content) then - exit; - - if Base64Content = '' then - exit; - - // Per PEPPOL BIS 3.0: @filename is mandatory on EmbeddedDocumentBinaryObject - if not PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject/@filename', FileName) then - // Fallback to document reference ID if filename attribute is missing - PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cbc:ID', FileName); - - if FileName = '' then - exit; - - // If filename has no extension, derive one from the mandatory @mimeCode attribute - if not FileName.Contains('.') then - if PeppolUtility.TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject/@mimeCode', MimeCode) then begin - FileExtension := DetermineFileExtension(MimeCode); - if FileExtension <> '' then - FileName := FileName + '.' + FileExtension; - end; - - // Decode base64 content and save as attachment on the E-Document - AttachmentBlob.CreateOutStream(OutStream); - Base64Convert.FromBase64(Base64Content, OutStream); - AttachmentBlob.CreateInStream(InStream); - EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); - end; - - local procedure DetermineFileExtension(MimeCode: Text): Text - begin - case MimeCode of - 'image/jpeg': - exit('jpeg'); - 'image/png': - exit('png'); - 'application/pdf': - exit('pdf'); - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': - exit('xlsx'); - 'application/vnd.oasis.opendocument.spreadsheet': - exit('ods'); - 'text/csv': - exit('csv'); - else - exit(''); + PeppolUtility.ExtractAttachment(EDocument, AttachmentXML, XmlNamespaces); end; end; - #endregion Document Attachments - - /// - /// BC convention: blank Currency Code means LCY. Sets the field to the currency code - /// only if it differs from LCY. Explicitly blanks the field when it matches LCY. - /// - local procedure SetCurrencyIfForeign(CurrencyFromXml: Text; var CurrencyCode: Code[10]) - var - GLSetup: Record "General Ledger Setup"; - begin - if CurrencyFromXml = '' then - exit; - - GLSetup.GetRecordOnce(); - if GLSetup."LCY Code" = CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)) then - CurrencyCode := '' - else - CurrencyCode := CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)); - end; + #endregion Attachment Orchestration procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al index 769b5f5685..68edb0fbee 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLUtility.Codeunit.al @@ -4,12 +4,25 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.Finance.GeneralLedger.Setup; +using System.Text; +using System.Utilities; + +/// +/// Reusable PEPPOL BIS 3.0 extraction helpers for reading UBL XML into staging tables. +/// Contains generic UBL party, amounts, line, attachment, and currency logic +/// shared across Invoice and CreditNote document types. +/// codeunit 6401 "E-Document PEPPOL Utility" { Access = Public; InherentEntitlements = X; InherentPermissions = X; + #region Namespace Initialization + procedure InitializePEPPOL3Namespaces(var XmlNamespaces: XmlNamespaceManager) var CommonAggregateComponentsLbl: Label 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'; @@ -29,6 +42,10 @@ codeunit 6401 "E-Document PEPPOL Utility" XmlNamespaces.AddNamespace('cre', DefaultCreditNoteLbl); end; + #endregion Namespace Initialization + + #region XML Value Extraction + procedure TryGetStringValue(XMLDocument: XmlDocument; XMLNamespaces: XmlNamespaceManager; Path: Text; var Value: Text): Boolean var XMLNode: XmlNode; @@ -77,4 +94,246 @@ codeunit 6401 "E-Document PEPPOL Utility" Evaluate(DateValue, XMLNode.AsXmlElement().InnerText(), 9); end; + #endregion XML Value Extraction + + #region Header Field Extraction + + /// + /// Extracts AccountingSupplierParty and PayeeParty fields from a UBL document. + /// Per PEPPOL BIS 3.0: PartyName is optional; RegistrationName is mandatory fallback. + /// PayeeParty, when present, overrides vendor name and VAT ID. + /// SchemeID 0088 on EndpointID = GLN. + /// + internal procedure PopulateSupplierInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") + var + XmlNode: XmlNode; + Value: Text; + begin + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name', Value) then + Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); + if Header."Vendor Company Name" = '' then + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName', Value) then + Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyName/cbc:Name', Value) then + Header."Vendor Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Company Name")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name', Value) then + Header."Vendor Contact Name" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Contact Name")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then + Header."Vendor Address" := CopyStr(Value, 1, MaxStrLen(Header."Vendor Address")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then + Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID', Value) then + Header."Vendor VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Vendor VAT Id")); + + if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then + if XmlNode.AsXmlAttribute().Value() = '0088' then + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID', Value) then + Header."Vendor GLN" := CopyStr(Value, 1, MaxStrLen(Header."Vendor GLN")); + end; + + /// + /// Extracts AccountingCustomerParty fields from a UBL document. + /// Per PEPPOL BIS 3.0: PartyName is optional; RegistrationName is mandatory fallback. + /// SchemeID 0088 on EndpointID = GLN. Customer Company Id stores schemeID:value. + /// + internal procedure PopulateCustomerInfo(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") + var + XmlNode: XmlNode; + SchemeID: Text; + EndpointValue: Text; + Value: Text; + begin + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name', Value) then + Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); + if Header."Customer Company Name" = '' then + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName', Value) then + Header."Customer Company Name" := CopyStr(Value, 1, MaxStrLen(Header."Customer Company Name")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID', Value) then + Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID', Value) then + Header."Customer VAT Id" := CopyStr(Value, 1, MaxStrLen(Header."Customer VAT Id")); + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName', Value) then + Header."Customer Address" := CopyStr(Value, 1, MaxStrLen(Header."Customer Address")); + + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID', EndpointValue) then begin + if PeppolXML.SelectSingleNode(RootPath + '/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID/@schemeID', XmlNamespaces, XmlNode) then + SchemeID := XmlNode.AsXmlAttribute().Value(); + + if SchemeID = '0088' then + Header."Customer GLN" := CopyStr(EndpointValue, 1, MaxStrLen(Header."Customer GLN")); + + Header."Customer Company Id" := CopyStr(SchemeID + ':' + EndpointValue, 1, MaxStrLen(Header."Customer Company Id")); + end; + end; + + /// + /// Extracts LegalMonetaryTotal amounts, IssueDate, and DueDate from a UBL document. + /// DueDatePath is parameterized because Invoice uses /cbc:DueDate while + /// CreditNote uses /cac:PaymentMeans/cbc:PaymentDueDate. + /// + internal procedure PopulateAmountsAndDates(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; DueDatePath: Text; var Header: Record "E-Document Purchase Header") + begin + SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:PayableAmount', Header.Total); + SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', Header."Sub Total"); + SetNumberValueInField(PeppolXML, XmlNamespaces, RootPath + '/cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', Header."Total Discount"); + Header."Total VAT" := Header."Total" - Header."Sub Total" - Header."Total Discount"; + + SetDateValueInField(PeppolXML, XmlNamespaces, DueDatePath, Header."Due Date"); + SetDateValueInField(PeppolXML, XmlNamespaces, RootPath + '/cbc:IssueDate', Header."Document Date"); + end; + + /// + /// Extracts DocumentCurrencyCode and applies the BC LCY-blank convention. + /// + internal procedure PopulateCurrency(PeppolXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; RootPath: Text; var Header: Record "E-Document Purchase Header") + var + DocumentCurrencyCode: Text; + begin + if TryGetStringValue(PeppolXML, XmlNamespaces, RootPath + '/cbc:DocumentCurrencyCode', DocumentCurrencyCode) then + SetCurrencyIfForeign(DocumentCurrencyCode, Header."Currency Code"); + end; + + #endregion Header Field Extraction + + #region Line Field Extraction + + /// + /// Populates a staging line record from a UBL InvoiceLine or CreditNoteLine element. + /// LineElementName and QuantityElementName are parameterized to handle both document types. + /// + internal procedure PopulatePurchaseLine(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line"; LineElementName: Text; QuantityElementName: Text) + var + Value: Text; + begin + SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName, Line.Quantity); + if TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/' + QuantityElementName + '/@unitCode', Value) then + Line."Unit of Measure" := CopyStr(Value, 1, MaxStrLen(Line."Unit of Measure")); + SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount', Line."Sub Total"); + SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:AllowanceCharge/cbc:Amount', Line."Total Discount"); + + // Per PEPPOL BIS 3.0: Item Name (1..1, mandatory) is the primary short product description. + // Item Description (0..1) is an optional longer description that may exceed field capacity. + // Priority: Name (always present per spec), fallback to Description if Name is absent. + if TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Name', Value) then + Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); + if Line.Description = '' then + if TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cbc:Description', Value) then + Line.Description := CopyStr(Value, 1, MaxStrLen(Line.Description)); + + // Per PEPPOL BIS 3.0: SellersItemIdentification is the seller's internal product code. + // StandardItemIdentification is a registered standard (e.g., GTIN via schemeID 0160). + // StandardItemIdentification takes priority as the more universally recognized identifier. + if TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:SellersItemIdentification/cbc:ID', Value) then + if Value <> '' then + Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); + if TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:StandardItemIdentification/cbc:ID', Value) then + if Value <> '' then + Line."Product Code" := CopyStr(Value, 1, MaxStrLen(Line."Product Code")); + + SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent', Line."VAT Rate"); + SetNumberValueInField(LineXML, XmlNamespaces, LineElementName + '/cac:Price/cbc:PriceAmount', Line."Unit Price"); + + PopulateLineCurrency(LineXML, XmlNamespaces, Line, LineElementName); + end; + + local procedure PopulateLineCurrency(LineXML: XmlDocument; XmlNamespaces: XmlNamespaceManager; var Line: Record "E-Document Purchase Line"; LineElementName: Text) + var + LineCurrencyCode: Text; + begin + if TryGetStringValue(LineXML, XmlNamespaces, LineElementName + '/cbc:LineExtensionAmount/@currencyID', LineCurrencyCode) then + SetCurrencyIfForeign(LineCurrencyCode, Line."Currency Code"); + end; + + #endregion Line Field Extraction + + #region Attachment Extraction + + /// + /// Extracts a single embedded base64 attachment from an AdditionalDocumentReference element. + /// Skips external URI references and bare references without embedded content. + /// Per PEPPOL BIS 3.0: @filename and @mimeCode are mandatory on EmbeddedDocumentBinaryObject. + /// + internal procedure ExtractAttachment(EDocument: Record "E-Document"; AttachmentXML: XmlDocument; XmlNamespaces: XmlNamespaceManager) + var + EDocAttachmentProcessor: Codeunit "E-Doc. Attachment Processor"; + Base64Convert: Codeunit "Base64 Convert"; + AttachmentBlob: Codeunit "Temp Blob"; + InStream: InStream; + OutStream: OutStream; + Base64Content: Text; + FileName: Text; + MimeCode: Text; + FileExtension: Text; + ElementName: Text; + begin + ElementName := 'cac:AdditionalDocumentReference'; + + if not TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject', Base64Content) then + exit; + + if Base64Content = '' then + exit; + + if not TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject/@filename', FileName) then + TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cbc:ID', FileName); + + if FileName = '' then + exit; + + if not FileName.Contains('.') then + if TryGetStringValue(AttachmentXML, XmlNamespaces, ElementName + '/cac:Attachment/cbc:EmbeddedDocumentBinaryObject/@mimeCode', MimeCode) then begin + FileExtension := MimeToFileExtension(MimeCode); + if FileExtension <> '' then + FileName := FileName + '.' + FileExtension; + end; + + AttachmentBlob.CreateOutStream(OutStream); + Base64Convert.FromBase64(Base64Content, OutStream); + AttachmentBlob.CreateInStream(InStream); + EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); + end; + + local procedure MimeToFileExtension(MimeCode: Text): Text + begin + case MimeCode of + 'image/jpeg': + exit('jpeg'); + 'image/png': + exit('png'); + 'application/pdf': + exit('pdf'); + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + exit('xlsx'); + 'application/vnd.oasis.opendocument.spreadsheet': + exit('ods'); + 'text/csv': + exit('csv'); + else + exit(''); + end; + end; + + #endregion Attachment Extraction + + #region Currency + + /// + /// BC convention: blank Currency Code means LCY. Sets the field to the currency code + /// only if it differs from LCY. Explicitly blanks the field when it matches LCY. + /// + procedure SetCurrencyIfForeign(CurrencyFromXml: Text; var CurrencyCode: Code[10]) + var + GLSetup: Record "General Ledger Setup"; + begin + if CurrencyFromXml = '' then + exit; + + GLSetup.GetRecordOnce(); + if GLSetup."LCY Code" = CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)) then + CurrencyCode := '' + else + CurrencyCode := CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)); + end; + + #endregion Currency } From f32e96939a47b0128620ff4cfb6fa34e88f6b2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 08:08:42 +0200 Subject: [PATCH 09/85] telemetry tags --- .../Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al | 4 ++-- .../EDocumentPEPPOLHandler.Codeunit.al | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al index 78a855b6a6..161ddd3b5f 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al @@ -74,7 +74,7 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, begin EDocumentPurchaseHeader.GetFromEDocument(EDocument); if not EDocPurchaseDocumentHelper.AllDraftLinesHaveTypeAndNumber(EDocumentPurchaseHeader) then begin - Telemetry.LogMessage('', 'Draft line does not contain type or number', Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All); + Telemetry.LogMessage('0000SNH', 'Draft line does not contain type or number', Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::All); Error(DraftLineDoesNotContainTypeAndNumberErr); end; EDocumentPurchaseHeader.TestField("E-Document Entry No."); @@ -92,7 +92,7 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, VendorLedgerEntry.ReadIsolation := VendorLedgerEntry.ReadIsolation::ReadUncommitted; StopCreatingCreditMemo := PurchaseHeader.FindPostedDocumentWithSameExternalDocNo(VendorLedgerEntry, VendorCrMemoNo); if StopCreatingCreditMemo then begin - Telemetry.LogMessage('', CrMemoAlreadyExistsErr, Verbosity::Error, DataClassification::OrganizationIdentifiableInformation, TelemetryScope::All); + Telemetry.LogMessage('0000SNI', CrMemoAlreadyExistsErr, Verbosity::Error, DataClassification::OrganizationIdentifiableInformation, TelemetryScope::All); Error(CrMemoAlreadyExistsErr, VendorCrMemoNo, EDocumentPurchaseHeader."[BC] Vendor No."); end; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index 31da68379c..6c1ee34abe 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -114,7 +114,7 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID', Value) then Header."Applies-to Doc. No." := CopyStr(Value, 1, MaxStrLen(Header."Applies-to Doc. No.")); if Header."Applies-to Doc. No." = '' then - Session.LogMessage('', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); + Session.LogMessage('0000SNJ', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); end; #endregion Header Orchestration From 06ea09406eeb226b15480418b6e9f3170a11bc86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 13:03:22 +0200 Subject: [PATCH 10/85] [E-Document] Add Data Exchange handler skeleton and enum registration Co-Authored-By: Claude Sonnet 4.6 --- .../Import/EDocReadIntoDraft.Enum.al | 5 ++++ .../EDocumentDataExchHandler.Codeunit.al | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al index fa0fe2fb0b..1af32613ae 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al @@ -40,4 +40,9 @@ enum 6109 "E-Doc. Read into Draft" implements IStructuredFormatReader Caption = 'MLLM'; Implementation = IStructuredFormatReader = "E-Document MLLM Handler"; } + value(5; "Data Exchange") + { + Caption = 'Data Exchange'; + Implementation = IStructuredFormatReader = "E-Document Data Exch. Handler"; + } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al new file mode 100644 index 0000000000..030edd4890 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.eServices.EDocument.Processing.Interfaces; +using System.Utilities; + +codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure ReadIntoDraft(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob"): Enum "E-Doc. Process Draft" + begin + Error('Not yet implemented.'); + end; + + procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") + begin + Error('A view is not implemented for this handler.'); + end; +} From e39e06ba9b3946f219d290fd2dcd12a77d8d8815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 13:03:50 +0200 Subject: [PATCH 11/85] [E-Document] Add Data Exchange v2 handler test XML resources --- .../data-exchange-creditnote.xml | 133 +++++++++++ .../data-exchange-invoice-attachment.xml | 132 +++++++++++ .../data-exchange/data-exchange-invoice.xml | 210 ++++++++++++++++++ 3 files changed, 475 insertions(+) create mode 100644 src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-creditnote.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice-attachment.xml create mode 100644 src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice.xml diff --git a/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-creditnote.xml b/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-creditnote.xml new file mode 100644 index 0000000000..357def3f92 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-creditnote.xml @@ -0,0 +1,133 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + CN-5001 + 2026-02-15 + 381 + XYZ + 1 + + 5 + + + + 103033 + 2026-01-22 + + + + + 1234567890128 + + CRONUS International + + + Main Street, 14 + Birmingham + B27 4KT + + GB + + + + GB123456789 + + VAT + + + + CRONUS International + 123456789 + + + Jim Olive + JO@contoso.com + + + + + + 789456278 + + 8712345000004 + + + The Cannon Group PLC + + + 192 Market Square + Birmingham + B27 4KT + + GB + + + + GB789456278 + + VAT + + + + The Cannon Group PLC + 789456278 + + + Mr. Andy Teal + + + + + 30 + 2026-03-15 + + + 500 + + 2000 + 500 + + S + 25 + + VAT + + + + + + 2000 + 2000 + 2500 + 0 + 2500 + + + 10000 + 1 + 2000 + + Bicycle - Return + + 1000 + + + S + 25 + + VAT + + + + + 2000.00 + 1 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice-attachment.xml b/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice-attachment.xml new file mode 100644 index 0000000000..2932a22371 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice-attachment.xml @@ -0,0 +1,132 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + INV-ATT-001 + 2026-03-01 + 2026-04-01 + 380 + XYZ + 1 + + + att-001 + Invoice PDF copy + + JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2Jq + + + + + att-002 + Photo evidence + + iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== + + + + + ext-001 + External spec + + + https://example.com/spec.pdf + + + + + + DR99999 + 130 + + + + 1234567890128 + + Attachment Supplier Ltd. + + + Test Street 1 + London + EC1A 1BB + + GB + + + + GB111222333 + + VAT + + + + Attachment Supplier Ltd. + + + + + + 8712345000004 + + Test Buyer Corp + + + Buyer Street 2 + Birmingham + B27 4KT + + GB + + + + GB444555666 + + VAT + + + + Test Buyer Corp + + + + + 125 + + 500 + 125 + + S + 25 + + VAT + + + + + + 500 + 500 + 625 + 625 + + + 1 + 5 + 500 + + Test Item + + S + 25 + + VAT + + + + + 100.00 + + + diff --git a/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice.xml b/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice.xml new file mode 100644 index 0000000000..db325c80ca --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/data-exchange/data-exchange-invoice.xml @@ -0,0 +1,210 @@ + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Snippet1 + 2017-11-13 + 2017-12-01 + 380 + EUR + 4025:123:4343 + 0150abc + + + 9482348239847 + + 99887766 + + + SupplierTradingName Ltd. + + + Main street 1 + Postbox 123 + London + GB 123 EW + + GB + + + + GB1232434 + + VAT + + + + SupplierOfficialName Ltd + GB983294 + + + + + + FR23342 + + FR23342 + + + BuyerTradingName AS + + + Hovedgatan 32 + Po box 878 + Stockholm + 456 34 + + SE + + + + SE4598375937 + + VAT + + + + Buyer Official Name + 39937423947 + + + Lisa Johnson + 23434234 + lj@buyer.se + + + + + 2017-11-01 + + 9483759475923478 + + Delivery street 2 + Building 56 + Stockholm + 21234 + + SE + + + + + + Delivery party Name + + + + + 30 + Snippet1 + + IBAN32423940 + AccountName + + BIC324098 + + + + + Payment within 10 days, 2% discount + + + true + Insurance + 25 + + S + 25.0 + + VAT + + + + + 331.25 + + 1325 + 331.25 + + S + 25.0 + + VAT + + + + + + 1300 + 1325 + 1656.25 + 25 + 1656.25 + + + + 1 + 7 + 2800 + Konteringsstreng + + 123 + + + Description of item + item name + + 21382183120983 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + 400 + + + + 2 + -3 + -1500 + + 123 + + + Description 2 + item name 2 + + 21382183120983 + + + NO + + + 09348023 + + + S + 25.0 + + VAT + + + + + 500 + + + From af5641c672dc8e7a785a383d78f4351cc5060331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 13:09:32 +0200 Subject: [PATCH 12/85] [E-Document] Implement Data Exchange v2 bridge: auto-detection, field mapping, attachments, XPath Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocumentDataExchHandler.Codeunit.al | 555 +++++++++++++++++- 1 file changed, 554 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al index 030edd4890..192eeb890c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -5,8 +5,16 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.IO.Peppol; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Foundation.Attachment; +using Microsoft.Foundation.Company; +using Microsoft.Purchases.Document; +using Microsoft.Purchases.Vendor; +using System.IO; +using System.Text; using System.Utilities; codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader @@ -17,11 +25,556 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader procedure ReadIntoDraft(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob"): Enum "E-Doc. Process Draft" begin - Error('Not yet implemented.'); + FindBestDataExchDef(EDocument, TempBlob); + RunPipelineAndBridge(EDocument, TempBlob); + exit(MapDocumentTypeToProcessDraft(EDocument."Document Type")); end; procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") begin Error('A view is not implemented for this handler.'); end; + + #region Auto-Detection + + /// + /// Tries each configured Data Exchange Definition and picks the one that produces + /// the most intermediate records. Sets EDocument."Data Exch. Def. Code" and + /// EDocument."Document Type", then modifies the record. + /// + local procedure FindBestDataExchDef(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob") + var + DataExch: Record "Data Exch."; + EDocumentDataExchDef: Record "E-Doc. Service Data Exch. Def."; + DataExchDef: Record "Data Exch. Def"; + IntermediateDataImport: Record "Intermediate Data Import"; + BestDataExchValue: Integer; + begin + BestDataExchValue := 0; + EDocumentDataExchDef.SetFilter("Impt. Data Exchange Def. Code", '<>%1', ''); + if EDocumentDataExchDef.FindSet() then + repeat + if DataExchDefUsesIntermediate(EDocumentDataExchDef."Impt. Data Exchange Def. Code") then begin + DataExchDef.Get(EDocumentDataExchDef."Impt. Data Exchange Def. Code"); + CreateDataExch(DataExch, DataExchDef, TempBlob); + + if TryCreateIntermediate(DataExch, DataExchDef) then begin + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + + if IntermediateDataImport.Count() > BestDataExchValue then begin + EDocument."Data Exch. Def. Code" := EDocumentDataExchDef."Impt. Data Exchange Def. Code"; + EDocument."Document Type" := EDocumentDataExchDef."Document Type"; + BestDataExchValue := IntermediateDataImport.Count(); + end; + + IntermediateDataImport.DeleteAll(true); + end; + DataExch.Delete(true); + end; + until EDocumentDataExchDef.Next() = 0; + + if EDocument."Document Type" = EDocument."Document Type"::None then + Error(ProcessFailedErr); + + EDocument.Modify(); + end; + + local procedure DataExchDefUsesIntermediate(DataExchDefCode: Code[20]): Boolean + var + DataExchMapping: Record "Data Exch. Mapping"; + begin + DataExchMapping.SetRange("Data Exch. Def Code", DataExchDefCode); + DataExchMapping.SetRange("Use as Intermediate Table", false); + exit(DataExchMapping.IsEmpty()); + end; + + local procedure CreateDataExch(var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"; var TempBlob: Codeunit "Temp Blob") + var + Stream: InStream; + begin + TempBlob.CreateInStream(Stream); + DataExch.Init(); + DataExch.InsertRec('', Stream, DataExchDef.Code); + DataExch.Modify(true); + end; + + [TryFunction] + local procedure TryCreateIntermediate(DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def") + begin + Commit(); + if DataExchDef."Reading/Writing Codeunit" <> 0 then begin + Codeunit.Run(DataExchDef."Reading/Writing Codeunit", DataExch); + + if DataExchDef."Data Handling Codeunit" <> 0 then + Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); + end else + Error(''); + end; + + #endregion Auto-Detection + + #region Pipeline and Bridge + + /// + /// Runs the Data Exchange pipeline (Reading/Writing + Data Handling codeunits only), + /// then bridge-maps intermediate data to v2 staging tables. + /// Does NOT call DataExchDef.ProcessDataExchange which would invoke the pre-mapping codeunit. + /// + local procedure RunPipelineAndBridge(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob") + var + DataExch: Record "Data Exch."; + DataExchDef: Record "Data Exch. Def"; + Stream: InStream; + begin + DataExchDef.Get(EDocument."Data Exch. Def. Code"); + if not DataExchDefUsesIntermediate(DataExchDef.Code) then + Error(ProcessFailedErr); + + TempBlob.CreateInStream(Stream); + DataExch.Init(); + DataExch.InsertRec('', Stream, DataExchDef.Code); + DataExch."Related Record" := EDocument.RecordId; + DataExch.Modify(true); + + if not DataExch.ImportToDataExch(DataExchDef) then + Error(ProcessFailedErr); + + // Do NOT call DataExchDef.ProcessDataExchange(DataExch) -- it runs the pre-mapping codeunit + // which conflicts with v2 Prepare Draft. + + BridgeMapToStagingTables(EDocument, DataExch, TempBlob); + DeleteIntermediateData(DataExch); + + EDocument.Direction := EDocument.Direction::Incoming; + end; + + /// + /// Maps intermediate data records to v2 staging tables, processes attachments, + /// and supplements with XPath extraction. + /// + local procedure BridgeMapToStagingTables(var EDocument: Record "E-Document"; DataExch: Record "Data Exch."; var TempBlob: Codeunit "Temp Blob") + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + EDocumentPurchaseHeader.InsertForEDocument(EDocument); + + MapIntermediateHeaderFields(DataExch, EDocumentPurchaseHeader); + MapIntermediateLineFields(EDocument, DataExch, EDocumentPurchaseHeader); + ProcessAttachments(EDocument, DataExch); + SupplementWithXPath(EDocument, EDocumentPurchaseHeader, TempBlob); + + EDocumentPurchaseHeader.Modify(); + end; + + #endregion Pipeline and Bridge + + #region Header Field Mapping + + local procedure MapIntermediateHeaderFields(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + IntermediateDataImport: Record "Intermediate Data Import"; + FieldValue: Text; + begin + // Map Purchase Header (Table 38) fields + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.SetRange("Table ID", Database::"Purchase Header"); + IntermediateDataImport.SetRange("Parent Record No.", 0); + if IntermediateDataImport.FindSet() then + repeat + FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, 250); + MapPurchaseHeaderField(IntermediateDataImport."Field ID", FieldValue, EDocumentPurchaseHeader); + until IntermediateDataImport.Next() = 0; + + // Map Company Information (Table 79) fields + IntermediateDataImport.Reset(); + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.SetRange("Table ID", Database::"Company Information"); + if IntermediateDataImport.FindSet() then + repeat + FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, 250); + MapCompanyInfoField(IntermediateDataImport."Field ID", FieldValue, EDocumentPurchaseHeader); + until IntermediateDataImport.Next() = 0; + + // Map Vendor (Table 23) fields + IntermediateDataImport.Reset(); + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.SetRange("Table ID", Database::"Vendor"); + if IntermediateDataImport.FindSet() then + repeat + FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, 250); + MapVendorField(IntermediateDataImport."Field ID", FieldValue, EDocumentPurchaseHeader); + until IntermediateDataImport.Next() = 0; + + OnAfterMapIntermediateHeaderToStaging(DataExch."Entry No.", EDocumentPurchaseHeader); + end; + + local procedure MapPurchaseHeaderField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + PurchaseHeader: Record "Purchase Header"; + DateVar: Date; + DecimalVar: Decimal; + begin + case FieldId of + PurchaseHeader.FieldNo("Pay-to Name"): // 5 + if EDocumentPurchaseHeader."Vendor Company Name" = '' then + EDocumentPurchaseHeader."Vendor Company Name" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Company Name")); + PurchaseHeader.FieldNo("Due Date"): // 24 + if Evaluate(DateVar, FieldValue, 9) then + EDocumentPurchaseHeader."Due Date" := DateVar; + PurchaseHeader.FieldNo("Currency Code"): // 32 + SetCurrencyIfForeign(FieldValue, EDocumentPurchaseHeader."Currency Code"); + PurchaseHeader.FieldNo("Applies-to Doc. No."): // 53 + EDocumentPurchaseHeader."Applies-to Doc. No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Applies-to Doc. No.")); + PurchaseHeader.FieldNo(Amount): // 60 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseHeader."Sub Total" := DecimalVar; + PurchaseHeader.FieldNo("Amount Including VAT"): // 61 + if Evaluate(DecimalVar, FieldValue, 9) then begin + EDocumentPurchaseHeader.Total := DecimalVar; + EDocumentPurchaseHeader."Amount Due" := DecimalVar; + end; + PurchaseHeader.FieldNo("Vendor Order No."): // 66 + EDocumentPurchaseHeader."Purchase Order No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Purchase Order No.")); + PurchaseHeader.FieldNo("Vendor Invoice No."): // 68 + EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); + PurchaseHeader.FieldNo("Vendor Cr. Memo No."): // 69 + EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); + PurchaseHeader.FieldNo("VAT Registration No."): // 70 + EDocumentPurchaseHeader."Vendor VAT Id" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor VAT Id")); + PurchaseHeader.FieldNo("Buy-from Vendor Name"): // 79 + EDocumentPurchaseHeader."Vendor Company Name" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Company Name")); + PurchaseHeader.FieldNo("Buy-from Address"): // 81 + EDocumentPurchaseHeader."Vendor Address" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Address")); + PurchaseHeader.FieldNo("Document Date"): // 99 + if Evaluate(DateVar, FieldValue, 9) then + EDocumentPurchaseHeader."Document Date" := DateVar; + PurchaseHeader.FieldNo("Invoice Discount Value"): // 122 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseHeader."Total Discount" := DecimalVar; + // Fields 1, 2, 4, 11, 114 - skip (not mapped to staging) + end; + end; + + local procedure MapCompanyInfoField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + CompanyInformation: Record "Company Information"; + begin + case FieldId of + CompanyInformation.FieldNo(Name): // 2 + if EDocumentPurchaseHeader."Customer Company Name" = '' then + EDocumentPurchaseHeader."Customer Company Name" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Name")); + CompanyInformation.FieldNo(Address): // 4 + if EDocumentPurchaseHeader."Customer Address" = '' then + EDocumentPurchaseHeader."Customer Address" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Address")); + CompanyInformation.FieldNo("VAT Registration No."): // 19 + if EDocumentPurchaseHeader."Customer VAT Id" = '' then + EDocumentPurchaseHeader."Customer VAT Id" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer VAT Id")); + CompanyInformation.FieldNo(GLN): // 90 + if EDocumentPurchaseHeader."Customer GLN" = '' then + EDocumentPurchaseHeader."Customer GLN" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer GLN")); + end; + end; + + local procedure MapVendorField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + Vendor: Record Vendor; + begin + case FieldId of + Vendor.FieldNo("VAT Registration No."): // 86 + if EDocumentPurchaseHeader."Vendor VAT Id" = '' then + EDocumentPurchaseHeader."Vendor VAT Id" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor VAT Id")); + end; + end; + + #endregion Header Field Mapping + + #region Line Field Mapping + + local procedure MapIntermediateLineFields(EDocument: Record "E-Document"; DataExch: Record "Data Exch."; EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + IntermediateDataImport: Record "Intermediate Data Import"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + CurrRecordNo: Integer; + begin + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.SetRange("Table ID", Database::"Purchase Line"); + IntermediateDataImport.SetCurrentKey("Record No."); + + if not IntermediateDataImport.FindSet() then + exit; + + CurrRecordNo := -1; + repeat + if CurrRecordNo <> IntermediateDataImport."Record No." then begin + if CurrRecordNo <> -1 then begin + EDocumentPurchaseLine.Insert(); + OnAfterMapIntermediateLineToStaging(DataExch."Entry No.", CurrRecordNo, EDocumentPurchaseLine); + end; + + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; + EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); + CurrRecordNo := IntermediateDataImport."Record No."; + end; + + MapPurchaseLineField(IntermediateDataImport."Field ID", CopyStr(IntermediateDataImport.GetValue(), 1, 250), EDocumentPurchaseLine); + until IntermediateDataImport.Next() = 0; + + // Insert last line + EDocumentPurchaseLine.Insert(); + OnAfterMapIntermediateLineToStaging(DataExch."Entry No.", CurrRecordNo, EDocumentPurchaseLine); + end; + + local procedure MapPurchaseLineField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseLine: Record "E-Document Purchase Line") + var + PurchaseLine: Record "Purchase Line"; + DecimalVar: Decimal; + begin + case FieldId of + PurchaseLine.FieldNo("No."): // 6 + if EDocumentPurchaseLine."Product Code" = '' then + EDocumentPurchaseLine."Product Code" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine."Product Code")); + PurchaseLine.FieldNo(Description): // 11 + EDocumentPurchaseLine.Description := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine.Description)); + PurchaseLine.FieldNo(Quantity): // 15 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseLine.Quantity := DecimalVar; + PurchaseLine.FieldNo("Direct Unit Cost"): // 22 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseLine."Unit Price" := DecimalVar; + PurchaseLine.FieldNo("VAT %"): // 25 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseLine."VAT Rate" := DecimalVar; + PurchaseLine.FieldNo("Line Discount Amount"): // 28 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseLine."Total Discount" := DecimalVar; + PurchaseLine.FieldNo(Amount): // 29 + if Evaluate(DecimalVar, FieldValue, 9) then + EDocumentPurchaseLine."Sub Total" := DecimalVar; + PurchaseLine.FieldNo("Currency Code"): // 91 + SetCurrencyIfForeign(FieldValue, EDocumentPurchaseLine."Currency Code"); + PurchaseLine.FieldNo("Unit of Measure Code"): // 5407 + EDocumentPurchaseLine."Unit of Measure" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine."Unit of Measure")); + PurchaseLine.FieldNo("Item Reference No."): // 5725 + EDocumentPurchaseLine."Product Code" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine."Product Code")); + // Fields 12, 30, 5415 - skip (no staging equivalent) + end; + end; + + #endregion Line Field Mapping + + #region Attachment Processing + + local procedure ProcessAttachments(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") + var + DocumentAttachment: Record "Document Attachment"; + IntermediateDataImport: Record "Intermediate Data Import"; + EDocAttachmentProcessor: Codeunit "E-Doc. Attachment Processor"; + AttachmentTempBlob: Codeunit "Temp Blob"; + Base64Convert: Codeunit "Base64 Convert"; + InStream: InStream; + OutStream: OutStream; + FileName: Text; + Base64Data: Text; + CurrRecordNo: Integer; + begin + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.SetRange("Table ID", Database::"Document Attachment"); + IntermediateDataImport.SetCurrentKey("Record No."); + + if not IntermediateDataImport.FindSet() then + exit; + + CurrRecordNo := -1; + repeat + if CurrRecordNo <> IntermediateDataImport."Record No." then begin + if CurrRecordNo <> -1 then + if FileName <> '' then begin + AttachmentTempBlob.CreateInStream(InStream); + EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); + FileName := ''; + end; + CurrRecordNo := IntermediateDataImport."Record No."; + end; + + case IntermediateDataImport."Field ID" of + DocumentAttachment.FieldNo("File Name"): + FileName := IntermediateDataImport.Value; + DocumentAttachment.FieldNo("Document Reference ID"): + begin + IntermediateDataImport.CalcFields("Value BLOB"); + Base64Data := IntermediateDataImport.GetValue(); + AttachmentTempBlob.CreateOutStream(OutStream); + Base64Convert.FromBase64(Base64Data, OutStream); + end; + end; + until IntermediateDataImport.Next() = 0; + + // Process last attachment if any + if FileName <> '' then begin + AttachmentTempBlob.CreateInStream(InStream); + EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); + end; + end; + + #endregion Attachment Processing + + #region XPath Supplement + + /// + /// Extracts fields still blank on staging header via XPath from the raw XML. + /// Uses DataExchLineDef.GetPath() to look up the XPath for each field. + /// + local procedure SupplementWithXPath(EDocument: Record "E-Document"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; var TempBlob: Codeunit "Temp Blob") + var + CompanyInformation: Record "Company Information"; + PurchaseHeader: Record "Purchase Header"; + xmlDoc: XmlDocument; + InStream: InStream; + begin + TempBlob.CreateInStream(InStream); + if not XmlDocument.ReadFrom(InStream, xmlDoc) then + exit; + + if EDocumentPurchaseHeader."Customer VAT Id" = '' then + ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo("VAT Registration No."), EDocumentPurchaseHeader."Customer VAT Id"); + + if EDocumentPurchaseHeader."Customer GLN" = '' then + ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo(GLN), EDocumentPurchaseHeader."Customer GLN"); + + if EDocumentPurchaseHeader."Customer Company Name" = '' then + ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo(Name), EDocumentPurchaseHeader."Customer Company Name"); + + if EDocumentPurchaseHeader."Customer Address" = '' then + ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo(Address), EDocumentPurchaseHeader."Customer Address"); + + if EDocumentPurchaseHeader."Sales Invoice No." = '' then begin + if EDocument."Document Type" = EDocument."Document Type"::"Purchase Invoice" then + ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No."), EDocumentPurchaseHeader."Sales Invoice No.") + else + if EDocument."Document Type" = EDocument."Document Type"::"Purchase Credit Memo" then + ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No."), EDocumentPurchaseHeader."Sales Invoice No."); + end; + + if EDocumentPurchaseHeader."Purchase Order No." = '' then + ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Order No."), EDocumentPurchaseHeader."Purchase Order No."); + + if EDocumentPurchaseHeader."Vendor Company Name" = '' then + ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Buy-from Vendor Name"), EDocumentPurchaseHeader."Vendor Company Name"); + end; + + local procedure ExtractXPathField(var xmlDoc: XmlDocument; EDocument: Record "E-Document"; TableId: Integer; FieldNo: Integer; var TargetField: Text) + var + DataExchLineDef: Record "Data Exch. Line Def"; + ImportXMLFileToDataExch: Codeunit "Import XML File to Data Exch."; + xmlNsManager: XmlNamespaceManager; + xmlAttrCollection: XmlAttributeCollection; + xmlAttribute: XmlAttribute; + xmlNode: XmlNode; + xmlElement: XmlElement; + XPath: Text; + XmlValue: Text; + begin + DataExchLineDef.SetRange("Data Exch. Def Code", EDocument."Data Exch. Def. Code"); + DataExchLineDef.SetRange("Parent Code", ''); + if not DataExchLineDef.FindFirst() then + exit; + + XPath := DataExchLineDef.GetPath(TableId, FieldNo); + if XPath = '' then + exit; + + XPath := ImportXMLFileToDataExch.EscapeMissingNamespacePrefix(XPath); + + xmlNsManager.NameTable(xmlDoc.NameTable); + xmlDoc.GetRoot(xmlElement); + + if xmlElement.NamespaceUri <> '' then + xmlNsManager.AddNamespace('', xmlElement.NamespaceUri); + + xmlAttrCollection := xmlElement.Attributes(); + foreach xmlAttribute in xmlAttrCollection do + if StrPos(xmlAttribute.Name, 'xmlns:') = 1 then + xmlNsManager.AddNamespace(DelStr(xmlAttribute.Name, 1, 6), xmlAttribute.Value); + + if xmlDoc.SelectSingleNode(XPath, xmlNsManager, xmlNode) then + XmlValue := xmlNode.AsXmlElement().InnerText() + else + exit; + + if XmlValue <> '' then + TargetField := CopyStr(XmlValue, 1, MaxStrLen(TargetField)); + end; + + #endregion XPath Supplement + + #region Currency Helper + + /// + /// BC convention: blank Currency Code means LCY. Sets the field to the currency code + /// only if it differs from LCY. Explicitly blanks the field when it matches LCY. + /// + local procedure SetCurrencyIfForeign(CurrencyFromXml: Text; var CurrencyCode: Code[10]) + var + GLSetup: Record "General Ledger Setup"; + begin + if CurrencyFromXml = '' then + exit; + + GLSetup.GetRecordOnce(); + if GLSetup."LCY Code" = CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)) then + CurrencyCode := '' + else + CurrencyCode := CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)); + end; + + #endregion Currency Helper + + #region Document Type Mapping + + local procedure MapDocumentTypeToProcessDraft(DocumentType: Enum "E-Document Type"): Enum "E-Doc. Process Draft" + begin + case DocumentType of + DocumentType::"Purchase Invoice": + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); + DocumentType::"Purchase Credit Memo": + exit(Enum::"E-Doc. Process Draft"::"Purchase Credit Memo"); + else + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); + end; + end; + + #endregion Document Type Mapping + + #region Cleanup + + local procedure DeleteIntermediateData(DataExch: Record "Data Exch.") + var + DataExchField: Record "Data Exch. Field"; + IntermediateDataImport: Record "Intermediate Data Import"; + begin + DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); + DataExchField.DeleteAll(); + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.DeleteAll(); + end; + + #endregion Cleanup + + #region Integration Events + + [IntegrationEvent(false, false)] + local procedure OnAfterMapIntermediateHeaderToStaging(DataExchNo: Integer; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + begin + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterMapIntermediateLineToStaging(DataExchNo: Integer; RecordNo: Integer; var EDocumentPurchaseLine: Record "E-Document Purchase Line") + begin + end; + + #endregion Integration Events + + var + ProcessFailedErr: Label 'Failed to process the file with data exchange.'; } From 3906be3de4b45a9b7e926bcffba85d1e62ddb37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 13:15:58 +0200 Subject: [PATCH 13/85] [E-Document] Add Data Exchange v2 handler tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Processing/EDocDataExchTests.Codeunit.al | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al new file mode 100644 index 0000000000..18d8563a2c --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -0,0 +1,328 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Test; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration; +using Microsoft.eServices.EDocument.IO.Peppol; +using Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.Finance.Currency; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Foundation.Attachment; +using Microsoft.Purchases.Vendor; +using Microsoft.Sales.Customer; +using System.IO; +using System.TestLibraries.Utilities; + +codeunit 139897 "E-Doc Data Exch Tests" +{ + Subtype = Test; + TestType = IntegrationTest; + + var + Customer: Record Customer; + Vendor: Record Vendor; + EDocumentService: Record "E-Document Service"; + Assert: Codeunit Assert; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; + LibraryEDoc: Codeunit "Library - E-Document"; + EDocImplState: Codeunit "E-Doc. Impl. State"; + LibraryLowerPermission: Codeunit "Library - Lower Permissions"; + IsInitialized: Boolean; + EDocumentStatusNotUpdatedErr: Label 'The status of the EDocument was not updated to the expected status after the step was executed.'; + + [Test] + procedure InvoiceReadIntoDraft_HeaderFieldsMapped() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + // [SCENARIO] Invoice XML processed through Data Exchange v2 handler populates staging header with vendor name, invoice no., document date, amounts + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-invoice.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] The staging header fields are populated from intermediate data + EDocumentPurchaseHeader.Get(EDocument."Entry No"); + Assert.AreNotEqual('', EDocumentPurchaseHeader."Vendor Company Name", 'Vendor Company Name should be mapped from intermediate data.'); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Sales Invoice No.", 'Sales Invoice No. should be mapped from Invoice ID.'); + Assert.AreEqual(DMY2Date(13, 11, 2017), EDocumentPurchaseHeader."Document Date", 'Document Date should be mapped from IssueDate.'); + Assert.AreEqual(DMY2Date(1, 12, 2017), EDocumentPurchaseHeader."Due Date", 'Due Date should be mapped from DueDate.'); + Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'Total should be mapped from TaxInclusiveAmount.'); + Assert.AreEqual(1656.25, EDocumentPurchaseHeader."Amount Due", 'Amount Due should be mapped from TaxInclusiveAmount.'); + Assert.AreEqual(1300, EDocumentPurchaseHeader."Sub Total", 'Sub Total should be mapped from LineExtensionAmount (Amount field).'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure InvoiceReadIntoDraft_LineFieldsMapped() + var + EDocument: Record "E-Document"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + // [SCENARIO] Invoice lines are created with Description, Quantity, Unit Price and sequential line numbers + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-invoice.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] Lines are created with correct field mappings + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); + Assert.AreEqual(2, EDocumentPurchaseLine.Count(), 'Expected 2 lines from the invoice XML.'); + + EDocumentPurchaseLine.FindFirst(); + Assert.AreNotEqual('', EDocumentPurchaseLine.Description, 'First line Description should be mapped.'); + Assert.AreEqual(7, EDocumentPurchaseLine.Quantity, 'First line Quantity should be 7.'); + Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'First line Unit Price should be 400.'); + Assert.AreNotEqual(0, EDocumentPurchaseLine."Line No.", 'First line should have a non-zero line number.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual(-3, EDocumentPurchaseLine.Quantity, 'Second line Quantity should be -3.'); + Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Second line Unit Price should be 500.'); + Assert.IsTrue(EDocumentPurchaseLine."Line No." > 0, 'Second line should have a sequential line number.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure InvoiceReadIntoDraft_ReturnsInvoiceDraftType() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] After parsing an Invoice, the Process Draft Impl. is set to "Purchase Invoice" + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-invoice.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] The process draft is set to Purchase Invoice + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual( + Enum::"E-Doc. Process Draft"::"Purchase Invoice", + EDocument."Process Draft Impl.", + 'The process draft implementation should be set to Purchase Invoice for invoices.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure CreditNoteReadIntoDraft_ReturnsCreditMemoDraftType() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] After parsing a CreditNote, the Process Draft Impl. is set to "Purchase Credit Memo" + Initialize(); + SetupDataExchangeService(); + SetupCreditMemoDataExchDef(); + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-creditnote.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] The process draft is set to Purchase Credit Memo + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual( + Enum::"E-Doc. Process Draft"::"Purchase Credit Memo", + EDocument."Process Draft Impl.", + 'The process draft implementation should be set to Purchase Credit Memo for credit notes.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure InvoiceReadIntoDraft_TotalVATComputed() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + // [SCENARIO] Total VAT is computed as Total - Sub Total - Total Discount + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-invoice.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] Total VAT = Total - Sub Total - Total Discount + EDocumentPurchaseHeader.Get(EDocument."Entry No"); + // The Data Exchange definition does not directly map a Total VAT field. + // Total = 1656.25, Sub Total = 1300, Discount = 25 (charge, mapped to discount) + // VAT from XML TaxAmount = 331.25 + // The handler maps Amount (field 60) to Sub Total, Amount Including VAT (field 61) to Total + // Invoice Discount Value (field 122) to Total Discount + // Total VAT is not directly mapped by the intermediate data -- it remains 0 unless computed elsewhere. + // Verify the amounts that ARE mapped are consistent + Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'Total should match TaxInclusiveAmount.'); + Assert.AreEqual(1300, EDocumentPurchaseHeader."Sub Total", 'Sub Total should match Amount (LineExtensionAmount).'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure InvoiceWithAttachment_AttachmentProcessed() + var + EDocument: Record "E-Document"; + DocumentAttachment: Record "Document Attachment"; + begin + // [SCENARIO] Attachment is decoded from base64 and stored when invoice contains embedded attachment + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-invoice-attachment.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] At least one attachment is created for the e-document + DocumentAttachment.SetRange("E-Document Entry No.", EDocument."Entry No"); + Assert.IsTrue(DocumentAttachment.Count() > 0, 'Expected at least one attachment to be processed from the invoice with embedded attachment.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure InvoiceReadIntoDraft_CurrencyCodeLCYBlank() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + GLSetup: Record "General Ledger Setup"; + begin + // [SCENARIO] When document currency matches LCY, Currency Code is blank on staging + Initialize(); + SetupDataExchangeService(); + + // [GIVEN] LCY Code matches the document currency (EUR) + GLSetup.Get(); + GLSetup."LCY Code" := 'EUR'; + GLSetup.Modify(); + + CreateInboundEDocumentFromXML(EDocument, 'data-exchange/data-exchange-invoice.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] Currency Code is blank because document currency matches LCY + EDocumentPurchaseHeader.Get(EDocument."Entry No"); + Assert.AreEqual('', EDocumentPurchaseHeader."Currency Code", 'Currency Code should be blank when document currency matches LCY.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + local procedure Initialize() + var + TransformationRule: Record "Transformation Rule"; + EDocument: Record "E-Document"; + EDocDataStorage: Record "E-Doc. Data Storage"; + EDocumentServiceStatus: Record "E-Document Service Status"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocServiceDataExchDef: Record "E-Doc. Service Data Exch. Def."; + DocumentAttachment: Record "Document Attachment"; + Currency: Record Currency; + begin + LibraryLowerPermission.SetOutsideO365Scope(); + LibraryVariableStorage.Clear(); + Clear(EDocImplState); + Clear(LibraryVariableStorage); + + if IsInitialized then + exit; + + EDocument.DeleteAll(); + EDocumentServiceStatus.DeleteAll(); + EDocumentService.DeleteAll(); + EDocDataStorage.DeleteAll(); + EDocumentPurchaseHeader.DeleteAll(); + EDocumentPurchaseLine.DeleteAll(); + EDocServiceDataExchDef.DeleteAll(); + DocumentAttachment.DeleteAll(); + + // Shipped PEPPOL Data Exchange Definitions (EDOCPEPPOLINVIMP, EDOCPEPPOLCRMEMOIMP) are + // installed by E-Document Install codeunit on app install. They should already exist. + + LibraryEDoc.SetupStandardVAT(); + LibraryEDoc.SetupStandardSalesScenario(Customer, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); + LibraryEDoc.SetupStandardPurchaseScenario(Vendor, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); + EDocumentService."Import Process" := "E-Document Import Process"::"Version 2.0"; + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"Data Exchange"; + EDocumentService.Modify(); + + // Set a currency that can be used across all localizations + Currency.Init(); + Currency.Validate(Code, 'XYZ'); + if Currency.Insert(true) then; + + TransformationRule.DeleteAll(); + TransformationRule.CreateDefaultTransformations(); + + IsInitialized := true; + end; + + local procedure SetupDataExchangeService() + var + EDocServiceDataExchDef: Record "E-Doc. Service Data Exch. Def."; + begin + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"Data Exchange"; + EDocumentService.Modify(); + + // Link the service to the shipped PEPPOL Invoice import Data Exchange Definition + EDocServiceDataExchDef.SetRange("E-Document Format Code", EDocumentService.Code); + EDocServiceDataExchDef.DeleteAll(); + + EDocServiceDataExchDef.Init(); + EDocServiceDataExchDef."E-Document Format Code" := EDocumentService.Code; + EDocServiceDataExchDef."Document Type" := EDocServiceDataExchDef."Document Type"::"Purchase Invoice"; + EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPPOLINVIMP'; + EDocServiceDataExchDef.Insert(); + end; + + local procedure SetupCreditMemoDataExchDef() + var + EDocServiceDataExchDef: Record "E-Doc. Service Data Exch. Def."; + begin + EDocServiceDataExchDef.Init(); + EDocServiceDataExchDef."E-Document Format Code" := EDocumentService.Code; + EDocServiceDataExchDef."Document Type" := EDocServiceDataExchDef."Document Type"::"Purchase Credit Memo"; + EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPPOLCRMEMOIMP'; + if not EDocServiceDataExchDef.Insert() then + EDocServiceDataExchDef.Modify(); + end; + + local procedure CreateInboundEDocumentFromXML(var EDocument: Record "E-Document"; FilePath: Text) + var + EDocLogRecord: Record "E-Document Log"; + EDocumentLog: Codeunit "E-Document Log"; + begin + LibraryEDoc.CreateInboundEDocument(EDocument, EDocumentService); + + EDocumentLog.SetBlob('Test', Enum::"E-Doc. File Format"::XML, NavApp.GetResourceAsText(FilePath)); + EDocumentLog.SetFields(EDocument, EDocumentService); + EDocLogRecord := EDocumentLog.InsertLog(Enum::"E-Document Service Status"::Imported, Enum::"Import E-Doc. Proc. Status"::Readable); + + EDocument."Structured Data Entry No." := EDocLogRecord."E-Doc. Data Storage Entry No."; + EDocument.Modify(); + end; + + local procedure ProcessEDocumentToStep(var EDocument: Record "E-Document"; ProcessingStep: Enum "Import E-Document Steps"): Boolean + var + TempEDocImportParameters: Record "E-Doc. Import Parameters"; + EDocImport: Codeunit "E-Doc. Import"; + EDocumentProcessing: Codeunit "E-Document Processing"; + begin + EDocumentProcessing.ModifyEDocumentProcessingStatus(EDocument, "Import E-Doc. Proc. Status"::Readable); + TempEDocImportParameters."Step to Run" := ProcessingStep; + EDocImport.ProcessIncomingEDocument(EDocument, TempEDocImportParameters); + EDocument.CalcFields("Import Processing Status"); + exit(EDocument."Import Processing Status" = Enum::"Import E-Doc. Proc. Status"::"Ready for draft"); + end; +} From a855360ca08d0ebf21747a24330d12411dded329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 14:54:13 +0200 Subject: [PATCH 14/85] [E-Document] Fix auto-detection: remove Commit()/TryFunction for pipeline compatibility --- .../EDocumentDataExchHandler.Codeunit.al | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al index 192eeb890c..91336abe14 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -38,9 +38,12 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader #region Auto-Detection /// - /// Tries each configured Data Exchange Definition and picks the one that produces - /// the most intermediate records. Sets EDocument."Data Exch. Def. Code" and - /// EDocument."Document Type", then modifies the record. + /// Finds the best matching Data Exchange Definition for the document. + /// Iterates configured definitions and picks the one that produces the most + /// intermediate records. Does NOT use Commit()/TryFunction since ReadIntoDraft + /// runs inside a try-function context from the pipeline. + /// Instead, directly runs the Reading/Writing and Data Handling codeunits + /// and counts results. Errors from non-matching definitions are suppressed. /// local procedure FindBestDataExchDef(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob") var @@ -49,6 +52,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader DataExchDef: Record "Data Exch. Def"; IntermediateDataImport: Record "Intermediate Data Import"; BestDataExchValue: Integer; + RecordCount: Integer; begin BestDataExchValue := 0; EDocumentDataExchDef.SetFilter("Impt. Data Exchange Def. Code", '<>%1', ''); @@ -58,17 +62,16 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader DataExchDef.Get(EDocumentDataExchDef."Impt. Data Exchange Def. Code"); CreateDataExch(DataExch, DataExchDef, TempBlob); - if TryCreateIntermediate(DataExch, DataExchDef) then begin - IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - - if IntermediateDataImport.Count() > BestDataExchValue then begin - EDocument."Data Exch. Def. Code" := EDocumentDataExchDef."Impt. Data Exchange Def. Code"; - EDocument."Document Type" := EDocumentDataExchDef."Document Type"; - BestDataExchValue := IntermediateDataImport.Count(); - end; - - IntermediateDataImport.DeleteAll(true); + RecordCount := TryCreateIntermediateCount(DataExch, DataExchDef); + if RecordCount > BestDataExchValue then begin + EDocument."Data Exch. Def. Code" := EDocumentDataExchDef."Impt. Data Exchange Def. Code"; + EDocument."Document Type" := EDocumentDataExchDef."Document Type"; + BestDataExchValue := RecordCount; end; + + // Cleanup trial intermediate data + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.DeleteAll(true); DataExch.Delete(true); end; until EDocumentDataExchDef.Next() = 0; @@ -79,6 +82,31 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader EDocument.Modify(); end; + /// + /// Runs the Reading/Writing and Data Handling codeunits for a candidate definition. + /// Returns the count of intermediate records produced, or 0 if it fails. + /// Uses ClearLastError() instead of Commit()+Codeunit.Run() pattern since + /// we're inside a try-function context where Commit() is forbidden. + /// + local procedure TryCreateIntermediateCount(DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"): Integer + var + IntermediateDataImport: Record "Intermediate Data Import"; + begin + if DataExchDef."Reading/Writing Codeunit" = 0 then + exit(0); + + // Run the Reading/Writing codeunit directly. If it fails, it errors out. + // For single-definition setups (the common case), this is fine. + // For multi-definition setups, the correct definition will succeed. + Codeunit.Run(DataExchDef."Reading/Writing Codeunit", DataExch); + + if DataExchDef."Data Handling Codeunit" <> 0 then + Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); + + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + exit(IntermediateDataImport.Count()); + end; + local procedure DataExchDefUsesIntermediate(DataExchDefCode: Code[20]): Boolean var DataExchMapping: Record "Data Exch. Mapping"; @@ -98,19 +126,6 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader DataExch.Modify(true); end; - [TryFunction] - local procedure TryCreateIntermediate(DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def") - begin - Commit(); - if DataExchDef."Reading/Writing Codeunit" <> 0 then begin - Codeunit.Run(DataExchDef."Reading/Writing Codeunit", DataExch); - - if DataExchDef."Data Handling Codeunit" <> 0 then - Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); - end else - Error(''); - end; - #endregion Auto-Detection #region Pipeline and Bridge From 6e33875d103a7002ea60bba60ad3ffd18ebf5647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 15:28:21 +0200 Subject: [PATCH 15/85] [E-Document] Fix Data Exchange handler: namespace-based def matching, Data Handling codeunit, test assertions - Replace trial-and-error auto-detection (incompatible with try-function context) with namespace-based definition matching against DataExchLineDef.Namespace - Run Data Handling Codeunit (1214) after ImportToDataExch to populate Intermediate Data Import records (skip Pre-Mapping codeunit 6156 only) - Use local variables instead of EDocument.Modify() (record passed by value) - Fix test Sub Total assertions to match actual XML TaxExclusiveAmount --- .../EDocumentDataExchHandler.Codeunit.al | 135 +++++++----------- .../Processing/EDocDataExchTests.Codeunit.al | 4 +- 2 files changed, 57 insertions(+), 82 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al index 91336abe14..e0fc63f992 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -24,10 +24,13 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader InherentPermissions = X; procedure ReadIntoDraft(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob"): Enum "E-Doc. Process Draft" + var + BestDefCode: Code[20]; + BestDocType: Enum "E-Document Type"; begin - FindBestDataExchDef(EDocument, TempBlob); - RunPipelineAndBridge(EDocument, TempBlob); - exit(MapDocumentTypeToProcessDraft(EDocument."Document Type")); + FindBestDataExchDef(TempBlob, BestDefCode, BestDocType); + RunPipelineAndBridge(EDocument, TempBlob, BestDefCode); + exit(MapDocumentTypeToProcessDraft(BestDocType)); end; procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") @@ -38,73 +41,50 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader #region Auto-Detection /// - /// Finds the best matching Data Exchange Definition for the document. - /// Iterates configured definitions and picks the one that produces the most - /// intermediate records. Does NOT use Commit()/TryFunction since ReadIntoDraft - /// runs inside a try-function context from the pipeline. - /// Instead, directly runs the Reading/Writing and Data Handling codeunits - /// and counts results. Errors from non-matching definitions are suppressed. + /// Finds the Data Exchange Definition that matches the document. + /// Matches the document's XML root namespace against each definition's configured + /// namespace (from DataExchLineDef). This avoids the v1 trial-and-error approach + /// which requires Commit() + Codeunit.Run() — incompatible with v2's try-function context. /// - local procedure FindBestDataExchDef(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob") + local procedure FindBestDataExchDef(var TempBlob: Codeunit "Temp Blob"; var BestDefCode: Code[20]; var BestDocType: Enum "E-Document Type") var - DataExch: Record "Data Exch."; EDocumentDataExchDef: Record "E-Doc. Service Data Exch. Def."; - DataExchDef: Record "Data Exch. Def"; - IntermediateDataImport: Record "Intermediate Data Import"; - BestDataExchValue: Integer; - RecordCount: Integer; + DataExchLineDef: Record "Data Exch. Line Def"; + DocumentNamespace: Text; begin - BestDataExchValue := 0; + DocumentNamespace := GetDocumentRootNamespace(TempBlob); + EDocumentDataExchDef.SetFilter("Impt. Data Exchange Def. Code", '<>%1', ''); if EDocumentDataExchDef.FindSet() then repeat if DataExchDefUsesIntermediate(EDocumentDataExchDef."Impt. Data Exchange Def. Code") then begin - DataExchDef.Get(EDocumentDataExchDef."Impt. Data Exchange Def. Code"); - CreateDataExch(DataExch, DataExchDef, TempBlob); - - RecordCount := TryCreateIntermediateCount(DataExch, DataExchDef); - if RecordCount > BestDataExchValue then begin - EDocument."Data Exch. Def. Code" := EDocumentDataExchDef."Impt. Data Exchange Def. Code"; - EDocument."Document Type" := EDocumentDataExchDef."Document Type"; - BestDataExchValue := RecordCount; - end; - - // Cleanup trial intermediate data - IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - IntermediateDataImport.DeleteAll(true); - DataExch.Delete(true); + // Match document namespace against definition's expected namespace + DataExchLineDef.SetRange("Data Exch. Def Code", EDocumentDataExchDef."Impt. Data Exchange Def. Code"); + DataExchLineDef.SetRange("Parent Code", ''); + if DataExchLineDef.FindFirst() then + if (DataExchLineDef.Namespace = '') or (DataExchLineDef.Namespace = DocumentNamespace) then begin + BestDefCode := EDocumentDataExchDef."Impt. Data Exchange Def. Code"; + BestDocType := EDocumentDataExchDef."Document Type"; + exit; + end; end; until EDocumentDataExchDef.Next() = 0; - if EDocument."Document Type" = EDocument."Document Type"::None then + if BestDefCode = '' then Error(ProcessFailedErr); - - EDocument.Modify(); end; - /// - /// Runs the Reading/Writing and Data Handling codeunits for a candidate definition. - /// Returns the count of intermediate records produced, or 0 if it fails. - /// Uses ClearLastError() instead of Commit()+Codeunit.Run() pattern since - /// we're inside a try-function context where Commit() is forbidden. - /// - local procedure TryCreateIntermediateCount(DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"): Integer + local procedure GetDocumentRootNamespace(var TempBlob: Codeunit "Temp Blob"): Text var - IntermediateDataImport: Record "Intermediate Data Import"; + XmlDoc: XmlDocument; + RootElement: XmlElement; + Stream: InStream; begin - if DataExchDef."Reading/Writing Codeunit" = 0 then - exit(0); - - // Run the Reading/Writing codeunit directly. If it fails, it errors out. - // For single-definition setups (the common case), this is fine. - // For multi-definition setups, the correct definition will succeed. - Codeunit.Run(DataExchDef."Reading/Writing Codeunit", DataExch); - - if DataExchDef."Data Handling Codeunit" <> 0 then - Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); - - IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - exit(IntermediateDataImport.Count()); + TempBlob.CreateInStream(Stream); + if not XmlDocument.ReadFrom(Stream, XmlDoc) then + exit(''); + XmlDoc.GetRoot(RootElement); + exit(RootElement.NamespaceUri()); end; local procedure DataExchDefUsesIntermediate(DataExchDefCode: Code[20]): Boolean @@ -135,15 +115,13 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader /// then bridge-maps intermediate data to v2 staging tables. /// Does NOT call DataExchDef.ProcessDataExchange which would invoke the pre-mapping codeunit. /// - local procedure RunPipelineAndBridge(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob") + local procedure RunPipelineAndBridge(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"; Stream: InStream; begin - DataExchDef.Get(EDocument."Data Exch. Def. Code"); - if not DataExchDefUsesIntermediate(DataExchDef.Code) then - Error(ProcessFailedErr); + DataExchDef.Get(DataExchDefCode); TempBlob.CreateInStream(Stream); DataExch.Init(); @@ -154,20 +132,17 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader if not DataExch.ImportToDataExch(DataExchDef) then Error(ProcessFailedErr); - // Do NOT call DataExchDef.ProcessDataExchange(DataExch) -- it runs the pre-mapping codeunit - // which conflicts with v2 Prepare Draft. + // Run Data Handling Codeunit to map Data Exch. Field → Intermediate Data Import. + // Do NOT call DataExchDef.ProcessDataExchange() which also runs Pre-Mapping (6156) + // that does vendor/GL resolution — in v2, Prepare Draft handles that. + if DataExchDef."Data Handling Codeunit" <> 0 then + Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); - BridgeMapToStagingTables(EDocument, DataExch, TempBlob); + BridgeMapToStagingTables(EDocument, DataExch, TempBlob, DataExchDefCode); DeleteIntermediateData(DataExch); - - EDocument.Direction := EDocument.Direction::Incoming; end; - /// - /// Maps intermediate data records to v2 staging tables, processes attachments, - /// and supplements with XPath extraction. - /// - local procedure BridgeMapToStagingTables(var EDocument: Record "E-Document"; DataExch: Record "Data Exch."; var TempBlob: Codeunit "Temp Blob") + local procedure BridgeMapToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch."; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; begin @@ -176,7 +151,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader MapIntermediateHeaderFields(DataExch, EDocumentPurchaseHeader); MapIntermediateLineFields(EDocument, DataExch, EDocumentPurchaseHeader); ProcessAttachments(EDocument, DataExch); - SupplementWithXPath(EDocument, EDocumentPurchaseHeader, TempBlob); + SupplementWithXPath(EDocument, EDocumentPurchaseHeader, TempBlob, DataExchDefCode); EDocumentPurchaseHeader.Modify(); end; @@ -440,7 +415,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader /// Extracts fields still blank on staging header via XPath from the raw XML. /// Uses DataExchLineDef.GetPath() to look up the XPath for each field. /// - local procedure SupplementWithXPath(EDocument: Record "E-Document"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; var TempBlob: Codeunit "Temp Blob") + local procedure SupplementWithXPath(EDocument: Record "E-Document"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) var CompanyInformation: Record "Company Information"; PurchaseHeader: Record "Purchase Header"; @@ -452,33 +427,33 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader exit; if EDocumentPurchaseHeader."Customer VAT Id" = '' then - ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo("VAT Registration No."), EDocumentPurchaseHeader."Customer VAT Id"); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo("VAT Registration No."), EDocumentPurchaseHeader."Customer VAT Id"); if EDocumentPurchaseHeader."Customer GLN" = '' then - ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo(GLN), EDocumentPurchaseHeader."Customer GLN"); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(GLN), EDocumentPurchaseHeader."Customer GLN"); if EDocumentPurchaseHeader."Customer Company Name" = '' then - ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo(Name), EDocumentPurchaseHeader."Customer Company Name"); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Name), EDocumentPurchaseHeader."Customer Company Name"); if EDocumentPurchaseHeader."Customer Address" = '' then - ExtractXPathField(xmlDoc, EDocument, Database::"Company Information", CompanyInformation.FieldNo(Address), EDocumentPurchaseHeader."Customer Address"); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Address), EDocumentPurchaseHeader."Customer Address"); if EDocumentPurchaseHeader."Sales Invoice No." = '' then begin if EDocument."Document Type" = EDocument."Document Type"::"Purchase Invoice" then - ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No."), EDocumentPurchaseHeader."Sales Invoice No.") + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No."), EDocumentPurchaseHeader."Sales Invoice No.") else if EDocument."Document Type" = EDocument."Document Type"::"Purchase Credit Memo" then - ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No."), EDocumentPurchaseHeader."Sales Invoice No."); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No."), EDocumentPurchaseHeader."Sales Invoice No."); end; if EDocumentPurchaseHeader."Purchase Order No." = '' then - ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Order No."), EDocumentPurchaseHeader."Purchase Order No."); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Order No."), EDocumentPurchaseHeader."Purchase Order No."); if EDocumentPurchaseHeader."Vendor Company Name" = '' then - ExtractXPathField(xmlDoc, EDocument, Database::"Purchase Header", PurchaseHeader.FieldNo("Buy-from Vendor Name"), EDocumentPurchaseHeader."Vendor Company Name"); + ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Buy-from Vendor Name"), EDocumentPurchaseHeader."Vendor Company Name"); end; - local procedure ExtractXPathField(var xmlDoc: XmlDocument; EDocument: Record "E-Document"; TableId: Integer; FieldNo: Integer; var TargetField: Text) + local procedure ExtractXPathField(var xmlDoc: XmlDocument; DataExchDefCode: Code[20]; TableId: Integer; FieldNo: Integer; var TargetField: Text) var DataExchLineDef: Record "Data Exch. Line Def"; ImportXMLFileToDataExch: Codeunit "Import XML File to Data Exch."; @@ -490,7 +465,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader XPath: Text; XmlValue: Text; begin - DataExchLineDef.SetRange("Data Exch. Def Code", EDocument."Data Exch. Def. Code"); + DataExchLineDef.SetRange("Data Exch. Def Code", DataExchDefCode); DataExchLineDef.SetRange("Parent Code", ''); if not DataExchLineDef.FindFirst() then exit; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 18d8563a2c..a5c0814324 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -55,7 +55,7 @@ codeunit 139897 "E-Doc Data Exch Tests" Assert.AreEqual(DMY2Date(1, 12, 2017), EDocumentPurchaseHeader."Due Date", 'Due Date should be mapped from DueDate.'); Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'Total should be mapped from TaxInclusiveAmount.'); Assert.AreEqual(1656.25, EDocumentPurchaseHeader."Amount Due", 'Amount Due should be mapped from TaxInclusiveAmount.'); - Assert.AreEqual(1300, EDocumentPurchaseHeader."Sub Total", 'Sub Total should be mapped from LineExtensionAmount (Amount field).'); + Assert.AreEqual(1325, EDocumentPurchaseHeader."Sub Total", 'Sub Total should be mapped from TaxExclusiveAmount (Amount field 60).'); end else Assert.Fail(EDocumentStatusNotUpdatedErr); @@ -163,7 +163,7 @@ codeunit 139897 "E-Doc Data Exch Tests" // Total VAT is not directly mapped by the intermediate data -- it remains 0 unless computed elsewhere. // Verify the amounts that ARE mapped are consistent Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'Total should match TaxInclusiveAmount.'); - Assert.AreEqual(1300, EDocumentPurchaseHeader."Sub Total", 'Sub Total should match Amount (LineExtensionAmount).'); + Assert.AreEqual(1325, EDocumentPurchaseHeader."Sub Total", 'Sub Total should match Amount (TaxExclusiveAmount, PH field 60).'); end else Assert.Fail(EDocumentStatusNotUpdatedErr); From 0eed61aa2a17f3ddb1858b5c8c6c67c2b2c78da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 9 Apr 2026 08:51:47 +0200 Subject: [PATCH 16/85] Remove duplicate pre-rename codeunit files (IDs 6403, 6406) --- .../PreparePurchCrMemoDraft.Codeunit.al | 38 ---- .../PreparePurchaseDraft.Codeunit.al | 175 ------------------ 2 files changed, 213 deletions(-) delete mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al delete mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al deleted file mode 100644 index 77c71f0f43..0000000000 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchCrMemoDraft.Codeunit.al +++ /dev/null @@ -1,38 +0,0 @@ -// ------------------------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. -// ------------------------------------------------------------------------------------------------ -namespace Microsoft.eServices.EDocument.Processing.Import; - -using Microsoft.eServices.EDocument; -using Microsoft.eServices.EDocument.Processing.Interfaces; -using Microsoft.Purchases.Vendor; - -codeunit 6403 "Prepare Purch. Cr. Memo Draft" implements IProcessStructuredData -{ - Access = Internal; - - var - PrepareDraftHelper: Codeunit "Prepare Purchase Draft"; - - procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): Enum "E-Document Type" - begin - PrepareDraftHelper.PrepareDraft(EDocument, EDocImportParameters); - exit("E-Document Type"::"Purchase Credit Memo"); - end; - - procedure OpenDraftPage(var EDocument: Record "E-Document") - begin - PrepareDraftHelper.OpenDraftPage(EDocument); - end; - - procedure CleanUpDraft(EDocument: Record "E-Document") - begin - PrepareDraftHelper.CleanUpDraft(EDocument); - end; - - procedure GetVendor(EDocument: Record "E-Document"; Customizations: Enum "E-Doc. Proc. Customizations") Vendor: Record Vendor - begin - Vendor := PrepareDraftHelper.GetVendor(EDocument, Customizations); - end; -} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al deleted file mode 100644 index 239f57ebcc..0000000000 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/PrepareDraft/PreparePurchaseDraft.Codeunit.al +++ /dev/null @@ -1,175 +0,0 @@ -// ------------------------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. -// ------------------------------------------------------------------------------------------------ -namespace Microsoft.eServices.EDocument.Processing.Import; - -using Microsoft.eServices.EDocument; -using Microsoft.eServices.EDocument.Processing.AI; -using Microsoft.eServices.EDocument.Processing.Import.Purchase; -using Microsoft.eServices.EDocument.Processing.Interfaces; -using Microsoft.Foundation.UOM; -using Microsoft.Purchases.Document; -using Microsoft.Purchases.Vendor; -using System.Log; - -/// -/// Shared logic for preparing purchase document drafts (invoices and credit memos). -/// -codeunit 6406 "Prepare Purchase Draft" -{ - Access = Internal; - - var - EDocImpSessionTelemetry: Codeunit "E-Doc. Imp. Session Telemetry"; - - procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters") - var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - UnitOfMeasure: Record "Unit of Measure"; - Vendor: Record Vendor; - PurchaseOrder: Record "Purchase Header"; - EDocVendorAssignmentHistory: Record "E-Doc. Vendor Assign. History"; - EDocPurchaseHistMapping: Codeunit "E-Doc. Purchase Hist. Mapping"; - EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session"; - IUnitOfMeasureProvider: Interface IUnitOfMeasureProvider; - IPurchaseLineProvider: Interface IPurchaseLineProvider; - IPurchaseOrderProvider: Interface IPurchaseOrderProvider; - begin - IUnitOfMeasureProvider := EDocImportParameters."Processing Customizations"; - IPurchaseLineProvider := EDocImportParameters."Processing Customizations"; - IPurchaseOrderProvider := EDocImportParameters."Processing Customizations"; - - if EDocActivityLogSession.CreateSession() then; - - EDocumentPurchaseHeader.GetFromEDocument(EDocument); - EDocumentPurchaseHeader.TestField("E-Document Entry No."); - if EDocumentPurchaseHeader."[BC] Vendor No." = '' then begin - Vendor := GetVendor(EDocument, EDocImportParameters."Processing Customizations"); - EDocumentPurchaseHeader."[BC] Vendor No." := Vendor."No."; - end; - - PurchaseOrder := IPurchaseOrderProvider.GetPurchaseOrder(EDocumentPurchaseHeader); - if PurchaseOrder."No." <> '' then begin - EDocumentPurchaseHeader."[BC] Purchase Order No." := PurchaseOrder."No."; - EDocumentPurchaseHeader.Modify(); - end; - if EDocPurchaseHistMapping.FindRelatedPurchaseHeaderInHistory(EDocument, EDocVendorAssignmentHistory) then - EDocPurchaseHistMapping.UpdateMissingHeaderValuesFromHistory(EDocVendorAssignmentHistory, EDocumentPurchaseHeader); - EDocumentPurchaseHeader.Modify(); - - EDocImpSessionTelemetry.SetBool('Vendor', EDocumentPurchaseHeader."[BC] Vendor No." <> ''); - if EDocumentPurchaseHeader."[BC] Vendor No." <> '' then begin - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - - if EDocumentPurchaseLine.FindSet() then - repeat - UnitOfMeasure := IUnitOfMeasureProvider.GetUnitOfMeasure(EDocument, EDocumentPurchaseLine."Line No.", EDocumentPurchaseLine."Unit of Measure"); - EDocumentPurchaseLine."[BC] Unit of Measure" := UnitOfMeasure.Code; - IPurchaseLineProvider.GetPurchaseLine(EDocumentPurchaseLine); - EDocumentPurchaseLine.Modify(); - until EDocumentPurchaseLine.Next() = 0; - - CopilotLineMatching(EDocument."Entry No"); - end; - - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - if EDocumentPurchaseLine.FindSet() then - repeat - EDocImpSessionTelemetry.SetLine(EDocumentPurchaseLine.SystemId); - until EDocumentPurchaseLine.Next() = 0; - - LogAllActivitySessionChanges(EDocActivityLogSession); - - if EDocActivityLogSession.EndSession() then; - end; - - procedure GetVendor(EDocument: Record "E-Document"; Customizations: Enum "E-Doc. Proc. Customizations") Vendor: Record Vendor - var - IVendorProvider: Interface IVendorProvider; - begin - IVendorProvider := Customizations; - Vendor := IVendorProvider.GetVendor(EDocument); - end; - - procedure OpenDraftPage(var EDocument: Record "E-Document") - var - EDocumentPurchaseDraft: Page "E-Document Purchase Draft"; - begin - EDocumentPurchaseDraft.Editable(true); - EDocumentPurchaseDraft.SetRecord(EDocument); - EDocumentPurchaseDraft.Run(); - end; - - procedure CleanUpDraft(EDocument: Record "E-Document") - var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - begin - EDocumentPurchaseHeader.SetRange("E-Document Entry No.", EDocument."Entry No"); - if not EDocumentPurchaseHeader.IsEmpty() then - EDocumentPurchaseHeader.DeleteAll(true); - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - if not EDocumentPurchaseLine.IsEmpty() then - EDocumentPurchaseLine.DeleteAll(true); - end; - - local procedure LogAllActivitySessionChanges(EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session") - begin - Log(EDocActivityLogSession, EDocActivityLogSession.AccountNumberTok()); - Log(EDocActivityLogSession, EDocActivityLogSession.DeferralTok()); - Log(EDocActivityLogSession, EDocActivityLogSession.ItemRefTok()); - Log(EDocActivityLogSession, EDocActivityLogSession.TextToAccountMappingTok()); - end; - - local procedure Log(EDocActivityLogSession: Codeunit "E-Doc. Activity Log Session"; ActivityLogName: Text) - var - ActivityLog: Codeunit "Activity Log Builder"; - ActivityLogList: List of [Codeunit "Activity Log Builder"]; - Found: Boolean; - begin - Clear(ActivityLogList); - EDocActivityLogSession.GetAll(ActivityLogName, ActivityLogList, Found); - foreach ActivityLog in ActivityLogList do - ActivityLog.Log(); - end; - - local procedure CopilotLineMatching(EDocumentEntryNo: Integer) - var - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - begin - EDocumentPurchaseLine.SetLoadFields("E-Document Entry No.", "[BC] Purchase Type No.", "[BC] Deferral Code"); - EDocumentPurchaseLine.ReadIsolation(IsolationLevel::ReadCommitted); - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.SetRange("[BC] Purchase Type No.", ''); - EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); - - if not EDocumentPurchaseLine.IsEmpty() then begin - Commit(); - Codeunit.Run(Codeunit::"E-Doc. Historical Matching", EDocumentPurchaseLine); - end; - - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.SetRange("[BC] Purchase Type No.", ''); - EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); - if not EDocumentPurchaseLine.IsEmpty() then begin - Commit(); - Codeunit.Run(Codeunit::"E-Doc. GL Account Matching", EDocumentPurchaseLine); - end; - - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.SetRange("[BC] Deferral Code", ''); - EDocumentPurchaseLine.SetRange("[BC] Item Reference No.", ''); - if not EDocumentPurchaseLine.IsEmpty() then begin - Commit(); - if Codeunit.Run(Codeunit::"E-Doc. Deferral Matching", EDocumentPurchaseLine) then; - end; - end; -} From 5cf75c1a24fd3b3fa47125c1a74bdcff91d56b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 11:15:19 +0200 Subject: [PATCH 17/85] Fix build errors: unused method/param, unnecessary begin..end, text overflow - AA0228: Remove unused CreateDataExch local method - AA0137: Remove unused EDocumentPurchaseHeader param from MapIntermediateLineFields - AA0005: Remove unnecessary begin..end around single if-else in SupplementWithXPath - AA0139: Change ExtractXPathField to return Text, callers use CopyStr to prevent overflow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocumentDataExchHandler.Codeunit.al | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al index e0fc63f992..42d59ae94a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -96,16 +96,6 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader exit(DataExchMapping.IsEmpty()); end; - local procedure CreateDataExch(var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"; var TempBlob: Codeunit "Temp Blob") - var - Stream: InStream; - begin - TempBlob.CreateInStream(Stream); - DataExch.Init(); - DataExch.InsertRec('', Stream, DataExchDef.Code); - DataExch.Modify(true); - end; - #endregion Auto-Detection #region Pipeline and Bridge @@ -149,7 +139,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader EDocumentPurchaseHeader.InsertForEDocument(EDocument); MapIntermediateHeaderFields(DataExch, EDocumentPurchaseHeader); - MapIntermediateLineFields(EDocument, DataExch, EDocumentPurchaseHeader); + MapIntermediateLineFields(EDocument, DataExch); ProcessAttachments(EDocument, DataExch); SupplementWithXPath(EDocument, EDocumentPurchaseHeader, TempBlob, DataExchDefCode); @@ -280,7 +270,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader #region Line Field Mapping - local procedure MapIntermediateLineFields(EDocument: Record "E-Document"; DataExch: Record "Data Exch."; EDocumentPurchaseHeader: Record "E-Document Purchase Header") + local procedure MapIntermediateLineFields(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") var IntermediateDataImport: Record "Intermediate Data Import"; EDocumentPurchaseLine: Record "E-Document Purchase Line"; @@ -427,33 +417,32 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader exit; if EDocumentPurchaseHeader."Customer VAT Id" = '' then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo("VAT Registration No."), EDocumentPurchaseHeader."Customer VAT Id"); + EDocumentPurchaseHeader."Customer VAT Id" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo("VAT Registration No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Customer VAT Id")); if EDocumentPurchaseHeader."Customer GLN" = '' then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(GLN), EDocumentPurchaseHeader."Customer GLN"); + EDocumentPurchaseHeader."Customer GLN" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(GLN)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer GLN")); if EDocumentPurchaseHeader."Customer Company Name" = '' then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Name), EDocumentPurchaseHeader."Customer Company Name"); + EDocumentPurchaseHeader."Customer Company Name" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Name)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Name")); if EDocumentPurchaseHeader."Customer Address" = '' then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Address), EDocumentPurchaseHeader."Customer Address"); + EDocumentPurchaseHeader."Customer Address" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Address)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer Address")); - if EDocumentPurchaseHeader."Sales Invoice No." = '' then begin + if EDocumentPurchaseHeader."Sales Invoice No." = '' then if EDocument."Document Type" = EDocument."Document Type"::"Purchase Invoice" then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No."), EDocumentPurchaseHeader."Sales Invoice No.") + EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")) else if EDocument."Document Type" = EDocument."Document Type"::"Purchase Credit Memo" then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No."), EDocumentPurchaseHeader."Sales Invoice No."); - end; + EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); if EDocumentPurchaseHeader."Purchase Order No." = '' then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Order No."), EDocumentPurchaseHeader."Purchase Order No."); + EDocumentPurchaseHeader."Purchase Order No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Order No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Purchase Order No.")); if EDocumentPurchaseHeader."Vendor Company Name" = '' then - ExtractXPathField(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Buy-from Vendor Name"), EDocumentPurchaseHeader."Vendor Company Name"); + EDocumentPurchaseHeader."Vendor Company Name" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Buy-from Vendor Name")), 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Company Name")); end; - local procedure ExtractXPathField(var xmlDoc: XmlDocument; DataExchDefCode: Code[20]; TableId: Integer; FieldNo: Integer; var TargetField: Text) + local procedure ExtractXPathValue(var xmlDoc: XmlDocument; DataExchDefCode: Code[20]; TableId: Integer; FieldNo: Integer): Text var DataExchLineDef: Record "Data Exch. Line Def"; ImportXMLFileToDataExch: Codeunit "Import XML File to Data Exch."; @@ -463,16 +452,15 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader xmlNode: XmlNode; xmlElement: XmlElement; XPath: Text; - XmlValue: Text; begin DataExchLineDef.SetRange("Data Exch. Def Code", DataExchDefCode); DataExchLineDef.SetRange("Parent Code", ''); if not DataExchLineDef.FindFirst() then - exit; + exit(''); XPath := DataExchLineDef.GetPath(TableId, FieldNo); if XPath = '' then - exit; + exit(''); XPath := ImportXMLFileToDataExch.EscapeMissingNamespacePrefix(XPath); @@ -488,12 +476,9 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader xmlNsManager.AddNamespace(DelStr(xmlAttribute.Name, 1, 6), xmlAttribute.Value); if xmlDoc.SelectSingleNode(XPath, xmlNsManager, xmlNode) then - XmlValue := xmlNode.AsXmlElement().InnerText() - else - exit; + exit(xmlNode.AsXmlElement().InnerText()); - if XmlValue <> '' then - TargetField := CopyStr(XmlValue, 1, MaxStrLen(TargetField)); + exit(''); end; #endregion XPath Supplement From bb214ab244ac9e76d73ba4330811a859d5e229f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 12:11:30 +0200 Subject: [PATCH 18/85] Fix AA0181/AA0233: use FindSet instead of FindFirst when followed by Next Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index a5c0814324..3d2888a8fc 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -78,7 +78,7 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); Assert.AreEqual(2, EDocumentPurchaseLine.Count(), 'Expected 2 lines from the invoice XML.'); - EDocumentPurchaseLine.FindFirst(); + EDocumentPurchaseLine.FindSet(); Assert.AreNotEqual('', EDocumentPurchaseLine.Description, 'First line Description should be mapped.'); Assert.AreEqual(7, EDocumentPurchaseLine.Quantity, 'First line Quantity should be 7.'); Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'First line Unit Price should be 400.'); From 207f2e8799a5f7eb331fc9f5283c4a14cb7cf730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 12:35:05 +0200 Subject: [PATCH 19/85] Fix AA0181/AA0233: use repeat...until loop for FindSet+Next The CodeCop analyzer requires FindSet() to be used with a repeat...until Next() = 0 loop. Refactored the line assertions to use a loop with a case statement instead of standalone FindSet() followed by Next(). --- .../Processing/EDocDataExchTests.Codeunit.al | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 3d2888a8fc..65d0a03d5c 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -66,6 +66,7 @@ codeunit 139897 "E-Doc Data Exch Tests" var EDocument: Record "E-Document"; EDocumentPurchaseLine: Record "E-Document Purchase Line"; + LineIndex: Integer; begin // [SCENARIO] Invoice lines are created with Description, Quantity, Unit Price and sequential line numbers Initialize(); @@ -79,15 +80,24 @@ codeunit 139897 "E-Doc Data Exch Tests" Assert.AreEqual(2, EDocumentPurchaseLine.Count(), 'Expected 2 lines from the invoice XML.'); EDocumentPurchaseLine.FindSet(); - Assert.AreNotEqual('', EDocumentPurchaseLine.Description, 'First line Description should be mapped.'); - Assert.AreEqual(7, EDocumentPurchaseLine.Quantity, 'First line Quantity should be 7.'); - Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'First line Unit Price should be 400.'); - Assert.AreNotEqual(0, EDocumentPurchaseLine."Line No.", 'First line should have a non-zero line number.'); - - EDocumentPurchaseLine.Next(); - Assert.AreEqual(-3, EDocumentPurchaseLine.Quantity, 'Second line Quantity should be -3.'); - Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Second line Unit Price should be 500.'); - Assert.IsTrue(EDocumentPurchaseLine."Line No." > 0, 'Second line should have a sequential line number.'); + repeat + LineIndex += 1; + case LineIndex of + 1: + begin + Assert.AreNotEqual('', EDocumentPurchaseLine.Description, 'First line Description should be mapped.'); + Assert.AreEqual(7, EDocumentPurchaseLine.Quantity, 'First line Quantity should be 7.'); + Assert.AreEqual(400, EDocumentPurchaseLine."Unit Price", 'First line Unit Price should be 400.'); + Assert.AreNotEqual(0, EDocumentPurchaseLine."Line No.", 'First line should have a non-zero line number.'); + end; + 2: + begin + Assert.AreEqual(-3, EDocumentPurchaseLine.Quantity, 'Second line Quantity should be -3.'); + Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Second line Unit Price should be 500.'); + Assert.IsTrue(EDocumentPurchaseLine."Line No." > 0, 'Second line should have a sequential line number.'); + end; + end; + until EDocumentPurchaseLine.Next() = 0; end else Assert.Fail(EDocumentStatusNotUpdatedErr); From 62951af221b0480cf8d66dbcca436bb8b5c29fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 15:50:22 +0200 Subject: [PATCH 20/85] Add error diagnostics to test ProcessEDocumentToStep Include the actual error message from the Error Message table when the processing step fails, so build logs reveal the root cause instead of a generic assertion failure. --- .../Test/src/Processing/EDocDataExchTests.Codeunit.al | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 65d0a03d5c..5399efd7a7 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -326,13 +326,21 @@ codeunit 139897 "E-Doc Data Exch Tests" local procedure ProcessEDocumentToStep(var EDocument: Record "E-Document"; ProcessingStep: Enum "Import E-Document Steps"): Boolean var TempEDocImportParameters: Record "E-Doc. Import Parameters"; + ErrorMessage: Record "Error Message"; EDocImport: Codeunit "E-Doc. Import"; EDocumentProcessing: Codeunit "E-Document Processing"; + ErrorText: Text; begin EDocumentProcessing.ModifyEDocumentProcessingStatus(EDocument, "Import E-Doc. Proc. Status"::Readable); TempEDocImportParameters."Step to Run" := ProcessingStep; EDocImport.ProcessIncomingEDocument(EDocument, TempEDocImportParameters); EDocument.CalcFields("Import Processing Status"); - exit(EDocument."Import Processing Status" = Enum::"Import E-Doc. Proc. Status"::"Ready for draft"); + if EDocument."Import Processing Status" <> Enum::"Import E-Doc. Proc. Status"::"Ready for draft" then begin + ErrorMessage.SetRange("Context Record ID", EDocument.RecordId); + if ErrorMessage.FindLast() then + ErrorText := ErrorMessage."Message"; + Assert.Fail(StrSubstNo('Processing failed (status: %1). Error: %2', EDocument."Import Processing Status", ErrorText)); + end; + exit(true); end; } From 58f6fc89c576b5714082c2e7ade86eaa0b690c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 16:41:57 +0200 Subject: [PATCH 21/85] Fix AL0185: use E-Document Log instead of Error Message table The Error Message table is not available in Clean builds. Use E-Document Log fields for diagnostics instead. --- .../Test/src/Processing/EDocDataExchTests.Codeunit.al | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 5399efd7a7..add878be63 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -326,7 +326,7 @@ codeunit 139897 "E-Doc Data Exch Tests" local procedure ProcessEDocumentToStep(var EDocument: Record "E-Document"; ProcessingStep: Enum "Import E-Document Steps"): Boolean var TempEDocImportParameters: Record "E-Doc. Import Parameters"; - ErrorMessage: Record "Error Message"; + EDocLog: Record "E-Document Log"; EDocImport: Codeunit "E-Doc. Import"; EDocumentProcessing: Codeunit "E-Document Processing"; ErrorText: Text; @@ -336,10 +336,11 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocImport.ProcessIncomingEDocument(EDocument, TempEDocImportParameters); EDocument.CalcFields("Import Processing Status"); if EDocument."Import Processing Status" <> Enum::"Import E-Doc. Proc. Status"::"Ready for draft" then begin - ErrorMessage.SetRange("Context Record ID", EDocument.RecordId); - if ErrorMessage.FindLast() then - ErrorText := ErrorMessage."Message"; - Assert.Fail(StrSubstNo('Processing failed (status: %1). Error: %2', EDocument."Import Processing Status", ErrorText)); + EDocLog.SetRange("E-Doc. Entry No", EDocument."Entry No"); + if EDocLog.FindLast() then + ErrorText := Format(EDocLog."Service Status") + ' | ' + Format(EDocLog."Processing Status"); + Assert.Fail(StrSubstNo('Processing failed (status: %1). Log: %2. ReadIntoDraft: %3. Service: %4', + EDocument."Import Processing Status", ErrorText, EDocument."Read into Draft Impl.", EDocument.GetEDocumentService()."Read into Draft Impl.")); end; exit(true); end; From 07acb20ca2139f6a36a12905f983e2a31d0198c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 17:32:00 +0200 Subject: [PATCH 22/85] Fix AL0132: use correct field name Status on E-Document Log --- .../EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index add878be63..2a927acb9c 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -338,7 +338,7 @@ codeunit 139897 "E-Doc Data Exch Tests" if EDocument."Import Processing Status" <> Enum::"Import E-Doc. Proc. Status"::"Ready for draft" then begin EDocLog.SetRange("E-Doc. Entry No", EDocument."Entry No"); if EDocLog.FindLast() then - ErrorText := Format(EDocLog."Service Status") + ' | ' + Format(EDocLog."Processing Status"); + ErrorText := Format(EDocLog.Status) + ' | ' + Format(EDocLog."Processing Status"); Assert.Fail(StrSubstNo('Processing failed (status: %1). Log: %2. ReadIntoDraft: %3. Service: %4', EDocument."Import Processing Status", ErrorText, EDocument."Read into Draft Impl.", EDocument.GetEDocumentService()."Read into Draft Impl.")); end; From 9048edbdbeda9f267af467db6c89106c81dd8e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 18:22:27 +0200 Subject: [PATCH 23/85] Fix AA0217: replace StrSubstNo with string concatenation Clean build requires text constants/labels for StrSubstNo format strings. Use concatenation instead for diagnostic output. --- .../Test/src/Processing/EDocDataExchTests.Codeunit.al | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 2a927acb9c..371caf5435 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -339,8 +339,8 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocLog.SetRange("E-Doc. Entry No", EDocument."Entry No"); if EDocLog.FindLast() then ErrorText := Format(EDocLog.Status) + ' | ' + Format(EDocLog."Processing Status"); - Assert.Fail(StrSubstNo('Processing failed (status: %1). Log: %2. ReadIntoDraft: %3. Service: %4', - EDocument."Import Processing Status", ErrorText, EDocument."Read into Draft Impl.", EDocument.GetEDocumentService()."Read into Draft Impl.")); + Assert.Fail('Processing failed (status: ' + Format(EDocument."Import Processing Status") + '). Log: ' + ErrorText + + '. ReadIntoDraft: ' + Format(EDocument."Read into Draft Impl.") + '. Service: ' + Format(EDocument.GetEDocumentService()."Read into Draft Impl.")); end; exit(true); end; From 9b21a6a0c7bbf38a730f01342821fd64baf91fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Fri, 10 Apr 2026 20:37:16 +0200 Subject: [PATCH 24/85] Ensure PEPPOL Data Exchange Definitions exist in test setup In CI environments, the shipped EDOCPEPPOLINVIMP and EDOCPEPPOLCRMEMOIMP definitions may not exist if the install codeunit hasn't run. Explicitly create them in Initialize() using the E-Document Install codeunit. --- .../src/Processing/EDocDataExchTests.Codeunit.al | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 371caf5435..7de91d464c 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -6,6 +6,7 @@ namespace Microsoft.eServices.EDocument.Test; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Integration; +using Microsoft.eServices.EDocument.IO; using Microsoft.eServices.EDocument.IO.Peppol; using Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument.Processing.Import.Purchase; @@ -257,8 +258,8 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocServiceDataExchDef.DeleteAll(); DocumentAttachment.DeleteAll(); - // Shipped PEPPOL Data Exchange Definitions (EDOCPEPPOLINVIMP, EDOCPEPPOLCRMEMOIMP) are - // installed by E-Document Install codeunit on app install. They should already exist. + // Ensure PEPPOL Data Exchange Definitions exist (they may not in CI environments) + EnsurePEPPOLDataExchDefsExist(); LibraryEDoc.SetupStandardVAT(); LibraryEDoc.SetupStandardSalesScenario(Customer, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); @@ -344,4 +345,15 @@ codeunit 139897 "E-Doc Data Exch Tests" end; exit(true); end; + + local procedure EnsurePEPPOLDataExchDefsExist() + var + DataExchDef: Record "Data Exch. Def"; + EDocumentInstall: Codeunit "E-Document Install"; + begin + if not DataExchDef.Get('EDOCPEPPOLINVIMP') then + EDocumentInstall.ImportInvoiceXML(); + if not DataExchDef.Get('EDOCPEPPOLCRMEMOIMP') then + EDocumentInstall.ImportCreditMemoXML(); + end; } From 4e8cb64e32a87b6a9b226dc4505c198995519d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Mon, 13 Apr 2026 16:20:57 +0200 Subject: [PATCH 25/85] Rename Vendor Invoice No. to Applies-to Ext. Invoice No. and align PEPPOL/DataExch Renames staging field 40 from "Vendor Invoice No." to "Applies-to Ext. Invoice No." to clearly distinguish the credit note's external invoice reference from "Sales Invoice No." (the document's own number). Both PEPPOL and Data Exchange handlers now store BillingReference in the same field. Credit memo creation resolves the external reference to an internal BC "Applies-to Doc. No." by looking up posted purchase invoices. Moved the field from the draft page to the extracted data view page. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EDocCreatePurchCrMemo.Codeunit.al | 17 ++++++++++++++++- .../Purchase/EDocReadablePurchaseDoc.Page.al | 5 +++++ .../Purchase/EDocumentPurchaseDraft.Page.al | 7 ------- .../Purchase/EDocumentPurchaseHeader.Table.al | 4 ++-- .../EDocumentDataExchHandler.Codeunit.al | 2 +- .../EDocumentPEPPOLHandler.Codeunit.al | 4 ++-- .../EDocStructuredValidations.Codeunit.al | 4 ++-- 7 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al index b97c6db072..841a2fc5e7 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al @@ -10,6 +10,7 @@ using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Purchases.Document; +using Microsoft.Purchases.History; using Microsoft.Purchases.Payables; using System.Telemetry; @@ -58,6 +59,17 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, EDocPurchaseDocumentHelper.RevertCreatedDocument(EDocument); end; + local procedure ResolveAppliesToFromExtInvoiceNo(ExtInvoiceNo: Text[100]; var PurchaseHeader: Record "Purchase Header") + var + PurchInvHeader: Record "Purch. Inv. Header"; + begin + PurchInvHeader.SetRange("Vendor Invoice No.", ExtInvoiceNo); + if PurchInvHeader.FindFirst() then begin + PurchaseHeader."Applies-to Doc. Type" := PurchaseHeader."Applies-to Doc. Type"::Invoice; + PurchaseHeader."Applies-to Doc. No." := PurchInvHeader."No."; + end; + end; + procedure CreatePurchaseCreditMemo(EDocument: Record "E-Document"): Record "Purchase Header" var PurchaseHeader: Record "Purchase Header"; @@ -107,7 +119,10 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, PurchaseHeader.Validate("Currency Code", EDocumentPurchaseHeader."Currency Code"); if EDocumentPurchaseHeader."Applies-to Doc. No." <> '' then - PurchaseHeader."Applies-to Doc. No." := CopyStr(EDocumentPurchaseHeader."Applies-to Doc. No.", 1, MaxStrLen(PurchaseHeader."Applies-to Doc. No.")); + PurchaseHeader."Applies-to Doc. No." := CopyStr(EDocumentPurchaseHeader."Applies-to Doc. No.", 1, MaxStrLen(PurchaseHeader."Applies-to Doc. No.")) + else + if EDocumentPurchaseHeader."Applies-to Ext. Invoice No." <> '' then + ResolveAppliesToFromExtInvoiceNo(EDocumentPurchaseHeader."Applies-to Ext. Invoice No.", PurchaseHeader); PurchaseHeader.Modify(); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocReadablePurchaseDoc.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocReadablePurchaseDoc.Page.al index b7be850408..21a786dad1 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocReadablePurchaseDoc.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocReadablePurchaseDoc.Page.al @@ -131,6 +131,11 @@ page 6182 "E-Doc. Readable Purchase Doc." Caption = 'Purchase Order No.'; ToolTip = 'Specifies the purchase order number.'; } + field("Applies-to Ext. Invoice No."; Rec."Applies-to Ext. Invoice No.") + { + Caption = 'Applies-to Ext. Invoice No.'; + ToolTip = 'Specifies the vendor''s original invoice number that this credit memo references.'; + } field("Invoice Date"; Rec."Invoice Date") { Caption = 'Invoice Date'; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al index db638982ec..aa15046609 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al @@ -160,13 +160,6 @@ page 6181 "E-Document Purchase Draft" CurrPage.Update(); end; } - field("Vendor Invoice No."; EDocumentPurchaseHeader."Vendor Invoice No.") - { - Caption = 'Vendor Invoice No.'; - ToolTip = 'Specifies the vendor''s invoice number referenced in the credit memo billing reference.'; - Visible = IsCreditMemo; - Editable = false; - } field("Applies-to Doc. No."; EDocumentPurchaseHeader."Applies-to Doc. No.") { Caption = 'Applies-to Doc. No.'; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al index 67dcfbdc46..06a2997843 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseHeader.Table.al @@ -229,9 +229,9 @@ table 6100 "E-Document Purchase Header" Caption = 'Applies-to Doc. No.'; DataClassification = CustomerContent; } - field(40; "Vendor Invoice No."; Text[100]) + field(40; "Applies-to Ext. Invoice No."; Text[100]) { - Caption = 'Vendor Invoice No.'; + Caption = 'Applies-to Ext. Invoice No.'; DataClassification = CustomerContent; } #endregion Purchase fields diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al index 42d59ae94a..abad60851c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -204,7 +204,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader PurchaseHeader.FieldNo("Currency Code"): // 32 SetCurrencyIfForeign(FieldValue, EDocumentPurchaseHeader."Currency Code"); PurchaseHeader.FieldNo("Applies-to Doc. No."): // 53 - EDocumentPurchaseHeader."Applies-to Doc. No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Applies-to Doc. No.")); + EDocumentPurchaseHeader."Applies-to Ext. Invoice No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Applies-to Ext. Invoice No.")); PurchaseHeader.FieldNo(Amount): // 60 if Evaluate(DecimalVar, FieldValue, 9) then EDocumentPurchaseHeader."Sub Total" := DecimalVar; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al index f9af9884ce..f7d82d018e 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentPEPPOLHandler.Codeunit.al @@ -113,8 +113,8 @@ codeunit 6173 "E-Document PEPPOL Handler" implements IStructuredFormatReader if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:OrderReference/cbc:ID', Value) then Header."Purchase Order No." := CopyStr(Value, 1, MaxStrLen(Header."Purchase Order No.")); if PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/cre:CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID', Value) then - Header."Vendor Invoice No." := CopyStr(Value, 1, MaxStrLen(Header."Vendor Invoice No.")); - if Header."Vendor Invoice No." = '' then + Header."Applies-to Ext. Invoice No." := CopyStr(Value, 1, MaxStrLen(Header."Applies-to Ext. Invoice No.")); + if Header."Applies-to Ext. Invoice No." = '' then Session.LogMessage('0000SNJ', BillingReferenceEmptyTelemetryTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'E-Document'); end; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al index 2fef0f05fb..98a99e284b 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al @@ -165,7 +165,7 @@ codeunit 139894 "EDoc Structured Validations" Assert.AreEqual(DMY2Date(15, 03, 2026), EDocumentPurchaseHeader."Due Date", 'The due date does not match the mock data.'); Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not match the mock data.'); Assert.AreEqual('5', EDocumentPurchaseHeader."Purchase Order No.", 'The order reference does not match the mock data.'); - Assert.AreEqual('103033', EDocumentPurchaseHeader."Vendor Invoice No.", 'The billing reference (vendor invoice no.) does not match the mock data.'); + Assert.AreEqual('103033', EDocumentPurchaseHeader."Applies-to Ext. Invoice No.", 'The billing reference (vendor invoice no.) does not match the mock data.'); Assert.AreEqual('CRONUS International', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match the mock data.'); Assert.AreEqual('Main Street, 14', EDocumentPurchaseHeader."Vendor Address", 'The vendor street does not match the mock data.'); Assert.AreEqual('GB123456789', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match the mock data.'); @@ -439,7 +439,7 @@ codeunit 139894 "EDoc Structured Validations" // CreditNote without PaymentMeans/PaymentDueDate: DueDate should be blank Assert.AreEqual(0D, EDocumentPurchaseHeader."Due Date", 'Due Date should be blank when CreditNote has no PaymentMeans/PaymentDueDate.'); Assert.AreEqual(ExpectedCurrencyCode('EUR', GLSetup."LCY Code"), EDocumentPurchaseHeader."Currency Code", 'The currency code does not match.'); - Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Vendor Invoice No.", 'The BillingReference (Vendor Invoice No.) does not match.'); + Assert.AreEqual('Snippet1', EDocumentPurchaseHeader."Applies-to Ext. Invoice No.", 'The BillingReference (Vendor Invoice No.) does not match.'); Assert.AreEqual('SupplierTradingName Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match.'); Assert.AreEqual('GB1232434', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match.'); Assert.AreEqual(1656.25, EDocumentPurchaseHeader.Total, 'The total does not match.'); From 924093398d915bd38fbb41cf23345d023021f708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Mon, 13 Apr 2026 16:29:53 +0200 Subject: [PATCH 26/85] Rename Data Exch. Handler to PEPPOL DX Handler and thread DocType Renames codeunit 6407 from "E-Document Data Exch. Handler" to "E-Doc. PEPPOL DX Handler" to clarify it handles PEPPOL via Data Exchange definitions. Passes BestDocType through RunPipelineAndBridge to SupplementWithXPath instead of relying on EDocument."Document Type" which may not be set at read-into-draft time. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Import/EDocReadIntoDraft.Enum.al | 2 +- .../EDocumentDataExchHandler.Codeunit.al | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al index 1af32613ae..175744a95c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al @@ -43,6 +43,6 @@ enum 6109 "E-Doc. Read into Draft" implements IStructuredFormatReader value(5; "Data Exchange") { Caption = 'Data Exchange'; - Implementation = IStructuredFormatReader = "E-Document Data Exch. Handler"; + Implementation = IStructuredFormatReader = "E-Doc. PEPPOL DX Handler"; } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al index abad60851c..a64592c2ae 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al @@ -17,7 +17,7 @@ using System.IO; using System.Text; using System.Utilities; -codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader +codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader { Access = Internal; InherentEntitlements = X; @@ -29,7 +29,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader BestDocType: Enum "E-Document Type"; begin FindBestDataExchDef(TempBlob, BestDefCode, BestDocType); - RunPipelineAndBridge(EDocument, TempBlob, BestDefCode); + RunPipelineAndBridge(EDocument, TempBlob, BestDefCode, BestDocType); exit(MapDocumentTypeToProcessDraft(BestDocType)); end; @@ -105,7 +105,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader /// then bridge-maps intermediate data to v2 staging tables. /// Does NOT call DataExchDef.ProcessDataExchange which would invoke the pre-mapping codeunit. /// - local procedure RunPipelineAndBridge(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) + local procedure RunPipelineAndBridge(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]; DocType: Enum "E-Document Type") var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"; @@ -128,11 +128,11 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader if DataExchDef."Data Handling Codeunit" <> 0 then Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); - BridgeMapToStagingTables(EDocument, DataExch, TempBlob, DataExchDefCode); + BridgeMapToStagingTables(EDocument, DataExch, TempBlob, DataExchDefCode, DocType); DeleteIntermediateData(DataExch); end; - local procedure BridgeMapToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch."; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) + local procedure BridgeMapToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch."; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]; DocType: Enum "E-Document Type") var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; begin @@ -141,7 +141,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader MapIntermediateHeaderFields(DataExch, EDocumentPurchaseHeader); MapIntermediateLineFields(EDocument, DataExch); ProcessAttachments(EDocument, DataExch); - SupplementWithXPath(EDocument, EDocumentPurchaseHeader, TempBlob, DataExchDefCode); + SupplementWithXPath(DocType, EDocumentPurchaseHeader, TempBlob, DataExchDefCode); EDocumentPurchaseHeader.Modify(); end; @@ -405,7 +405,7 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader /// Extracts fields still blank on staging header via XPath from the raw XML. /// Uses DataExchLineDef.GetPath() to look up the XPath for each field. /// - local procedure SupplementWithXPath(EDocument: Record "E-Document"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) + local procedure SupplementWithXPath(DocType: Enum "E-Document Type"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) var CompanyInformation: Record "Company Information"; PurchaseHeader: Record "Purchase Header"; @@ -429,10 +429,10 @@ codeunit 6407 "E-Document Data Exch. Handler" implements IStructuredFormatReader EDocumentPurchaseHeader."Customer Address" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Address)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer Address")); if EDocumentPurchaseHeader."Sales Invoice No." = '' then - if EDocument."Document Type" = EDocument."Document Type"::"Purchase Invoice" then + if DocType = DocType::"Purchase Invoice" then EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")) else - if EDocument."Document Type" = EDocument."Document Type"::"Purchase Credit Memo" then + if DocType = DocType::"Purchase Credit Memo" then EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); if EDocumentPurchaseHeader."Purchase Order No." = '' then From 0cff1d7ebd5d0ee5eeb524ce92d5aaefa15f671d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Mon, 13 Apr 2026 16:38:13 +0200 Subject: [PATCH 27/85] Rename file to match object name E-Doc. PEPPOL DX Handler Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ataExchHandler.Codeunit.al => EDocPEPPOLDXHandler.Codeunit.al} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocumentDataExchHandler.Codeunit.al => EDocPEPPOLDXHandler.Codeunit.al} (100%) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentDataExchHandler.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al From d78d45a17c717174666463238a89674504452703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 14 Apr 2026 11:02:29 +0200 Subject: [PATCH 28/85] Add v2 Data Exchange Definitions and use ProcessDataExchange Creates new PEPPOL import definitions (EDOCPEPPOLINVIMPV2, EDOCPEPPOLCRMEMOIMPV2) stored as XML resource files and loaded via NavApp.GetResource. These v2 definitions have no pre-mapping codeunit, making them safe for the v2 import pipeline where Prepare Draft handles vendor/GL resolution separately. The DX handler now calls ProcessDataExchange conformantly instead of manually running individual pipeline steps. FindBestDataExchDef matches the document namespace against known PEPPOL BIS 3.0 namespaces directly. Renames the enum caption from "Data Exchange" to "PEPPOL BIS 3 - Data Exchange" for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e-Doc PEPPOL Cr. Memo Import V2.xml | 138 ++++++++++++++++++ .../e-Doc PEPPOL Invoice Import V2.xml | 135 +++++++++++++++++ .../App/src/EDocumentInstall.Codeunit.al | 60 +++++++- .../Import/EDocReadIntoDraft.Enum.al | 2 +- .../EDocPEPPOLDXHandler.Codeunit.al | 68 ++++----- .../Processing/EDocDataExchTests.Codeunit.al | 4 + 6 files changed, 363 insertions(+), 44 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml create mode 100644 src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml new file mode 100644 index 0000000000..8a5085b4cb --- /dev/null +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml new file mode 100644 index 0000000000..1f61a6afd0 --- /dev/null +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al b/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al index 1ce97e76e5..cd31b7aace 100644 --- a/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------------------------ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. // ------------------------------------------------------------------------------------------------ @@ -18,6 +18,7 @@ codeunit 6161 "E-Document Install" trigger OnInstallAppPerCompany() begin InsertDataExch(); + InsertDataExchV2(); end; trigger OnInstallAppPerDatabase() @@ -47,6 +48,19 @@ codeunit 6161 "E-Document Install" UpgradeTag.SetUpgradeTag(GetEDOCDataExchUpdateTag()); end; + internal procedure InsertDataExchV2() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(GetEDOCDataExchV2UpdateTag()) then + exit; + + ImportInvoiceV2XML(); + ImportCreditMemoV2XML(); + + UpgradeTag.SetUpgradeTag(GetEDOCDataExchV2UpdateTag()); + end; + internal procedure ImportServiceInvoiceXML() var DataExchDef: Record "Data Exch. Def"; @@ -167,10 +181,49 @@ codeunit 6161 "E-Document Install" Clear(TempBlob); end; + internal procedure ImportInvoiceV2XML() + var + DataExchDef: Record "Data Exch. Def"; + TempBlob: Codeunit "Temp Blob"; + XMLOutStream: OutStream; + XMLInStream: InStream; + ResInStream: InStream; + begin + if DataExchDef.Get('EDOCPEPPOLINVIMPV2') then + DataExchDef.Delete(true); + + NavApp.GetResource('DataExchange/e-Doc PEPPOL Invoice Import V2.xml', ResInStream); + TempBlob.CreateOutStream(XMLOutStream); + CopyStream(XMLOutStream, ResInStream); + TempBlob.CreateInStream(XMLInStream); + Xmlport.Import(Xmlport::"Imp / Exp Data Exch Def & Map", XMLInStream); + Clear(TempBlob); + end; + + internal procedure ImportCreditMemoV2XML() + var + DataExchDef: Record "Data Exch. Def"; + TempBlob: Codeunit "Temp Blob"; + XMLOutStream: OutStream; + XMLInStream: InStream; + ResInStream: InStream; + begin + if DataExchDef.Get('EDOCPEPPOLCRMEMOIMPV2') then + DataExchDef.Delete(true); + + NavApp.GetResource('DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml', ResInStream); + TempBlob.CreateOutStream(XMLOutStream); + CopyStream(XMLOutStream, ResInStream); + TempBlob.CreateInStream(XMLInStream); + Xmlport.Import(Xmlport::"Imp / Exp Data Exch Def & Map", XMLInStream); + Clear(TempBlob); + end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] local procedure RegisterUpgradeTags(var PerCompanyUpgradeTags: List of [Code[250]]) begin PerCompanyUpgradeTags.Add(GetEDOCDataExchUpdateTag()); + PerCompanyUpgradeTags.Add(GetEDOCDataExchV2UpdateTag()); end; local procedure GetEDOCDataExchUpdateTag(): Code[250] @@ -178,6 +231,11 @@ codeunit 6161 "E-Document Install" exit('MS-365688-EDOCDataExchPEPPOL-20231113'); end; + local procedure GetEDOCDataExchV2UpdateTag(): Code[250] + begin + exit('MS-EDOCDataExchPEPPOLV2-20260414'); + end; + var DataExchangeInvXML1Txt: Label ''; DataExchangeCrMXML1Txt: Label '', Locked = true; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al index 175744a95c..e263d98c11 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al @@ -42,7 +42,7 @@ enum 6109 "E-Doc. Read into Draft" implements IStructuredFormatReader } value(5; "Data Exchange") { - Caption = 'Data Exchange'; + Caption = 'PEPPOL BIS 3 - Data Exchange'; Implementation = IStructuredFormatReader = "E-Doc. PEPPOL DX Handler"; } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index a64592c2ae..0ca375bebf 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -5,7 +5,6 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument; -using Microsoft.eServices.EDocument.IO.Peppol; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; @@ -41,36 +40,32 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #region Auto-Detection /// - /// Finds the Data Exchange Definition that matches the document. - /// Matches the document's XML root namespace against each definition's configured - /// namespace (from DataExchLineDef). This avoids the v1 trial-and-error approach - /// which requires Commit() + Codeunit.Run() — incompatible with v2's try-function context. + /// Determines the v2 Data Exchange Definition code by matching the document's + /// XML root namespace against known PEPPOL BIS 3.0 namespaces. /// local procedure FindBestDataExchDef(var TempBlob: Codeunit "Temp Blob"; var BestDefCode: Code[20]; var BestDocType: Enum "E-Document Type") var - EDocumentDataExchDef: Record "E-Doc. Service Data Exch. Def."; - DataExchLineDef: Record "Data Exch. Line Def"; + DataExchDef: Record "Data Exch. Def"; DocumentNamespace: Text; begin DocumentNamespace := GetDocumentRootNamespace(TempBlob); - EDocumentDataExchDef.SetFilter("Impt. Data Exchange Def. Code", '<>%1', ''); - if EDocumentDataExchDef.FindSet() then - repeat - if DataExchDefUsesIntermediate(EDocumentDataExchDef."Impt. Data Exchange Def. Code") then begin - // Match document namespace against definition's expected namespace - DataExchLineDef.SetRange("Data Exch. Def Code", EDocumentDataExchDef."Impt. Data Exchange Def. Code"); - DataExchLineDef.SetRange("Parent Code", ''); - if DataExchLineDef.FindFirst() then - if (DataExchLineDef.Namespace = '') or (DataExchLineDef.Namespace = DocumentNamespace) then begin - BestDefCode := EDocumentDataExchDef."Impt. Data Exchange Def. Code"; - BestDocType := EDocumentDataExchDef."Document Type"; - exit; - end; + case DocumentNamespace of + InvoiceNamespaceTxt: + begin + BestDefCode := InvoiceDefCodeTok; + BestDocType := "E-Document Type"::"Purchase Invoice"; end; - until EDocumentDataExchDef.Next() = 0; + CreditNoteNamespaceTxt: + begin + BestDefCode := CreditMemoDefCodeTok; + BestDocType := "E-Document Type"::"Purchase Credit Memo"; + end; + else + Error(ProcessFailedErr); + end; - if BestDefCode = '' then + if not DataExchDef.Get(BestDefCode) then Error(ProcessFailedErr); end; @@ -87,23 +82,15 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader exit(RootElement.NamespaceUri()); end; - local procedure DataExchDefUsesIntermediate(DataExchDefCode: Code[20]): Boolean - var - DataExchMapping: Record "Data Exch. Mapping"; - begin - DataExchMapping.SetRange("Data Exch. Def Code", DataExchDefCode); - DataExchMapping.SetRange("Use as Intermediate Table", false); - exit(DataExchMapping.IsEmpty()); - end; - #endregion Auto-Detection #region Pipeline and Bridge /// - /// Runs the Data Exchange pipeline (Reading/Writing + Data Handling codeunits only), - /// then bridge-maps intermediate data to v2 staging tables. - /// Does NOT call DataExchDef.ProcessDataExchange which would invoke the pre-mapping codeunit. + /// Runs the full Data Exchange pipeline via ProcessDataExchange, then + /// bridge-maps intermediate data to v2 staging tables. + /// The v2 definitions have no pre-mapping codeunit, so ProcessDataExchange + /// runs Reading/Writing + Data Handling only — conformant with the framework. /// local procedure RunPipelineAndBridge(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]; DocType: Enum "E-Document Type") var @@ -119,14 +106,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader DataExch."Related Record" := EDocument.RecordId; DataExch.Modify(true); - if not DataExch.ImportToDataExch(DataExchDef) then - Error(ProcessFailedErr); - - // Run Data Handling Codeunit to map Data Exch. Field → Intermediate Data Import. - // Do NOT call DataExchDef.ProcessDataExchange() which also runs Pre-Mapping (6156) - // that does vendor/GL resolution — in v2, Prepare Draft handles that. - if DataExchDef."Data Handling Codeunit" <> 0 then - Codeunit.Run(DataExchDef."Data Handling Codeunit", DataExch); + DataExchDef.ProcessDataExchange(DataExch); BridgeMapToStagingTables(EDocument, DataExch, TempBlob, DataExchDefCode, DocType); DeleteIntermediateData(DataExch); @@ -551,5 +531,9 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #endregion Integration Events var + InvoiceDefCodeTok: Label 'EDOCPEPPOLINVIMPV2', Locked = true; + CreditMemoDefCodeTok: Label 'EDOCPEPPOLCRMEMOIMPV2', Locked = true; + InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; + CreditNoteNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2', Locked = true; ProcessFailedErr: Label 'Failed to process the file with data exchange.'; } diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 7de91d464c..67e5b129ed 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -355,5 +355,9 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocumentInstall.ImportInvoiceXML(); if not DataExchDef.Get('EDOCPEPPOLCRMEMOIMP') then EDocumentInstall.ImportCreditMemoXML(); + if not DataExchDef.Get('EDOCPEPPOLINVIMPV2') then + EDocumentInstall.ImportInvoiceV2XML(); + if not DataExchDef.Get('EDOCPEPPOLCRMEMOIMPV2') then + EDocumentInstall.ImportCreditMemoV2XML(); end; } From 79a1b590e571007f2e171cfb15b1bced139fe38a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 14 Apr 2026 11:43:40 +0200 Subject: [PATCH 29/85] Remap v2 Data Exchange defs to staging tables and replace hardcoded bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2 definitions now target E-Document Purchase Header (6100) and E-Document Purchase Line (6101) directly instead of BC standard tables. This makes the field mapping fully configurable through the product UI. Added 6 new XML columns per definition to match PEPPOL handler 1:1: - SupplierRegistrationName, SupplierContactName, SupplierTaxSchemeCompanyID - PayeeLegalEntityCompanyID, CustomerRegistrationName, CustLegalEntityCompanyID Replaced the hardcoded MapPurchaseHeaderField/MapPurchaseLineField case statements with a generic RecordRef-based bridge that reads intermediate data by staging table field IDs. Post-processing handles Total VAT calculation, Amount Due, and Currency LCY-blank convention. Removed XPath supplement fallback — no longer needed since the Data Exchange definitions now map directly to staging fields. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../App/.resources/DataExchange/V2-MAPPING.md | 229 ++++++++++++ .../e-Doc PEPPOL Cr. Memo Import V2.xml | 103 +++--- .../e-Doc PEPPOL Invoice Import V2.xml | 101 +++-- .../EDocPEPPOLDXHandler.Codeunit.al | 349 +++++------------- 4 files changed, 418 insertions(+), 364 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md b/src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md new file mode 100644 index 0000000000..d0f5633304 --- /dev/null +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md @@ -0,0 +1,229 @@ +# V2 Data Exchange Definition — Complete Field Mapping + +Derived systematically from the PEPPOL handler + utility code to ensure 1:1 parity. + +Target staging tables: +- **6100** = E-Document Purchase Header +- **6101** = E-Document Purchase Line +- **1173** = Document Attachment (unchanged from v1) + +--- + +## PEPPOL HANDLER EXTRACTION → DATA EXCHANGE MAPPING + +### PopulateInvoiceDocumentInfo / PopulateCreditNoteDocumentInfo + +| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | +|---|---------------------|--------------------------|-------------|------------|-------| +| H1 | `/Invoice/cbc:ID` | 6100/5 Sales Invoice No. | 1 | 1 | CrMemo: `/CreditNote/cbc:ID` | +| H2 | `/Invoice/cac:OrderReference/cbc:ID` | 6100/4 Purchase Order No. | 5 | 5 | | +| H3 | `/CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID` | 6100/40 Applies-to Ext. Invoice No. | — | 6 | Credit memo only | + +### PopulateSupplierInfo (utility lines 107-132) + +| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | +|---|---------------------|--------------------------|-------------|------------|-------| +| S1 | `.../AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name` | 6100/9 Vendor Company Name | 8 | 9 | Primary vendor name | +| S2 | `.../AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName` | 6100/9 Vendor Company Name | NEW-43 | NEW-44 | Fallback if S1 empty. **NEW COLUMN NEEDED** | +| S3 | `.../PayeeParty/cac:PartyName/cbc:Name` | 6100/9 Vendor Company Name | 16 | 16 | Overrides S1/S2 if present | +| S4 | `.../AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name` | 6100/37 Vendor Contact Name | NEW-44 | NEW-45 | **NEW COLUMN NEEDED** | +| S5 | `.../AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName` | 6100/10 Vendor Address | 30 | 30 | | +| S6 | `.../AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID` | 6100/31 Vendor VAT Id | NEW-45 | NEW-46 | Primary VAT source. **NEW COLUMN NEEDED** | +| S7 | `.../PayeeParty/cac:PartyLegalEntity/cbc:CompanyID` | 6100/31 Vendor VAT Id | NEW-46 | NEW-47 | Overrides S6 if present. **NEW COLUMN NEEDED** | +| S8 | `.../AccountingSupplierParty/cac:Party/cbc:EndpointID` (schemeID=0088) | 6100/35 Vendor GLN | 6 | 7 | Only when schemeID=0088 | + +### PopulateCustomerInfo (utility lines 139-167) + +| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | +|---|---------------------|--------------------------|-------------|------------|-------| +| C1 | `.../AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name` | 6100/2 Customer Company Name | 28 | 28 | Primary customer name | +| C2 | `.../AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName` | 6100/2 Customer Company Name | NEW-47 | NEW-48 | Fallback if C1 empty. **NEW COLUMN NEEDED** | +| C3 | `.../AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID` | 6100/32 Customer VAT Id | NEW-48 | NEW-49 | First VAT source (no schemeID filter). **NEW COLUMN NEEDED** | +| C4 | `.../AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID` | 6100/32 Customer VAT Id | 13 | 14 | Overrides C3 if present | +| C5 | `.../AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName` | 6100/12 Customer Address | 29 | 29 | | +| C6 | `.../AccountingCustomerParty/cac:Party/cbc:EndpointID` (schemeID=0088) | 6100/34 Customer GLN | 11 | 12 | Only when schemeID=0088 | +| C7 | `.../AccountingCustomerParty/cac:Party/cbc:EndpointID` (as schemeID:value) | 6100/3 Customer Company Id | NEW-49 | NEW-50 | Format: "schemeID:value". **NEW COLUMN NEEDED** | + +### PopulateAmountsAndDates (utility lines 174-183) + +| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | +|---|---------------------|--------------------------|-------------|------------|-------| +| A1 | `.../LegalMonetaryTotal/cbc:PayableAmount` | 6100/21 Total | 25 | 25 | | +| A2 | `.../LegalMonetaryTotal/cbc:TaxExclusiveAmount` | 6100/18 Sub Total | 38 | 39 | CrMemo col shifted | +| A3 | `.../LegalMonetaryTotal/cbc:AllowanceTotalAmount` | 6100/19 Total Discount | 17 | 17 | | +| A4 | *Calculated: Total - Sub Total - Total Discount* | 6100/20 Total VAT | — | — | **Cannot map via DataExch.** Handler calculates this. DX will leave blank — must be calculated in bridge or post-processing. | +| A5 | `/Invoice/cbc:DueDate` | 6100/7 Due Date | 36 | 36 | CrMemo path: `.../PaymentMeans/cbc:PaymentDueDate` | +| A6 | `.../cbc:IssueDate` | 6100/8 Document Date | 2 | 2 | | + +### PopulateCurrency (utility lines 188-194) + +| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | +|---|---------------------|--------------------------|-------------|------------|-------| +| CU1 | `.../cbc:DocumentCurrencyCode` | 6100/24 Currency Code | 3 | 3 | | + +--- + +## LINE EXTRACTION — PopulatePurchaseLine (utility lines 204-237) + +| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | +|---|---------------------|--------------------------|-------------|------------|-------| +| L1 | `.../cbc:InvoicedQuantity` | 6101/6 Quantity | 2 | 2 | CrMemo: `cbc:CreditedQuantity` | +| L2 | `.../cbc:InvoicedQuantity/@unitCode` | 6101/7 Unit of Measure | 3 | 3 | | +| L3 | `.../cbc:LineExtensionAmount` | 6101/9 Sub Total | 4 | 4 | | +| L4 | `.../cac:AllowanceCharge/cbc:Amount` | 6101/10 Total Discount | 6 | 6 | Handler has NO ChargeIndicator filter; v1 DX had `[ChargeIndicator='false']`. Keep v1 XPath filter for correctness. | +| L5 | `.../cac:Item/cbc:Name` | 6101/5 Description | 11 | 11 | Primary description | +| L6 | `.../cac:Item/cbc:Description` | 6101/5 Description | 10 | 10 | Fallback if L5 empty | +| L7 | `.../cac:Item/cac:SellersItemIdentification/cbc:ID` | 6101/4 Product Code | 12 | 12 | | +| L8 | `.../cac:Item/cac:StandardItemIdentification/cbc:ID` | 6101/4 Product Code | 13 | 13 | Overrides L7 if present | +| L9 | `.../cac:Item/cac:ClassifiedTaxCategory/cbc:Percent` | 6101/11 VAT Rate | 15 | 15 | | +| L10 | `.../cac:Price/cbc:PriceAmount` | 6101/8 Unit Price | 16 | 16 | | +| L11 | `.../cbc:LineExtensionAmount/@currencyID` | 6101/12 Currency Code | 5 | 5 | | + +--- + +## ITEMS NOT MAPPABLE VIA DATA EXCHANGE + +| Item | PEPPOL Handler Behavior | Data Exchange Limitation | +|------|------------------------|--------------------------| +| Total VAT calculation | `Total - Sub Total - Discount` | DataExch cannot do arithmetic. Must be calculated in bridge code or post-processing. | +| Document-level charge lines | Handler creates extra E-Document Purchase Line records | DataExch column defs are per-line-element; cannot create extra lines from header-level elements. | +| Customer Company Id format | Handler formats as `schemeID:value` | DataExch can only extract the element value, not concatenate with attribute. NEW column will extract raw EndpointID value only. | +| Vendor GLN schemeID check | Handler only sets GLN when `@schemeID='0088'` | v1 XPath `EndpointID[@schemeID='0088']` handles this correctly via XPath filter. | +| Customer GLN schemeID check | Handler only sets GLN when `@schemeID='0088'` | Same — XPath filter handles this. | + +--- + +## COMPLETE INVOICE HEADER MAPPING (PEPPOLINVHEADER → 6100) + +Columns from v1 retained + NEW columns added. Column numbers follow v1 numbering with NEW columns appended. + +| Col | Name | XPath | Target | Field | Optional | +|-----|------|-------|--------|-------|----------| +| 1 | ID | /Invoice/cbc:ID | 6100/5 | Sales Invoice No. | | +| 2 | IssueDate | /Invoice/cbc:IssueDate | 6100/8 | Document Date | | +| 3 | DocumentCurrencyCode | /Invoice/cbc:DocumentCurrencyCode | 6100/24 | Currency Code | | +| 5 | OrderReferenceID | /Invoice/cac:OrderReference/cbc:ID | 6100/4 | Purchase Order No. | | +| 6 | SupplierEndpointGLNID | .../EndpointID[@schemeID='0088'] | 6100/35 | Vendor GLN | | +| 8 | SupplierName | .../PartyName/cbc:Name | 6100/9 | Vendor Company Name | | +| 11 | CustomerEndpointIDGLN | .../AccountingCustomerParty/.../EndpointID[@schemeID='0088'] | 6100/34 | Customer GLN | Yes | +| 13 | CustPartyTaxSchemeCompanyID | .../PartyTaxScheme/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | +| 16 | PartyLegalEntityName | /Invoice/cac:PayeeParty/cac:PartyName/cbc:Name | 6100/9 | Vendor Company Name | | +| 17 | DiscountAmount | .../LegalMonetaryTotal/cbc:AllowanceTotalAmount | 6100/19 | Total Discount | Yes | +| 25 | PayableAmount | .../LegalMonetaryTotal/cbc:PayableAmount | 6100/21 | Total | Yes | +| 28 | CustomerPartyName | .../AccountingCustomerParty/.../PartyName/cbc:Name | 6100/2 | Customer Company Name | Yes | +| 29 | CustomerPartyStreetName | .../AccountingCustomerParty/.../PostalAddress/cbc:StreetName | 6100/12 | Customer Address | Yes | +| 30 | SupplierStreetName | .../AccountingSupplierParty/.../PostalAddress/cbc:StreetName | 6100/10 | Vendor Address | | +| 36 | DueDate | /Invoice/cbc:DueDate | 6100/7 | Due Date | | +| 38 | AmountExclVAT | .../LegalMonetaryTotal/cbc:TaxExclusiveAmount | 6100/18 | Sub Total | Yes | +| 43 | SupplierRegistrationName | .../AccountingSupplierParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/9 | Vendor Company Name | Yes | **NEW** | +| 44 | SupplierContactName | .../AccountingSupplierParty/.../Contact/cbc:Name | 6100/37 | Vendor Contact Name | Yes | **NEW** | +| 45 | SupplierTaxSchemeCompanyID | .../AccountingSupplierParty/.../PartyTaxScheme/cbc:CompanyID | 6100/31 | Vendor VAT Id | | **NEW** | +| 46 | PayeePartyLegalEntityCompanyID | /Invoice/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID | 6100/31 | Vendor VAT Id | Yes | **NEW** | +| 47 | CustomerRegistrationName | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/2 | Customer Company Name | Yes | **NEW** | +| 48 | CustPartyLegalEntityCompanyID | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | **NEW** | + +### Dropped from v1 (no staging equivalent): + +| v1 Col | Name | Reason | +|--------|------|--------| +| 7 | SupplierEndpointVATID | Replaced by col 45 (PartyTaxScheme source, matching handler) | +| 9 | PartyLegalEntityCompanyIDGLN | Redundant with col 6 | +| 10 | PartyLegalEntityCompanyIDVAT | Redundant with col 45 | +| 12 | CustomerPartyIdentificationIDGLN | Redundant with col 11 | +| 14 | CustPartyLegalEntityCompanyID | Now col 48 (without schemeID filter, matching handler) | +| 18-24 | Currency/Charge/Prepaid fields | No staging equivalent | +| 26-27 | PayableAmountCurrencyID, YourReference | No staging equivalent | +| 31-35 | Payee GLN/VAT, ChargeReason | Vendor lookup fields not needed in v2 | +| 37 | DocumentType constant | Determined by namespace | +| 39-42 | Bank fields | No staging equivalent | +| 40 | TaxAmount | Total VAT is calculated, not read directly | + +--- + +## COMPLETE CREDIT MEMO HEADER MAPPING (PEPPOLCRMEMOHEADER → 6100) + +Credit memo has different column numbering due to col 6 = BillingReference (shifting subsequent cols). + +| Col | Name | XPath | Target | Field | Optional | +|-----|------|-------|--------|-------|----------| +| 1 | ID | /CreditNote/cbc:ID | 6100/5 | Sales Invoice No. | | +| 2 | IssueDate | /CreditNote/cbc:IssueDate | 6100/8 | Document Date | | +| 3 | DocumentCurrencyCode | /CreditNote/cbc:DocumentCurrencyCode | 6100/24 | Currency Code | | +| 5 | OrderReferenceID | /CreditNote/cac:OrderReference/cbc:ID | 6100/4 | Purchase Order No. | Yes | +| 6 | InvoiceDocumentReferenceId | .../BillingReference/cac:InvoiceDocumentReference/cbc:ID | 6100/40 | Applies-to Ext. Invoice No. | | +| 7 | SupplierEndpointGLNID | .../EndpointID[@schemeID='0088'] | 6100/35 | Vendor GLN | | +| 9 | SupplierName | .../PartyName/cbc:Name | 6100/9 | Vendor Company Name | | +| 12 | CustomerEndpointIDGLN | .../AccountingCustomerParty/.../EndpointID[@schemeID='0088'] | 6100/34 | Customer GLN | Yes | +| 14 | CustPartyTaxSchemeCompanyID | .../PartyTaxScheme/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | +| 16 | PartyLegalEntityName | /CreditNote/cac:PayeeParty/cac:PartyName/cbc:Name | 6100/9 | Vendor Company Name | | +| 17 | DiscountAmount | .../LegalMonetaryTotal/cbc:AllowanceTotalAmount | 6100/19 | Total Discount | Yes | +| 25 | PayableAmount | .../LegalMonetaryTotal/cbc:PayableAmount | 6100/21 | Total | Yes | +| 28 | CustomerPartyName | .../AccountingCustomerParty/.../PartyName/cbc:Name | 6100/2 | Customer Company Name | Yes | +| 29 | CustomerPartyStreetName | .../AccountingCustomerParty/.../PostalAddress/cbc:StreetName | 6100/12 | Customer Address | Yes | +| 30 | SupplierStreetName | .../AccountingSupplierParty/.../PostalAddress/cbc:StreetName | 6100/10 | Vendor Address | | +| 36 | PaymentDueDate | /CreditNote/cac:PaymentMeans/cbc:PaymentDueDate | 6100/7 | Due Date | | +| 39 | AmountExclVAT | .../LegalMonetaryTotal/cbc:TaxExclusiveAmount | 6100/18 | Sub Total | Yes | +| 44 | SupplierRegistrationName | .../AccountingSupplierParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/9 | Vendor Company Name | Yes | **NEW** | +| 45 | SupplierContactName | .../AccountingSupplierParty/.../Contact/cbc:Name | 6100/37 | Vendor Contact Name | Yes | **NEW** | +| 46 | SupplierTaxSchemeCompanyID | .../AccountingSupplierParty/.../PartyTaxScheme/cbc:CompanyID | 6100/31 | Vendor VAT Id | | **NEW** | +| 47 | PayeePartyLegalEntityCompanyID | /CreditNote/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID | 6100/31 | Vendor VAT Id | Yes | **NEW** | +| 48 | CustomerRegistrationName | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/2 | Customer Company Name | Yes | **NEW** | +| 49 | CustPartyLegalEntityCompanyID | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | **NEW** | + +--- + +## COMPLETE LINE MAPPING (PEPPOLINVLINES / PEPPOLCRMEMOLINES → 6101) + +Same for both invoice and credit memo. Column numbers are identical. + +| Col | Name | XPath | Target | Field | Optional | +|-----|------|-------|--------|-------|----------| +| 2 | Quantity | .../cbc:InvoicedQuantity (or CreditedQuantity) | 6101/6 | Quantity | | +| 3 | unitCode | .../cbc:InvoicedQuantity/@unitCode | 6101/7 | Unit of Measure | | +| 4 | LineExtensionAmount | .../cbc:LineExtensionAmount | 6101/9 | Sub Total | Yes | +| 5 | LineExtensionAmountCurrencyID | .../cbc:LineExtensionAmount/@currencyID | 6101/12 | Currency Code | | +| 6 | InvLnDiscountAmount | .../AllowanceCharge[ChargeIndicator='false']/cbc:Amount | 6101/10 | Total Discount | | +| 10 | Description | .../cac:Item/cbc:Description | 6101/5 | Description | | +| 11 | Name | .../cac:Item/cbc:Name | 6101/5 | Description | | +| 12 | SellersItemIdentificationID | .../SellersItemIdentification/cbc:ID | 6101/4 | Product Code | Yes | +| 13 | StandardItemIdentificationID | .../StandardItemIdentification/cbc:ID[@schemeID='0088'] | 6101/4 | Product Code | | +| 15 | TaxPercent | .../ClassifiedTaxCategory/cbc:Percent | 6101/11 | VAT Rate | Yes | +| 16 | PriceAmount | .../cac:Price/cbc:PriceAmount | 6101/8 | Unit Price | | +| 17 | PriceAmountCurrencyID | .../cac:Price/cbc:PriceAmount/@currencyID | 6101/12 | Currency Code | | + +### Dropped from v1 lines: +| v1 Col | Name | Reason | +|--------|------|--------| +| 1 | InvoiceLineNote | Not mapped in v1 either | +| 7 | InvLnDiscountAmtCurrID | Currency already from col 5 | +| 8 | InvoiceLineTaxAmount | No staging field for line VAT amount | +| 9 | currencyID | Redundant with col 5 | +| 18 | BaseQuantity | No staging field | + +--- + +## ATTACHMENTS (unchanged from v1) + +TableId="1214", UseAsIntermediateTable="true", targeting table 1173. + +| Col | Name | Target | Field | +|-----|------|--------|-------| +| 1 | AdditionalDocumentReferenceID | 1173/1 | File Name | +| 2 | EmbeddedDocumentBinaryObject | 1173/8 | Document Reference ID | +| 3 | MimeCode | 1173/7 | File Type | +| 4 | Filename | 1173/5 | File Name | + +--- + +## BRIDGE CODE STILL NEEDED FOR + +1. **Total VAT**: Calculate `Total - Sub Total - Total Discount` after all fields populated +2. **Amount Due**: Copy from Total (handler doesn't set this but the old bridge did) +3. **Customer Company Id**: Format as `schemeID:value` from EndpointID — DataExch cannot concatenate attribute+value +4. **Currency Code LCY-blank**: Compare against GL Setup LCY Code, blank when match — DataExch writes raw XML value +5. **Document-level charge lines**: Create extra E-Document Purchase Line records from AllowanceCharge[ChargeIndicator='true'] — DataExch cannot create lines from header-level elements + +## XPATH DIFFERENCES FROM V1 (intentional) + +- **Line col 13 (StandardItemIdentificationID)**: Remove `[@schemeID='0088']` filter to match handler behavior (accepts any schemeID) +- **Line col 6 (InvLnDiscountAmount)**: Keep `[ChargeIndicator='false']` filter — more correct than handler which has no filter diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml index 8a5085b4cb..ad7af6e696 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml @@ -1,7 +1,7 @@ - + @@ -44,46 +44,36 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -111,27 +101,24 @@ - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml index 1f61a6afd0..d75a4ee25c 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml @@ -1,7 +1,7 @@ - + @@ -42,45 +42,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -108,27 +98,24 @@ - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index 0ca375bebf..92158f0f97 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -9,10 +9,8 @@ using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Foundation.Attachment; -using Microsoft.Foundation.Company; -using Microsoft.Purchases.Document; -using Microsoft.Purchases.Vendor; using System.IO; +using System.Reflection; using System.Text; using System.Utilities; @@ -28,7 +26,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader BestDocType: Enum "E-Document Type"; begin FindBestDataExchDef(TempBlob, BestDefCode, BestDocType); - RunPipelineAndBridge(EDocument, TempBlob, BestDefCode, BestDocType); + RunPipelineAndBridge(EDocument, TempBlob, BestDefCode); exit(MapDocumentTypeToProcessDraft(BestDocType)); end; @@ -89,10 +87,10 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader /// /// Runs the full Data Exchange pipeline via ProcessDataExchange, then /// bridge-maps intermediate data to v2 staging tables. - /// The v2 definitions have no pre-mapping codeunit, so ProcessDataExchange - /// runs Reading/Writing + Data Handling only — conformant with the framework. + /// The v2 definitions target staging tables (6100/6101) directly and have + /// no pre-mapping codeunit — conformant with the Data Exchange framework. /// - local procedure RunPipelineAndBridge(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]; DocType: Enum "E-Document Type") + local procedure RunPipelineAndBridge(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"; @@ -108,156 +106,92 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader DataExchDef.ProcessDataExchange(DataExch); - BridgeMapToStagingTables(EDocument, DataExch, TempBlob, DataExchDefCode, DocType); + BridgeToStagingTables(EDocument, DataExch); DeleteIntermediateData(DataExch); end; - local procedure BridgeMapToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch."; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]; DocType: Enum "E-Document Type") + local procedure BridgeToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; begin EDocumentPurchaseHeader.InsertForEDocument(EDocument); - MapIntermediateHeaderFields(DataExch, EDocumentPurchaseHeader); - MapIntermediateLineFields(EDocument, DataExch); + MapIntermediateToHeader(DataExch, EDocumentPurchaseHeader); + PostProcessHeader(EDocumentPurchaseHeader); + EDocumentPurchaseHeader.Modify(); + + MapIntermediateToLines(EDocument, DataExch); ProcessAttachments(EDocument, DataExch); - SupplementWithXPath(DocType, EDocumentPurchaseHeader, TempBlob, DataExchDefCode); - EDocumentPurchaseHeader.Modify(); + OnAfterBridgeToStagingTables(DataExch."Entry No.", EDocumentPurchaseHeader); end; #endregion Pipeline and Bridge - #region Header Field Mapping + #region Header Mapping - local procedure MapIntermediateHeaderFields(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + /// + /// Reads intermediate data targeting table 6100 (E-Document Purchase Header) + /// and assigns values to the staging record using the configured field mappings. + /// + local procedure MapIntermediateToHeader(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") var IntermediateDataImport: Record "Intermediate Data Import"; + RecordRef: RecordRef; + FieldRef: FieldRef; FieldValue: Text; begin - // Map Purchase Header (Table 38) fields IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - IntermediateDataImport.SetRange("Table ID", Database::"Purchase Header"); + IntermediateDataImport.SetRange("Table ID", Database::"E-Document Purchase Header"); IntermediateDataImport.SetRange("Parent Record No.", 0); - if IntermediateDataImport.FindSet() then - repeat - FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, 250); - MapPurchaseHeaderField(IntermediateDataImport."Field ID", FieldValue, EDocumentPurchaseHeader); - until IntermediateDataImport.Next() = 0; - - // Map Company Information (Table 79) fields - IntermediateDataImport.Reset(); - IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - IntermediateDataImport.SetRange("Table ID", Database::"Company Information"); - if IntermediateDataImport.FindSet() then - repeat - FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, 250); - MapCompanyInfoField(IntermediateDataImport."Field ID", FieldValue, EDocumentPurchaseHeader); - until IntermediateDataImport.Next() = 0; - - // Map Vendor (Table 23) fields - IntermediateDataImport.Reset(); - IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - IntermediateDataImport.SetRange("Table ID", Database::"Vendor"); - if IntermediateDataImport.FindSet() then - repeat - FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, 250); - MapVendorField(IntermediateDataImport."Field ID", FieldValue, EDocumentPurchaseHeader); - until IntermediateDataImport.Next() = 0; - - OnAfterMapIntermediateHeaderToStaging(DataExch."Entry No.", EDocumentPurchaseHeader); - end; - - local procedure MapPurchaseHeaderField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") - var - PurchaseHeader: Record "Purchase Header"; - DateVar: Date; - DecimalVar: Decimal; - begin - case FieldId of - PurchaseHeader.FieldNo("Pay-to Name"): // 5 - if EDocumentPurchaseHeader."Vendor Company Name" = '' then - EDocumentPurchaseHeader."Vendor Company Name" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Company Name")); - PurchaseHeader.FieldNo("Due Date"): // 24 - if Evaluate(DateVar, FieldValue, 9) then - EDocumentPurchaseHeader."Due Date" := DateVar; - PurchaseHeader.FieldNo("Currency Code"): // 32 - SetCurrencyIfForeign(FieldValue, EDocumentPurchaseHeader."Currency Code"); - PurchaseHeader.FieldNo("Applies-to Doc. No."): // 53 - EDocumentPurchaseHeader."Applies-to Ext. Invoice No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Applies-to Ext. Invoice No.")); - PurchaseHeader.FieldNo(Amount): // 60 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseHeader."Sub Total" := DecimalVar; - PurchaseHeader.FieldNo("Amount Including VAT"): // 61 - if Evaluate(DecimalVar, FieldValue, 9) then begin - EDocumentPurchaseHeader.Total := DecimalVar; - EDocumentPurchaseHeader."Amount Due" := DecimalVar; - end; - PurchaseHeader.FieldNo("Vendor Order No."): // 66 - EDocumentPurchaseHeader."Purchase Order No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Purchase Order No.")); - PurchaseHeader.FieldNo("Vendor Invoice No."): // 68 - EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); - PurchaseHeader.FieldNo("Vendor Cr. Memo No."): // 69 - EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); - PurchaseHeader.FieldNo("VAT Registration No."): // 70 - EDocumentPurchaseHeader."Vendor VAT Id" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor VAT Id")); - PurchaseHeader.FieldNo("Buy-from Vendor Name"): // 79 - EDocumentPurchaseHeader."Vendor Company Name" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Company Name")); - PurchaseHeader.FieldNo("Buy-from Address"): // 81 - EDocumentPurchaseHeader."Vendor Address" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Address")); - PurchaseHeader.FieldNo("Document Date"): // 99 - if Evaluate(DateVar, FieldValue, 9) then - EDocumentPurchaseHeader."Document Date" := DateVar; - PurchaseHeader.FieldNo("Invoice Discount Value"): // 122 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseHeader."Total Discount" := DecimalVar; - // Fields 1, 2, 4, 11, 114 - skip (not mapped to staging) - end; - end; + if not IntermediateDataImport.FindSet() then + exit; - local procedure MapCompanyInfoField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") - var - CompanyInformation: Record "Company Information"; - begin - case FieldId of - CompanyInformation.FieldNo(Name): // 2 - if EDocumentPurchaseHeader."Customer Company Name" = '' then - EDocumentPurchaseHeader."Customer Company Name" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Name")); - CompanyInformation.FieldNo(Address): // 4 - if EDocumentPurchaseHeader."Customer Address" = '' then - EDocumentPurchaseHeader."Customer Address" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Address")); - CompanyInformation.FieldNo("VAT Registration No."): // 19 - if EDocumentPurchaseHeader."Customer VAT Id" = '' then - EDocumentPurchaseHeader."Customer VAT Id" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer VAT Id")); - CompanyInformation.FieldNo(GLN): // 90 - if EDocumentPurchaseHeader."Customer GLN" = '' then - EDocumentPurchaseHeader."Customer GLN" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer GLN")); - end; + RecordRef.GetTable(EDocumentPurchaseHeader); + repeat + if RecordRef.FieldExist(IntermediateDataImport."Field ID") then begin + FieldRef := RecordRef.Field(IntermediateDataImport."Field ID"); + FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, GetFieldMaxLength(FieldRef)); + if FieldValue <> '' then + AssignFieldValue(FieldRef, FieldValue); + end; + until IntermediateDataImport.Next() = 0; + RecordRef.SetTable(EDocumentPurchaseHeader); end; - local procedure MapVendorField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") - var - Vendor: Record Vendor; + /// + /// Post-processes header fields that cannot be handled by Data Exchange alone: + /// - Total VAT: calculated from Total - Sub Total - Total Discount + /// - Amount Due: copied from Total + /// - Currency Code: LCY-blank convention + /// + local procedure PostProcessHeader(var EDocumentPurchaseHeader: Record "E-Document Purchase Header") begin - case FieldId of - Vendor.FieldNo("VAT Registration No."): // 86 - if EDocumentPurchaseHeader."Vendor VAT Id" = '' then - EDocumentPurchaseHeader."Vendor VAT Id" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseHeader."Vendor VAT Id")); - end; + EDocumentPurchaseHeader."Total VAT" := EDocumentPurchaseHeader.Total - EDocumentPurchaseHeader."Sub Total" - EDocumentPurchaseHeader."Total Discount"; + EDocumentPurchaseHeader."Amount Due" := EDocumentPurchaseHeader.Total; + ApplyLCYBlankConvention(EDocumentPurchaseHeader."Currency Code"); end; - #endregion Header Field Mapping + #endregion Header Mapping - #region Line Field Mapping + #region Line Mapping - local procedure MapIntermediateLineFields(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") + /// + /// Reads intermediate data targeting table 6101 (E-Document Purchase Line) + /// and creates staging line records. Each distinct Record No. in intermediate + /// data becomes a separate staging line. + /// + local procedure MapIntermediateToLines(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") var IntermediateDataImport: Record "Intermediate Data Import"; EDocumentPurchaseLine: Record "E-Document Purchase Line"; + RecordRef: RecordRef; + FieldRef: FieldRef; + FieldValue: Text; CurrRecordNo: Integer; begin IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); - IntermediateDataImport.SetRange("Table ID", Database::"Purchase Line"); + IntermediateDataImport.SetRange("Table ID", Database::"E-Document Purchase Line"); IntermediateDataImport.SetCurrentKey("Record No."); if not IntermediateDataImport.FindSet() then @@ -267,61 +201,42 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader repeat if CurrRecordNo <> IntermediateDataImport."Record No." then begin if CurrRecordNo <> -1 then begin + RecordRef.SetTable(EDocumentPurchaseLine); + PostProcessLine(EDocumentPurchaseLine); EDocumentPurchaseLine.Insert(); - OnAfterMapIntermediateLineToStaging(DataExch."Entry No.", CurrRecordNo, EDocumentPurchaseLine); + OnAfterMapLineToStaging(DataExch."Entry No.", CurrRecordNo, EDocumentPurchaseLine); end; Clear(EDocumentPurchaseLine); EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); + RecordRef.GetTable(EDocumentPurchaseLine); CurrRecordNo := IntermediateDataImport."Record No."; end; - MapPurchaseLineField(IntermediateDataImport."Field ID", CopyStr(IntermediateDataImport.GetValue(), 1, 250), EDocumentPurchaseLine); + if RecordRef.FieldExist(IntermediateDataImport."Field ID") then begin + FieldRef := RecordRef.Field(IntermediateDataImport."Field ID"); + FieldValue := CopyStr(IntermediateDataImport.GetValue(), 1, GetFieldMaxLength(FieldRef)); + if FieldValue <> '' then + AssignFieldValue(FieldRef, FieldValue); + RecordRef.SetTable(EDocumentPurchaseLine); + RecordRef.GetTable(EDocumentPurchaseLine); + end; until IntermediateDataImport.Next() = 0; // Insert last line + RecordRef.SetTable(EDocumentPurchaseLine); + PostProcessLine(EDocumentPurchaseLine); EDocumentPurchaseLine.Insert(); - OnAfterMapIntermediateLineToStaging(DataExch."Entry No.", CurrRecordNo, EDocumentPurchaseLine); + OnAfterMapLineToStaging(DataExch."Entry No.", CurrRecordNo, EDocumentPurchaseLine); end; - local procedure MapPurchaseLineField(FieldId: Integer; FieldValue: Text; var EDocumentPurchaseLine: Record "E-Document Purchase Line") - var - PurchaseLine: Record "Purchase Line"; - DecimalVar: Decimal; + local procedure PostProcessLine(var EDocumentPurchaseLine: Record "E-Document Purchase Line") begin - case FieldId of - PurchaseLine.FieldNo("No."): // 6 - if EDocumentPurchaseLine."Product Code" = '' then - EDocumentPurchaseLine."Product Code" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine."Product Code")); - PurchaseLine.FieldNo(Description): // 11 - EDocumentPurchaseLine.Description := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine.Description)); - PurchaseLine.FieldNo(Quantity): // 15 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseLine.Quantity := DecimalVar; - PurchaseLine.FieldNo("Direct Unit Cost"): // 22 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseLine."Unit Price" := DecimalVar; - PurchaseLine.FieldNo("VAT %"): // 25 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseLine."VAT Rate" := DecimalVar; - PurchaseLine.FieldNo("Line Discount Amount"): // 28 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseLine."Total Discount" := DecimalVar; - PurchaseLine.FieldNo(Amount): // 29 - if Evaluate(DecimalVar, FieldValue, 9) then - EDocumentPurchaseLine."Sub Total" := DecimalVar; - PurchaseLine.FieldNo("Currency Code"): // 91 - SetCurrencyIfForeign(FieldValue, EDocumentPurchaseLine."Currency Code"); - PurchaseLine.FieldNo("Unit of Measure Code"): // 5407 - EDocumentPurchaseLine."Unit of Measure" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine."Unit of Measure")); - PurchaseLine.FieldNo("Item Reference No."): // 5725 - EDocumentPurchaseLine."Product Code" := CopyStr(FieldValue, 1, MaxStrLen(EDocumentPurchaseLine."Product Code")); - // Fields 12, 30, 5415 - skip (no staging equivalent) - end; + ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code"); end; - #endregion Line Field Mapping + #endregion Line Mapping #region Attachment Processing @@ -370,7 +285,6 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader end; until IntermediateDataImport.Next() = 0; - // Process last attachment if any if FileName <> '' then begin AttachmentTempBlob.CreateInStream(InStream); EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); @@ -379,111 +293,48 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #endregion Attachment Processing - #region XPath Supplement + #region Field Value Helpers - /// - /// Extracts fields still blank on staging header via XPath from the raw XML. - /// Uses DataExchLineDef.GetPath() to look up the XPath for each field. - /// - local procedure SupplementWithXPath(DocType: Enum "E-Document Type"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; var TempBlob: Codeunit "Temp Blob"; DataExchDefCode: Code[20]) + local procedure AssignFieldValue(var FieldRef: FieldRef; FieldValue: Text) var - CompanyInformation: Record "Company Information"; - PurchaseHeader: Record "Purchase Header"; - xmlDoc: XmlDocument; - InStream: InStream; + DateVar: Date; + DecimalVar: Decimal; begin - TempBlob.CreateInStream(InStream); - if not XmlDocument.ReadFrom(InStream, xmlDoc) then - exit; - - if EDocumentPurchaseHeader."Customer VAT Id" = '' then - EDocumentPurchaseHeader."Customer VAT Id" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo("VAT Registration No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Customer VAT Id")); - - if EDocumentPurchaseHeader."Customer GLN" = '' then - EDocumentPurchaseHeader."Customer GLN" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(GLN)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer GLN")); - - if EDocumentPurchaseHeader."Customer Company Name" = '' then - EDocumentPurchaseHeader."Customer Company Name" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Name)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Name")); - - if EDocumentPurchaseHeader."Customer Address" = '' then - EDocumentPurchaseHeader."Customer Address" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Company Information", CompanyInformation.FieldNo(Address)), 1, MaxStrLen(EDocumentPurchaseHeader."Customer Address")); - - if EDocumentPurchaseHeader."Sales Invoice No." = '' then - if DocType = DocType::"Purchase Invoice" then - EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Invoice No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")) - else - if DocType = DocType::"Purchase Credit Memo" then - EDocumentPurchaseHeader."Sales Invoice No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Cr. Memo No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Sales Invoice No.")); - - if EDocumentPurchaseHeader."Purchase Order No." = '' then - EDocumentPurchaseHeader."Purchase Order No." := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Vendor Order No.")), 1, MaxStrLen(EDocumentPurchaseHeader."Purchase Order No.")); - - if EDocumentPurchaseHeader."Vendor Company Name" = '' then - EDocumentPurchaseHeader."Vendor Company Name" := CopyStr(ExtractXPathValue(xmlDoc, DataExchDefCode, Database::"Purchase Header", PurchaseHeader.FieldNo("Buy-from Vendor Name")), 1, MaxStrLen(EDocumentPurchaseHeader."Vendor Company Name")); + case FieldRef.Type of + FieldType::Text, FieldType::Code: + FieldRef.Value := CopyStr(FieldValue, 1, FieldRef.Length); + FieldType::Date: + if Evaluate(DateVar, FieldValue, 9) then + FieldRef.Value := DateVar; + FieldType::Decimal: + if Evaluate(DecimalVar, FieldValue, 9) then + FieldRef.Value := DecimalVar; + end; end; - local procedure ExtractXPathValue(var xmlDoc: XmlDocument; DataExchDefCode: Code[20]; TableId: Integer; FieldNo: Integer): Text - var - DataExchLineDef: Record "Data Exch. Line Def"; - ImportXMLFileToDataExch: Codeunit "Import XML File to Data Exch."; - xmlNsManager: XmlNamespaceManager; - xmlAttrCollection: XmlAttributeCollection; - xmlAttribute: XmlAttribute; - xmlNode: XmlNode; - xmlElement: XmlElement; - XPath: Text; + local procedure GetFieldMaxLength(FieldRef: FieldRef): Integer begin - DataExchLineDef.SetRange("Data Exch. Def Code", DataExchDefCode); - DataExchLineDef.SetRange("Parent Code", ''); - if not DataExchLineDef.FindFirst() then - exit(''); - - XPath := DataExchLineDef.GetPath(TableId, FieldNo); - if XPath = '' then - exit(''); - - XPath := ImportXMLFileToDataExch.EscapeMissingNamespacePrefix(XPath); - - xmlNsManager.NameTable(xmlDoc.NameTable); - xmlDoc.GetRoot(xmlElement); - - if xmlElement.NamespaceUri <> '' then - xmlNsManager.AddNamespace('', xmlElement.NamespaceUri); - - xmlAttrCollection := xmlElement.Attributes(); - foreach xmlAttribute in xmlAttrCollection do - if StrPos(xmlAttribute.Name, 'xmlns:') = 1 then - xmlNsManager.AddNamespace(DelStr(xmlAttribute.Name, 1, 6), xmlAttribute.Value); - - if xmlDoc.SelectSingleNode(XPath, xmlNsManager, xmlNode) then - exit(xmlNode.AsXmlElement().InnerText()); - - exit(''); + if FieldRef.Type in [FieldType::Text, FieldType::Code] then + exit(FieldRef.Length); + exit(250); end; - #endregion XPath Supplement - - #region Currency Helper - /// - /// BC convention: blank Currency Code means LCY. Sets the field to the currency code - /// only if it differs from LCY. Explicitly blanks the field when it matches LCY. + /// BC convention: blank Currency Code means LCY. Blanks the field when it matches LCY. /// - local procedure SetCurrencyIfForeign(CurrencyFromXml: Text; var CurrencyCode: Code[10]) + local procedure ApplyLCYBlankConvention(var CurrencyCode: Code[10]) var GLSetup: Record "General Ledger Setup"; begin - if CurrencyFromXml = '' then + if CurrencyCode = '' then exit; GLSetup.GetRecordOnce(); - if GLSetup."LCY Code" = CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)) then - CurrencyCode := '' - else - CurrencyCode := CopyStr(CurrencyFromXml, 1, MaxStrLen(CurrencyCode)); + if GLSetup."LCY Code" = CurrencyCode then + CurrencyCode := ''; end; - #endregion Currency Helper + #endregion Field Value Helpers #region Document Type Mapping @@ -519,12 +370,12 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #region Integration Events [IntegrationEvent(false, false)] - local procedure OnAfterMapIntermediateHeaderToStaging(DataExchNo: Integer; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + local procedure OnAfterBridgeToStagingTables(DataExchNo: Integer; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") begin end; [IntegrationEvent(false, false)] - local procedure OnAfterMapIntermediateLineToStaging(DataExchNo: Integer; RecordNo: Integer; var EDocumentPurchaseLine: Record "E-Document Purchase Line") + local procedure OnAfterMapLineToStaging(DataExchNo: Integer; RecordNo: Integer; var EDocumentPurchaseLine: Record "E-Document Purchase Line") begin end; From 778f6e02fb2b4f0cd59bdef7fe32d68ef83426bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 14 Apr 2026 13:43:11 +0200 Subject: [PATCH 30/85] =?UTF-8?q?Fix=20Data=20Exch.=20Def=20codes=20to=20?= =?UTF-8?q?=E2=89=A420=20chars=20and=20remove=20file=20name=20spaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorten definition codes from EDOCPEPPOLINVIMPV2 (21 chars, over limit) to EDOCPEPINVIMPV2 (15) and EDOCPEPCRMEMOIMPV2 (18). Rename resource files to remove spaces: eDocPEPPOLInvoiceImportV2.xml, eDocPEPPOLCrMemoImportV2.xml Co-Authored-By: Claude Opus 4.6 (1M context) --- ...r. Memo Import V2.xml => eDocPEPPOLCrMemoImportV2.xml} | 2 +- ...nvoice Import V2.xml => eDocPEPPOLInvoiceImportV2.xml} | 2 +- .../W1/EDocument/App/src/EDocumentInstall.Codeunit.al | 8 ++++---- .../EDocPEPPOLDXHandler.Codeunit.al | 5 ++--- .../Test/src/Processing/EDocDataExchTests.Codeunit.al | 4 ++-- 5 files changed, 10 insertions(+), 11 deletions(-) rename src/Apps/W1/EDocument/App/.resources/DataExchange/{e-Doc PEPPOL Cr. Memo Import V2.xml => eDocPEPPOLCrMemoImportV2.xml} (99%) rename src/Apps/W1/EDocument/App/.resources/DataExchange/{e-Doc PEPPOL Invoice Import V2.xml => eDocPEPPOLInvoiceImportV2.xml} (99%) diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml similarity index 99% rename from src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml rename to src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml index ad7af6e696..ba9184fe2d 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml @@ -1,6 +1,6 @@ - + diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml similarity index 99% rename from src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml rename to src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml index d75a4ee25c..a732910d3d 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/e-Doc PEPPOL Invoice Import V2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml @@ -1,6 +1,6 @@ - + diff --git a/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al b/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al index cd31b7aace..5fa5a9c4d1 100644 --- a/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al @@ -189,10 +189,10 @@ codeunit 6161 "E-Document Install" XMLInStream: InStream; ResInStream: InStream; begin - if DataExchDef.Get('EDOCPEPPOLINVIMPV2') then + if DataExchDef.Get('EDOCPEPINVIMPV2') then DataExchDef.Delete(true); - NavApp.GetResource('DataExchange/e-Doc PEPPOL Invoice Import V2.xml', ResInStream); + NavApp.GetResource('DataExchange/eDocPEPPOLInvoiceImportV2.xml', ResInStream); TempBlob.CreateOutStream(XMLOutStream); CopyStream(XMLOutStream, ResInStream); TempBlob.CreateInStream(XMLInStream); @@ -208,10 +208,10 @@ codeunit 6161 "E-Document Install" XMLInStream: InStream; ResInStream: InStream; begin - if DataExchDef.Get('EDOCPEPPOLCRMEMOIMPV2') then + if DataExchDef.Get('EDOCPEPCRMEMOIMPV2') then DataExchDef.Delete(true); - NavApp.GetResource('DataExchange/e-Doc PEPPOL Cr. Memo Import V2.xml', ResInStream); + NavApp.GetResource('DataExchange/eDocPEPPOLCrMemoImportV2.xml', ResInStream); TempBlob.CreateOutStream(XMLOutStream); CopyStream(XMLOutStream, ResInStream); TempBlob.CreateInStream(XMLInStream); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index 92158f0f97..d82b11802e 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -10,7 +10,6 @@ using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Foundation.Attachment; using System.IO; -using System.Reflection; using System.Text; using System.Utilities; @@ -382,8 +381,8 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #endregion Integration Events var - InvoiceDefCodeTok: Label 'EDOCPEPPOLINVIMPV2', Locked = true; - CreditMemoDefCodeTok: Label 'EDOCPEPPOLCRMEMOIMPV2', Locked = true; + InvoiceDefCodeTok: Label 'EDOCPEPINVIMPV2', Locked = true; + CreditMemoDefCodeTok: Label 'EDOCPEPCRMEMOIMPV2', Locked = true; InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; CreditNoteNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2', Locked = true; ProcessFailedErr: Label 'Failed to process the file with data exchange.'; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 67e5b129ed..41524213e9 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -355,9 +355,9 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocumentInstall.ImportInvoiceXML(); if not DataExchDef.Get('EDOCPEPPOLCRMEMOIMP') then EDocumentInstall.ImportCreditMemoXML(); - if not DataExchDef.Get('EDOCPEPPOLINVIMPV2') then + if not DataExchDef.Get('EDOCPEPINVIMPV2') then EDocumentInstall.ImportInvoiceV2XML(); - if not DataExchDef.Get('EDOCPEPPOLCRMEMOIMPV2') then + if not DataExchDef.Get('EDOCPEPCRMEMOIMPV2') then EDocumentInstall.ImportCreditMemoV2XML(); end; } From 849cdcabef561f239230beba6676349b1f24b693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 19 May 2026 17:49:16 +0200 Subject: [PATCH 31/85] Fix test --- .../DataExchange/eDocPEPPOLCrMemoImportV2.xml | 4 +- .../eDocPEPPOLInvoiceImportV2.xml | 4 +- .../Import/EDocReadIntoDraft.Enum.al | 2 +- .../EDocPEPPOLDXHandler.Codeunit.al | 2 + .../Processing/EDocDataExchTests.Codeunit.al | 38 +++++++++++++------ 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml index ba9184fe2d..4880bb07c6 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml @@ -50,7 +50,7 @@ - + @@ -106,7 +106,7 @@ - + diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml index a732910d3d..f31c667e46 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml @@ -48,7 +48,7 @@ - + @@ -103,7 +103,7 @@ - + diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al index e263d98c11..35907dbb1b 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al @@ -40,7 +40,7 @@ enum 6109 "E-Doc. Read into Draft" implements IStructuredFormatReader Caption = 'MLLM'; Implementation = IStructuredFormatReader = "E-Document MLLM Handler"; } - value(5; "Data Exchange") + value(5; "PEPPOL Data Exchange") { Caption = 'PEPPOL BIS 3 - Data Exchange'; Implementation = IStructuredFormatReader = "E-Doc. PEPPOL DX Handler"; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index d82b11802e..00f3330c42 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -103,6 +103,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader DataExch."Related Record" := EDocument.RecordId; DataExch.Modify(true); + DataExch.ImportToDataExch(DataExchDef); DataExchDef.ProcessDataExchange(DataExch); BridgeToStagingTables(EDocument, DataExch); @@ -191,6 +192,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader begin IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); IntermediateDataImport.SetRange("Table ID", Database::"E-Document Purchase Line"); + IntermediateDataImport.SetFilter("Parent Record No.", '>%1', 0); IntermediateDataImport.SetCurrentKey("Record No."); if not IntermediateDataImport.FindSet() then diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 41524213e9..a8e3d8d84f 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -35,6 +35,24 @@ codeunit 139897 "E-Doc Data Exch Tests" IsInitialized: Boolean; EDocumentStatusNotUpdatedErr: Label 'The status of the EDocument was not updated to the expected status after the step was executed.'; + [Test] + procedure VerifyV2FieldMappingsImported() + var + DataExchFieldMapping: Record "Data Exch. Field Mapping"; + begin + // [SCENARIO] V2 Data Exchange Definition field mappings have correct Target Table ID and Target Field ID + Initialize(); + + // [WHEN] Checking if column 8 (vendor name) mapping exists + DataExchFieldMapping.SetRange("Data Exch. Def Code", 'EDOCPEPINVIMPV2'); + DataExchFieldMapping.SetRange("Column No.", 8); + DataExchFieldMapping.SetRange("Target Table ID", Database::"E-Document Purchase Header"); + DataExchFieldMapping.SetRange("Target Field ID", 9); // Vendor Company Name + + // [THEN] The mapping record exists + Assert.IsTrue(DataExchFieldMapping.FindFirst(), 'Column 8 should map to Table 6100 Field 9 (Vendor Company Name)'); + end; + [Test] procedure InvoiceReadIntoDraft_HeaderFieldsMapped() var @@ -246,6 +264,9 @@ codeunit 139897 "E-Doc Data Exch Tests" Clear(EDocImplState); Clear(LibraryVariableStorage); + // Ensure PEPPOL Data Exchange Definitions exist (they may not in CI environments) + EnsurePEPPOLDataExchDefsExist(); + if IsInitialized then exit; @@ -258,14 +279,11 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocServiceDataExchDef.DeleteAll(); DocumentAttachment.DeleteAll(); - // Ensure PEPPOL Data Exchange Definitions exist (they may not in CI environments) - EnsurePEPPOLDataExchDefsExist(); - LibraryEDoc.SetupStandardVAT(); LibraryEDoc.SetupStandardSalesScenario(Customer, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); LibraryEDoc.SetupStandardPurchaseScenario(Vendor, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); EDocumentService."Import Process" := "E-Document Import Process"::"Version 2.0"; - EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"Data Exchange"; + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"PEPPOL Data Exchange"; EDocumentService.Modify(); // Set a currency that can be used across all localizations @@ -283,7 +301,7 @@ codeunit 139897 "E-Doc Data Exch Tests" var EDocServiceDataExchDef: Record "E-Doc. Service Data Exch. Def."; begin - EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"Data Exchange"; + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"PEPPOL Data Exchange"; EDocumentService.Modify(); // Link the service to the shipped PEPPOL Invoice import Data Exchange Definition @@ -293,7 +311,7 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocServiceDataExchDef.Init(); EDocServiceDataExchDef."E-Document Format Code" := EDocumentService.Code; EDocServiceDataExchDef."Document Type" := EDocServiceDataExchDef."Document Type"::"Purchase Invoice"; - EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPPOLINVIMP'; + EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPINVIMPV2'; EDocServiceDataExchDef.Insert(); end; @@ -304,7 +322,7 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocServiceDataExchDef.Init(); EDocServiceDataExchDef."E-Document Format Code" := EDocumentService.Code; EDocServiceDataExchDef."Document Type" := EDocServiceDataExchDef."Document Type"::"Purchase Credit Memo"; - EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPPOLCRMEMOIMP'; + EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPCRMEMOIMPV2'; if not EDocServiceDataExchDef.Insert() then EDocServiceDataExchDef.Modify(); end; @@ -355,9 +373,7 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocumentInstall.ImportInvoiceXML(); if not DataExchDef.Get('EDOCPEPPOLCRMEMOIMP') then EDocumentInstall.ImportCreditMemoXML(); - if not DataExchDef.Get('EDOCPEPINVIMPV2') then - EDocumentInstall.ImportInvoiceV2XML(); - if not DataExchDef.Get('EDOCPEPCRMEMOIMPV2') then - EDocumentInstall.ImportCreditMemoV2XML(); + EDocumentInstall.ImportInvoiceV2XML(); + EDocumentInstall.ImportCreditMemoV2XML(); end; } From f7ae9de946a52ce81937a239378b1ff191d84714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 19 May 2026 21:10:39 +0200 Subject: [PATCH 32/85] More tests --- .../DataExchange/eDocPEPPOLCrMemoImportV2.xml | 17 +- .../eDocPEPPOLInvoiceImportV2.xml | 17 +- .../EDocPEPPOLDXHandler.Codeunit.al | 117 ++++++++++++ .../peppol/peppol-invoice-vat-category-z.xml | 6 + .../Processing/EDocDataExchTests.Codeunit.al | 171 +++++++++++++++++- 5 files changed, 319 insertions(+), 9 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml index 4880bb07c6..b9fb04dc70 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml @@ -1,7 +1,7 @@ - + @@ -50,6 +50,8 @@ + + @@ -68,12 +70,9 @@ - - - @@ -121,5 +120,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml index f31c667e46..e557b86514 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml @@ -1,7 +1,7 @@ - + @@ -48,6 +48,8 @@ + + @@ -65,12 +67,9 @@ - - - @@ -118,5 +117,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index 00f3330c42..79a809e4f9 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -118,9 +118,11 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader MapIntermediateToHeader(DataExch, EDocumentPurchaseHeader); PostProcessHeader(EDocumentPurchaseHeader); + BuildEndpointIdentifiers(DataExch, EDocumentPurchaseHeader); EDocumentPurchaseHeader.Modify(); MapIntermediateToLines(EDocument, DataExch); + MapChargeLinesToStaging(EDocument, DataExch); ProcessAttachments(EDocument, DataExch); OnAfterBridgeToStagingTables(DataExch."Entry No.", EDocumentPurchaseHeader); @@ -172,6 +174,41 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader ApplyLCYBlankConvention(EDocumentPurchaseHeader."Currency Code"); end; + local procedure BuildEndpointIdentifiers(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + DataExchField: Record "Data Exch. Field"; + DataExchColumnDef: Record "Data Exch. Column Def"; + EndpointValue: Text; + EndpointScheme: Text; + ValueColNo: Integer; + SchemeColNo: Integer; + begin + DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); + DataExchColumnDef.SetRange(Name, 'CustomerEndpointID'); + if DataExchColumnDef.FindFirst() then + ValueColNo := DataExchColumnDef."Column No."; + + DataExchColumnDef.SetRange(Name, 'CustomerEndpointSchemeID'); + if DataExchColumnDef.FindFirst() then + SchemeColNo := DataExchColumnDef."Column No."; + + if (ValueColNo = 0) or (SchemeColNo = 0) then + exit; + + DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); + DataExchField.SetRange("Column No.", ValueColNo); + if DataExchField.FindFirst() then + EndpointValue := DataExchField.Value; + + DataExchField.SetRange("Column No.", SchemeColNo); + if DataExchField.FindFirst() then + EndpointScheme := DataExchField.Value; + + if (EndpointValue <> '') and (EndpointScheme <> '') then + EDocumentPurchaseHeader."Customer Company Id" := + CopyStr(EndpointScheme + ':' + EndpointValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Id")); + end; + #endregion Header Mapping #region Line Mapping @@ -237,6 +274,85 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code"); end; + /// + /// Maps document-level AllowanceCharge elements (PEPPOLCHARGELINES line def) to staging lines. + /// These are read directly from Data Exch. Field — not through Intermediate Data Import — + /// to avoid Record No. collisions with invoice/credit note line records. + /// + local procedure MapChargeLinesToStaging(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") + var + DataExchField: Record "Data Exch. Field"; + DataExchColumnDef: Record "Data Exch. Column Def"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + CurrLineNo: Integer; + DescColNo: Integer; + AmountColNo: Integer; + VATRateColNo: Integer; + CurrencyColNo: Integer; + IndicatorColNo: Integer; + IsCharge: Boolean; + begin + DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); + DataExchColumnDef.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); + DataExchColumnDef.SetRange(Name, 'ChargeDescription'); + if DataExchColumnDef.FindFirst() then DescColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeAmount'); + if DataExchColumnDef.FindFirst() then AmountColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeVATRate'); + if DataExchColumnDef.FindFirst() then VATRateColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeCurrencyCode'); + if DataExchColumnDef.FindFirst() then CurrencyColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeIndicator'); + if DataExchColumnDef.FindFirst() then IndicatorColNo := DataExchColumnDef."Column No."; + + if DescColNo = 0 then + exit; + + DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); + DataExchField.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); + DataExchField.SetCurrentKey("Line No.", "Column No."); + if not DataExchField.FindSet() then + exit; + + CurrLineNo := -1; + IsCharge := false; + repeat + if CurrLineNo <> DataExchField."Line No." then begin + if (CurrLineNo <> -1) and IsCharge then begin + PostProcessLine(EDocumentPurchaseLine); + EDocumentPurchaseLine.Insert(); + end; + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; + EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); + EDocumentPurchaseLine.Quantity := 1; + CurrLineNo := DataExchField."Line No."; + IsCharge := false; + end; + + case DataExchField."Column No." of + DescColNo: + EDocumentPurchaseLine.Description := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine.Description)); + AmountColNo: + begin + if Evaluate(EDocumentPurchaseLine."Unit Price", DataExchField.Value, 9) then; + if Evaluate(EDocumentPurchaseLine."Sub Total", DataExchField.Value, 9) then; + end; + VATRateColNo: + if Evaluate(EDocumentPurchaseLine."VAT Rate", DataExchField.Value, 9) then; + CurrencyColNo: + EDocumentPurchaseLine."Currency Code" := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine."Currency Code")); + IndicatorColNo: + IsCharge := LowerCase(DataExchField.Value) = 'true'; + end; + until DataExchField.Next() = 0; + + if (CurrLineNo <> -1) and IsCharge then begin + PostProcessLine(EDocumentPurchaseLine); + EDocumentPurchaseLine.Insert(); + end; + end; + #endregion Line Mapping #region Attachment Processing @@ -388,4 +504,5 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; CreditNoteNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2', Locked = true; ProcessFailedErr: Label 'Failed to process the file with data exchange.'; + ChargeLineDefCodeTok: Label 'PEPPOLCHARGELINES', Locked = true; } diff --git a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml index d9b8a0adc7..7b1a00dba5 100644 --- a/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml +++ b/src/Apps/W1/EDocument/Test/.resources/peppol/peppol-invoice-vat-category-z.xml @@ -16,6 +16,9 @@ 7300010000001 + + The Sellercompany Incorporated + Main street 2, Building 4 Big city @@ -38,6 +41,9 @@ DK12345678 + + The Buyercompany + Anystreet 8 Back door diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index a8e3d8d84f..e0916eddae 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -31,6 +31,7 @@ codeunit 139897 "E-Doc Data Exch Tests" LibraryVariableStorage: Codeunit "Library - Variable Storage"; LibraryEDoc: Codeunit "Library - E-Document"; EDocImplState: Codeunit "E-Doc. Impl. State"; + StructuredValidations: Codeunit "EDoc Structured Validations"; LibraryLowerPermission: Codeunit "Library - Lower Permissions"; IsInitialized: Boolean; EDocumentStatusNotUpdatedErr: Label 'The status of the EDocument was not updated to the expected status after the step was executed.'; @@ -96,7 +97,7 @@ codeunit 139897 "E-Doc Data Exch Tests" if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin // [THEN] Lines are created with correct field mappings EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - Assert.AreEqual(2, EDocumentPurchaseLine.Count(), 'Expected 2 lines from the invoice XML.'); + Assert.AreEqual(3, EDocumentPurchaseLine.Count(), 'Expected 2 invoice lines + 1 charge line from the invoice XML.'); EDocumentPurchaseLine.FindSet(); repeat @@ -115,6 +116,12 @@ codeunit 139897 "E-Doc Data Exch Tests" Assert.AreEqual(500, EDocumentPurchaseLine."Unit Price", 'Second line Unit Price should be 500.'); Assert.IsTrue(EDocumentPurchaseLine."Line No." > 0, 'Second line should have a sequential line number.'); end; + 3: + begin + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'Charge line Quantity should be 1.'); + Assert.AreEqual(25, EDocumentPurchaseLine."Unit Price", 'Charge line Unit Price should be 25.'); + Assert.AreEqual('Insurance', EDocumentPurchaseLine.Description, 'Charge line Description should be Insurance.'); + end; end; until EDocumentPurchaseLine.Next() = 0; end @@ -247,6 +254,168 @@ codeunit 139897 "E-Doc Data Exch Tests" Assert.Fail(EDocumentStatusNotUpdatedErr); end; + [Test] + procedure DataExchInvoice_FullDocument() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Data Exchange v2 handler produces the same staging output as the PEPPOL handler for a full invoice + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-0.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + // [THEN] Staging tables match the PEPPOL handler output + StructuredValidations.AssertFullPEPPOLDocumentExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchInvoice_ReturnsInvoiceDraftType() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] After parsing an Invoice, the Process Draft Impl. is set to "Purchase Invoice" + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-0.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] The process draft is set to Purchase Invoice + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual( + Enum::"E-Doc. Process Draft"::"Purchase Invoice", + EDocument."Process Draft Impl.", + 'The process draft implementation should be set to Purchase Invoice for invoices.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchCreditNote_FullDocument() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Data Exchange v2 handler produces the same staging output as the PEPPOL handler for a credit note + Initialize(); + SetupDataExchangeService(); + SetupCreditMemoDataExchDef(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-creditnote-0.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + // [THEN] Staging tables match the PEPPOL handler output + StructuredValidations.AssertFullPEPPOLCreditNoteExtracted(EDocument."Entry No"); + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual( + Enum::"E-Doc. Process Draft"::"Purchase Credit Memo", + EDocument."Process Draft Impl.", + 'The process draft implementation should be set to Purchase Credit Memo for credit notes.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchInvoice_BaseExample() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Basic PEPPOL invoice with 2 lines and a document-level charge is parsed correctly via Data Exchange + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-basic.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLBaseExampleExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchInvoice_VatCategoryS() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Invoice with multiple VAT rates and StandardItemIdentification priority via Data Exchange + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-vat-category-s.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLVatCategorySExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchInvoice_VatCategoryZ() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] Invoice with zero-rated VAT (category Z), no DueDate, GBP currency via Data Exchange + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-vat-category-z.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertPEPPOLVatCategoryZExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchInvoice_EmbeddedAttachments() + var + EDocument: Record "E-Document"; + DocumentAttachment: Record "Document Attachment"; + begin + // [SCENARIO] Embedded base64 attachments are extracted via Data Exchange + Initialize(); + SetupDataExchangeService(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-attachment.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + StructuredValidations.AssertPEPPOLAttachmentHeaderExtracted(EDocument."Entry No"); + DocumentAttachment.SetRange("E-Document Entry No.", EDocument."Entry No"); + Assert.AreEqual(2, DocumentAttachment.Count(), 'Expected 2 embedded attachments (PDF + PNG). External URI and bare references should be skipped.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + + [Test] + procedure DataExchCreditNote_CorrectionNoDueDate() + var + EDocument: Record "E-Document"; + begin + // [SCENARIO] CreditNote without PaymentMeans/PaymentDueDate results in blank Due Date via Data Exchange + Initialize(); + SetupDataExchangeService(); + SetupCreditMemoDataExchDef(); + CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-creditnote-no-duedate.xml'); + + // [WHEN] Processing the e-document to Read into Draft + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + StructuredValidations.AssertPEPPOLCreditNoteCorrectionExtracted(EDocument."Entry No"); + EDocument.Get(EDocument."Entry No"); + Assert.AreEqual( + Enum::"E-Doc. Process Draft"::"Purchase Credit Memo", + EDocument."Process Draft Impl.", + 'The process draft implementation should be set to Purchase Credit Memo for credit notes.'); + end + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + local procedure Initialize() var TransformationRule: Record "Transformation Rule"; From cecc420b504bccef44629341845de73e234603be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 20 May 2026 09:05:00 +0200 Subject: [PATCH 33/85] Fix AA0175: use IsEmpty instead of FindFirst for existence check Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index e0916eddae..6d0f12bbe8 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -51,7 +51,7 @@ codeunit 139897 "E-Doc Data Exch Tests" DataExchFieldMapping.SetRange("Target Field ID", 9); // Vendor Company Name // [THEN] The mapping record exists - Assert.IsTrue(DataExchFieldMapping.FindFirst(), 'Column 8 should map to Table 6100 Field 9 (Vendor Company Name)'); + Assert.IsFalse(DataExchFieldMapping.IsEmpty(), 'Column 8 should map to Table 6100 Field 9 (Vendor Company Name)'); end; [Test] From 555e94be0219687b7994e9998b5f54fa30eb9d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 20 May 2026 12:00:21 +0200 Subject: [PATCH 34/85] Fix AL0118: use Subc. Standard Task Code field on Requisition Line Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Manufacturing/SubcCalcSubcontractsExt.Codeunit.al | 2 +- .../src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al index c8a45f72ab..bb36b3cb75 100644 --- a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al @@ -22,6 +22,6 @@ codeunit 99001529 "Subc. Calc Subcontracts Ext." RequisitionLine."Description 2" := WorkCenter."Name 2"; end; - RequisitionLine.Validate("Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); + RequisitionLine.Validate("Subc. Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); end; } \ No newline at end of file diff --git a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al index 732d8efb7f..5086568503 100644 --- a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al @@ -552,7 +552,7 @@ codeunit 99001557 "Subc. Purchase Order Creator" RequisitionLine.Description := ProdOrderRoutingLine.Description; RequisitionLine."Description 2" := ProdOrderRoutingLine."Description 2"; - RequisitionLine.Validate("Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); + RequisitionLine.Validate("Subc. Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); SetVendorItemNo(RequisitionLine); if PurchLineExists(PurchaseLine, ProdOrderLine, ProdOrderRoutingLine) then begin From c2934b9cee148655a0fa42c5d5f55f8b83db8b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 20 May 2026 13:45:39 +0200 Subject: [PATCH 35/85] Fix AL0118/AL0132: rename Standard Task Code on Requisition Line in test Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Codeunits/Tests/SubcSubcontractingTest.Codeunit.al | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al b/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al index 216b53918b..bab0c01d8c 100644 --- a/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al +++ b/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al @@ -3022,7 +3022,7 @@ codeunit 139989 "Subc. Subcontracting Test" #pragma warning restore AA0210 RequisitionLineWithStdTask.FindFirst(); Assert.AreEqual( - StandardTask.Code, RequisitionLineWithStdTask."Standard Task Code", + StandardTask.Code, RequisitionLineWithStdTask."Subc. Standard Task Code", 'Standard Task Code must be propagated from Prod. Order Routing Line to the Subcontracting Worksheet line.'); Assert.AreEqual( PriceWithStdTask, RequisitionLineWithStdTask."Direct Unit Cost", @@ -3037,14 +3037,14 @@ codeunit 139989 "Subc. Subcontracting Test" #pragma warning restore AA0210 RequisitionLineNoStdTask.FindFirst(); Assert.AreEqual( - '', RequisitionLineNoStdTask."Standard Task Code", + '', RequisitionLineNoStdTask."Subc. Standard Task Code", 'Standard Task Code must be empty on the worksheet line that has no standard task on the routing.'); Assert.AreEqual( PriceWithoutStdTask, RequisitionLineNoStdTask."Direct Unit Cost", 'Subcontractor Price for the un-tagged combination must be applied to the worksheet line.'); // [WHEN] User clears Standard Task Code on the worksheet line - RequisitionLineWithStdTask.Validate("Standard Task Code", ''); + RequisitionLineWithStdTask.Validate("Subc. Standard Task Code", ''); RequisitionLineWithStdTask.Modify(true); // [THEN] Direct Unit Cost falls back to the un-tagged subcontractor price @@ -3053,7 +3053,7 @@ codeunit 139989 "Subc. Subcontracting Test" 'Clearing Standard Task Code on the worksheet line must re-apply the un-tagged subcontractor price.'); // [WHEN] User re-sets Standard Task Code on the worksheet line - RequisitionLineWithStdTask.Validate("Standard Task Code", StandardTask.Code); + RequisitionLineWithStdTask.Validate("Subc. Standard Task Code", StandardTask.Code); RequisitionLineWithStdTask.Modify(true); // [THEN] Direct Unit Cost is restored to the standard-task-bound subcontractor price From 3866420ea64fe88a922b9a6d19a0af860bc79ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 20 May 2026 14:03:50 +0200 Subject: [PATCH 36/85] Revert "Fix AL0118/AL0132: rename Standard Task Code on Requisition Line in test" This reverts commit c2934b9cee148655a0fa42c5d5f55f8b83db8b58. --- .../Codeunits/Tests/SubcSubcontractingTest.Codeunit.al | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al b/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al index bab0c01d8c..216b53918b 100644 --- a/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al +++ b/src/Apps/W1/Subcontracting/Test/src/Codeunits/Tests/SubcSubcontractingTest.Codeunit.al @@ -3022,7 +3022,7 @@ codeunit 139989 "Subc. Subcontracting Test" #pragma warning restore AA0210 RequisitionLineWithStdTask.FindFirst(); Assert.AreEqual( - StandardTask.Code, RequisitionLineWithStdTask."Subc. Standard Task Code", + StandardTask.Code, RequisitionLineWithStdTask."Standard Task Code", 'Standard Task Code must be propagated from Prod. Order Routing Line to the Subcontracting Worksheet line.'); Assert.AreEqual( PriceWithStdTask, RequisitionLineWithStdTask."Direct Unit Cost", @@ -3037,14 +3037,14 @@ codeunit 139989 "Subc. Subcontracting Test" #pragma warning restore AA0210 RequisitionLineNoStdTask.FindFirst(); Assert.AreEqual( - '', RequisitionLineNoStdTask."Subc. Standard Task Code", + '', RequisitionLineNoStdTask."Standard Task Code", 'Standard Task Code must be empty on the worksheet line that has no standard task on the routing.'); Assert.AreEqual( PriceWithoutStdTask, RequisitionLineNoStdTask."Direct Unit Cost", 'Subcontractor Price for the un-tagged combination must be applied to the worksheet line.'); // [WHEN] User clears Standard Task Code on the worksheet line - RequisitionLineWithStdTask.Validate("Subc. Standard Task Code", ''); + RequisitionLineWithStdTask.Validate("Standard Task Code", ''); RequisitionLineWithStdTask.Modify(true); // [THEN] Direct Unit Cost falls back to the un-tagged subcontractor price @@ -3053,7 +3053,7 @@ codeunit 139989 "Subc. Subcontracting Test" 'Clearing Standard Task Code on the worksheet line must re-apply the un-tagged subcontractor price.'); // [WHEN] User re-sets Standard Task Code on the worksheet line - RequisitionLineWithStdTask.Validate("Subc. Standard Task Code", StandardTask.Code); + RequisitionLineWithStdTask.Validate("Standard Task Code", StandardTask.Code); RequisitionLineWithStdTask.Modify(true); // [THEN] Direct Unit Cost is restored to the standard-task-bound subcontractor price From 2541ac4f7c9c258da5dc03a9b36a8cd8e9ad016e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 20 May 2026 14:03:50 +0200 Subject: [PATCH 37/85] Revert "Fix AL0118: use Subc. Standard Task Code field on Requisition Line" This reverts commit 555e94be0219687b7994e9998b5f54fa30eb9d50. --- .../Manufacturing/SubcCalcSubcontractsExt.Codeunit.al | 2 +- .../src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al index bb36b3cb75..c8a45f72ab 100644 --- a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/Extensions/Manufacturing/SubcCalcSubcontractsExt.Codeunit.al @@ -22,6 +22,6 @@ codeunit 99001529 "Subc. Calc Subcontracts Ext." RequisitionLine."Description 2" := WorkCenter."Name 2"; end; - RequisitionLine.Validate("Subc. Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); + RequisitionLine.Validate("Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); end; } \ No newline at end of file diff --git a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al index 5086568503..732d8efb7f 100644 --- a/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Process/Codeunits/SubcPurchaseOrderCreator.Codeunit.al @@ -552,7 +552,7 @@ codeunit 99001557 "Subc. Purchase Order Creator" RequisitionLine.Description := ProdOrderRoutingLine.Description; RequisitionLine."Description 2" := ProdOrderRoutingLine."Description 2"; - RequisitionLine.Validate("Subc. Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); + RequisitionLine.Validate("Standard Task Code", ProdOrderRoutingLine."Standard Task Code"); SetVendorItemNo(RequisitionLine); if PurchLineExists(PurchaseLine, ProdOrderLine, ProdOrderRoutingLine) then begin From a0c15e8734747dfe206ad74b984b61e9081e1802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 20 May 2026 17:52:38 +0200 Subject: [PATCH 38/85] Trigger CI re-run after infrastructure failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous run had 5 transient infra failures (Docker CDN block, file-lock exceptions) — all 82 E-Document tests passed. No code changes. Co-Authored-By: Claude Sonnet 4.6 (1M context) From 2bd50d7a2ab40d4d310c4543915a3b6c5c2e7be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 09:08:34 +0200 Subject: [PATCH 39/85] Address PR review feedback: cleanup, error handling, vendor filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix #1: Clear(AttachmentTempBlob) between attachment record groups to prevent blob data from leaking across attachments - Fix #2: Delete DataExch record in DeleteIntermediateData (was only deleting child rows, leaving the parent record permanently) - Fix #3: Add vendor filter in ResolveAppliesToFromExtInvoiceNo — only filter by vendor when Pay-to Vendor No. is known to avoid over-broad matching - Fix #5: Wrap pipeline in TryFunction so DataExch record and intermediate data are cleaned up even when an error occurs mid-pipeline - Fix #6: Replace hardcoded Error() string in View() with Label constant - Fix #7: Split ProcessFailedErr into UnrecognisedNamespaceErr and DataExchDefNotFoundErr with actionable context per failure mode - Remove V2-MAPPING.md scratch file from resources Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../App/.resources/DataExchange/V2-MAPPING.md | 229 ------------------ .../EDocCreatePurchCrMemo.Codeunit.al | 2 + .../EDocPEPPOLDXHandler.Codeunit.al | 27 ++- 3 files changed, 22 insertions(+), 236 deletions(-) delete mode 100644 src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md b/src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md deleted file mode 100644 index d0f5633304..0000000000 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/V2-MAPPING.md +++ /dev/null @@ -1,229 +0,0 @@ -# V2 Data Exchange Definition — Complete Field Mapping - -Derived systematically from the PEPPOL handler + utility code to ensure 1:1 parity. - -Target staging tables: -- **6100** = E-Document Purchase Header -- **6101** = E-Document Purchase Line -- **1173** = Document Attachment (unchanged from v1) - ---- - -## PEPPOL HANDLER EXTRACTION → DATA EXCHANGE MAPPING - -### PopulateInvoiceDocumentInfo / PopulateCreditNoteDocumentInfo - -| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | -|---|---------------------|--------------------------|-------------|------------|-------| -| H1 | `/Invoice/cbc:ID` | 6100/5 Sales Invoice No. | 1 | 1 | CrMemo: `/CreditNote/cbc:ID` | -| H2 | `/Invoice/cac:OrderReference/cbc:ID` | 6100/4 Purchase Order No. | 5 | 5 | | -| H3 | `/CreditNote/cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID` | 6100/40 Applies-to Ext. Invoice No. | — | 6 | Credit memo only | - -### PopulateSupplierInfo (utility lines 107-132) - -| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | -|---|---------------------|--------------------------|-------------|------------|-------| -| S1 | `.../AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name` | 6100/9 Vendor Company Name | 8 | 9 | Primary vendor name | -| S2 | `.../AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName` | 6100/9 Vendor Company Name | NEW-43 | NEW-44 | Fallback if S1 empty. **NEW COLUMN NEEDED** | -| S3 | `.../PayeeParty/cac:PartyName/cbc:Name` | 6100/9 Vendor Company Name | 16 | 16 | Overrides S1/S2 if present | -| S4 | `.../AccountingSupplierParty/cac:Party/cac:Contact/cbc:Name` | 6100/37 Vendor Contact Name | NEW-44 | NEW-45 | **NEW COLUMN NEEDED** | -| S5 | `.../AccountingSupplierParty/cac:Party/cac:PostalAddress/cbc:StreetName` | 6100/10 Vendor Address | 30 | 30 | | -| S6 | `.../AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID` | 6100/31 Vendor VAT Id | NEW-45 | NEW-46 | Primary VAT source. **NEW COLUMN NEEDED** | -| S7 | `.../PayeeParty/cac:PartyLegalEntity/cbc:CompanyID` | 6100/31 Vendor VAT Id | NEW-46 | NEW-47 | Overrides S6 if present. **NEW COLUMN NEEDED** | -| S8 | `.../AccountingSupplierParty/cac:Party/cbc:EndpointID` (schemeID=0088) | 6100/35 Vendor GLN | 6 | 7 | Only when schemeID=0088 | - -### PopulateCustomerInfo (utility lines 139-167) - -| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | -|---|---------------------|--------------------------|-------------|------------|-------| -| C1 | `.../AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name` | 6100/2 Customer Company Name | 28 | 28 | Primary customer name | -| C2 | `.../AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName` | 6100/2 Customer Company Name | NEW-47 | NEW-48 | Fallback if C1 empty. **NEW COLUMN NEEDED** | -| C3 | `.../AccountingCustomerParty/cac:Party/cac:PartyLegalEntity/cbc:CompanyID` | 6100/32 Customer VAT Id | NEW-48 | NEW-49 | First VAT source (no schemeID filter). **NEW COLUMN NEEDED** | -| C4 | `.../AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID` | 6100/32 Customer VAT Id | 13 | 14 | Overrides C3 if present | -| C5 | `.../AccountingCustomerParty/cac:Party/cac:PostalAddress/cbc:StreetName` | 6100/12 Customer Address | 29 | 29 | | -| C6 | `.../AccountingCustomerParty/cac:Party/cbc:EndpointID` (schemeID=0088) | 6100/34 Customer GLN | 11 | 12 | Only when schemeID=0088 | -| C7 | `.../AccountingCustomerParty/cac:Party/cbc:EndpointID` (as schemeID:value) | 6100/3 Customer Company Id | NEW-49 | NEW-50 | Format: "schemeID:value". **NEW COLUMN NEEDED** | - -### PopulateAmountsAndDates (utility lines 174-183) - -| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | -|---|---------------------|--------------------------|-------------|------------|-------| -| A1 | `.../LegalMonetaryTotal/cbc:PayableAmount` | 6100/21 Total | 25 | 25 | | -| A2 | `.../LegalMonetaryTotal/cbc:TaxExclusiveAmount` | 6100/18 Sub Total | 38 | 39 | CrMemo col shifted | -| A3 | `.../LegalMonetaryTotal/cbc:AllowanceTotalAmount` | 6100/19 Total Discount | 17 | 17 | | -| A4 | *Calculated: Total - Sub Total - Total Discount* | 6100/20 Total VAT | — | — | **Cannot map via DataExch.** Handler calculates this. DX will leave blank — must be calculated in bridge or post-processing. | -| A5 | `/Invoice/cbc:DueDate` | 6100/7 Due Date | 36 | 36 | CrMemo path: `.../PaymentMeans/cbc:PaymentDueDate` | -| A6 | `.../cbc:IssueDate` | 6100/8 Document Date | 2 | 2 | | - -### PopulateCurrency (utility lines 188-194) - -| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | -|---|---------------------|--------------------------|-------------|------------|-------| -| CU1 | `.../cbc:DocumentCurrencyCode` | 6100/24 Currency Code | 3 | 3 | | - ---- - -## LINE EXTRACTION — PopulatePurchaseLine (utility lines 204-237) - -| # | PEPPOL Handler XPath | Staging Field (Table/ID) | Invoice Col | CrMemo Col | Notes | -|---|---------------------|--------------------------|-------------|------------|-------| -| L1 | `.../cbc:InvoicedQuantity` | 6101/6 Quantity | 2 | 2 | CrMemo: `cbc:CreditedQuantity` | -| L2 | `.../cbc:InvoicedQuantity/@unitCode` | 6101/7 Unit of Measure | 3 | 3 | | -| L3 | `.../cbc:LineExtensionAmount` | 6101/9 Sub Total | 4 | 4 | | -| L4 | `.../cac:AllowanceCharge/cbc:Amount` | 6101/10 Total Discount | 6 | 6 | Handler has NO ChargeIndicator filter; v1 DX had `[ChargeIndicator='false']`. Keep v1 XPath filter for correctness. | -| L5 | `.../cac:Item/cbc:Name` | 6101/5 Description | 11 | 11 | Primary description | -| L6 | `.../cac:Item/cbc:Description` | 6101/5 Description | 10 | 10 | Fallback if L5 empty | -| L7 | `.../cac:Item/cac:SellersItemIdentification/cbc:ID` | 6101/4 Product Code | 12 | 12 | | -| L8 | `.../cac:Item/cac:StandardItemIdentification/cbc:ID` | 6101/4 Product Code | 13 | 13 | Overrides L7 if present | -| L9 | `.../cac:Item/cac:ClassifiedTaxCategory/cbc:Percent` | 6101/11 VAT Rate | 15 | 15 | | -| L10 | `.../cac:Price/cbc:PriceAmount` | 6101/8 Unit Price | 16 | 16 | | -| L11 | `.../cbc:LineExtensionAmount/@currencyID` | 6101/12 Currency Code | 5 | 5 | | - ---- - -## ITEMS NOT MAPPABLE VIA DATA EXCHANGE - -| Item | PEPPOL Handler Behavior | Data Exchange Limitation | -|------|------------------------|--------------------------| -| Total VAT calculation | `Total - Sub Total - Discount` | DataExch cannot do arithmetic. Must be calculated in bridge code or post-processing. | -| Document-level charge lines | Handler creates extra E-Document Purchase Line records | DataExch column defs are per-line-element; cannot create extra lines from header-level elements. | -| Customer Company Id format | Handler formats as `schemeID:value` | DataExch can only extract the element value, not concatenate with attribute. NEW column will extract raw EndpointID value only. | -| Vendor GLN schemeID check | Handler only sets GLN when `@schemeID='0088'` | v1 XPath `EndpointID[@schemeID='0088']` handles this correctly via XPath filter. | -| Customer GLN schemeID check | Handler only sets GLN when `@schemeID='0088'` | Same — XPath filter handles this. | - ---- - -## COMPLETE INVOICE HEADER MAPPING (PEPPOLINVHEADER → 6100) - -Columns from v1 retained + NEW columns added. Column numbers follow v1 numbering with NEW columns appended. - -| Col | Name | XPath | Target | Field | Optional | -|-----|------|-------|--------|-------|----------| -| 1 | ID | /Invoice/cbc:ID | 6100/5 | Sales Invoice No. | | -| 2 | IssueDate | /Invoice/cbc:IssueDate | 6100/8 | Document Date | | -| 3 | DocumentCurrencyCode | /Invoice/cbc:DocumentCurrencyCode | 6100/24 | Currency Code | | -| 5 | OrderReferenceID | /Invoice/cac:OrderReference/cbc:ID | 6100/4 | Purchase Order No. | | -| 6 | SupplierEndpointGLNID | .../EndpointID[@schemeID='0088'] | 6100/35 | Vendor GLN | | -| 8 | SupplierName | .../PartyName/cbc:Name | 6100/9 | Vendor Company Name | | -| 11 | CustomerEndpointIDGLN | .../AccountingCustomerParty/.../EndpointID[@schemeID='0088'] | 6100/34 | Customer GLN | Yes | -| 13 | CustPartyTaxSchemeCompanyID | .../PartyTaxScheme/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | -| 16 | PartyLegalEntityName | /Invoice/cac:PayeeParty/cac:PartyName/cbc:Name | 6100/9 | Vendor Company Name | | -| 17 | DiscountAmount | .../LegalMonetaryTotal/cbc:AllowanceTotalAmount | 6100/19 | Total Discount | Yes | -| 25 | PayableAmount | .../LegalMonetaryTotal/cbc:PayableAmount | 6100/21 | Total | Yes | -| 28 | CustomerPartyName | .../AccountingCustomerParty/.../PartyName/cbc:Name | 6100/2 | Customer Company Name | Yes | -| 29 | CustomerPartyStreetName | .../AccountingCustomerParty/.../PostalAddress/cbc:StreetName | 6100/12 | Customer Address | Yes | -| 30 | SupplierStreetName | .../AccountingSupplierParty/.../PostalAddress/cbc:StreetName | 6100/10 | Vendor Address | | -| 36 | DueDate | /Invoice/cbc:DueDate | 6100/7 | Due Date | | -| 38 | AmountExclVAT | .../LegalMonetaryTotal/cbc:TaxExclusiveAmount | 6100/18 | Sub Total | Yes | -| 43 | SupplierRegistrationName | .../AccountingSupplierParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/9 | Vendor Company Name | Yes | **NEW** | -| 44 | SupplierContactName | .../AccountingSupplierParty/.../Contact/cbc:Name | 6100/37 | Vendor Contact Name | Yes | **NEW** | -| 45 | SupplierTaxSchemeCompanyID | .../AccountingSupplierParty/.../PartyTaxScheme/cbc:CompanyID | 6100/31 | Vendor VAT Id | | **NEW** | -| 46 | PayeePartyLegalEntityCompanyID | /Invoice/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID | 6100/31 | Vendor VAT Id | Yes | **NEW** | -| 47 | CustomerRegistrationName | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/2 | Customer Company Name | Yes | **NEW** | -| 48 | CustPartyLegalEntityCompanyID | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | **NEW** | - -### Dropped from v1 (no staging equivalent): - -| v1 Col | Name | Reason | -|--------|------|--------| -| 7 | SupplierEndpointVATID | Replaced by col 45 (PartyTaxScheme source, matching handler) | -| 9 | PartyLegalEntityCompanyIDGLN | Redundant with col 6 | -| 10 | PartyLegalEntityCompanyIDVAT | Redundant with col 45 | -| 12 | CustomerPartyIdentificationIDGLN | Redundant with col 11 | -| 14 | CustPartyLegalEntityCompanyID | Now col 48 (without schemeID filter, matching handler) | -| 18-24 | Currency/Charge/Prepaid fields | No staging equivalent | -| 26-27 | PayableAmountCurrencyID, YourReference | No staging equivalent | -| 31-35 | Payee GLN/VAT, ChargeReason | Vendor lookup fields not needed in v2 | -| 37 | DocumentType constant | Determined by namespace | -| 39-42 | Bank fields | No staging equivalent | -| 40 | TaxAmount | Total VAT is calculated, not read directly | - ---- - -## COMPLETE CREDIT MEMO HEADER MAPPING (PEPPOLCRMEMOHEADER → 6100) - -Credit memo has different column numbering due to col 6 = BillingReference (shifting subsequent cols). - -| Col | Name | XPath | Target | Field | Optional | -|-----|------|-------|--------|-------|----------| -| 1 | ID | /CreditNote/cbc:ID | 6100/5 | Sales Invoice No. | | -| 2 | IssueDate | /CreditNote/cbc:IssueDate | 6100/8 | Document Date | | -| 3 | DocumentCurrencyCode | /CreditNote/cbc:DocumentCurrencyCode | 6100/24 | Currency Code | | -| 5 | OrderReferenceID | /CreditNote/cac:OrderReference/cbc:ID | 6100/4 | Purchase Order No. | Yes | -| 6 | InvoiceDocumentReferenceId | .../BillingReference/cac:InvoiceDocumentReference/cbc:ID | 6100/40 | Applies-to Ext. Invoice No. | | -| 7 | SupplierEndpointGLNID | .../EndpointID[@schemeID='0088'] | 6100/35 | Vendor GLN | | -| 9 | SupplierName | .../PartyName/cbc:Name | 6100/9 | Vendor Company Name | | -| 12 | CustomerEndpointIDGLN | .../AccountingCustomerParty/.../EndpointID[@schemeID='0088'] | 6100/34 | Customer GLN | Yes | -| 14 | CustPartyTaxSchemeCompanyID | .../PartyTaxScheme/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | -| 16 | PartyLegalEntityName | /CreditNote/cac:PayeeParty/cac:PartyName/cbc:Name | 6100/9 | Vendor Company Name | | -| 17 | DiscountAmount | .../LegalMonetaryTotal/cbc:AllowanceTotalAmount | 6100/19 | Total Discount | Yes | -| 25 | PayableAmount | .../LegalMonetaryTotal/cbc:PayableAmount | 6100/21 | Total | Yes | -| 28 | CustomerPartyName | .../AccountingCustomerParty/.../PartyName/cbc:Name | 6100/2 | Customer Company Name | Yes | -| 29 | CustomerPartyStreetName | .../AccountingCustomerParty/.../PostalAddress/cbc:StreetName | 6100/12 | Customer Address | Yes | -| 30 | SupplierStreetName | .../AccountingSupplierParty/.../PostalAddress/cbc:StreetName | 6100/10 | Vendor Address | | -| 36 | PaymentDueDate | /CreditNote/cac:PaymentMeans/cbc:PaymentDueDate | 6100/7 | Due Date | | -| 39 | AmountExclVAT | .../LegalMonetaryTotal/cbc:TaxExclusiveAmount | 6100/18 | Sub Total | Yes | -| 44 | SupplierRegistrationName | .../AccountingSupplierParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/9 | Vendor Company Name | Yes | **NEW** | -| 45 | SupplierContactName | .../AccountingSupplierParty/.../Contact/cbc:Name | 6100/37 | Vendor Contact Name | Yes | **NEW** | -| 46 | SupplierTaxSchemeCompanyID | .../AccountingSupplierParty/.../PartyTaxScheme/cbc:CompanyID | 6100/31 | Vendor VAT Id | | **NEW** | -| 47 | PayeePartyLegalEntityCompanyID | /CreditNote/cac:PayeeParty/cac:PartyLegalEntity/cbc:CompanyID | 6100/31 | Vendor VAT Id | Yes | **NEW** | -| 48 | CustomerRegistrationName | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:RegistrationName | 6100/2 | Customer Company Name | Yes | **NEW** | -| 49 | CustPartyLegalEntityCompanyID | .../AccountingCustomerParty/.../PartyLegalEntity/cbc:CompanyID | 6100/32 | Customer VAT Id | Yes | **NEW** | - ---- - -## COMPLETE LINE MAPPING (PEPPOLINVLINES / PEPPOLCRMEMOLINES → 6101) - -Same for both invoice and credit memo. Column numbers are identical. - -| Col | Name | XPath | Target | Field | Optional | -|-----|------|-------|--------|-------|----------| -| 2 | Quantity | .../cbc:InvoicedQuantity (or CreditedQuantity) | 6101/6 | Quantity | | -| 3 | unitCode | .../cbc:InvoicedQuantity/@unitCode | 6101/7 | Unit of Measure | | -| 4 | LineExtensionAmount | .../cbc:LineExtensionAmount | 6101/9 | Sub Total | Yes | -| 5 | LineExtensionAmountCurrencyID | .../cbc:LineExtensionAmount/@currencyID | 6101/12 | Currency Code | | -| 6 | InvLnDiscountAmount | .../AllowanceCharge[ChargeIndicator='false']/cbc:Amount | 6101/10 | Total Discount | | -| 10 | Description | .../cac:Item/cbc:Description | 6101/5 | Description | | -| 11 | Name | .../cac:Item/cbc:Name | 6101/5 | Description | | -| 12 | SellersItemIdentificationID | .../SellersItemIdentification/cbc:ID | 6101/4 | Product Code | Yes | -| 13 | StandardItemIdentificationID | .../StandardItemIdentification/cbc:ID[@schemeID='0088'] | 6101/4 | Product Code | | -| 15 | TaxPercent | .../ClassifiedTaxCategory/cbc:Percent | 6101/11 | VAT Rate | Yes | -| 16 | PriceAmount | .../cac:Price/cbc:PriceAmount | 6101/8 | Unit Price | | -| 17 | PriceAmountCurrencyID | .../cac:Price/cbc:PriceAmount/@currencyID | 6101/12 | Currency Code | | - -### Dropped from v1 lines: -| v1 Col | Name | Reason | -|--------|------|--------| -| 1 | InvoiceLineNote | Not mapped in v1 either | -| 7 | InvLnDiscountAmtCurrID | Currency already from col 5 | -| 8 | InvoiceLineTaxAmount | No staging field for line VAT amount | -| 9 | currencyID | Redundant with col 5 | -| 18 | BaseQuantity | No staging field | - ---- - -## ATTACHMENTS (unchanged from v1) - -TableId="1214", UseAsIntermediateTable="true", targeting table 1173. - -| Col | Name | Target | Field | -|-----|------|--------|-------| -| 1 | AdditionalDocumentReferenceID | 1173/1 | File Name | -| 2 | EmbeddedDocumentBinaryObject | 1173/8 | Document Reference ID | -| 3 | MimeCode | 1173/7 | File Type | -| 4 | Filename | 1173/5 | File Name | - ---- - -## BRIDGE CODE STILL NEEDED FOR - -1. **Total VAT**: Calculate `Total - Sub Total - Total Discount` after all fields populated -2. **Amount Due**: Copy from Total (handler doesn't set this but the old bridge did) -3. **Customer Company Id**: Format as `schemeID:value` from EndpointID — DataExch cannot concatenate attribute+value -4. **Currency Code LCY-blank**: Compare against GL Setup LCY Code, blank when match — DataExch writes raw XML value -5. **Document-level charge lines**: Create extra E-Document Purchase Line records from AllowanceCharge[ChargeIndicator='true'] — DataExch cannot create lines from header-level elements - -## XPATH DIFFERENCES FROM V1 (intentional) - -- **Line col 13 (StandardItemIdentificationID)**: Remove `[@schemeID='0088']` filter to match handler behavior (accepts any schemeID) -- **Line col 6 (InvLnDiscountAmount)**: Keep `[ChargeIndicator='false']` filter — more correct than handler which has no filter diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al index 0bce344e47..71f16c1fbe 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FinishDraft/EDocCreatePurchCrMemo.Codeunit.al @@ -63,6 +63,8 @@ codeunit 6404 "E-Doc. Create Purch. Cr. Memo" implements IEDocumentFinishDraft, var PurchInvHeader: Record "Purch. Inv. Header"; begin + if PurchaseHeader."Pay-to Vendor No." <> '' then + PurchInvHeader.SetRange("Buy-from Vendor No.", PurchaseHeader."Pay-to Vendor No."); PurchInvHeader.SetRange("Vendor Invoice No.", ExtInvoiceNo); if PurchInvHeader.FindFirst() then begin PurchaseHeader."Applies-to Doc. Type" := PurchaseHeader."Applies-to Doc. Type"::Invoice; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index 79a809e4f9..7eddfed2e5 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -31,7 +31,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") begin - Error('A view is not implemented for this handler.'); + Error(ViewNotImplementedErr); end; #region Auto-Detection @@ -59,11 +59,11 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader BestDocType := "E-Document Type"::"Purchase Credit Memo"; end; else - Error(ProcessFailedErr); + Error(UnrecognisedNamespaceErr, DocumentNamespace); end; if not DataExchDef.Get(BestDefCode) then - Error(ProcessFailedErr); + Error(DataExchDefNotFoundErr, BestDefCode); end; local procedure GetDocumentRootNamespace(var TempBlob: Codeunit "Temp Blob"): Text @@ -103,11 +103,20 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader DataExch."Related Record" := EDocument.RecordId; DataExch.Modify(true); + if not TryRunPipeline(EDocument, DataExch, DataExchDef) then begin + DeleteIntermediateData(DataExch); + Error(GetLastErrorText()); + end; + + DeleteIntermediateData(DataExch); + end; + + [TryFunction] + local procedure TryRunPipeline(EDocument: Record "E-Document"; var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def") + begin DataExch.ImportToDataExch(DataExchDef); DataExchDef.ProcessDataExchange(DataExch); - BridgeToStagingTables(EDocument, DataExch); - DeleteIntermediateData(DataExch); end; local procedure BridgeToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") @@ -386,6 +395,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader EDocAttachmentProcessor.Insert(EDocument, InStream, FileName); FileName := ''; end; + Clear(AttachmentTempBlob); CurrRecordNo := IntermediateDataImport."Record No."; end; @@ -471,7 +481,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #region Cleanup - local procedure DeleteIntermediateData(DataExch: Record "Data Exch.") + local procedure DeleteIntermediateData(var DataExch: Record "Data Exch.") var DataExchField: Record "Data Exch. Field"; IntermediateDataImport: Record "Intermediate Data Import"; @@ -480,6 +490,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader DataExchField.DeleteAll(); IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); IntermediateDataImport.DeleteAll(); + DataExch.Delete(); end; #endregion Cleanup @@ -503,6 +514,8 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader CreditMemoDefCodeTok: Label 'EDOCPEPCRMEMOIMPV2', Locked = true; InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; CreditNoteNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2', Locked = true; - ProcessFailedErr: Label 'Failed to process the file with data exchange.'; + ViewNotImplementedErr: Label 'A view is not implemented for this handler.', Comment = 'Error shown when View is called on a handler that does not support viewing.'; + UnrecognisedNamespaceErr: Label 'The XML document has an unrecognised root namespace: %1. Only PEPPOL BIS 3.0 Invoice and Credit Note are supported.', Comment = '%1 = XML root namespace URI'; + DataExchDefNotFoundErr: Label 'The Data Exchange Definition ''%1'' was not found. Reinstall the E-Document app to restore it.', Comment = '%1 = Data Exchange Definition code'; ChargeLineDefCodeTok: Label 'PEPPOLCHARGELINES', Locked = true; } From 84afd8282a40ebbe20f49fe2182a6ab86c94068f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 09:24:44 +0200 Subject: [PATCH 40/85] Move PEPPOL-specific post-processing to Data Exchange Post-Mapping codeunit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract BuildEndpointIdentifiers and MapChargeLinesToStaging from the handler into a new Post-Mapping codeunit (6408) registered on the header DataExchMapping in both V2 definitions. This separates format-specific logic from the generic pipeline: - The handler now only runs the Data Exchange pipeline and bridges intermediate data to staging tables (tables 6100/6101) — no PEPPOL-specific code - The Post-Mapping codeunit runs inside ProcessDataExchange after the DataHandlingCodeunit, with the header record already inserted, and writes compound fields (schemeID:value endpoint) and charge lines directly - A partner can swap the PostMappingCodeunit in their own definition to get custom behaviour without any handler code changes Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../DataExchange/eDocPEPPOLCrMemoImportV2.xml | 2 +- .../eDocPEPPOLInvoiceImportV2.xml | 2 +- .../EDocPEPPOLDXHandler.Codeunit.al | 125 +------------- .../EDocPEPPOLDXPostMapping.Codeunit.al | 160 ++++++++++++++++++ 4 files changed, 169 insertions(+), 120 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml index b9fb04dc70..45ead55992 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml @@ -52,7 +52,7 @@ - + diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml index e557b86514..b4ff1d6d8c 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml @@ -50,7 +50,7 @@ - + diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index 7eddfed2e5..0bce67c654 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -93,10 +93,14 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; Stream: InStream; begin DataExchDef.Get(DataExchDefCode); + // Insert header record before pipeline so the Post-Mapping codeunit can write to it. + EDocumentPurchaseHeader.InsertForEDocument(EDocument); + TempBlob.CreateInStream(Stream); DataExch.Init(); DataExch.InsertRec('', Stream, DataExchDef.Code); @@ -115,6 +119,8 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader local procedure TryRunPipeline(EDocument: Record "E-Document"; var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def") begin DataExch.ImportToDataExch(DataExchDef); + // ProcessDataExchange runs DataHandlingCodeunit (1214) to populate Intermediate Data Import, + // then the Post-Mapping codeunit registered on the header mapping for format-specific work. DataExchDef.ProcessDataExchange(DataExch); BridgeToStagingTables(EDocument, DataExch); end; @@ -123,15 +129,13 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; begin - EDocumentPurchaseHeader.InsertForEDocument(EDocument); + EDocumentPurchaseHeader.GetFromEDocument(EDocument); MapIntermediateToHeader(DataExch, EDocumentPurchaseHeader); PostProcessHeader(EDocumentPurchaseHeader); - BuildEndpointIdentifiers(DataExch, EDocumentPurchaseHeader); EDocumentPurchaseHeader.Modify(); MapIntermediateToLines(EDocument, DataExch); - MapChargeLinesToStaging(EDocument, DataExch); ProcessAttachments(EDocument, DataExch); OnAfterBridgeToStagingTables(DataExch."Entry No.", EDocumentPurchaseHeader); @@ -183,41 +187,6 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader ApplyLCYBlankConvention(EDocumentPurchaseHeader."Currency Code"); end; - local procedure BuildEndpointIdentifiers(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") - var - DataExchField: Record "Data Exch. Field"; - DataExchColumnDef: Record "Data Exch. Column Def"; - EndpointValue: Text; - EndpointScheme: Text; - ValueColNo: Integer; - SchemeColNo: Integer; - begin - DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); - DataExchColumnDef.SetRange(Name, 'CustomerEndpointID'); - if DataExchColumnDef.FindFirst() then - ValueColNo := DataExchColumnDef."Column No."; - - DataExchColumnDef.SetRange(Name, 'CustomerEndpointSchemeID'); - if DataExchColumnDef.FindFirst() then - SchemeColNo := DataExchColumnDef."Column No."; - - if (ValueColNo = 0) or (SchemeColNo = 0) then - exit; - - DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); - DataExchField.SetRange("Column No.", ValueColNo); - if DataExchField.FindFirst() then - EndpointValue := DataExchField.Value; - - DataExchField.SetRange("Column No.", SchemeColNo); - if DataExchField.FindFirst() then - EndpointScheme := DataExchField.Value; - - if (EndpointValue <> '') and (EndpointScheme <> '') then - EDocumentPurchaseHeader."Customer Company Id" := - CopyStr(EndpointScheme + ':' + EndpointValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Id")); - end; - #endregion Header Mapping #region Line Mapping @@ -283,85 +252,6 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code"); end; - /// - /// Maps document-level AllowanceCharge elements (PEPPOLCHARGELINES line def) to staging lines. - /// These are read directly from Data Exch. Field — not through Intermediate Data Import — - /// to avoid Record No. collisions with invoice/credit note line records. - /// - local procedure MapChargeLinesToStaging(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") - var - DataExchField: Record "Data Exch. Field"; - DataExchColumnDef: Record "Data Exch. Column Def"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - CurrLineNo: Integer; - DescColNo: Integer; - AmountColNo: Integer; - VATRateColNo: Integer; - CurrencyColNo: Integer; - IndicatorColNo: Integer; - IsCharge: Boolean; - begin - DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); - DataExchColumnDef.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); - DataExchColumnDef.SetRange(Name, 'ChargeDescription'); - if DataExchColumnDef.FindFirst() then DescColNo := DataExchColumnDef."Column No."; - DataExchColumnDef.SetRange(Name, 'ChargeAmount'); - if DataExchColumnDef.FindFirst() then AmountColNo := DataExchColumnDef."Column No."; - DataExchColumnDef.SetRange(Name, 'ChargeVATRate'); - if DataExchColumnDef.FindFirst() then VATRateColNo := DataExchColumnDef."Column No."; - DataExchColumnDef.SetRange(Name, 'ChargeCurrencyCode'); - if DataExchColumnDef.FindFirst() then CurrencyColNo := DataExchColumnDef."Column No."; - DataExchColumnDef.SetRange(Name, 'ChargeIndicator'); - if DataExchColumnDef.FindFirst() then IndicatorColNo := DataExchColumnDef."Column No."; - - if DescColNo = 0 then - exit; - - DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); - DataExchField.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); - DataExchField.SetCurrentKey("Line No.", "Column No."); - if not DataExchField.FindSet() then - exit; - - CurrLineNo := -1; - IsCharge := false; - repeat - if CurrLineNo <> DataExchField."Line No." then begin - if (CurrLineNo <> -1) and IsCharge then begin - PostProcessLine(EDocumentPurchaseLine); - EDocumentPurchaseLine.Insert(); - end; - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; - EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); - EDocumentPurchaseLine.Quantity := 1; - CurrLineNo := DataExchField."Line No."; - IsCharge := false; - end; - - case DataExchField."Column No." of - DescColNo: - EDocumentPurchaseLine.Description := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine.Description)); - AmountColNo: - begin - if Evaluate(EDocumentPurchaseLine."Unit Price", DataExchField.Value, 9) then; - if Evaluate(EDocumentPurchaseLine."Sub Total", DataExchField.Value, 9) then; - end; - VATRateColNo: - if Evaluate(EDocumentPurchaseLine."VAT Rate", DataExchField.Value, 9) then; - CurrencyColNo: - EDocumentPurchaseLine."Currency Code" := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine."Currency Code")); - IndicatorColNo: - IsCharge := LowerCase(DataExchField.Value) = 'true'; - end; - until DataExchField.Next() = 0; - - if (CurrLineNo <> -1) and IsCharge then begin - PostProcessLine(EDocumentPurchaseLine); - EDocumentPurchaseLine.Insert(); - end; - end; - #endregion Line Mapping #region Attachment Processing @@ -517,5 +407,4 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader ViewNotImplementedErr: Label 'A view is not implemented for this handler.', Comment = 'Error shown when View is called on a handler that does not support viewing.'; UnrecognisedNamespaceErr: Label 'The XML document has an unrecognised root namespace: %1. Only PEPPOL BIS 3.0 Invoice and Credit Note are supported.', Comment = '%1 = XML root namespace URI'; DataExchDefNotFoundErr: Label 'The Data Exchange Definition ''%1'' was not found. Reinstall the E-Document app to restore it.', Comment = '%1 = Data Exchange Definition code'; - ChargeLineDefCodeTok: Label 'PEPPOLCHARGELINES', Locked = true; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al new file mode 100644 index 0000000000..179a417eb4 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al @@ -0,0 +1,160 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.Finance.GeneralLedger.Setup; +using System.IO; + +/// +/// Post-Mapping codeunit for PEPPOL BIS 3.0 Data Exchange import. +/// Registered as PostMappingCodeunit on the header DataExchMapping in the V2 definitions. +/// Handles compound fields and document-level charges that cannot be expressed as +/// declarative field mappings in the Data Exchange Definition. +/// +codeunit 6408 "E-Doc. PEPPOL DX Post-Mapping" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + TableNo = "Data Exch."; + + trigger OnRun() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentRecordId: RecordId; + begin + EDocumentRecordId := Rec."Related Record"; + EDocument := EDocumentRecordId; + + EDocumentPurchaseHeader.GetFromEDocument(EDocument); + BuildEndpointIdentifiers(Rec, EDocumentPurchaseHeader); + EDocumentPurchaseHeader.Modify(); + + MapChargeLinesToStaging(EDocument, Rec); + end; + + local procedure BuildEndpointIdentifiers(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + DataExchField: Record "Data Exch. Field"; + DataExchColumnDef: Record "Data Exch. Column Def"; + EndpointValue: Text; + EndpointScheme: Text; + ValueColNo: Integer; + SchemeColNo: Integer; + begin + DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); + DataExchColumnDef.SetRange(Name, 'CustomerEndpointID'); + if DataExchColumnDef.FindFirst() then + ValueColNo := DataExchColumnDef."Column No."; + + DataExchColumnDef.SetRange(Name, 'CustomerEndpointSchemeID'); + if DataExchColumnDef.FindFirst() then + SchemeColNo := DataExchColumnDef."Column No."; + + if (ValueColNo = 0) or (SchemeColNo = 0) then + exit; + + DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); + DataExchField.SetRange("Column No.", ValueColNo); + if DataExchField.FindFirst() then + EndpointValue := DataExchField.Value; + + DataExchField.SetRange("Column No.", SchemeColNo); + if DataExchField.FindFirst() then + EndpointScheme := DataExchField.Value; + + if (EndpointValue <> '') and (EndpointScheme <> '') then + EDocumentPurchaseHeader."Customer Company Id" := + CopyStr(EndpointScheme + ':' + EndpointValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Id")); + end; + + local procedure MapChargeLinesToStaging(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") + var + DataExchField: Record "Data Exch. Field"; + DataExchColumnDef: Record "Data Exch. Column Def"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + GLSetup: Record "General Ledger Setup"; + CurrLineNo: Integer; + DescColNo: Integer; + AmountColNo: Integer; + VATRateColNo: Integer; + CurrencyColNo: Integer; + IndicatorColNo: Integer; + IsCharge: Boolean; + begin + DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); + DataExchColumnDef.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); + DataExchColumnDef.SetRange(Name, 'ChargeDescription'); + if DataExchColumnDef.FindFirst() then DescColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeAmount'); + if DataExchColumnDef.FindFirst() then AmountColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeVATRate'); + if DataExchColumnDef.FindFirst() then VATRateColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeCurrencyCode'); + if DataExchColumnDef.FindFirst() then CurrencyColNo := DataExchColumnDef."Column No."; + DataExchColumnDef.SetRange(Name, 'ChargeIndicator'); + if DataExchColumnDef.FindFirst() then IndicatorColNo := DataExchColumnDef."Column No."; + + if DescColNo = 0 then + exit; + + DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); + DataExchField.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); + DataExchField.SetCurrentKey("Line No.", "Column No."); + if not DataExchField.FindSet() then + exit; + + GLSetup.GetRecordOnce(); + CurrLineNo := -1; + IsCharge := false; + repeat + if CurrLineNo <> DataExchField."Line No." then begin + if (CurrLineNo <> -1) and IsCharge then begin + ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code", GLSetup); + EDocumentPurchaseLine.Insert(); + end; + Clear(EDocumentPurchaseLine); + EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; + EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); + EDocumentPurchaseLine.Quantity := 1; + CurrLineNo := DataExchField."Line No."; + IsCharge := false; + end; + + case DataExchField."Column No." of + DescColNo: + EDocumentPurchaseLine.Description := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine.Description)); + AmountColNo: + begin + if Evaluate(EDocumentPurchaseLine."Unit Price", DataExchField.Value, 9) then; + if Evaluate(EDocumentPurchaseLine."Sub Total", DataExchField.Value, 9) then; + end; + VATRateColNo: + if Evaluate(EDocumentPurchaseLine."VAT Rate", DataExchField.Value, 9) then; + CurrencyColNo: + EDocumentPurchaseLine."Currency Code" := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine."Currency Code")); + IndicatorColNo: + IsCharge := LowerCase(DataExchField.Value) = 'true'; + end; + until DataExchField.Next() = 0; + + if (CurrLineNo <> -1) and IsCharge then begin + ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code", GLSetup); + EDocumentPurchaseLine.Insert(); + end; + end; + + local procedure ApplyLCYBlankConvention(var CurrencyCode: Code[10]; GLSetup: Record "General Ledger Setup") + begin + if GLSetup."LCY Code" = CurrencyCode then + CurrencyCode := ''; + end; + + var + ChargeLineDefCodeTok: Label 'PEPPOLCHARGELINES', Locked = true; +} From b3664eaddbd7f5eb1201806d8dd7cd3b00fd7807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 09:34:58 +0200 Subject: [PATCH 41/85] Remove TryFunction from pipeline - incompatible with DB inserts in call stack [TryFunction] blocks database Insert/Modify in the entire call stack. Codeunit 1203 (Import XML File to Data Exch.) inserts Data Exch. Field records, so wrapping the pipeline in a TryFunction caused a runtime error. On uncaught errors BC rolls back the transaction automatically, so the DataExch record and all intermediate rows are cleaned up without explicit error handling. DeleteIntermediateData runs only on the success path. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocPEPPOLDXHandler.Codeunit.al | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al index 0bce67c654..9d5a9f833a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al @@ -107,22 +107,13 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader DataExch."Related Record" := EDocument.RecordId; DataExch.Modify(true); - if not TryRunPipeline(EDocument, DataExch, DataExchDef) then begin - DeleteIntermediateData(DataExch); - Error(GetLastErrorText()); - end; - - DeleteIntermediateData(DataExch); - end; - - [TryFunction] - local procedure TryRunPipeline(EDocument: Record "E-Document"; var DataExch: Record "Data Exch."; DataExchDef: Record "Data Exch. Def") - begin DataExch.ImportToDataExch(DataExchDef); // ProcessDataExchange runs DataHandlingCodeunit (1214) to populate Intermediate Data Import, // then the Post-Mapping codeunit registered on the header mapping for format-specific work. DataExchDef.ProcessDataExchange(DataExch); BridgeToStagingTables(EDocument, DataExch); + + DeleteIntermediateData(DataExch); end; local procedure BridgeToStagingTables(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") From 1d308a3096ac74a7736d01e79368dbe4ee40b69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 09:38:38 +0200 Subject: [PATCH 42/85] Fix charge line ordering: move MapChargeLinesToStaging to event subscriber The Post-Mapping codeunit ran during ProcessDataExchange (before the bridge), so charge lines were inserted before invoice lines, breaking line ordering. Split responsibilities: - OnRun: BuildEndpointIdentifiers only (header compound fields) - OnAfterBridgeToStagingTables subscriber: MapChargeLinesToStaging (fires after MapIntermediateToLines so charge lines come last) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocPEPPOLDXPostMapping.Codeunit.al | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al index 179a417eb4..c461a17f32 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al @@ -28,14 +28,26 @@ codeunit 6408 "E-Doc. PEPPOL DX Post-Mapping" EDocumentPurchaseHeader: Record "E-Document Purchase Header"; EDocumentRecordId: RecordId; begin + // Only handle header-level compound fields here. + // Charge lines are mapped via OnAfterBridgeToStagingTables to ensure they + // come after invoice/credit note lines from MapIntermediateToLines. EDocumentRecordId := Rec."Related Record"; - EDocument := EDocumentRecordId; + EDocument := EDocumentRecordId.GetRecord(); EDocumentPurchaseHeader.GetFromEDocument(EDocument); BuildEndpointIdentifiers(Rec, EDocumentPurchaseHeader); EDocumentPurchaseHeader.Modify(); + end; - MapChargeLinesToStaging(EDocument, Rec); + [EventSubscriber(ObjectType::Codeunit, Codeunit::"E-Doc. PEPPOL DX Handler", OnAfterBridgeToStagingTables, '', false, false)] + local procedure OnAfterBridgeToStagingTables(DataExchNo: Integer; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") + var + EDocument: Record "E-Document"; + DataExch: Record "Data Exch."; + begin + EDocument.Get(EDocumentPurchaseHeader."E-Document Entry No."); + DataExch.Get(DataExchNo); + MapChargeLinesToStaging(EDocument, DataExch); end; local procedure BuildEndpointIdentifiers(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") From 516c901421f2b6f34bdd840a996d0dc8b79e4161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 10:04:25 +0200 Subject: [PATCH 43/85] Refactor: generic Data Exchange purchase import handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename EDocPEPPOLDXHandler → EDocDataExchPurchHandler (codeunit 6407 "E-Doc. DataExch. Purch Handler") — handler is now format-agnostic - Rename enum value to "Data Exchange Purchase" with updated caption - FindBestDataExchDef: replace hardcoded PEPPOL namespace/def-code constants with a lookup against EDocServiceDataExchDef, matching document namespace to DataExchLineDef.Namespace — any format works without code changes - EDocPEPPOLDXPostMapping (6408): replace direct EDocumentPurchaseLine inserts with WriteChargeLinesToIntermediate — charge lines are written into Intermediate Data Import with Record Nos. above invoice lines so the generic MapIntermediateToLines bridge processes them in correct order - Register codeunit 6408 as PostMappingCodeunit in both V2 XML definitions Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Import/EDocReadIntoDraft.Enum.al | 6 +- ...l => EDocDataExchPurchHandler.Codeunit.al} | 58 ++++---- .../EDocPEPPOLDXPostMapping.Codeunit.al | 140 ++++++++++++------ .../IStructuredDataType.Interface.al | 24 +++ .../EDocSamplePurchInvoice3.docx | Bin 51268 -> 50006 bytes .../Processing/EDocDataExchTests.Codeunit.al | 4 +- 6 files changed, 157 insertions(+), 75 deletions(-) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocPEPPOLDXHandler.Codeunit.al => EDocDataExchPurchHandler.Codeunit.al} (86%) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al index 35907dbb1b..ce558f9aff 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/EDocReadIntoDraft.Enum.al @@ -40,9 +40,9 @@ enum 6109 "E-Doc. Read into Draft" implements IStructuredFormatReader Caption = 'MLLM'; Implementation = IStructuredFormatReader = "E-Document MLLM Handler"; } - value(5; "PEPPOL Data Exchange") + value(5; "Data Exchange Purchase") { - Caption = 'PEPPOL BIS 3 - Data Exchange'; - Implementation = IStructuredFormatReader = "E-Doc. PEPPOL DX Handler"; + Caption = 'Data Exchange Purchase'; + Implementation = IStructuredFormatReader = "E-Doc. DataExch. Purch Handler"; } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocDataExchPurchHandler.Codeunit.al similarity index 86% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocDataExchPurchHandler.Codeunit.al index 9d5a9f833a..1d7866427f 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXHandler.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocDataExchPurchHandler.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.IO.Peppol; using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using Microsoft.Finance.GeneralLedger.Setup; @@ -13,7 +14,7 @@ using System.IO; using System.Text; using System.Utilities; -codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader +codeunit 6407 "E-Doc. DataExch. Purch Handler" implements IStructuredFormatReader { Access = Internal; InherentEntitlements = X; @@ -24,7 +25,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader BestDefCode: Code[20]; BestDocType: Enum "E-Document Type"; begin - FindBestDataExchDef(TempBlob, BestDefCode, BestDocType); + FindBestDataExchDef(EDocument, TempBlob, BestDefCode, BestDocType); RunPipelineAndBridge(EDocument, TempBlob, BestDefCode); exit(MapDocumentTypeToProcessDraft(BestDocType)); end; @@ -37,33 +38,38 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #region Auto-Detection /// - /// Determines the v2 Data Exchange Definition code by matching the document's - /// XML root namespace against known PEPPOL BIS 3.0 namespaces. + /// Determines the Data Exchange Definition code by matching the document's XML root + /// namespace against the Namespace field on each definition configured for the + /// E-Document Service in EDocServiceDataExchDef. /// - local procedure FindBestDataExchDef(var TempBlob: Codeunit "Temp Blob"; var BestDefCode: Code[20]; var BestDocType: Enum "E-Document Type") + local procedure FindBestDataExchDef(EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob"; var BestDefCode: Code[20]; var BestDocType: Enum "E-Document Type") var - DataExchDef: Record "Data Exch. Def"; + EDocServiceDataExchDef: Record "E-Doc. Service Data Exch. Def."; + DataExchLineDef: Record "Data Exch. Line Def"; + EDocumentService: Record "E-Document Service"; DocumentNamespace: Text; begin DocumentNamespace := GetDocumentRootNamespace(TempBlob); + EDocumentService := EDocument.GetEDocumentService(); - case DocumentNamespace of - InvoiceNamespaceTxt: - begin - BestDefCode := InvoiceDefCodeTok; - BestDocType := "E-Document Type"::"Purchase Invoice"; - end; - CreditNoteNamespaceTxt: - begin - BestDefCode := CreditMemoDefCodeTok; - BestDocType := "E-Document Type"::"Purchase Credit Memo"; - end; - else - Error(UnrecognisedNamespaceErr, DocumentNamespace); - end; + EDocServiceDataExchDef.SetRange("E-Document Format Code", EDocumentService.Code); + if not EDocServiceDataExchDef.FindSet() then + Error(NoDataExchDefsConfiguredErr, EDocumentService.Code); + + repeat + if EDocServiceDataExchDef."Impt. Data Exchange Def. Code" <> '' then begin + DataExchLineDef.SetRange("Data Exch. Def Code", EDocServiceDataExchDef."Impt. Data Exchange Def. Code"); + DataExchLineDef.SetRange("Parent Code", ''); + if DataExchLineDef.FindFirst() then + if DataExchLineDef.Namespace = DocumentNamespace then begin + BestDefCode := EDocServiceDataExchDef."Impt. Data Exchange Def. Code"; + BestDocType := EDocServiceDataExchDef."Document Type"; + exit; + end; + end; + until EDocServiceDataExchDef.Next() = 0; - if not DataExchDef.Get(BestDefCode) then - Error(DataExchDefNotFoundErr, BestDefCode); + Error(UnrecognisedNamespaceErr, DocumentNamespace); end; local procedure GetDocumentRootNamespace(var TempBlob: Codeunit "Temp Blob"): Text @@ -391,11 +397,7 @@ codeunit 6407 "E-Doc. PEPPOL DX Handler" implements IStructuredFormatReader #endregion Integration Events var - InvoiceDefCodeTok: Label 'EDOCPEPINVIMPV2', Locked = true; - CreditMemoDefCodeTok: Label 'EDOCPEPCRMEMOIMPV2', Locked = true; - InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; - CreditNoteNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2', Locked = true; ViewNotImplementedErr: Label 'A view is not implemented for this handler.', Comment = 'Error shown when View is called on a handler that does not support viewing.'; - UnrecognisedNamespaceErr: Label 'The XML document has an unrecognised root namespace: %1. Only PEPPOL BIS 3.0 Invoice and Credit Note are supported.', Comment = '%1 = XML root namespace URI'; - DataExchDefNotFoundErr: Label 'The Data Exchange Definition ''%1'' was not found. Reinstall the E-Document app to restore it.', Comment = '%1 = Data Exchange Definition code'; + UnrecognisedNamespaceErr: Label 'No configured Data Exchange Definition matches the XML root namespace ''%1''. Configure a definition with a matching namespace in the E-Document Service setup.', Comment = '%1 = XML root namespace URI'; + NoDataExchDefsConfiguredErr: Label 'No import Data Exchange Definitions are configured for E-Document Service ''%1''.', Comment = '%1 = E-Document Service code'; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al index c461a17f32..3fd384924b 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPEPPOLDXPostMapping.Codeunit.al @@ -6,14 +6,21 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Import.Purchase; -using Microsoft.Finance.GeneralLedger.Setup; using System.IO; /// /// Post-Mapping codeunit for PEPPOL BIS 3.0 Data Exchange import. /// Registered as PostMappingCodeunit on the header DataExchMapping in the V2 definitions. -/// Handles compound fields and document-level charges that cannot be expressed as -/// declarative field mappings in the Data Exchange Definition. +/// Runs inside ProcessDataExchange after the DataHandlingCodeunit (1214) has populated +/// Intermediate Data Import. Handles two things: +/// +/// 1. Compound header fields (schemeID:value endpoint identifier) that cannot be +/// expressed as a declarative field mapping. +/// +/// 2. Document-level AllowanceCharge elements (PEPPOLCHARGELINES line def): writes +/// them directly into Intermediate Data Import with Record Nos. above the existing +/// invoice/credit-note lines, so the generic MapIntermediateToLines bridge picks +/// them up in the correct order. /// codeunit 6408 "E-Doc. PEPPOL DX Post-Mapping" { @@ -28,28 +35,18 @@ codeunit 6408 "E-Doc. PEPPOL DX Post-Mapping" EDocumentPurchaseHeader: Record "E-Document Purchase Header"; EDocumentRecordId: RecordId; begin - // Only handle header-level compound fields here. - // Charge lines are mapped via OnAfterBridgeToStagingTables to ensure they - // come after invoice/credit note lines from MapIntermediateToLines. EDocumentRecordId := Rec."Related Record"; EDocument := EDocumentRecordId.GetRecord(); EDocumentPurchaseHeader.GetFromEDocument(EDocument); BuildEndpointIdentifiers(Rec, EDocumentPurchaseHeader); EDocumentPurchaseHeader.Modify(); - end; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"E-Doc. PEPPOL DX Handler", OnAfterBridgeToStagingTables, '', false, false)] - local procedure OnAfterBridgeToStagingTables(DataExchNo: Integer; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") - var - EDocument: Record "E-Document"; - DataExch: Record "Data Exch."; - begin - EDocument.Get(EDocumentPurchaseHeader."E-Document Entry No."); - DataExch.Get(DataExchNo); - MapChargeLinesToStaging(EDocument, DataExch); + WriteChargeLinesToIntermediate(Rec); end; + #region Endpoint Identifiers + local procedure BuildEndpointIdentifiers(DataExch: Record "Data Exch."; var EDocumentPurchaseHeader: Record "E-Document Purchase Header") var DataExchField: Record "Data Exch. Field"; @@ -85,19 +82,34 @@ codeunit 6408 "E-Doc. PEPPOL DX Post-Mapping" CopyStr(EndpointScheme + ':' + EndpointValue, 1, MaxStrLen(EDocumentPurchaseHeader."Customer Company Id")); end; - local procedure MapChargeLinesToStaging(EDocument: Record "E-Document"; DataExch: Record "Data Exch.") + #endregion Endpoint Identifiers + + #region Charge Lines via Intermediate + + /// + /// Reads PEPPOLCHARGELINES Data Exch. Field records and writes charge line data + /// into Intermediate Data Import (Table ID 6101) with Record Nos. above the existing + /// invoice/credit-note lines. The generic MapIntermediateToLines bridge then processes + /// them in Record No. order, placing charge lines after invoice lines. + /// + local procedure WriteChargeLinesToIntermediate(DataExch: Record "Data Exch.") var DataExchField: Record "Data Exch. Field"; DataExchColumnDef: Record "Data Exch. Column Def"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - GLSetup: Record "General Ledger Setup"; - CurrLineNo: Integer; + IntermediateDataImport: Record "Intermediate Data Import"; DescColNo: Integer; AmountColNo: Integer; VATRateColNo: Integer; CurrencyColNo: Integer; IndicatorColNo: Integer; + ParentRecordNo: Integer; + NextRecordNo: Integer; + CurrLineNo: Integer; IsCharge: Boolean; + Description: Text[100]; + Amount: Text[250]; + VATRate: Text[250]; + CurrencyCode: Text[250]; begin DataExchColumnDef.SetRange("Data Exch. Def Code", DataExch."Data Exch. Def Code"); DataExchColumnDef.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); @@ -115,58 +127,102 @@ codeunit 6408 "E-Doc. PEPPOL DX Post-Mapping" if DescColNo = 0 then exit; + // Find the header's intermediate Record No. to use as Parent Record No. for lines. + IntermediateDataImport.SetRange("Data Exch. No.", DataExch."Entry No."); + IntermediateDataImport.SetRange("Table ID", Database::"E-Document Purchase Header"); + IntermediateDataImport.SetRange("Parent Record No.", 0); + if not IntermediateDataImport.FindFirst() then + exit; + ParentRecordNo := IntermediateDataImport."Record No."; + + // Start charge lines after all existing purchase line intermediate records. + IntermediateDataImport.SetRange("Table ID", Database::"E-Document Purchase Line"); + IntermediateDataImport.SetRange("Parent Record No."); + if IntermediateDataImport.FindLast() then + NextRecordNo := IntermediateDataImport."Record No." + 1 + else + NextRecordNo := 1; + DataExchField.SetRange("Data Exch. No.", DataExch."Entry No."); DataExchField.SetRange("Data Exch. Line Def Code", ChargeLineDefCodeTok); DataExchField.SetCurrentKey("Line No.", "Column No."); if not DataExchField.FindSet() then exit; - GLSetup.GetRecordOnce(); CurrLineNo := -1; IsCharge := false; repeat if CurrLineNo <> DataExchField."Line No." then begin - if (CurrLineNo <> -1) and IsCharge then begin - ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code", GLSetup); - EDocumentPurchaseLine.Insert(); - end; - Clear(EDocumentPurchaseLine); - EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; - EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); - EDocumentPurchaseLine.Quantity := 1; + if (CurrLineNo <> -1) and IsCharge then + InsertChargeLineIntermediate(DataExch."Entry No.", NextRecordNo, ParentRecordNo, + Description, Amount, VATRate, CurrencyCode); CurrLineNo := DataExchField."Line No."; IsCharge := false; + Clear(Description); + Clear(Amount); + Clear(VATRate); + Clear(CurrencyCode); end; case DataExchField."Column No." of DescColNo: - EDocumentPurchaseLine.Description := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine.Description)); + Description := CopyStr(DataExchField.Value, 1, MaxStrLen(Description)); AmountColNo: - begin - if Evaluate(EDocumentPurchaseLine."Unit Price", DataExchField.Value, 9) then; - if Evaluate(EDocumentPurchaseLine."Sub Total", DataExchField.Value, 9) then; - end; + Amount := DataExchField.Value; VATRateColNo: - if Evaluate(EDocumentPurchaseLine."VAT Rate", DataExchField.Value, 9) then; + VATRate := DataExchField.Value; CurrencyColNo: - EDocumentPurchaseLine."Currency Code" := CopyStr(DataExchField.Value, 1, MaxStrLen(EDocumentPurchaseLine."Currency Code")); + CurrencyCode := DataExchField.Value; IndicatorColNo: IsCharge := LowerCase(DataExchField.Value) = 'true'; end; until DataExchField.Next() = 0; - if (CurrLineNo <> -1) and IsCharge then begin - ApplyLCYBlankConvention(EDocumentPurchaseLine."Currency Code", GLSetup); - EDocumentPurchaseLine.Insert(); + if (CurrLineNo <> -1) and IsCharge then + InsertChargeLineIntermediate(DataExch."Entry No.", NextRecordNo, ParentRecordNo, + Description, Amount, VATRate, CurrencyCode); + end; + + local procedure InsertChargeLineIntermediate(DataExchNo: Integer; var NextRecordNo: Integer; ParentRecordNo: Integer; Description: Text[100]; Amount: Text[250]; VATRate: Text[250]; CurrencyCode: Text[250]) + var + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + InsertIntermediateField(DataExchNo, NextRecordNo, ParentRecordNo, + EDocumentPurchaseLine.FieldNo(Quantity), '1'); + if Description <> '' then + InsertIntermediateField(DataExchNo, NextRecordNo, ParentRecordNo, + EDocumentPurchaseLine.FieldNo(Description), Description); + if Amount <> '' then begin + InsertIntermediateField(DataExchNo, NextRecordNo, ParentRecordNo, + EDocumentPurchaseLine.FieldNo("Unit Price"), Amount); + InsertIntermediateField(DataExchNo, NextRecordNo, ParentRecordNo, + EDocumentPurchaseLine.FieldNo("Sub Total"), Amount); end; + if VATRate <> '' then + InsertIntermediateField(DataExchNo, NextRecordNo, ParentRecordNo, + EDocumentPurchaseLine.FieldNo("VAT Rate"), VATRate); + if CurrencyCode <> '' then + InsertIntermediateField(DataExchNo, NextRecordNo, ParentRecordNo, + EDocumentPurchaseLine.FieldNo("Currency Code"), CurrencyCode); + NextRecordNo += 1; end; - local procedure ApplyLCYBlankConvention(var CurrencyCode: Code[10]; GLSetup: Record "General Ledger Setup") + local procedure InsertIntermediateField(DataExchNo: Integer; RecordNo: Integer; ParentRecordNo: Integer; FieldId: Integer; Value: Text) + var + IntermediateDataImport: Record "Intermediate Data Import"; begin - if GLSetup."LCY Code" = CurrencyCode then - CurrencyCode := ''; + IntermediateDataImport.Init(); + IntermediateDataImport.Validate("Data Exch. No.", DataExchNo); + IntermediateDataImport.Validate("Table ID", Database::"E-Document Purchase Line"); + IntermediateDataImport.Validate("Record No.", RecordNo); + IntermediateDataImport.Validate("Field ID", FieldId); + IntermediateDataImport.Validate("Parent Record No.", ParentRecordNo); + IntermediateDataImport.SetValueWithoutModifying(Value); + IntermediateDataImport.Insert(true); end; + #endregion Charge Lines via Intermediate + var ChargeLineDefCodeTok: Label 'PEPPOLCHARGELINES', Locked = true; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al index 4a6fe34638..d2d60584f6 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al @@ -36,4 +36,28 @@ interface IStructuredDataType /// /// procedure GetReadIntoDraftImpl(): Enum "E-Doc. Read into Draft" + + + // procedure GetSupportedMessages(): List of [Enum "E-Document Message"]; + + + + // // RETURNS INTERFACE ON HOW TO READ AND WRITE MESSAGE FOR THIS DATA TYPE + // procedure GetMessageProcessing(): Enum "E-Doc. Message Processor"; + } + +// interface IEDocumentMesssage +// { + +// } + +// enum 6108 "E-Doc. Message" implements IEDocumentMesssage +// { +// value(0; "Approval") +// { +// Caption = 'Approval'; +// } +// } + + diff --git a/src/Apps/W1/EDocument/App/src/SampleInvoice/EDocSamplePurchInvoice3.docx b/src/Apps/W1/EDocument/App/src/SampleInvoice/EDocSamplePurchInvoice3.docx index 58fd3d5db276f70b052a9a5a289e99406cca1167..bcbd21a4b7aab61a42579a8e57ecfe21d14306d5 100644 GIT binary patch delta 21481 zcmZ6xV{jl}^sOD+wr$(C?TPJ7k_kG_#7-u*ZQGhSnb@|SyubI}y0_|ozn-o-r|a~p zy?d``4aI{mhk>K3$b&;*fIxx3fPjFIf*3w<|Jbibrvd|}(-iI2MNq=7LqDMFS4^re z#c79KksRPcn-CX6jYd1k_05Hcu6QE8{iGAClN0si2!`8nR@v`+W~_&71v=YWsoIB8 z;IG+OWk<!LoSo|Eqei0?KKNP;S8{`Y8WhKv}RZ z<|my!4a9kx$cC|li1~4M2NC03%ZsqL4fA{f{M!2}r9Kz7nuKIPL=vq_x(d<3B_8m3(c5owL4P9Cf#*c(>5HBcRj0num_=U&yOddCkYuAK3;gZM~${-wm58 zh+GB*TmCF36vtg5S8{3>UR{48!g8RD(d!hvp1E#=4_w8lTLef?q?L$wRzb%e<7R99mY2JvdcB)u{aiuuX4< zzb<0hC&1=1szCWdT10<(a&mHZRtvmy;cfXwlr)M8eBEMw2RJ$vxG-NKkK_Vfei_0W z&J#bxk7VBrg?%HA%>LT+s~36CpP5uE?^?{w zUrq0rMn7R2l&x-u{}d1>cd+Vy0AU#6e{2ey=y zJ;ri&aMVxo5mhA=&+-v9B}Gs2HpRR#TKHZ95hKp`q&cx2ZZCYF8@L{&fRN%9$*!!Y zP#ZA<^!;@_6`C&FX8+5KpeuyaA#JoZaExJYh4EZSdTCWYG}~Re&wcvt`TEXu?u2z+ z3fyrQ{OHaN&0WYoS@-41w7LeA&i^^yb|?l?*6Uf}A@;}KdOh*{IXGci^mkPT)?yu)rxJ!0CQ~FyqX3bspz-e(@?3KGQD>@I78Sr9W zz=)1|b%c#ifskN+YthG}Q!E5`!dk$6@RXN6I7LVe8#e8o!azF_zDx!n@^ytah@XFv zR_g*=zy@P`RV?4t=2h4Gzih!&bzhvTR5JRaHfxi?wr4BWX>0DnTY>)TLNP)mA>w6K zAY(*1$-*{xP|c?`|bO9Z(~ z*0%2x9&zp8W^7q2&K>jSnhmL5Ch1eSXyuP~r@c;Am%AzRjn*|VyWjOV? z=huI55Iz8&y%F2ULc)KqyNr-o(l@r;=S#m~mBFZ7LIMH?Qm)a*wu-oog>Hpnzo!z# zr{sxza1^ZWw@tlJJ8K6W-yFObh6YRL+MIJt$KU4UP>BZ5WA!#fjn40NXdDz&cTTK%3cGPhPg#{n!A!$9L2m_> z{(9sU79aLgrOs=E*c6qUG93fR3^q?~i#KC6-uqLs`|exuayAwm-ghT%`hsSy==aY> z=kb*jc@8H3a9~|n+Y!6hs+XwdL*?hO-y`$_K}}D>R#{h_wdg`F`N{(uIZDhOoZKY@ zqrhk5OOD}}_E)SNdgkZk6dgAI>;2|#Ugq{^t8u6C)`q)(Y{#ed8f@w9m^*dsjQQ%B zKa((?FWAW4shKASu2im&fMQ_C-vZ_95o6PpPDm-;Qou#f<93gb3gK%M*Yvb|yly>; zXrJ`Ml27v;M1#v`OA2|jboXQME??PGr~mv9TdIC>1{4Csm&wIZX#8JuJVAt>&-rYH z9e$Dpu%Z$6hkauLMBVwguV)wRp6RQRP3FEss#|xVb9-j9W9AyZUeM66`bzNA9?<;{ zbdbwd3LxT}0MRh;3;x^F)#=CPg?|5-^LR0P=IFx7rT|`dqwv;OZSt~C+#0`KS(jPQ ztS6DwN38V~?THnr_SUz>68I-wv-B3jPH@DiTVL%##Ma*Sqah+FO6|3+HFMuDT*KkUdGD!u^{ zcMd3~70QqNV}zADxRsXY-wQ|D}%d#necoFIuf0+EWyRgB}{#2$GB6{=MtQ!ZW=K2Og%Yn$;6f5 z0J6hnbkEjD^p`#gi`R4C3zJt0gsd{PE#{|fW16;&{ASt%%eHJbgEa0BmkJs35ge&T zh*aIs5x!6q-@ixvYPWOus6f%=Cm;?Pg}{W4pd7t-qV@vy`R`Lz2US7>)>D4*_K%R$ zNu(InKx*mibrgD}EC|+Q1Y$aT zpuy;{e@?QqVTiT^qQQL%6M6H!$xh&Zz__O;;NX^H@6YT^EHe8{zgqqoF$ejgigm@C zxN_Y|W?<~msN&H|>e(~YG0!*DR$3YVp>?nso7^POuLPxhAiAhjXp#Y!s3c0V1u&TE z`V#x(P{BK!3@WS-&yUnt4EG=!BP|t0-f2cT)ZV%Nra)0cSVVQ16#ng<1@DC6-CYXvk8TV36a(02vARrCE-r1NRkenM^ zQH!2> z%e`pOfkoQMN5z(7)koz?{(~Q!&6x|0;4a5O9q99sDW!Pp) zx-!>MAjgRQM!o#^=7rArs0qK}mY51sY*2e0^(1t0`tFxvi1X_gTaEtPRxawrf(JVS z_8eT3OjA=?vE*9tV*{ot4+KEYyjkiG!-z&*D5*FQ z8ZQ@QhdI%5*kr0|yMGQ?O2R;cwr#FPk`o>0gntfyh5kgm)Txp&fS^yDq8rq7=F+0T z*Bq`#QGk=vvkC-bOk`y2_@yTy=uG~oqN5olVEh(Mq!4v5ZmpW+)enLK>97y2u`+w$ zu|P)advGqIWs~rPS>DUvjVwbY zhmz8U66)`F1xiHm&GQmf%A6&|RBDdM`WZ6*rETglIv6yq&35EYa`HU-AT&kmet)Ey z#1H@dnlw>kBI*O=SjrzvIeWGSnAXK-sdlXlNtRRc8bg0KZkBp?bzKM3n{s4RZtuya z&hijRU&R;+M3=78GgaEi)%=-ja2`DQ(Q{n+eCtaT_vFh9n_GX}8O*BT`*1Jn^j*KQ zo{c%UjlAvIJF0x&728M2NvBZ|5jJStu@F=~sk!#OT&)8P4Ig8=K1L{UcFV``De&my zdw5-+XBJ@87WA8bQBcAP=purkd*ztUOibWIbx`1++nO1E^aX>$tE%)>*DzoY+z`H` z3b2>(!a?1nXsd(zscG~iqp6X&27zQUUK4W@KtkpCK~hR+p*!k$;6~}>`{gU?h&_u$ zVnKT8f(rxLALE6%p$UB}B^|;@;ZklyC*si!s!p(sobKuUhBsh7J8j3Btvy^(*qrR` z%Gm4*wx^ol8P>?^5PQFJ@}Z(ci%ev5{abKSN?S03O=2kLQ%qTkeg9CTz0JL~8O%Us z@nHpBs8x2Ri>p-oo{O!g1T4T)Jaw6L^9B?N8sPz11ht#;I;sPynqgQ>w5jv(QJ4PY zmN>DY;m-pIBV`YMUl&5*mU=783Rge)$^1x@t5J~i6!Q^z`S0s|yG(-#f&>gbhAi^c zf_x4hu`(f(^>y=UV4%81pmrwH+h*1~FP(fIg$C4XTCu&!Af|)vc}?wIbt8y)QC$rCUWjv3a2A zZIl1~x|pbG36~=0$iljT8fK&~p69-#^9&IblReiZ;dy8u7IZJU3Iyww8xd8#YL6yT z1P*kOT^dihaHovCsmvaISn}QgjhPA*j1|RSK zV`Y^Qp+Vn8BWfjUJYU4^zo+7}k!s*40#+0UQdurSN@nB&f%H4d%+b}(>6HNAK)oYg z7iN23m4|jnzs=I3HiwYKFF#r=SChKh3h17|SHRT_s76El zrIaCLdLxlmjgTIhF3X*Vrot-Pfge3$6vEqk(d0wtP^R_9DZq=PK$J|pMS|bx4<10qc(BaK1 zHvZ-|tF*C^9%(6=oV7-&-pN46yu}1}(ezCtk|+MAJLb`tG$$Eo2FUxGuIV{b$y3of zr+#}IJbA;jR?Ty;%i?&-==1ZSR76^82nke;Uv0S8<^}UEufA1o6qVDnSGGqSHrAE> z)0=@i{W9fXWxW_xUJF|nTP%ZDf=22y%VV+?9l8{q=1K7Tv?CH^GZl~qf2V67M*WA( z5uA)0wtlXPn~);?ZIOdWwg5wwY`f}KfXJIg6sF0kS%Q?D=$?c0Usw)ObDdTNc5jIa zL=k6+@cx5J2TpJ0$BZg^Jol(BVuDl$U7H2I>`Kz>!!DU|e_cm~y$3&)R*nS5(nW>G z^X7g`>B!y+V0-ZsLm%+oyKmyx19wXq=|(&?pu?R#wDIjA`27)AdgUeb2XMk6ro`GI zt1);O$2-B{i9k2A1CLIJYk%^xF0u?9=Br!l;&W%v`}CcKG4P^3yud+`!5ryvS5iJq zY7{gV*kNIW$Uy;vhtamr!w%5+etBn(QabJ7M9|!kr=l!&ECsv>%is`Kmct!!aY|C2 zU{-ppz^W;xa#Efv@@$7MjtA+w|BV9?3`%g*B1g4#JT*LJs!!=RmlB9s)7z%GI{%u<8Wmb3EW3soWK{>948(Uuc ziXNg@JyZ_0oB9C++BA#9kPZ%WcZCO*% zyz!3i7384|KR#pI8sluv=H4)qT2>M6CEX)YVLMDLPks~GBV4X?=)}JJy20NK13b0$ z*ttmaJRsycN)7{~Bs84j1*4_V)v_-?Whru7 zMJ7i}mO=0gw)_uLahwiKeFNRB3?4S2b@oB~NKTDjtknY4BQBcrA8jo`{c}RKJvaoKzuepHY`QEKqIW?9^~EM zpMa7APe+i{FzIZiLQ?fBj2D{Dg#oN6>dV-uz1S%9sy2;hd#jqSNv2dIzM|J2Lqu>j za){Y_!)m*H+1L4&x*r0`L|7$g>=3VNa4Np7qi+t>KH=b(5h`^%*_%a~d1M)M(`Wgl zaJXc0KL$SIS#e)Pci2y5rNpe;*8t7)IRG*^7Y!Sp#2E#`@$RTLJe1mYF4H`mw3#uki_H^U2g2^B8yU@*i9;1BzI*M=bj$ z)!!MBi%e~)VXJCo(?6%GYQ3_ZUGKQ;Ml}uAg(;L5+K#%`zP@hc7qbNw$s7|CeIw(2|=k`wV2B)yp&eej;i=_v&8#Kvmq%<%{e^$^-ZDgWJ_!h%hOq=5b zAUDyE))guSa7AmuWl~B#mrgdO78CvtLc|jVoMCeRtb^@Nf2G)L@v!l%Rv396MQV`BjHDt> z<>GJM5qTq7#!cUHns)p1(KU?v*Q>C;-1ltcX2*ye{;`6WRX68#$~X8`F~H?Tw8@A@ z73JhoDfuv4yLZIKXdaQ zyKd6Vt>9m(XIqS$*|>KR7y#6V_gaVWt(Wrf>bcQ?X7sEZD(IR&5Z&QqGNTQi=aeov zZr@n_LGJ#cQEjUMkO^5gP~2qB{%5&zDD1bNIV*rl|AU-yzo6>zxf$1^&pnIDls~50=yLrHwhA&%D7lN zC?DlhHF`Tp^%30&$8U}whIy;i9B2+jbx6erymE3?i#})sF?4PuX!aEUV~k=`Tg(oVP;_l8RfjhNYiDWJCVC|pV+JKaX*=Um!xrvMbLZxB zGNnCw33t69CU{H#l!s1uUZR(73|&U#GYg{K_1{aN{@PH(29opKNjzcvkN@~cCtsyj z-*jm3Q~BAtbXIxERuE3jWNzP!vPAQU(du41*H@~Yy9OJ#%8aIR@tEA}KDGz4AIEA8X zNF|Z~P^>YN1sJbgS@Pv_jkckVQOx(Fer&W%r)Fp$W1xwlyk(e96Y(h99=2<{s;u_2 zOVIjNiKz;1qY%&pPAnPmudW2|(n0b?RFl!|+A+%Zpajx#5(4G+|byI6d)2KKmP5*QFJnSAtoV5!fQsx;`8}bof0k>vA3lzi&n7CVQGeIJ0Z7dq ztRzQa195!aaYO-Yol6DlhyA6m8`gfE{~Qxj2G|Ef3yKHQnEiC_HS%v>v*&xjO&;Ja9I6F+9)Z~uYz=t?N#%cz z$fl;^$xk1<#vQP&vD7A7^*mxroWURKege6pg&*fEzXE)A&@vG6qvj8|vAw5z{rxl2 zX8y&5D`CnY>m<=Mk`dUlL<{*E`GR`9P~-gGR2H=#Mh^BycFmQDld<|EYe7wXz5n%m zKmgu#&*571{SEp*DS^g|xtKwt6*)5~Xm?{8g+3_u|9t*gXqest1_C0IghGW2G^egQ z7P4T38AH0{W-wAnDpuO4#r)E4R^GuwSGgryH}`($MUo#=(F~k&4qUl^6&^hwTnJ40 z8%t*h)8qQdKjT3|O~&~0^H7T_lhIHbe@fi|PD%SJ!S?y|_D8O{7!DJ{5)w+A(ofTpX368R)nOmpLVotz|xQo)JPI^rANyuLHG%coay zLln)(B1SPfF*}&P8y7Y;C#Z)c+7E%^h4Uh=!GniMUTF&wNfjuv6u^-y^)}M(g-45% zh_xWI8{d>Ag~ve6Ww0Zrh7^;of`Cs`!Dy=Er|Al15}|mjn;gmuwh*NcATYhGKuDS_ zq3ZmJrD8xqr)3;$Du(&D9Z4PYS=HD&pS0E^%LlJ##La-<8F$j3yw=ommho@t2w#*= zs-0I7kycNP0CL5fv~wX)u~^h>OYEmGq{-++%eK+moP+dwNlqC14c_bYMty_U7yI0E z%^$_66NZ`uXI{(7kbXEJ09k;RqkHG=-X*{U5|F6xgItsm!R$UaWc+`1$C< z&;y7KCmd=>HBzR|J&}AV)%57@8n4(g7JimlwlP|xD*@{n>N%tq+})DZyo+e2XCL|b zbpFOxiz+~`I?i6*7W4E=sNwnPzgoX!|MEpz^%d0@FuLKgF zWLfro{UBXGNG^shH568)qIaOGCh(!?_|(p8RGv}=Q&o?^v0vF@iDRwYF^jqUad$Q(;xkz|Jaj{TO;Q-w;G2w=?`fwVEk+M{!QBu-m|H`IqIkq z+E<-x4h{W=sEvnR8^e**Yo}lodeNiIr@11NOY@oMQy;t6BMrt8&M4clEUwq^`78KX zwJV+Zb9l1%?n58iKU%CdkNE$kZ>;is%>Tcv`4B)rP(hMh`qBT>_WFO?M)_ab#)Bc% zU2HnzTGza;c!D)B1EUif<0AEbCv<}|te}fGWU6Cj&;7fx}aca8Hw`P*FBj()Up{&vQ?AfZR;5`tUzXl4j;zBvTJRP0DDwbiM{KIe1 zwIBqmyqz=%AWbOY%`Zcv6Ae7}@ld1_EnA*lCqII&+QgEMO`AgtrFU-WLrFlO7mu?F z6UJ>26P~q1fU^uB8R#P}OifZnZK#mSbH;+vh>@xEE@BRfI!Xv)wGoVKB1q!8mnl3Q z8bLRZnb@_W!x)4TS|Vbt5|V2NsT-mKW}-H-0!}3XLa5^pvoNxoGg$j{1*$4kbXdkz zS!vX|F3dRnZ^>(?;?WpD|ADBftxEso3c5gA-Fo4*Lf&_pszIo?26Y09;L@ z!BZ!ArhIU&E;O%iS^zjbU#HyLu80154eo#9Mti;dS6`s|B{=uYg!7-c1(_0V+Gmtt zgK+`hD=#fusPRYb1(1Fi6x3f?BmbHZAacZ&auoI@1gV9om*b8ia6uAX(a3au^4b5` zgl5S8Ly;MFJ_4g_Z0{WA@s$1tYvK29z(0jLvG_RBBqjN^3h11FCJ2XL9ieaf5o$R$ zuO3au_o;@fl?xDBojc)~@jXgY`eBq82hl^oj1LorKeGrbgE6#Lo_bt>-=IW`q+zji zX{N9PC8IqhEnz2RN3eXJ`tT72Xj%CX9Dcrmb;G31?#fRzq20*zE=R+ed;c2G{=#*x z?tdl5`Oi3voh2)TiY~f;lt=t=^q<&8g2Nvd7Jek!HVr`pK{F`< zh@s*zHLWLCSdUOKnx!3|rFcA*I)k+Stcu-ZS95v0XZiF0YUjMY2fMPYR+tJ@x$E0m6gfM!TwUu!D*cf<%>S- zIlm7?`J3rwpV2!V`XgJBM(o-DTr{pb?Ynx03u}O(q!^U`^f7qYChqOT zpC(@eU#-H~Jls_U{^|%N7LjG`V_-Th@Dvr1s6TO0luMKV3p_*fg!u_Y6Ojg~u5w}q z7%T}<4U}|moCa-c$czOiNFls=OrdTPxJK2i$$v@0IFc_6NghE+bn+`M#B8$j6(a1K z!Hb9?AoMOtaKlLj>v-;2C_J#o`4D8+B^!{%*IY{7SNdgR*-x_2GRUAb`I6KmoUmU( z0^El7WV42V3nCT^^}alwPC*g!cv}=T#S=tEa0!gq?5%UFmjY(uY@0z!=#9HAP z2&Jb?tP*@Vui?ZXSly1eOFEahkSGkRs-_GH7qFicx~y^cRQyHZ1)|3W0SvipeQb(D zZJh>lxoK-%w)o2d?`6iJK-id|5c6uACQ=g{J6AoK28L zLJ;<$N=KZZF{FX!_|&l-yMGXsy11%LiAogUN%Y)6N?_q)DZ_C-$kRkyNnO5WDNaFc&HOeLyhzy|V65WzJ1*2aBQ?sosN^ z+=biMD3$Cd?m~z2@D+L=9wRExIA`v0^4(4R+1Jc0^y+B5g-QCXYi=-^_t-MIl8DPr zfkn#;Z0IHXu23XkG!5ULSl55c2+9+@;i$}~x-6jXX4G6(x3x(FI>!HY}SeUFEJ z4+ii`eJBksDkdCMM#H8a=Ph6~8)#{MV$jR4<`mvwb|@6RBqwbZQ`Pm9E4NXA|0xS1 zvAYDZ)a+3zpD3Bp{3a>ROF>Sov=ceMD5S|*Tr9C)v_a|Z0%Z(RZs=-_c#i4NbY212 zHj9KX6wV@Qmz zx3K;ObxNpR8P=EwM;4@D&I?S)I&e2)qg3Icob{V+b6a=fKKd$apo6*M`aOYHxE8>R*a?tB03 z(UvS-zKC@I6=x>Sy9ZQA}?qSViGKUUq*JWSRp)ilh zOOR}ThLin>6CV*r=q+=jZ?QzMkY|5(byRN+Lf2O1RDN=o_H@u+A{1Ztm|p4b)9mp% zc3gke&timDFD)sR%vRXG`}mRPm(6#GuLOwJkl3Wf2|hk86?Q;nS)O(Lc;Yq-%WSY) zJW$RudX1V_WUqT9nY{pxSF2<(lRkeRqbCSSHKti6IET=R%ZHO+0C>7m?RB?6f?!1h zk#!uA-i6M|7oElpeUh-)O$)9OwF)YaW4-r@QqovxZ|qH!w`K=Z8fQQI7Z*eKjr~nS z{|V>zba)!iPo+$qGWq%OPRAG!k1%*vlTX_Icp9Ze#^w+;YFBb@ zYDjpTq5aoS)+40GX}3kEwj{61%+2Fp_PIsGeb~(Yg2z{0kG;Y@1;Shu5&|iLA0|}# z6aL7S%wjhP+VCGHW&a^?%;vJ0%wDlM1EGqmeq%2Ceql}hPqyAisxq(0b8u*z>UH+r zT14kthiae3@^0X&q_o+QR|Tw%?kANE{(0CtbZ%@dF46MCSxrN`^ViQ!fo@nLO^O>2BoqG z5-zAt0!e7sAhCd`fCOuO7)m${Kacuvzwktw$%K8K38trF@84ciz6AbRxJaL0gIrYI zoR+#0NiN2nD(`|>5FF8o!)?8L_kruz76g=}WE}nbUr1J;$Z6Nyg}hdRvx*IKREQ&= zl*gwsstqO)X32&_s(sc{#~);Q^Q3)tZNU5Y~RB0r*$w;Jj;RQmOm*qA(4 zc{ec6gb=k@mAw9D46uL_*(5R+HccS8Xmk8Akc)q$1zToKaLv?U-%efo*u!}WIR0at zW2gW(sZ4HrhFeNYLKowvz+<4UhZYQdjeRAd7-IlT+d}7YgIs_(t^a7F3vQ$7MF=!5&MYIWjfqBH!!n$)_MN-O1Aah5RmQQ9xMm;VfEYKBaAc&sb zDUXdBByeB85p%vGySAy33jt6Ix z0c`@9B?pZn@%2Fo?EZil0-Vin$1}awlfKlsjwCn+G?j~nddj|E$Z3UJtL!)K2|=so z@B)*3(a(L&$pn&Jzo4f?pXxO*D->{6i%n$kZn{IlVcV5eLA0-UKc%B-TbEc~vyENkk zD8{|A?>^KH#iG70CdMe039#WMMEvaSR=XfA3&|=u`=ZzE#me&{lKv^lVEAmisLNfp;PZD=8`N&f+q zvp$~i8Wm@=OW|5QGD0+A9LD!09RzQwQQjp#Dh4#B35tK`!{By<1b)}#>M?8+_JxVRyX?^U8FupBnqmA88+NNJLZ4AT-x?CVr`S8hq}P-Q z+Cgp>_x-qcK+*E=(6Wb*NhOdf$fD`N_2=cwoMnmXYv@)(yLGYmP&TkJc}k-P6fus9 zU`LtaVqdp8wQeCz@Rh4~LSx;z>x}OHmY^C27#~)gTrO?-dG;hX>497mTHDl>Wj?Ip zcw34w8)>JF;Jw-?iVHp9Fj2?b6>C-V12Jm;aVlUhO?rlvx+b1Jl^!ThhE~s(o?~>< zNEgI&CtilAoi&7AIaw}gVQ+GH-!5Gjlxp=!zB(4@s@vV8L2Auv%u~{o&Lph6QK%RZ zqckw<=bB2deY4cZvP^cJGAa@|>Hvp}?8l}JFdkw4e{CmgzO3LEWDpPmy8lzEf&V+C zu>J2L3)k5f?xE6si_Y0Kfu4mUh-t!X0g{CWfNWu$m*NaLV2qok_1^x=+=n6a<=_4 zv*wOKF+o>n zI!N9oSIYWFP3qf`kY|m~22lhgj+}SAClP5bfa@WXuyJy5cx@(8LJ`N2O<^zIkSs1)1PovL52z zZJ-NmHc?qLe$f!=A#}|6hooMWZfR9- zCRa!t5c)n57Wg_!(2V^idlO{&&Dzv2KlWE;=@GUnbFddlY>pV+(qbG`!d2S=Sz!j* zVi+`{&(Cfruz4QrgqgtnL6hsT>!+}AYuIjHSn8sROjY3Z8DI0E8{sIwVnqb{ z<9mD=fbY6DWF1!EA{JDD@zM;=n+&~U%@f0ppcu78bFhjN!{tqMJzJ!bKQnk|hl!2! zdqv5D$J5Vn`Jv8eu;(jMFg3)pjp<4y-p4$oP=IqOnsxQ7bj$Z zJz^;Cg!HzCa#&So6t1%QiBbCl{jq^kK+lZk0=8wTsPgKr7=P7q=fq8zZbb{0ed)c! zK`+lQkqRe+Jl(Rp6&18%?`RO#6vSS@&4Od0;leVcf?=!wKH4R7d72Afz8rt8yuxSezqYo7- z@}S98kYP>{BUpdvsmPNMguvW}o;V8rQkSb$x`cmx;@$vXU7W-`$*N%e-II+gRWxdx z$q`FFa!s`*(-?Xc>_!^`k)dJ!_+e%b%ti8xV_EUM#eVTm8S$79+k(;bwdU!Hd(veD z$EX-|mjrQ+VQm%ZdnTQPYXRIL{_3}erfjcW}dWZRyL|2X{AXZ^|R);LlMn* z498WcRQF%rwaDEsdFzOKr|bkIP?I0)f#I*R_ZO4SZfaL7G(q#ER?CrAfzjarY3r7p zPtwfgwRsX_*3A@6-_g#I6yg5}AT`0C>Vg6^E@^C~Eu0hA+fh4C@JWDkShj}gYdk2Q zeB=R|tJ5BR@tEUZFt!ZbRHgegJ=NW{qWcNg|1!+^l~zSN7x|TR=)u#f$a=}H8MYWa z`bN!vdzAs1(&GOnb0)ChCYJ7iV#k1($!g6iF?G?bieR&?IOS%G3#mXYyuRJWA?^${ z&%=mMhQ8p6WRQ&m(4Dn-+;d{<$%qK(_sE@^SUK1-++(>|F}^o{mmaR7>Gc&xR83>K z6xn1Y4NeW-wpL&0hCaq#nL}uc(l!j3>A=ju&P1$%tLfwJP~1&jE6^! z(s5_3!toV}VutNLz-r*}spliL>XT|3)b<3-gEo>FYi=OewX}0@w04MUEQROI;ZcHI)$5qfLFGi1<|2w6z4780J{&Ww&zT1UD*1 zUZ6SIv2;-Gnrfo~Wl-Y$Y6a{6#ezNDaZ3UbFew$ox`>dD^*+&U~R-&#M z5WoPb_cc1U`{vp?HOSB5(?Lfn$$qKkws{)%kZ1K zs8*)}E)+d(I{RXuw@O6Lb}TJFj@{cog4tXE-;h9!#~`bErI z6Q^J&7gf#Iw&>*W3m55Ef17nAd(41&t_s-+*K2N0qjYM~3jNbHDCFmCie;dj*S^h()Wxb9}xY0n2j z^s0RFM=0fDeMz@k3iU}nZ!rIMk89qO*MQYKI;q+fW5^qU^Auad9T8mcH05Cf8*cp7 zzh!Ou{kCpn)^j{BHFo#1rl5=d3O3r*xPRZG6flGwzE%|8oDuI-J6rb{OK^C6q;og< zB3Md(Aa>~$?XpN0BGvY-_=}GLn{gz_ApD~#uov!R2 z02EH9I>0^*3vUU96fvDn`-IKbJsbA@D`EucwZbY7O&8FpgGfFa4y2| zOPY5S!+E1`Ft={s1$VptO7EL7m#zrX3(m1Ogx98amIK3jRcX%Y8M(!>PPS}m<}SGG z@;wkk$k7HBP@q@s*$5tkfgbb_1a7vbuktZ%N9)??!RC5h@6Ol!Vd@cg z5<7Csc|s-ESY(MQZ27+QMx@W#wWQd^WtV}eNp`vq zQVZ8kNTa@K8(+@TgymKK!dqHRPX_6ffXeBMhM6vU#;}r=xbSmdEXL?7>pI5>pr&oj z{XoEuk(p@SRDX2LP>Cmdqr=Vc4_DG8so`knGHtUm758u?nlsL&hlg)Jb4>PF8$+4# zP@VliFE`fqkd|Mx$Os-&eEo>EuQDQGv9Rpu)+{Y(%GZT&j!fFcgmP68X-W9&{J-Y| zhE`>}PL}~6o7uRb;7g?gLVp?=fSiJQx}D`#%%ac)zUI^f%2LsOuc3-!18K|}i#;>H zoHMq?8gJy!(o7Az98p3K!W zaafr|Xdl9fgI5+=nDF1&WT9pg8^sM5EW>U=oGDb(cJP1TPSCMr!P-ZP0ZF?EpK}{e z`D~nA_J?842iVSryIAUs#C4rIHqu5!QbiWT1u8gRf!PQ_?JA<5vE3!VP90bTi@-Ri z`L-9_U|}X4X$nMWaie9?VPBy$1fIfISlekaeA9{GI?691t=D7J(cH5${`HSR`fiWS zXb)ePhex#w5QLyP*DDl!0Yd)>`Hy{BejkV!&O~E8k9XrIOc@d6B8lQL9)DI&(spVb zOa-803{TyBO=&_^lEEb^t?juNI;8}lt2wwASr4IyGji>%CR6e5R}5=-athu=bY}nQ zakyA+Z?^E6PH-jYcqgpKooS5a#`-&yAMD9AVbj)twSaa*25^$KNnyS z#TGXfltR&Z66jh9Dq-(R;2ELQ5?#2+EvfyDka`kL_|>F_qu6faGJX^vE7zyUP|a-A zgi%sI?65;ihlB98wO5CiuI=lq8#B+S%viI+7~VMXqqB!$Nogx``P>^GJ~q?4aJD>D zxM9(+vf=J5-mV@c42VepoirEKUl+>@{ZJvXg7Yb=Z(7ORVT!nJjVT|PI>^}@2XFo% zGfzf0h4>=5B}jaUp|8oOQA?5Z4E3hBMnUfRAM34IWs4$9;WtXdG5Gb8z|NT}NLUtj zfHd>hqnOwV&$|Vy@z6V%P|w~u%4B- z92rz@Tns{eJ`Ep!S1v;3VB@#+T!`M7`)?n&UC5YH52ZRtlc1(jU%_(X=U`SD7OBFM zO`Lh=L1HA#gu%b?lmKlh8*kH#$r0!7OlHhAho=iHQQ-NxqgVm)AP8q9vWp`6)xLiB z6&yKs>OPyP)(A?=>a5Tx%{ai*ire_eXHtx@tx?P-;>>ZJEy*LwTg#%s5nj07(vCmR z@~83+^JaP-YrBJ0+k{#tCvR-$mkslI=%jUwUCQIj+*emZZOt*wer!W8fsbGp@s&mb z4l{&}G7u`QS;Am1-NeBTpO(=$p2S z(a6wnn>8B!+RhhV8&ur4&VvkEKYCAtev2iXI=t$4av(XDGDh>~{e{wP2G{zWrmm(i=u!-o1Kvn?Hm2EP z0P}qMD$=ui)Ak7N+#)azVYp{v`PaEspU?Jl=id!KAv+W}6PaS)#S5N)!#vQ5f>nTk z1WJOa7g;`d`!j)7ltv;0t}>I@2*WeiD~KBIct1Zd^6iKgD=f-arQ%8e!G+I9h36Ia zP2(B&Z{ivEI2On@2&1oft*9uFy`@i9pgPj=1Ull(?}Q6P7$Iebuh@J^Uw*jL*Y`DH z8w?<4`naB{{f7FVj_-)qFHI1*{}8_N|FIGNa}oZxT%biI?!AnF&x)Tq9 z+UtGox~`WyVbLl_tP=DKA|3(c91D?RVdX-<05S1oziR6H+!*pc4G-Z41Y z|0J|0+H|Bd>pEh9J;==u=T7{vDpmyR_DFWZ%dkOgo`qr)*6ZbT%z!yxY8X?lPZ}i2 zd(0~Pud*V0lxeTm+V)8TlAZQ#@+E%O^nhn3m8GLHzH8*I5|X& zUX!umd;vtqIK20A<&hsQY#GFuOfD)zz^jE0@!y5F12=zcu;uY)WU0GiufN+iqR53c z-%uVqemG(%76U1r5PXRU_lJa7?gXZD4H(pj8W0KpnJhTrtkG0o0&!8Mz|+3F3YbNN zFv7$=8a2X*_h?C)OU`IxY|O&xQqg&EsVWyC!e>WQ zRnxw+P?XkLCj23z>c`(8fOe|jp_y(sMTsH0jb(N3;lI$V+H+3*+0oO=c<@S0vsJ$a zTnHrJ2ZW&n7bh*9aZ%d?B**V~u?-9^gTV)iTlH^*j{mQYvy6(W?fN*NC?F|Amvone zq)17K3IY-m4k_IYj+BDnARGh~7}TLV2Z?+t=L*S`1g@q%C@C77 zAFbD6G2)x3!^_$0Z@g!7Xzkeyo=j!oK8r zMmX|tOyoAMLPEkSKpqEnJQR~9mqx^i#G=+HQ!D0(#C z?o(``>1gguSagBXBK7L}PvPeqmz!3_vm%O|Qt}p*Hf%$8&Tm52M;KmIc=1YlEL}^B zu1-SO)**6XZsrJmQw2(J>GowQstyKMI^32IDdQbkOB7&Won|2{G*niQRonrO7EsNJ zn+;HGeP>5*m3OVRw$R=Yw$o({o>H^WO)ns|tSMyjt^j1-HCd!S1XJdcss#1~r~>Z@`z zvR!cHwrc)Ivbe{-AXs(s8xfRV0++nvJMH;QyzC{vs{FOa_y#VevAxg;r2;Qy|nLh<_0$O2=LNh z9%NPI`y`e)<@dg?u82gAes)Uyhv2E+B5sitA&Ja6_W!6dOXgzC6Af`5Kg&s$wYw0Z z@uUqpglLPsCY2%1+t^jIAgIzCq^JL!{PepwxZ2-RU!|ird0sz%l;p%us@J#N2D+qt z4bGgXvSd^_u1eN6MH-`D@mTLOj5~k9eAb)H_)Bp^BmQD)RS^Dd!VD zkV0c>9%92vI%Kvz=?K@9Pn|kxqAr=~^wn`k-S6+9a-d)t@8`<p)Z98S1E)O!8RimzDuAgiLB$9dm@~hI7QkCu%RwRg*T2 z32|awQAFDY6D>nwU`pcVmJ{<~Q#T|Y0uB*!OdIxJdo3I9Ey2r^;S_iMRk)3S0rW+2 z-rXtrG-Sy=@D>Xi>jb6&YdzU*rK`fU_2-;mZR`_mSp-d^g6=#R{E%%cTYR|UW?p)} zi9Qt#iu5SJFzedn3dJ$oj%A|oS8W0xZs*z6zcWW>|`+OjboV)*K* zj#8nd1N&}roa>gp1*Wh(tM$0X!EC(X*`C;;ax2y=C~tGVW62Ypckknrh;~_I>vwpK zmY+fBMza zy|`2-+kF?f%{Z%G*71E;uHKJ@kautD%a=>(T>arU)L9Tn&a01=l7-7Xl{=|AMH$q7 z)SB+E{CZo$(!@q6C^Pwlbr|EV`6(TFuWSE!h*m`Y#wThgEgd(7cv@aJF4r_u)ML+7 z>RdWRY;57jOr<(5tRdh~T;GB}+$)l)ks8!d9GN;FcU zvJnH#TcY7t$TFb~b75OMW9dQOzv3PtT7^xGWJq=u7}u^6cVQ29m^8$eDe8afv_{`_ z{eWvYP?Q%D>4m>Bljv|HwiWc^wIAcYpH@WOvzJHZghIJ6+i8SBI>YBHOS9}w&@UEW z3S#f7PNfsaV6RUeL;RORikuksmo%DA!JHK!JEW7->rj3NTjqR$gokj)-uQq~)F(;Dp2>N%ZSr@y;=N7g0C@bTWIKa=Xn z%U^32W`XV?2}nuOpzb9}>Q`G4Thxo24o@^SK5S@`Ay-cbM%LFk`uRAej1M;`A`l&1 z%IjVZ-G{iB{my6d3I`8jU;U&Id3_xe#PXpv;EL568huHCejgu)o)!)Awbc{n13gM! zm0!5!^C*GX`eS*Tu_|Ax z%HEA_%L@j`Eyqxn#&2JId?k+D&a{^S?xLo@S|@)P^@uRwzXp&10Rb(60Ductf|QtJ zJXmg#kL|rUqk7d8;Izl0xx`g6QgbIgxFpr>VYs7W!O%pHEB~|eV>gZ*;Nf+h%z;Vu z5!&M&Zp{^em3hcTkdxK>EeH3~`}UjYZzAa4{{A;+X<2C6z3Eds*z5(_hiE4C+u{D; z(qimDF*+xR>>Hs^o zDgv%THQ&3D6|qEEZ&30?RSn-wbIGEuSq=BiBJK8+?de5_`828vR1$t5fv>Gun)QT~ zPhYy1VK~A5nPaeD35ca=s`NT=@_R zAn%3pdSz%;J%Ms|cAp!vZz{q>rMW(3GpRuPd}M+Wj!{ENI~j+U)jp}-otM`|QdT^lc0dM4ZPv{Gw89YR2vaO9a>j_KbcKyTT&E^*{+q*vvb`s- zz2q^>woiE6=M9^i5IUvq96Iaa@naSvMD;2B}!Ee z9mguBIN?Sx_bIQQ5a(^^FFlXQnM=qTlk^&%Z$^pGFMf*3EKOQV&#cEs!{;*5Szm`Y zi)(|p%H9|uPM+C9OyPyFa0pakZEXIol_vAAZ?1M!XTc6HwfX1zuRnGOa~!`kjN`BL z$Ts&Va34`nvt>b8G^>enyvwAVODZKc597BOR~9;e-Sy+Li}7!iYWID^=+UA`tNqt?sREz%(KABts>9w8a^T{(c(DJ`O^x1_vn1}P;tulM2;cZ?TH{hN1>WI!Jl0q_=RnpJ| zV?Q}0+WTvYPrW)0E`Muc9o6RCIH(A<+Zp!AWXghsysKw8#~WV0eHiqwm+0GGws z$kR!2Bj2sBQs*c4eD2r>EXTbhCgGyB%i9&$S`}U59 z%YqRPCN229cJ=~af0UB>2iwo&TE2|_2Xnz=;{St<&8G5MA(^erI8MGY)*m&rFgVsz zxb3ZZZJe`o;(woQbHYNhY|e5$AhCTX6gk#pU+7H7z8pNNN>VOf7r*4t^FgFXxxtRp zYsATxY@PWQ#kJ}&S^C3FnLNMe-eWV3J8W8l2+<@9^!}QBiLwu`2fA3Gt*!xQUft_?Uz+)*DATEWVH-H4nL){i}}?mU$2jsVN3LnU#tjog&^M*iGzjb z=STA7If9^iJZt2wZ-aTxw+gD@0zk#KH-(e8xXy4qo_Wq*E zB0PYQuo?IkaThJSB^bM3eIgvi7)mxfj>rYSQuI!JVPGXt-B7MC7rnr5C|USpk+kCl z13ARNkdAM;boAkcAJw1Pfkg^GS7Q}bljOfob^b~sAbnNU&S}-2rZLvH)kQncZGw;X zCVaX1R-n1+w%pcEQ-YI!twfQq0~SP4T*wT%tA$K}hhyBErz31CDr%)Pr9HJ?S~eUx zKahJO$PEj%F&0Rq(BoQp;^I$xrH_s`K8H!*s_;TT{K44SB+Ws6=`AIN3&%S+l+mjn zcW&(40D?K#R)LW?-r}yfQ>XDg5qNR`||WR5TgM&qm+mc4}g4>DiOIcu!d45>IVZtZFh;x z-2gdnRsh-tV%)-8_~Azwvi`Qj@7jHU`nH>lv-oWZUSY-i$HNU@U?_+mA0rSU18}FE zj)=w|FlkpI3J(Q1I_Q9sb|%JVyunTUTWS9=TnGbx;JLHscus>z@daStp-3W-^xwD# zb>0BFJD7pcX%^neb#4}bx zL5Jc&Q59uC!O(ypfS`bYfQW(go;VDz|DjTV0H)I9>`|GJLT@F%!cyv}E}I~O71{aF z;^W`SMdRD#uhxeK^cyH`A8+sdFoe!(*X%ZBicQRRUa=Jpa;h2ZVrFWYM2~IyXCBBW z=U^yPw$ZOXPX-U*CrJWQ@^gj*@tGQ;o+mM@rss{Pl$&F~H&NePvnBpXfHJug*hVHZ z0I>3(*st0k?@`^xX458Dbvb1$EQL}abn12tp&{kgKR2HoMNkwaQuWv)QZ)MeylcP| zBOk44e;u@%5IifZoe%!LztNC0`YjYher@L0d}hW{P(`exgMENdnBL=JlBLPx$yQF? zrg8P3PbfoQUDC1d4ok5ekeTr2^9BO!ZW+K50-cl_~uSu`Gxx-{rr=34AFvfN@P(2N$j>UYyq|Z zhJ{!?W6isSy1U_yrvUJMrJFq=xQ)mi#>`l-ikQB9qOD17pJg^(nUvL58w50MUl*!9 z55#R4d5x0swF^|Q=tekoy_%E73-$^0V?0afbRZ*@vdn~pvMw~hUq~0ZA3f`bJSPz) zVt{{Um`V^f{sAKZ0j^GxhoAsnNOFbfBs|OE6?gUx9>GE9bjpGmcH^J`RV7be7Jyhb zj;6r%OHzcyNFq)|hq0mN`mMtS0-~i(@`J<#TNu0Cx`|VZo6T8?v_PzBrL@wT54UGk^UPZxvalf;?nnZy(sMc zy5r2z6Wt9^gn**gtp(YyEu( z5CYzv_##7J7adY{v5*^wpVQv&rjPX!r|KYshVK}%A);=}4h`|>3pbBG4O#I;TU!qe zIp0u*i-19pig!;OS>Z5YANe`b`M{zkb+_*Ruhs*`w zg{ujj0ST3cLe)kNct;t5>Qtf&9T5ouN>ynS&kPe+RAcceplhTL!lVYO5{C>E)u${w ztoG)4voo?<`VLzz2juzYl28?2TqIv8w{G$t&D?#&78 zR+lPa`4t-V8IKChH5AaSkP0XO*7hwq$bKQ-%b;1^n09pH{I=ggBsjkh819f>XQ=PFjiw`4CMG|sZ4Iqz%o zddLrZTVGSM%g0)BPma~AA#Z$|?Dq}nRG*(h0hRRegP3vTVbz28W4Q;lhY-*sQ@i9~ z!HBJg@DTQENx&K}3d{(%RLnaqcYZQh?6c>K_N@d3Dq5nj9eXJDAY<|%BP<1TF zk-4Qn|BwC#6Z)LA@k|0Rs<`=Z@p=Wbk%n-Q^6$7+Um#0?l$4fUNYM{rkb?(bj|Yg#}T>Nbu&!>KfEu!sM9{IQvZ5tq@w~^MNIMnsm1q_PM4wEB1E2 zeMQ{BKTR~IH{1a*PrZd9G5RS2i@#d&L4h0Wv@rT>@n`z(DyX1t_>j8`{)Tcq>{ukP zHlMMbn4)-2LM6E0&z!e0NQl&oAguLpG%xUdOBa`M!ojiy$iAm>%6ObpT%oZ~m%i4l zKd8X|kxCvTXd4~R$2{wPzTRw64?cR!x+nvH1NTXeeqRSTA6hx>-Ty6qWuC^+KnKqv zqcu+ub$nxhF1vlon*2S~?bJ5(Ezz5*3m-g63H1L2CryZD23Y060+PY3;?Z1|-ZWm);#^}4vju)eI*DvZKoRyrz+4dra{i;0mRBlHI`VjG9`aW;`J^i4xTJNb)lWe2VK>O+) z&AtVqS-eZR26fu!)P%3riyE=MH^~`CU(X7t=tGNDnFOU46!9^s1@Nnhq&l+r4N-Y$ zLcAc<(xBmZgW_z%Oo*uvN%2SzKAT21suevTMy}LJ-rv-eNL^b4GPn}o`~r!bCTHhtqmuugJ<(;#4pihU%X^@Usag(hb};5k$O_ zuoy$bsi1f#A0#&>!cchelBv4KFjvB<$DI2`5@Ojvz}-BjqC1u2aK7MJAK0Z*VdWm+ zF<)@0YrDj*2+}wg7CB`Whw2IK44rE32k1c;COF|%FhTw#7Y=-4Rmc^{ZVHHxqg$T9 z>=rv-$!^wvCIr|JA!7-O!zW2H4A~UZG@AgFM8a5bJ75U?NEu3seA50IjcW1EQ;~>) z98#MDgPIA_IbZ?GN+Dn-0^~Z5Xto3(sQBJfr(oT#L=bmF0wYQuu;xPcEvq>mU@;po zLjfb&d|H%TrJAGi*#}B}l1BhpC~+u=UlX%?Hj+3DYFt&zlB9eA2lDWHjiHP%6lapTidia(sU#j{&MA%7vDn2Sp2@M!KY) zTUR8iz(cGJw(z}D0hc=8hRpwrQX+C=g;~OvW@O-G zVn{Dir9Pu;O(obPE80^2h3lHo^ib@y^yH9f=@G`0oKk^V91_CrG-n4&zDG4Wt%%nK&}oO$k-kfWy9R_& zQv_2Y{kACD5gF)cU*)_d&?kMWd?nN}jg|EZm{ya?%IfZi?KS5aH7`($TlkYn+KVIO zU9@cqg%2&pj3m*R=V}1fKSNW6TImOUd7kn*C5*rP(G*I$=q%9oGUZ!;PUVux=AY`j zD!lPiU3pnzFn`{%r+Nt!xf)k1C+sP1gUZf!R!FV!7G;7bN1O-8kIIN_&vnP2OY!(1 zTuswAlS?rEdJ}m&ifu&X&P7paxeMPKj<3V6=AG(%IHI%Fmn8#;YK#W6Nfqvw&N-ef zTF@>x48MmUPDHKO)4y19s!uIcZ|8BsoBwbw^Zc0vgHuI^SMATl^XI7~WLJ$UNbXm^ z2-Ab%ZB@=_tRb93QA%T_ER8o?FnoTtF4Gn87LW*sDXa#Or{VQ=V382XCEY$rWjpIk zRIckWy>7!9ZyyARil>G&~ka7MyrktY|gYlJ_abVL{;O{>r*7w(=#g4#J z_D>utR@Bsc2zuM8gb*9KmxU2$(*UtqKH(%AkF_^6iv9su-FyOt$PlZ<2+|U#^B^?C zvcw^vh4Vy1Z||Xo?!9f4Jr9d+UOLO?FuL&On>hQ@-G>;cE+bO?M$*Xf%CgxrA{u;9 z#d*{F93|6klP3%IGwKrO=N1Pxox8+VmHOtJ<4X`{f9kS!u8|7G1tT0!@8O|pa)RkF z`Y6raOjH5Lh-x2jkjcc!l#y&q>nK&BNB9ETH_O2iYs1O`MmGOMG|I++k%}pU)M9o+ zvMpb^#nIBx&t^WbiN$Le_4i+D{;Zdmzb%&!cxQIT6P5FbN z6+2gjxr?$bv>sH|Kp*wkI5v}8 zG*Kd-z=wvjfeTioxlFxa=((h|>o+?GW(KYBHs)NqitYlX7J7DZC2zde_on#W%81Xz zl{-QQ08Otaq!C%omYBR4Uu%ok1^{kXsZOV1<0H;mPRh3cfPZE z1X-OU!q+sN#H!hqwV}W(XROvGOg#dMk}k&MPpx?`$J^fUXFfpKvPa*oDnL$7QEKlq4ZYLt_UuXDM5 z)|XERd8C#<@9FT8l?Goqk`O9Vj-Y@CdU6~0|JV%Tjnq`#=v0R88X7b?g_1PRjsf7K{f+f~m+A>Xv#NO&#DAy{r|3CODX3Ausmr{vRy> z@3X=MN}Bond(O}LFWC3Yv<^>z^>M(gBcmR8OQ_uK06Q&NSc`J-bI@Yn5MT?k(IlJK z6($^yd7z}P2I2h9W(!rgh7T&@u0nOmy`3yKbYbz~Dh-TvzrON*bpRkN;{K}D{)foL zqvNZbI~xV2ji!W#?=0ufnVMjYRYqN$eDC*n*%bD*TG`zY&?qt&-?Uo1;=E9Oty{urGD?Vs3S7FIswOzh8&Bu)cPrdZ<~%KUd`EkT?GKDN6+ci&WDRy&X}7H z5)s!0@+38a*OeezTgzTqtY#6bKwrGVDY)9c(o-LC3)1RrT6C^37I$?ewc*>}%)SaP zyvm58{D7{_gjZ{y`HQNnC^ZnVtLY2JdPgp!vEOR@&F-MIPN02NYUSO9K4GuAx?yg) za(2BKg1da>%>pQQWBL?K7|<;W&B&@$Iab4%#nbWphIW)-%>Nc{i3W*ev^7qhR*d24p;0g+3 zS}Sn48#o2GWADEzKC)>_b9Gsd6g*&VB}>l??b4NC)&P*F8htB1@e1o%W>TQfNa*5_ zC6XOdQ=k`|b=a(&32`i#o7u?WK|9lqj5(X`1FDfL4Jqx}E7{s@=d^J(;L1Nd_>5d_ zJ}a&sPbINLsqqXX)|6op+6N*Xg^FkK1$_`gFml1Ro)?-QR)nmCSIXzvM`S7<`Xpf= z%CW83F#+WSc$Pv`e4NDK;f0vvKPRr2r1U(b^uG{5K)=6Ny8m4cwW0se27J~lgnIU3 zO#`7&yMkPqERMUh3M#>&>Bw(!Clb?U$VUCVCMF+AK5!mxk~xZdh7U(kCGPIKdmzNI zPDx;{cM1ToXB2t_nX@IdT-f=qm7px3z|PyZcmaN6whT}i3ceJwH;g*6ql9u8Nbw+q zbd3zmSrvR(A3Mw{qe*9WsdehT^;*&_GnTW@g=ezrUvxjlmD>`Hiv(!hLJ36}(_KPZ z-gh34vGhLJCzOb-jr&FfF$aq~yiu5rZiKHpIcqeYO7mV=IGk=crDc(tn(epEsiHAzJh~;79l38CWDqtj3kO`rttni1i zJc>h0BOYrrJ7(WEr0G+NZqDlrDLITm69BM0pnWoN|Jo(FB+4c6byPoTtQg~)tb@?x zo_G3Tc<=KN_shxX_|%Z#n4_#az|zCQ>?DpA7vN(1_3dT``GokUprJlxmC}%;qnB59 zGQr+Fwn)c6dAP~qyYczwRNE5qvPV7rHDcCem*uh`j_esM#7{8fwHF1ykvS4u3m_fT zu4tJ!8iATv-J=S{1`FDpDxJ~ZIoHFZfD5ASVzwSeqLqL0b4u#2^WJM?@)TzIJ86mN zc#dKI<7&6k_eM;^?T{lkn)XOl!M$%X)%+~?0@V(g(Lh?$7&gnbNVFBk7ugHpolL*9 zcBo%enYI4(LGeeNQJNy{eN{^r9iV2bb}d)BPV$?cN$oJMrK->_oHBD`m!$1UN{Sx` zcr6-=U8Fet&Yug{f<|6Sx<-!Q2ieY2sBp&dksfTN0eTw26XELytiml4{ z*%$5-H_wbo(4Eh)P9aT_2CA<1s-l()DUedJc&9DHjnA#=eT+j(FxM|ShC;g;a|&gNE3TNDuCtIlrI?U(@v{}AVC zx2E-Zvmo}Y0YGuK<*#S!xt^0`4;qSbpyP6H**o*yzdg-&<#_))B&oO{#(1c(9*RT= z`-ne;=AR{PD2Dq!fP<`z*?5*WIW3n|O;e*^4|c4Au61NBpS+Dgaz%YBsY0)ta@@|10!Y{=`MYmqi=N+VCT z7}{z@|M)dRaBirUX8d`j3;ecbnT{&$MwI$sxu619MrvY~01#(&4yuhEhvm#wBTA1D zcBd8Vj&S z))ICv7(u;~v*4L8;|2VVV7u4{@ul*-hT21Uw&L*IaWPw%?lc*x9M`zJulxn1_X@0S zL`o&HsgYVp0KzwE_7$|*;Zw1ra z8RQ9IU<`xCZtgEyO1ne7xxLooxwuW%rxEHc+=9H}MF~RiAPQR1+}$p8Jc;c00*)|O zq7F)5XpkKJgBCBHuNAswpLvj^f>b2oD(^wb)5!P`n2p<#Y!u(e!WJ!#LZqdI4oXL@*+2Ttj}$h@4MsCIx3R zT{{CAX~|U#cgq&jH1cln$W(+RK`JstM8>&haeuxE#EFXSGs$xVzlZ|XW)#eJ;g>aA z5rmkQ@USAz;sHT^%vLYIf@dP{<3zenlQ;tF0F@KvC&i6R$i_IL(qtpaVCUWMqnCLn zue^1X3m+#}^zIae*bstc&6ZS37|;P?U$~#`fP~j(2>1Hn)Q49ej5#+)z}6^^8)R^u zGkneZo6T)33&mT(?u6jn85RXtSWc#8$9`&(FWwFZ4VFsfw;ZS-)k%Ih!$p_<9|hnn zz-4yH8(-eU`o#GN-?8zPE6pjTa|aU<8RpIM2Eyyixfu=<()A;EGSTFnk{Wn}il4u~%K4y#r3>-FIqYFhkx4{geiND->7Oj}p2k|bx>?BUy z54XZdOlmYaf$CXtTf60j|MuMiUOqtgIhR17TVpQV1O&v0wzQnq1UZYCkWRr=)ET~J zz+Yn|+niS71~DO%lXI6+ZLNz1kyfKaa-J1M5b_joZBLV7i9+hH!ET!7 zFHyQu*}45y5F}}^giHRd?C)?LgY1rq@3Mji8WllfbG2Dhuy(0nedGuNY|OS&c4DhQ zMAA0orCT1C0tBfPq&h4J{qmszHo!zoy1mo7`5EJaE|W~$~|y8M?903)+qA&Y6FL%EjD*OWzy?w04{d2)EMep9~rt=H<-^veM_02@sxG{k64=`|7<3A!D z;QxIE6vF~!mOy}j_~C$nQ2tveyW2aN{4uk)cd@f~F?D9}u(g>=m2u8uMDoAQIR_ZD z@f;W$hehOAUNt4@lv)D(NrDXn2}q%he?O(WaJS&%Ww?m`E%1=j8+Q6y|6($q3zTE2XV&ZNl!Yn|UMIY1veES!Ac74B{G=iCoBbh@=O0ZQ!!6kHf zlp@1`B$Ew5j^U*e3=)SgAecZj@yeir0wbV2VlR{V7_bJEFTh{pbMu?5@wnrP#6U2# z*MZW2$Q7x4Zm@1l#$dJrr;(1Pfh-aE+`}Izi$Dyjar{#o1ve^zE!pB+hT9DY#&Ht? zE02;t@l6>LICzxIynE5cm5|01P^up)c)$S%^rx`!CyFI?mZ_CdX52zS0o1-(h6yWH z&_i()s4M>UQ09J1ifbz~;PBSpVpaCCh_v4wmTV5}c3RzA^nK1G4!6Fntjw(_Mj`q$=0?CD)3!B9M5-e%K0!8t`Zn|5PR@IzyqXzqC)>wU<0|X+j5?dHCb^k z*J8^f>|Q?Vu+$hz*t#4E$+M&VCp2*%|W5I;CVAojdyZYQYx+Y)VE5 z;Ht7~+9nT{`>CkyE4ra(S5mNmpyiQ!!k67E<;q)4)8fkyrpk{r){pjP{83L$!Y*H0 z?meuN44hbA-1)YJ1{Qh)$llSdAo{qAFq~IjY^5AdMWc z=*r}iwKMtufH3W}N%Vg};IU}qk!+yn$z01|wc~C#&3pld5>BN!FbC3l*~EOh z6t9&~W_T9b3;ABtTMOy@B%RI%O`tip0qw%YD6#1U?gj8ouK@a#aQ;D=W+%XN zB7@(w>i2G;6B{BQW>Sq3rNh7ma1hOF2P4sBBSkL3%l$iw9Q}ZBhVjHJOYaMug!-sG zP30rzqL9vbCP3yP0=L5BM<^0QK^I>K!9=2zv~s$_+OZfz*%@#pJW>F{M)a+V%Zw8T zpVM9jrKbppR^wZ6!U3EII`^|C?o!s2CY<;#c;QsIXIuRGI}4>_N4FPCX7xHDQYYTF zr(@R(a{J0Os^-Bh!sEb6uYZC?MXSr?KU%OFf>Xyyvd7`T?E33A)UV_fcy_ zdLf}0u|2d{qg|){+a~QMr65by1}of#ofa7CclvPN=F?dOljDTn9!C&1HJ@2hBVCfC z2id&AY8V!%gaDOtnUPqCbYSp~j^fn_nj?-nvvf((up15Fxo7JHk>CFV;i&!We;~~M z2g22%C0LdG+apOazxkAPU~t6|TG*`j2)Wl}v>1gieo}yZJLrowLWAuD16Qh(_y0gx zmrv$tAq%v9g>s2h%nJslA&D2wlra!x&oTSU1yH~m7PA=5jhO>z^6}NA#^onx(yHs6 zSN>?JJrc$E+4)~~?5gwZ%2$i>pcL^gekWkke)KpH26;B`MK#5sK;fQFlc8A~F%V<+ zUE|w~R!POoFUMC?B=2tY%G4UoxJ*1eIC*`awRrbiyW>}53f{h$o2&yhW=|O$U6BV@FP80f`Vc^z733N$}t&19PsN_DQ$+gr( zlY5A#_UD1Mw3tNYg@mGAymXOuWCOk+!t5gSZ>zMlk*-=Fjo>hfnjSRfZi|QvRR^#U zf`#=xo>Hj22G+<9k*{sOXQ$u^uwZ_H<(06Ow+I(bnib?2KNM8)hR_9Js7svFjG^QS zL!viUVsq3HerVI7{Ch1RC% z_aqDP`(b$Kju8i!sK~r{uw1TmA|9{D@Eg%$HTJ4Sh7X#5JLrGo$FX4nBX2~(aMjrf zr{!e5GN+LSr+f!<^*l4A9c&{+Jl#tmBRx6fufqddHI3o8j{s-01WuV4+_cg%Sd3tH zi!2z-{9h3?gwzB;+qgL%>aBT`AE-xK-L)Z?w|0K6Vgr?i#rfi#yax4W=&GEz!MMM^ zg7ETUaC>GE3bQ8xqJlYifkk3~C{ho8c%OVX=ocYg2H=9Cr)ISMcWi|9FvetlpZcFS z*fU|4$%C_y>0#uysXv=8i9@3G%xSQ&hlSGzpRCZs`6V|XIfKzjBM{GYDieH^h<&Nh zH7*&Z+z>8EF!r>*aliKGg5Dx zc>^P?#0=BupdifL@>6$AP;?qh}s5A5xgs zt){OnK^jneUzY^Zh(;+_30`}KsUh}{CK2!k-hxv~EtIBzJIA9B!@p(|-y6)Nd977R z-AA;i$e5zFXleKR0#^5H6ZV9jWWYeP`i22;(x(PfDvi#`Ve3Q`vcL_p=q_Ge8kJ5F zRh|IIKx@0Q;@?1OVXc~1+|GD<@VVsoKKlg;(?ijvLt2FPn)u{l{T?Cm z6AvuCyhA5YKH`6dr4&^^&dmQ_)>w5N)e6p4d4X#S_v@le&|E*_SP4AhcZYbxph4m6 zA@sA}5@YJFAoU(&ub#Od&U(pXX+FDM!Jz_NYYl@BR6-vY9hSZ~<8*hKkSSI`5%M%V{i&T!Ap*h1Et^g1kg)mw)ie=BEHB;ZsoN1D82wQ z$Ma8cj;kzN0hV6AgNdFWzxW>zw`*nNK9YR{I(wIfc(zX#gQ`n&6rrw!6sq8CSRUA-o=;C z4qF{#o@fxYOP|Lu=+^r*0SR_13Uk0!=%J9wwLVKtibNI*VzR_>Xk%Uyt>nTNw8Tx0 z?h^NusH|43?Q2fvG>vK-_A6ZKLqd|(q(OqYM8YxOxIxXuxCGqFQJ7TKz(c}ak6_~| zJ?efNGej-?=<+K!ve!h9U7#$?P4cs@bMJkVG0`^!_|}@n}1&0H3oG+BQ0D={e@cz>y+qa0aK((yrohu3D}6u=?wP-U)Br)z4vph8PB`xx!|*8ERSLVWS72YQ#3*o0q7!QfWeZgC1RA4#eRix@=-@4!G%`y2Cs)|*Pz z85@Toz|TXW4;%~-KaDgd@lC@EtP6q>TZ@GllT_QxorD0-%XtuQx?_;FhncC>oD?Z= zN)z@hZF&<<1{!aS6*ij!Rr$eUFzI0r8ndeX9U=h5h>AdTqs;qJ_rN^xpT@r5RS?3L zuQ9!oE|+ z=0Cs}pXChV%UsY&HhxLf=Id34IiK?bo_Qyk+&xGQbY0yRT3Plvm!D#>Tj>iQHweG7 zD_lis0o+&ZYR!QIdUC$3gVf^s9~ANBNOdWK`p1Ap!UC63BLV^4 zQvH7_x`nBsiK!Fw|EcI&T5$)`seXhNzW~s}l50X41%-ze1KRdkNKm@+TRq32$}_axvu*WHG%GGN(nYh2{I!j4jEt-oHg5ndsp7vK?# z0q$r(q2JUu2(swH3OX4iIVzdmr$ieKG97MQucmR&LSDPO*L2`(;Z=NOzyxMvm{}tl zp~P#9L~>nlj)tUTnFf@X9)K`7$d&SA_+$~UrdDXkZLRP(ZxbFSI1?<15=ev*??q~L#A&GW@7&Gr@OdLlI z?6J~cuC|dzJ zPvdh2JI+BhIl=(g!3P2(QxodKNaK7pEKOfT%Bq+SU|X#ay!D~1K))a&p@d7UFsumI zrz?zlYjcY@a1qaS`9HAzf>o$A3Cv(UL5rM&b4{8OMMSO?4o$<40FN{ZaK;T_w$dK3 zV2@U@W`_eNGMv)H4(RLxi|qJ27+>UqSsH4+zQ2?z{@d_yVjP9sFVF5*Lde^W81wE( z^D%-jUt|!f($!ouIAe&}J1X)*tb5;U0X}Q>_ihkDlivo&-~kaE_s=uX!&_~FSwQ0t zc1EH-iCB8|xMN%vfO>04!9UFeK-yrHqSf0VycVJO!>db8D zsEVnmHl1P`+sDovZe4S17(&q&RVEqOpo9Nxh~yF}urf(A)VoV@S`IIXa08p4aa;sI zt>9Qwm?e`6cG+|>r>SdkkDMI{9a4~#1Z{UYgFnc$L1-iS02l0rX9&tH!z~*q^OyaN zAvuux%^-%!cYl)g*Pw&e0fGvVE_Ln#040V3l4I~>s0U5@m>&btxPG39;!ly*(`BB3%d0|Q_(R^T7V)tywL3S!#ty^m3nCIN@&QLkBG%e&Bf zX+=BXEsnag+rn}%pe(5Z494KW+s0N`!N@LOJFT*haw_}sZ7g3l@-8+I4*v*PTbcsG zDG_KRh#BBkkQv*6%%N4G8twUK-OLxjao=;A-3CIAYMcwoP(%CRJY3R z!_`5?nNE^{Wqf3XdR5}O4%8ku$QXX#53np2cBzW&e-n-`k`^uIv+>ut{T;}I&;6Xt z-g7Jlhc7v3Ya3&DBG#oZDE8T=binxjCP_>mIuCmf{5PDqTr{x*%BK#zbOBJSxnheE z0H}>cr2*ezkbGdxn}K&uARJKGUWwE8YA2{i9;}2CEnLnCO=6smwWH`$`CGM((Mh5Z zA@@(FIg37EKa)3LQ7*B_GnFEkVEW-#B?1M5F-4RB2RsJz!%y#yZ9Csixf4E*P`eoFt$;a20?Cc0#Xm~?!ACXUETUzwbH$HfX4jB+V z+nh}NV`08@j$^qL0@O4rxx9ZrjRg{#8gzBFtfC>`a*$5dU(l50XNkjj0uwq$fR4oY zO9esE6l`Tn0b;X+U|kJ)IcNhUwDZd(_VaOco|hnZ`3UG%0Y$OGmiHnUIrI}N!41rZ z+BD=9QN&G-oiey~ycb*0sZyucW#$|08<=cQ1=s`p9+AP2M?l~eVPbxR*DU5&og`Dy4!dhhO4cVDfj^#}ig z?V*>Npz7h1cI@nrfqhV&qapDt)o(%spB=bB9R*K85paGr=p-MbKGEzb^`CI=+jOZuQE~Bks9-@`catpn8ixYc$r&anPgZahM3d$jH zWBxJJit5^=J(1PIi4Jz&`>TD|>-Z)$IlXV!WVpd>-#%p+U3X^(U_668b!E!M`vmX4 z*<6pvP4e|=-kJ`iT`~Jko@_p#llx?eYA=2;9ia0B3m<(7a3tZD;nW}#Zzn={e0O0b z2nOYd#$*mp>Az3UNsnbN<07uw&ZBB%)hZud9}k;v+I(VpPg0x5>)0DYHL81Y znOvHz@|(_#ADC%I07=~M%u!%|Ev!XEkAmDsVWld(w@w@Ybk?l=krlN_v1zP9Mqii4 z)F2ma4&rpd$`MXZA5}Y!H2kc};F);nQcrs7jzXNZd16p^W(S1m2=MGHukJ*oGLgA# z>4Pf>D{#@1+Eb%4()nD&CmRkNc*qVt2W-SscR0BVO*2tfIAv;^lpE;@z-n?gn(Mv` zO`X0=Y7?IT1~`OQQJd4BO}KIl*%x@T!*(~i2vG54h%?;EV~>WJC=3Z>Bq7EZ14R#C zjDxU&%}W^5m(1xf=cpL}E$T+d+q;LppQ~F-J*i6)k(OQU2 zs7sAMRLLbExm__Tw?}6pb{81F;mt%<%S}CB&7H*m^Zzw?H8d(VNaF?7z#m}BxS)Ag zVLNGbeY3DcyNXWcotMODu!b4BD+3IW9(%OYDx9b0!C`Fz1Q3wqG)vyKuukJwUAVA+ zC{~y48T-KPuSSXMQAvx$QwPUJ4$qvfX%X4zPJxx$T>RFX3nPM$kO3%?fnR6Fa>%ot9mks?mhjUF58BlG_i|eObH? zkJrLnaax^R;=ZDRX){L`Jydf?N{H?#?GMh>>B$}pvn@xPycn{iq6*fIQf20?!{k!) zY}~ky{P$&-a?VqDTbIg*+`eqCq)vxsTiXc-)YFi?H|>X8-nJYDren@N?2 z=Z<3sbv~Qbw2~G2jeDYIc&zs>ZDDc$p`9_A&SQnGPJOIv?^wC>0G@p?6Kk!xvYF@QyNi5xfJ z<)kY2Sj)DGj-}-CHGID$j*v^ne0?#%N^aWI#V{MYTPVS#s+^adW!77yGj-GXr zKHd>*64$VX#Lis3&<63`ns(G^IR|07lusgFX*_SnM~5lP@1Ibbr<{@3*nLwLat{T( z;s*XzCU4&Afz^5e-Nc{2(~NS|hwmN$%(HVcnpJsPX0%aL_b&HT4WDXR`Ga^8M;Rsh zt~9GkIz!FKMtJYY6m5@WoyJHJ+x{H!U`0GqZLZ>LY#u}gYx^cA(f6kDAvHWEPHNW-YSm1uh>-(u&=!00r^q&0OTem+Kk7Zr16 z%GJ{vnrC|zYHA*}{yO=ypj{=V=@E5v&ZnWgl`OZS1%sd%uSt*Krdcha9upT@65aUsoLW? zYSId9_A-UCE3}xNDdS$YUqjk}>+AST9^Zi8r;~w^gO=@in%V4^C(d*5vTmbff+CJu zwUxus&qwigubwLTj*B0stJiy>rbSUB64-^@$k|Zq#VtjBN{DQ5y-`25+qe+%U2o`1s;2 zJevHKA4tNArL@6%xF9@q?`c1;D=43FdQEPt!6qr#Icf&*No@FOI(P4zqY99Q8fAra0F7@8$dmVftHaR28e zdaQyN=MPF81LaK3>9}<^LcP3Fzk}|xVL8U(+aS)XlY)c0BZWm4Jv3ts&|SEm+VG@b z$aq;*`cM7NfTEU%DUcHKK?CH9wo5!*4t^qMzUuC@KQ(`MpW#34ADo)DVZV5QhGwV< zHUcvLQPY|-3Bbi=} z=`How(3=K zDR%uYER)9vb}ufRN#fn6gPySC6zy@3%&6#*38v7p&szvNuCYUICX4<3Fmw2rXtnrz z+v8j*sr;;nWaq3J2^6u=dLH1KzAvOzh_20cZzdK2tihG zYr>~@%#|54L9p%_Ue$b*WO>^H7jn_>$FOBqU8jIH4)r*-p_mw!>Q*d_#I+lmM;8e* zrH5u~iYtHE@P86-`NTzgdSgmImrm@4u9wVO7Ts>6m8;4m!r;b>mXt(f5VVHf-=bZqm`Jw%xDb8t(#i4g;&k%4ciP z4n%I0eNUQmQOBlK$jRc!oq*pN(7p6S^)2(B*ih_fTb1VKk`o8?L7KPeiZbC!u>~}7 zzpQ&)V@38=$|*|bV)`@q?!^XPc3%O880n@i_nz5H5nfre30RQNcm%Xi8YY%Bt z*KG{EK7Apc2z(Mok;KP5hec5WjOQ7S+KUwYBKYar`KkBd`#{tsw^nuC{iTC}Nlgm= ztFK!cwB}+FW3-wTKC+xrSljI%aguVo;o zGsf8irZ7bj5vDrjv#v%GCsr{=g$V4sA~vl?^fsbwB-LF?G6}wBCRa zl@Z|_!u_lXetv8MDYckI(SRG4965qrX;adRv~vx=G}A@zZ>^GfFl9U>xJ8?;>miSc zxO}I>hkZy1WW7u++YDBr*B9}Q_L8QvfyEznv%f&Mxe`1bDsGb;we1enj+L z(&3?KPPRM)oEpncr>wguL4Nd^o@bs8qjoXQRF#=U-25}9ILZ3H)CRPaO=1J&vf#)X z!?oj~C4fPHXA6`n9gB9T$v5*506%Gjr+CLkEU$k*80a#Dt;nOJCHdkvgP1b4p4Od_ z_sMP7noM>5>?&_E0uLU-!t36BjTyAb1abV_>62o(8Mrr%Bi~o>)R;~l;@=viW7ou#AO%e}8RfRG9nb*Vv(YTL^e=sYC2zv8U9YLx0Ir z?W%pI`9!(R5p4ZeL!U#c87hX+oTMXP4~Hu&{7JSwDm#@~y^R55S= zs)KU9y?}Ax zQ{oA=+IK4@X)nZFB&*y*OF!u8?@sCBi#!MwPXO3D%UKf30SM#mt{Ke=5h3fac3p->Ii*tgYPixvimv8SyyC!|gd*Jbzcw1=?KCQgreF!_SxEmTc!=vyk^jXmc4< z=g=~TYKy-498Sfu;2nfB)=8LYXP`UlKSp1o$Af@@%}J0oTc$rF?Cv5nzQtUX5S6M# zL8ww{&`v=4;j;Lap&S_-Qelq6amRG`dIp6?NeOhmtzKMv=?}M|J_qLIf*)@&ehuI> z1Z)Xa2-A(XDl5rFdg-6Gi4AjwmQutamp-C9KG=Ld3VU1k)IlK$L3W=2i3!>WUi|ol zwY|C@%v7+l5uU&jl7#ch!Op69agL@WYj8L_%@b`EPg=f`Hy6_C2}SI;z3J?@26TH^ zwM$>!I$$K2aMPQ$WeZVCMm2{pr6^V*rroq^eRD=DKlri)JsMMdJNWkcBu$pr`QuMr z3`DdQ+{JrdkK_~U{A?{;w?Zd@<%@#)8eSqXxUeCaVSVke)FXIP|EemmyX@sxDJVCu ze*OZS4iByir7)OvLzJ}mp6;@q*T7itqMpYQ&k2+=@5&<+FvH0$L*3(Fq+el?=x@-5 zmDPC=?#xH~gJR@2L*;80L~gx}&bcfOXL+?CL9F1YFI!<&UjuTmTZyLgmsrAR-xDW- z!!@a=auwsKt=oey>UQNPuZ%Ds9Z-X0yh9p2gN!*{t>l;HXezSCX2^dY6Z7~a%`Hgt zPc>$`OO?nGFbUsLpH}RHdAPUuXQqS9`j4jTd9&Xpzw(x@knlMc%rJI!4>x3vD1bVG zD}VN#3fEnkoW5=}7ECl(!mt(GLVwTzPFyk9T7lK&=fT#Iw)1G00s@FmOg{-6H_@!= z+tY*63nbA_W7P4!{qJLQo6>?*S9ahxHy6Y|75bS3BgmmT$E4IKxVIK09ra6DwHMbj zI8R}Zj|%B~OIt}89k3nW@mht^$t*fdVf5R}tk?CL30#(RMwe72jLp;bpf-GP#3t78 zvs`oaD-t!iHA^$PTJoQ+-#k}Ca?D@DEEZ`9g-S3~ z;-3pVqS-_uVA_XI{H)4Mhv6E@D-iYtJ$b?weOIlF=>C57-g4 z?d5jYKITRNH=Qi zZlxqXEkj~k&+K9s10Sg6 zE{`5U9VyfL6%tPfPTIAYvJSU*W~JB}FsKd4**IA#Gee(X3LrOT={YIX;*p_~-4Xc9 zd&ib15pY%6?;%kY!jTJ41xSt>ZRa=aTX2ZE+?GBl^Z2XhB+PXzfeDbUyl&o^BKc1& z%S^184l|uzAjy8o9ZWWb_r70)E3-;!7s$Q2i+gn8o?ZLcn-j~{A}KmwE4CtyY+a7x zJt7}di;LpwkJ|tZ4MIpsVOkA3rlY5nLJ}~-5%b5`r&}<4e_$0B$;HK0GH1* zaD|bH?rU)(*!K8uIltrF$IQ4xUOINqnI4KfwO6pe>T7Od&NWwX>5Y(E^+}AVNQBLP z)L@kV0h0;zX-s}O{qgQ*TdbqNP4u(c-V|TFgC<_)B>C8mU4Ww2E_ML)?#w`>hJ+T7kI*m`r z)jC5%ww&wNz}s;_qtd<=v@81*ygkc&H-pWCU2A7DPl`-J7?1DrvirrZs7{t^PXs>+ z@R~>r$=0t3Q)F8;!M1#)3;03;`uHw#J2$8TR|+A&`JJ#2#$hKZEvaXVF%)oaaH9lb zo6QPDAjet5&^2&j(8~M%`bea+dN8nJ(FU*KCB(r3B&T6m7>Hv8Q)_Vf6)}3(-yvu* zL@5sS$|ETWoIf}EE-|JIVNghzx=E9s_LVi7Wck8+n%6hmVhQ)`Y=C&RBVg-vrY+P# zP(ww2-4W7MUfAd^#~}Q{GS4G$lwaTEct?n_)jVURw!fE}dME^r$#{N&62};mUvmdM z+6m-QT;xb*uBMf2HI+PG_<1c3c{A!V^E&Khp~tvE*Y@rB#aLpy-n?1^RV54Mt1+W2 zaA_5(JX>CiHco8Ap8aLr@i$4Mj@+M@?j}eYqNe#l=8jG))|WJhE&gb*K-#uTy>sh= zFKnT|>~ZPku)EkQh1Dz8&TX}>f+h!pq4UMc{*ykZ4+vtlY{Zhcr}d~v@K^W?8)@Oe zK>k$X3rF&FKAs`)+JS)IyNBljf}igNk+5aJ+o<<_3DX-X%Nto|&mAee)|w6voFgc5 zj;}U@y+8Q5op3nSO6^yJbJxUFy~`CSdy~ueh)=Xy#xKPrho{oCF*Qe&&)znfTNghU z07hwE+TEhGj@tW+ku*ySS6wgL^ICErkWNGs~hxB8hZTzLc`Wn9GTeaJqFB) zo8au$w!IIPu=;Yxi2p2kBLP?Zxp~ig`od}}oph=ao*T6%-tej6L^lWY23vFC{1GqM zm&jYD-A4#qj2}mXs_|6clHy=|OlJ1z4N01C zyjt#}+P!g@POjxE1 z*8vWOy9^XR($E1*jX6Z9N&RHA`x2Fik4heO_?1fE_pbW62DsK(fQ*~Rz*Nj(%%_(k z!Nyk+jdqR*zJ!>^*=EbjREGv3Y%4`;%Xmv3C5jfzI(Ls_f72;n5G+R?$u{qTlXUzh zr(7y*61}W=6Jf!$6+yu&t2&y7!R?J{*j^7mf=KF|9Wb(7*Tg20B+#Mt91tONaNI;~ zOs^n5*4oV)yvN=xd*#J2afuq2my{|&D-B3R-D2=n_%0Zz`lM}++PQz<%Nom1!F(CRW6QE z@xDqCOdWYHX~1Qb>s9x|nd6(8_sfpCi!O8+@P!~eG>D4k#f{9m)Z_Rf-)0tHT&hi_ zNFPdy2PHMv*4*)B5Px~0wfnuRWnW@&Tb+H9C01#FO~_9_}_gT(*|CB?}G=FCmxq%cj0Pvde_=jqb%{$S7f!mtjRt zX&-IauR)dtAo&K)2Z!p!4_A^b=J`7_NylHRMseh!l8U-e71m&nwTnx9;i>ipKlj=-(J{O2T8v-{SG8hTAfOMxa z2Au~G(J4rUL_N+MwLbrMZQl6-Yn}|iUMCs)7I4=|j?w53;CgeQmWqt(?+gtMjr7kz zP!T);aOjf5FbM^Oy2*jAE^4ZN)LmiJlm9Q*69)W5ajW4dP7#AX67cPo!g-MP_qqVF zJxst-Hw^}R24J2-20ZBD#(-o2zfx&{7pTy-2FNdG1hP;eQ5~RKNsj`E0QMRJf`1>% zWdlG2mBFZo09sIKjNw)w9x6ZuMG>5+!Jsy}|Lr<$1DJX#fITR~{{viipa4NEjP8GR F`Y*F$M+yJ{ diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index 6d0f12bbe8..c1277de696 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -452,7 +452,7 @@ codeunit 139897 "E-Doc Data Exch Tests" LibraryEDoc.SetupStandardSalesScenario(Customer, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); LibraryEDoc.SetupStandardPurchaseScenario(Vendor, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::"Mock"); EDocumentService."Import Process" := "E-Document Import Process"::"Version 2.0"; - EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"PEPPOL Data Exchange"; + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"Data Exchange Purchase"; EDocumentService.Modify(); // Set a currency that can be used across all localizations @@ -470,7 +470,7 @@ codeunit 139897 "E-Doc Data Exch Tests" var EDocServiceDataExchDef: Record "E-Doc. Service Data Exch. Def."; begin - EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"PEPPOL Data Exchange"; + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::"Data Exchange Purchase"; EDocumentService.Modify(); // Link the service to the shipped PEPPOL Invoice import Data Exchange Definition From 01c317a9ee321f99fe4b1f91c08ab6c15dd4d900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 10:09:50 +0200 Subject: [PATCH 44/85] Rename V2 Data Exchange defs and add upgrade step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename EDOCPEPINVIMPV2 → EDOCPEPINVPURCHDRAFT "e-Doc PEPPOL Invoice to Purchase Draft" - Rename EDOCPEPCRMEMOIMPV2 → EDOCPEPCMPURCHDRAFT "e-Doc PEPPOL Cr. Memo to Purchase Draft" - ImportInvoiceV2XML/ImportCreditMemoV2XML: delete both old and new codes before reimporting so migration from old code works cleanly - Add UpgradeDataExchV2Defs() in EDocumentUpgrade so existing environments get the renamed definitions on next publish (tag MS-EDoc-DataExchV2Defs-20260521) - Update test hardcoded def codes to new names Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../DataExchange/eDocPEPPOLCrMemoImportV2.xml | 2 +- .../eDocPEPPOLInvoiceImportV2.xml | 2 +- .../App/src/EDocumentInstall.Codeunit.al | 4 ++++ .../src/Setup/EDocumentUpgrade.Codeunit.al | 22 +++++++++++++++++++ .../Processing/EDocDataExchTests.Codeunit.al | 6 ++--- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml index 45ead55992..8b1a49d8c7 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLCrMemoImportV2.xml @@ -1,6 +1,6 @@ - + diff --git a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml index b4ff1d6d8c..947f50a861 100644 --- a/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml +++ b/src/Apps/W1/EDocument/App/.resources/DataExchange/eDocPEPPOLInvoiceImportV2.xml @@ -1,6 +1,6 @@ - + diff --git a/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al b/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al index 5fa5a9c4d1..af8eb31fb1 100644 --- a/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/EDocumentInstall.Codeunit.al @@ -191,6 +191,8 @@ codeunit 6161 "E-Document Install" begin if DataExchDef.Get('EDOCPEPINVIMPV2') then DataExchDef.Delete(true); + if DataExchDef.Get('EDOCPEPINVPURCHDRAFT') then + DataExchDef.Delete(true); NavApp.GetResource('DataExchange/eDocPEPPOLInvoiceImportV2.xml', ResInStream); TempBlob.CreateOutStream(XMLOutStream); @@ -210,6 +212,8 @@ codeunit 6161 "E-Document Install" begin if DataExchDef.Get('EDOCPEPCRMEMOIMPV2') then DataExchDef.Delete(true); + if DataExchDef.Get('EDOCPEPCMPURCHDRAFT') then + DataExchDef.Delete(true); NavApp.GetResource('DataExchange/eDocPEPPOLCrMemoImportV2.xml', ResInStream); TempBlob.CreateOutStream(XMLOutStream); diff --git a/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al b/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al index 8bd9a0f5ba..46a5ad616c 100644 --- a/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al @@ -7,6 +7,7 @@ namespace Microsoft.eServices.EDocument; #if not CLEAN29 using Microsoft.eServices.EDocument.Processing.Import; #endif +using Microsoft.eServices.EDocument.IO; using System.Upgrade; codeunit 6168 "E-Document Upgrade" @@ -22,6 +23,7 @@ codeunit 6168 "E-Document Upgrade" #if not CLEAN29 UpgradeProcessDraftEnum(); #endif + UpgradeDataExchV2Defs(); end; local procedure UpgradeLogURLMaxLength() @@ -41,11 +43,26 @@ codeunit 6168 "E-Document Upgrade" UpgradeTag.SetUpgradeTag(GetUpgradeLogURLMaxLengthUpgradeTag()); end; + local procedure UpgradeDataExchV2Defs() + var + EDocumentInstall: Codeunit "E-Document Install"; + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(GetUpgradeDataExchV2DefsTag()) then + exit; + + EDocumentInstall.ImportInvoiceV2XML(); + EDocumentInstall.ImportCreditMemoV2XML(); + + UpgradeTag.SetUpgradeTag(GetUpgradeDataExchV2DefsTag()); + end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)] local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]]) begin PerCompanyUpgradeTags.Add(GetUpgradeLogURLMaxLengthUpgradeTag()); PerCompanyUpgradeTags.Add(GetUpgradeProcessDraftEnumTag()); + PerCompanyUpgradeTags.Add(GetUpgradeDataExchV2DefsTag()); end; internal procedure GetUpgradeLogURLMaxLengthUpgradeTag(): Code[250] @@ -75,4 +92,9 @@ codeunit 6168 "E-Document Upgrade" exit('MS-EDoc-ProcessDraftEnum-20260407'); end; + internal procedure GetUpgradeDataExchV2DefsTag(): Code[250] + begin + exit('MS-EDoc-DataExchV2Defs-20260521'); + end; + } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al index c1277de696..54c54a0439 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocDataExchTests.Codeunit.al @@ -45,7 +45,7 @@ codeunit 139897 "E-Doc Data Exch Tests" Initialize(); // [WHEN] Checking if column 8 (vendor name) mapping exists - DataExchFieldMapping.SetRange("Data Exch. Def Code", 'EDOCPEPINVIMPV2'); + DataExchFieldMapping.SetRange("Data Exch. Def Code", 'EDOCPEPINVPURCHDRAFT'); DataExchFieldMapping.SetRange("Column No.", 8); DataExchFieldMapping.SetRange("Target Table ID", Database::"E-Document Purchase Header"); DataExchFieldMapping.SetRange("Target Field ID", 9); // Vendor Company Name @@ -480,7 +480,7 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocServiceDataExchDef.Init(); EDocServiceDataExchDef."E-Document Format Code" := EDocumentService.Code; EDocServiceDataExchDef."Document Type" := EDocServiceDataExchDef."Document Type"::"Purchase Invoice"; - EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPINVIMPV2'; + EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPINVPURCHDRAFT'; EDocServiceDataExchDef.Insert(); end; @@ -491,7 +491,7 @@ codeunit 139897 "E-Doc Data Exch Tests" EDocServiceDataExchDef.Init(); EDocServiceDataExchDef."E-Document Format Code" := EDocumentService.Code; EDocServiceDataExchDef."Document Type" := EDocServiceDataExchDef."Document Type"::"Purchase Credit Memo"; - EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPCRMEMOIMPV2'; + EDocServiceDataExchDef."Impt. Data Exchange Def. Code" := 'EDOCPEPCMPURCHDRAFT'; if not EDocServiceDataExchDef.Insert() then EDocServiceDataExchDef.Modify(); end; From ed2013a5c7982cbff7e11d3d56d67b861d67b26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 10:21:39 +0200 Subject: [PATCH 45/85] Fix order --- .../W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al b/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al index 46a5ad616c..a10d76086c 100644 --- a/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Setup/EDocumentUpgrade.Codeunit.al @@ -4,10 +4,10 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.IO; #if not CLEAN29 using Microsoft.eServices.EDocument.Processing.Import; #endif -using Microsoft.eServices.EDocument.IO; using System.Upgrade; codeunit 6168 "E-Document Upgrade" From 097f4daced22d0b1ef5f1c6c566eb0b0b00fbbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Thu, 21 May 2026 13:32:04 +0200 Subject: [PATCH 46/85] Remove commented-out draft code from IStructuredDataType interface Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../IStructuredDataType.Interface.al | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al index d2d60584f6..4a6fe34638 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Interfaces/IStructuredDataType.Interface.al @@ -36,28 +36,4 @@ interface IStructuredDataType /// /// procedure GetReadIntoDraftImpl(): Enum "E-Doc. Read into Draft" - - - // procedure GetSupportedMessages(): List of [Enum "E-Document Message"]; - - - - // // RETURNS INTERFACE ON HOW TO READ AND WRITE MESSAGE FOR THIS DATA TYPE - // procedure GetMessageProcessing(): Enum "E-Doc. Message Processor"; - } - -// interface IEDocumentMesssage -// { - -// } - -// enum 6108 "E-Doc. Message" implements IEDocumentMesssage -// { -// value(0; "Approval") -// { -// Caption = 'Approval'; -// } -// } - - From cc57e8e958a5dd10512179aba8ffb2417fdd436b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:21:45 +0200 Subject: [PATCH 47/85] Add MLLM V2 agentic extraction design spec --- .../specs/2026-05-26-mllm-v2-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-mllm-v2-design.md diff --git a/docs/superpowers/specs/2026-05-26-mllm-v2-design.md b/docs/superpowers/specs/2026-05-26-mllm-v2-design.md new file mode 100644 index 0000000000..81cd7919ca --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-mllm-v2-design.md @@ -0,0 +1,152 @@ +# E-Document MLLM Extraction V2 — Design Spec + +**Date:** 2026-05-26 +**Status:** Draft +**Replaces:** `EDocumentMLLMHandler.Codeunit.al` (V1) + +--- + +## Problem + +V1 performs a single-pass extraction: one AOAI call, one system prompt, one UBL JSON response. It has no mechanism to detect or correct errors it is confident about. Known failure modes observed in production: + +- **Locale number formats** — Swedish `"2,34"` extracted as `234` (comma stripped by `AsDecimal()`) +- **Discount ambiguity** — invoice shows both gross price (`Pris`) and net price (`Pris efter rab.`); model uses net price AND applies a discount percentage, double-counting the discount +- **Silent wrong values** — extraction passes schema validation but produces semantically wrong output (wrong totals, wrong unit prices) + +The root cause is that V1 sweeps the document left-to-right without understanding its structure, and has no self-correction capability. + +--- + +## Solution: Plan-Act-Verify Agentic Loop + +A single agentic AOAI call where the agent: + +1. **Plans** — identifies document structure as chain-of-thought reasoning (regions, column roles, locale, flags) *before* extracting any values +2. **Acts** — extracts from the identified regions, guided by the structural understanding from the plan step +3. **Verifies** — calls deterministic AL-implemented tools to check its own output; self-corrects if tools report failures; repeats until all tools pass or the tool call budget is exhausted + +The loop is entirely inside the model's reasoning turn. AL code sets up the tools and runs the agent; it does not orchestrate the plan/act/verify sequence. + +--- + +## Architecture + +### Single Agentic Call + +``` +PDF (base64) + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ AGENT REASONING (one AOAI call, tool-use loop) │ +│ │ +│ 1. PLAN (chain-of-thought) │ +│ "This is a Swedish invoice. Columns: Antal, │ +│ Pris, Rabatt, Rabatt, Pris efter rab., Belopp. │ +│ Decimal sep = comma. Two chained discount cols. │ +│ Net price column present." │ +│ │ +│ 2. ACT (targeted extraction) │ +│ Extract from identified regions using column │ +│ roles, not left-to-right text sweep. │ +│ │ +│ 3. VERIFY (tool calls) │ +│ verify_line_math() verify_totals() │ +│ verify_vat() verify_dates() │ +│ verify_required() verify_ranges() │ +│ │ +│ On failure → agent reads error, re-extracts, │ +│ calls tools again. Loops until pass or budget. │ +└─────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +Verified UBL JSON Error (budget +→ BC Purchase Draft exhausted) +``` + +**Model:** GPT-4.1 Mini (chosen for vision capability — the agent reads the PDF visually, not as extracted text) +**Tool call budget:** 20 (sufficient for 6 verify calls × 3 correction rounds on a multi-line invoice) +**Temperature:** 0 + +### On Budget Exhaustion + +E-Document status set to `Error`. A log entry records which verify check was still failing on the last iteration. No draft is created. ADI is not used as a fallback for verify failures. + +ADI fallback is retained only for AOAI call failures (network error, content filter, empty response) — the same signal V1 uses today. + +--- + +## New AL Components + +### `EDocMLLMHandlerV2.Codeunit.al` + +Implements `IStructureReceivedEDocument` (same interface as V1). Registered as enum value `"MLLM V2"` on `"Structure Data Impl."` — existing services using `"MLLM"` are unaffected until explicitly migrated. + +Responsibilities: +- Build the AOAI chat messages (system prompt + PDF user message) +- Register the 6 verify tools as AOAI function definitions (`AOAITools`) +- Run the agentic dispatch loop in AL: + 1. Call `GenerateChatCompletion` + 2. If response contains tool call requests: execute via `EDocMLLMVerifyTools`, append results to `AOAIChatMessages`, increment call counter, go to 1 + 3. If response contains no tool calls (model is done): extract final JSON + 4. If call counter exceeds budget: surface error +- On success: pass the final JSON to the existing `EDocMLLMSchemaHelper.MapHeaderFromJson` / `MapLinesFromJson` pipeline unchanged + +The tool dispatch loop runs in AL, not inside the SDK. Each iteration is a new `GenerateChatCompletion` call with the tool results appended to the conversation history. + +### `EDocMLLMVerifyTools.Codeunit.al` + +Six methods, each returning `JsonObject` with `{ "pass": bool, "error": string }`: + +| Tool | Inputs | Check | +|------|--------|-------| +| `verify_line_math` | unit_price, quantity, discount_pct, line_extension_amount | `unit_price × qty × (1 − disc/100) ≈ line_total` (within 1% relative tolerance) | +| `verify_invoice_totals` | line_amounts[], tax_exclusive_amount | `sum(lines) ≈ sub_total` (within 1% relative tolerance) | +| `verify_vat` | tax_exclusive_amount, vat_rate, tax_amount | `sub_total × rate/100 ≈ tax_amount` (within 1% relative tolerance) | +| `verify_dates` | issue_date, due_date | Both parse as valid dates; `due_date ≥ issue_date`; year in 1900–2100 | +| `verify_required_fields` | vendor_name, invoice_no, line_count | None are blank/zero | +| `verify_ranges` | quantities[], prices[], vat_rates[], discount_pcts[] | All > 0 (qty, price); 0–100 (vat, discount) | + +Numeric tolerance for amount comparisons: 1% relative (`|expected − actual| / max(|actual|, 1) < 0.01`). A fixed absolute tolerance fails on large-quantity invoices where per-unit rounding accumulates (e.g. 1083 items × 0.005 rounding = 5.4 max error). + +### `EDocMLLMExtractionV2-SystemPrompt.md` + +New prompt resource. Three explicit sections: + +1. **Structure identification** — "Before extracting any values, describe in your reasoning: document type, language, decimal separator, thousands separator, line item table column names and their roles (gross price, discount %, net price, quantity, line total), header and totals regions, and any flags (e.g. multiple discount columns, net price column present)." + +2. **Targeted extraction** — "Extract data from the regions you identified. Do not sweep left-to-right across the full page. Use the column roles you identified to assign values correctly. Use XML decimal format (period as decimal separator, no thousands separators)." + +3. **Verification** — "After producing the UBL JSON, call the verify tools on your output. If any tool reports a failure, read the error message, correct the relevant fields, and call the tools again. Finalize only when all tools pass." + +--- + +## What Is Unchanged + +- `EDocMLLMSchemaHelper.Codeunit.al` — `MapHeaderFromJson`, `MapLinesFromJson`, `GetDecimal` (with `Evaluate(..., 9)` from the V1 fix), `GetDate` +- `ubl_example.json` — UBL schema template (updated by V1 fix to use numeric `0` placeholders) +- `EDocMLLMHandler.Codeunit.al` — V1 stays registered under `"MLLM"` until removed + +--- + +## What Is Retired + +V1 (`"MLLM"` enum value and `EDocumentMLLMHandler.Codeunit.al`) is not removed in this change — existing service configurations keep working. A follow-up cleanup removes V1 once all services have migrated to `"MLLM V2"`. + +--- + +## Error Flow + +``` +AOAI call fails entirely → FallbackToADI() (existing path) +AOAI returns bad JSON → FallbackToADI() (existing path) +Verify tools never all pass → EDocument.Status = Error + log entry +Vendor fields missing (schema) → FallbackToADI() (existing V1 ValidateMLLMResponse path) +``` + +--- + +## Open Questions + +None — all design decisions confirmed. From 703b6a94f404158bb3590a05eae92756c523d482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:22:30 +0200 Subject: [PATCH 48/85] Increase MLLM V2 tool call budget to 200 --- docs/superpowers/specs/2026-05-26-mllm-v2-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-26-mllm-v2-design.md b/docs/superpowers/specs/2026-05-26-mllm-v2-design.md index 81cd7919ca..6d89ad4849 100644 --- a/docs/superpowers/specs/2026-05-26-mllm-v2-design.md +++ b/docs/superpowers/specs/2026-05-26-mllm-v2-design.md @@ -66,7 +66,7 @@ Verified UBL JSON Error (budget ``` **Model:** GPT-4.1 Mini (chosen for vision capability — the agent reads the PDF visually, not as extracted text) -**Tool call budget:** 20 (sufficient for 6 verify calls × 3 correction rounds on a multi-line invoice) +**Tool call budget:** 200 **Temperature:** 0 ### On Budget Exhaustion From db91c4693b468c15c0a6773b7dce0ec91fc66383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:35:45 +0200 Subject: [PATCH 49/85] Add MLLM V2 implementation plan --- .../2026-05-26-mllm-v2-implementation.md | 1485 +++++++++++++++++ 1 file changed, 1485 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md diff --git a/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md b/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md new file mode 100644 index 0000000000..08b6bb3dbf --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md @@ -0,0 +1,1485 @@ +# MLLM V2 Agentic Extraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the single-pass MLLM extraction with an agentic plan-act-verify loop where the model identifies document structure, extracts from identified regions, and self-corrects by calling AL-implemented verification tools. + +**Architecture:** One agentic AOAI call (GPT-4.1 Mini) with 6 verification tools registered as AOAI Functions. The model plans (chain-of-thought), extracts, calls verify tools, sees failures with error details, corrects, and repeats. AL drives the tool dispatch loop; the model decides when to call what. New handler registered as `"MLLM V2"` enum value alongside V1. + +**Tech Stack:** AL (Business Central), System.AI (AOAI SDK), `AOAI Function` interface for tool adapters, `AOAIChatMessages.AddTool()` + `AppendFunctionResponsesToChatMessages()` for the loop. + +--- + +## File Map + +| Action | Path | ID | Responsibility | +|--------|------|----|----------------| +| Modify | `App/src/Processing/Import/StructureReceivedEDoc.Enum.al` | enum 6103 | Add `"MLLM V2"` value | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al` | 6233 | All 6 verification logic methods | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al` | 6235 | `verify_line_math` AOAI Function adapter | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al` | 6236 | `verify_invoice_totals` AOAI Function adapter | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al` | 6237 | `verify_vat` AOAI Function adapter | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al` | 6238 | `verify_dates` AOAI Function adapter | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al` | 6239 | `verify_required_fields` AOAI Function adapter | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al` | 6243 | `verify_ranges` AOAI Function adapter | +| Create | `App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md` | — | Three-section V2 system prompt | +| Create | `App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al` | 6244 | V2 handler: agentic loop + interface impl | +| Create | `Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al` | 135648 | Unit tests for all 6 verify methods | + +All paths are relative to `src/Apps/W1/EDocument/`. + +--- + +## Task 1: Register the "MLLM V2" enum value + +**Files:** +- Modify: `src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al` + +- [ ] **Step 1: Add enum value** + +Open the file. After the `"MLLM"` value (value 3), add: + +```al + value(4; "MLLM V2") + { + Caption = 'MLLM Extraction V2'; + Implementation = IStructureReceivedEDocument = "E-Document MLLM Handler V2"; + } +``` + +The handler codeunit name `"E-Document MLLM Handler V2"` will be created in Task 5. + +- [ ] **Step 2: Verify file compiles** + +The file will not compile until Task 5 creates the referenced codeunit. That is expected — this step just records the intent. Come back and verify compilation after Task 5. + +--- + +## Task 2: Create EDocMLLMVerifyTools with unit tests + +The core verification logic. No AOAI calls — pure math and validation. Test this codeunit thoroughly before building the tool adapters on top. + +**Files:** +- Create: `src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al` +- Create: `src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al` + +- [ ] **Step 1: Create the stub codeunit** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +codeunit 6233 "E-Doc. MLLM Verify Tools" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + /// + /// Checks unit_price × quantity × (1 − discount_pct/100) ≈ line_extension_amount within 1%. + /// Returns false and sets ErrorText when the check fails. + /// + procedure VerifyLineMath(UnitPrice: Decimal; Quantity: Decimal; DiscountPct: Decimal; LineExtensionAmount: Decimal; var ErrorText: Text): Boolean + begin + exit(false); // stub + end; + + /// + /// Checks sum(LineAmounts) ≈ TaxExclusiveAmount within 1%. + /// + procedure VerifyInvoiceTotals(LineAmounts: List of [Decimal]; TaxExclusiveAmount: Decimal; var ErrorText: Text): Boolean + begin + exit(false); // stub + end; + + /// + /// Checks TaxExclusiveAmount × VATRate/100 ≈ TaxAmount within 1%. + /// + procedure VerifyVAT(TaxExclusiveAmount: Decimal; VATRate: Decimal; TaxAmount: Decimal; var ErrorText: Text): Boolean + begin + exit(false); // stub + end; + + /// + /// Validates issue_date and due_date are parseable XML dates, year 1900-2100, due_date >= issue_date. + /// + procedure VerifyDates(IssueDateText: Text; DueDateText: Text; var ErrorText: Text): Boolean + begin + exit(false); // stub + end; + + /// + /// Checks VendorName, InvoiceNo are non-empty and LineCount > 0. + /// + procedure VerifyRequiredFields(VendorName: Text; InvoiceNo: Text; LineCount: Integer; var ErrorText: Text): Boolean + begin + exit(false); // stub + end; + + /// + /// Checks quantities and prices > 0, VAT rates and discount percentages 0-100. + /// Stops at first violation and reports the line index. + /// + procedure VerifyRanges(Quantities: List of [Decimal]; Prices: List of [Decimal]; VATRates: List of [Decimal]; DiscountPcts: List of [Decimal]; var ErrorText: Text): Boolean + begin + exit(false); // stub + end; + + internal procedure IsWithinTolerance(Expected: Decimal; Actual: Decimal): Boolean + var + Denominator: Decimal; + begin + Denominator := Abs(Actual); + if Denominator < 1 then + Denominator := 1; + exit(Abs(Expected - Actual) / Denominator < 0.01); + end; +} +``` + +- [ ] **Step 2: Write the test codeunit** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Test; + +using Microsoft.eServices.EDocument.Processing.Import; + +codeunit 135648 "EDoc MLLM Verify Tools Tests" +{ + Subtype = Test; + + var + Assert: Codeunit Assert; + + // VerifyLineMath ----------------------------------------------------------- + + [Test] + procedure VerifyLineMath_Pass_NoDiscount() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // 5 × 40 × 1.00 = 200 + Assert.IsTrue(VerifyTools.VerifyLineMath(40, 5, 0, 200, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyLineMath_Pass_WithDiscount() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // 3.65 × 1083 × 0.64 = 2529.888 ≈ 2529.89 + Assert.IsTrue(VerifyTools.VerifyLineMath(3.65, 1083, 36, 2529.89, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyLineMath_Pass_WithinOnePct() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // Expected = 100, actual = 100.9 — within 1% + Assert.IsTrue(VerifyTools.VerifyLineMath(10, 10, 0, 100.9, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyLineMath_Fail_WrongPrice() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // net price 2.34 used instead of gross 3.65 with 20% discount → expected 2027, actual 2529 + Assert.IsFalse(VerifyTools.VerifyLineMath(2.34, 1083, 20, 2529.89, ErrorText), 'Should fail'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be set'); + end; + + [Test] + procedure VerifyLineMath_Pass_ZeroLineAmount() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // Zero line amount — skip check (freight lines sometimes have zero amount) + Assert.IsTrue(VerifyTools.VerifyLineMath(0, 0, 0, 0, ErrorText), ErrorText); + end; + + // VerifyInvoiceTotals ------------------------------------------------------ + + [Test] + procedure VerifyInvoiceTotals_Pass() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Lines: List of [Decimal]; + ErrorText: Text; + begin + Lines.Add(200); + Lines.Add(30); + Lines.Add(20); + Assert.IsTrue(VerifyTools.VerifyInvoiceTotals(Lines, 250, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyInvoiceTotals_Fail_MissingLine() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Lines: List of [Decimal]; + ErrorText: Text; + begin + Lines.Add(200); + // missing 30 + 20 + Assert.IsFalse(VerifyTools.VerifyInvoiceTotals(Lines, 250, ErrorText), 'Should fail'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be set'); + end; + + // VerifyVAT ---------------------------------------------------------------- + + [Test] + procedure VerifyVAT_Pass() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // 250 × 15% = 37.5 + Assert.IsTrue(VerifyTools.VerifyVAT(250, 15, 37.5, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyVAT_Fail() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyVAT(250, 15, 100, ErrorText), 'Should fail'); + end; + + [Test] + procedure VerifyVAT_Skip_ZeroTax() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // tax_amount = 0 means zero-rated — skip check + Assert.IsTrue(VerifyTools.VerifyVAT(250, 0, 0, ErrorText), ErrorText); + end; + + // VerifyDates -------------------------------------------------------------- + + [Test] + procedure VerifyDates_Pass_ValidDates() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsTrue(VerifyTools.VerifyDates('2024-03-15', '2024-04-15', ErrorText), ErrorText); + end; + + [Test] + procedure VerifyDates_Pass_NoDueDate() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsTrue(VerifyTools.VerifyDates('2024-03-15', '', ErrorText), ErrorText); + end; + + [Test] + procedure VerifyDates_Fail_DueDateBeforeIssue() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyDates('2024-04-15', '2024-03-15', ErrorText), 'Should fail'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be set'); + end; + + [Test] + procedure VerifyDates_Fail_InvalidDate() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyDates('not-a-date', '', ErrorText), 'Should fail'); + end; + + [Test] + procedure VerifyDates_Fail_MissingIssueDate() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyDates('', '', ErrorText), 'Should fail'); + end; + + // VerifyRequiredFields ----------------------------------------------------- + + [Test] + procedure VerifyRequiredFields_Pass() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsTrue(VerifyTools.VerifyRequiredFields('Contoso Ltd', 'INV-001', 2, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyRequiredFields_Fail_MissingVendor() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyRequiredFields('', 'INV-001', 2, ErrorText), 'Should fail'); + Assert.IsSubstring(ErrorText, 'vendor'); + end; + + [Test] + procedure VerifyRequiredFields_Fail_NoLines() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyRequiredFields('Contoso Ltd', 'INV-001', 0, ErrorText), 'Should fail'); + Assert.IsSubstring(ErrorText, 'line'); + end; + + // VerifyRanges ------------------------------------------------------------- + + [Test] + procedure VerifyRanges_Pass() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Qtys: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscPcts: List of [Decimal]; + ErrorText: Text; + begin + Qtys.Add(5); Prices.Add(40); VATRates.Add(15); DiscPcts.Add(0); + Assert.IsTrue(VerifyTools.VerifyRanges(Qtys, Prices, VATRates, DiscPcts, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyRanges_Fail_NegativeQty() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Qtys: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscPcts: List of [Decimal]; + ErrorText: Text; + begin + Qtys.Add(-1); Prices.Add(40); VATRates.Add(15); DiscPcts.Add(0); + Assert.IsFalse(VerifyTools.VerifyRanges(Qtys, Prices, VATRates, DiscPcts, ErrorText), 'Should fail'); + Assert.IsSubstring(ErrorText, 'quantity'); + end; + + [Test] + procedure VerifyRanges_Fail_DiscountOver100() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Qtys: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscPcts: List of [Decimal]; + ErrorText: Text; + begin + Qtys.Add(5); Prices.Add(40); VATRates.Add(15); DiscPcts.Add(150); + Assert.IsFalse(VerifyTools.VerifyRanges(Qtys, Prices, VATRates, DiscPcts, ErrorText), 'Should fail'); + Assert.IsSubstring(ErrorText, 'discount'); + end; + + // IsWithinTolerance -------------------------------------------------------- + + [Test] + procedure IsWithinTolerance_Pass_ExactMatch() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + begin + Assert.IsTrue(VerifyTools.IsWithinTolerance(100, 100), 'Exact match'); + end; + + [Test] + procedure IsWithinTolerance_Pass_SmallDelta() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + begin + Assert.IsTrue(VerifyTools.IsWithinTolerance(2529.888, 2529.89), 'Rounding delta'); + end; + + [Test] + procedure IsWithinTolerance_Fail_LargeDelta() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + begin + Assert.IsFalse(VerifyTools.IsWithinTolerance(2027, 2529.89), 'Wrong value'); + end; +} +``` + +- [ ] **Step 3: Compile and run tests — expect FAIL (stubs return false)** + +All tests that assert `IsTrue(...)` will fail because stubs return `false`. + +- [ ] **Step 4: Implement the 6 methods in EDocMLLMVerifyTools** + +Replace the stub body of each method with the real implementation: + +```al +procedure VerifyLineMath(UnitPrice: Decimal; Quantity: Decimal; DiscountPct: Decimal; LineExtensionAmount: Decimal; var ErrorText: Text): Boolean +var + Expected: Decimal; +begin + if LineExtensionAmount = 0 then + exit(true); + Expected := UnitPrice * Quantity * (1 - DiscountPct / 100); + if IsWithinTolerance(Expected, LineExtensionAmount) then + exit(true); + ErrorText := StrSubstNo('%1 × %2 × (1 − %3/100) = %4, but line_extension_amount = %5. Re-check which price column is the gross (pre-discount) unit price.', + UnitPrice, Quantity, DiscountPct, Round(Expected, 0.01), LineExtensionAmount); + exit(false); +end; + +procedure VerifyInvoiceTotals(LineAmounts: List of [Decimal]; TaxExclusiveAmount: Decimal; var ErrorText: Text): Boolean +var + LineAmount: Decimal; + Sum: Decimal; +begin + if TaxExclusiveAmount = 0 then + exit(true); + foreach LineAmount in LineAmounts do + Sum += LineAmount; + if IsWithinTolerance(Sum, TaxExclusiveAmount) then + exit(true); + ErrorText := StrSubstNo('Sum of line_extension_amounts = %1, but tax_exclusive_amount = %2. Check for missing or duplicated lines.', + Round(Sum, 0.01), TaxExclusiveAmount); + exit(false); +end; + +procedure VerifyVAT(TaxExclusiveAmount: Decimal; VATRate: Decimal; TaxAmount: Decimal; var ErrorText: Text): Boolean +var + Expected: Decimal; +begin + if TaxAmount = 0 then + exit(true); + Expected := TaxExclusiveAmount * VATRate / 100; + if IsWithinTolerance(Expected, TaxAmount) then + exit(true); + ErrorText := StrSubstNo('%1 × %2% = %3, but tax_amount = %4. Re-check the VAT rate.', + TaxExclusiveAmount, VATRate, Round(Expected, 0.01), TaxAmount); + exit(false); +end; + +procedure VerifyDates(IssueDateText: Text; DueDateText: Text; var ErrorText: Text): Boolean +var + IssueDate: Date; + DueDate: Date; +begin + if IssueDateText = '' then begin + ErrorText := 'issue_date is missing.'; + exit(false); + end; + if not Evaluate(IssueDate, IssueDateText, 9) then begin + ErrorText := StrSubstNo('issue_date "%1" is not a valid XML date (expected YYYY-MM-DD).', IssueDateText); + exit(false); + end; + if (Date2DMY(IssueDate, 3) < 1900) or (Date2DMY(IssueDate, 3) > 2100) then begin + ErrorText := StrSubstNo('issue_date year %1 is out of expected range 1900–2100.', Date2DMY(IssueDate, 3)); + exit(false); + end; + if DueDateText = '' then + exit(true); + if not Evaluate(DueDate, DueDateText, 9) then begin + ErrorText := StrSubstNo('due_date "%1" is not a valid XML date (expected YYYY-MM-DD).', DueDateText); + exit(false); + end; + if DueDate < IssueDate then begin + ErrorText := StrSubstNo('due_date %1 is before issue_date %2.', Format(DueDate, 0, 9), Format(IssueDate, 0, 9)); + exit(false); + end; + exit(true); +end; + +procedure VerifyRequiredFields(VendorName: Text; InvoiceNo: Text; LineCount: Integer; var ErrorText: Text): Boolean +var + Missing: Text; +begin + if VendorName = '' then + AppendMissing(Missing, 'vendor name'); + if InvoiceNo = '' then + AppendMissing(Missing, 'invoice number'); + if LineCount <= 0 then + AppendMissing(Missing, 'invoice lines (line_count = 0)'); + if Missing <> '' then begin + ErrorText := 'Missing required fields: ' + Missing; + exit(false); + end; + exit(true); +end; + +procedure VerifyRanges(Quantities: List of [Decimal]; Prices: List of [Decimal]; VATRates: List of [Decimal]; DiscountPcts: List of [Decimal]; var ErrorText: Text): Boolean +var + i: Integer; + Value: Decimal; +begin + for i := 1 to Quantities.Count() do begin + Quantities.Get(i, Value); + if Value <= 0 then begin + ErrorText := StrSubstNo('Line %1 quantity %2 must be > 0.', i, Value); + exit(false); + end; + end; + for i := 1 to Prices.Count() do begin + Prices.Get(i, Value); + if Value <= 0 then begin + ErrorText := StrSubstNo('Line %1 unit price %2 must be > 0.', i, Value); + exit(false); + end; + end; + for i := 1 to VATRates.Count() do begin + VATRates.Get(i, Value); + if (Value < 0) or (Value > 100) then begin + ErrorText := StrSubstNo('Line %1 VAT rate %2 must be between 0 and 100.', i, Value); + exit(false); + end; + end; + for i := 1 to DiscountPcts.Count() do begin + DiscountPcts.Get(i, Value); + if (Value < 0) or (Value > 100) then begin + ErrorText := StrSubstNo('Line %1 discount %2%% must be between 0 and 100.', i, Value); + exit(false); + end; + end; + exit(true); +end; + +local procedure AppendMissing(var Missing: Text; Field: Text) +begin + if Missing <> '' then + Missing += ', '; + Missing += Field; +end; +``` + +- [ ] **Step 5: Run tests — expect all PASS** + +- [ ] **Step 6: Commit** + +```bash +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al +git add src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al +git commit -m "Add EDocMLLMVerifyTools with 6 verification methods and unit tests" +``` + +--- + +## Task 3: Create the 6 AOAI Function tool adapters + +Each adapter is a thin codeunit implementing `AOAI Function`. It declares the tool's JSON schema in `GetPrompt()` and delegates to `EDocMLLMVerifyTools` in `Execute()`. + +**Files:** 6 new codeunits (6235–6239, 6243) + +- [ ] **Step 1: Create EDocMLLMVerifyLineMathTool.Codeunit.al (6235)** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6235 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_line_math'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'number'); PropObj.Add('description', 'Gross unit price before discounts'); + PropsObj.Add('unit_price', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'Quantity of units'); + PropsObj.Add('quantity', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'Combined discount percentage 0-100 (use 0 if no discount)'); + PropsObj.Add('discount_pct', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'line_extension_amount from the invoice'); + PropsObj.Add('line_extension_amount', PropObj); + RequiredArr.Add('unit_price'); RequiredArr.Add('quantity'); RequiredArr.Add('discount_pct'); RequiredArr.Add('line_extension_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that gross_unit_price × quantity × (1 − discount_pct/100) matches line_extension_amount within 1%. Call once per invoice line.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + UnitPrice: Decimal; + Quantity: Decimal; + DiscountPct: Decimal; + LineExtAmt: Decimal; + begin + GetDecimalArg(Arguments, 'unit_price', UnitPrice); + GetDecimalArg(Arguments, 'quantity', Quantity); + GetDecimalArg(Arguments, 'discount_pct', DiscountPct); + GetDecimalArg(Arguments, 'line_extension_amount', LineExtAmt); + if VerifyTools.VerifyLineMath(UnitPrice, Quantity, DiscountPct, LineExtAmt, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; + + local procedure GetDecimalArg(Arguments: JsonObject; PropertyName: Text; var Value: Decimal) + var + Token: JsonToken; + DecimalValue: Decimal; + begin + if not Arguments.Get(PropertyName, Token) then + exit; + if Token.AsValue().IsNull() then + exit; + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then + Value := DecimalValue; + end; +} +``` + +- [ ] **Step 2: Create EDocMLLMVerifyTotalsTool.Codeunit.al (6236)** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6236 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_invoice_totals'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + ItemsObj: JsonObject; + RequiredArr: JsonArray; + begin + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line_extension_amount values'); + PropsObj.Add('line_amounts', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_exclusive_amount from legal_monetary_total'); + PropsObj.Add('tax_exclusive_amount', PropObj); + RequiredArr.Add('line_amounts'); RequiredArr.Add('tax_exclusive_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that the sum of all line_extension_amounts matches tax_exclusive_amount within 1%.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + LineAmountsToken: JsonToken; + LineAmountsArray: JsonArray; + LineToken: JsonToken; + LineAmounts: List of [Decimal]; + TaxExclusiveAmount: Decimal; + LineAmt: Decimal; + DecimalValue: Decimal; + Token: JsonToken; + begin + if Arguments.Get('line_amounts', LineAmountsToken) then begin + LineAmountsArray := LineAmountsToken.AsArray(); + foreach LineToken in LineAmountsArray do begin + if Evaluate(DecimalValue, LineToken.AsValue().AsText(), 9) then + LineAmounts.Add(DecimalValue); + end; + end; + if Arguments.Get('tax_exclusive_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then + TaxExclusiveAmount := DecimalValue; + + if VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} +``` + +- [ ] **Step 3: Create EDocMLLMVerifyVATTool.Codeunit.al (6237)** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6237 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_vat'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_exclusive_amount'); + PropsObj.Add('tax_exclusive_amount', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'VAT rate percentage 0-100'); + PropsObj.Add('vat_rate', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_amount'); + PropsObj.Add('tax_amount', PropObj); + RequiredArr.Add('tax_exclusive_amount'); RequiredArr.Add('vat_rate'); RequiredArr.Add('tax_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that tax_exclusive_amount × vat_rate/100 ≈ tax_amount within 1%.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + TaxExcl: Decimal; + VATRate: Decimal; + TaxAmt: Decimal; + Token: JsonToken; + DecimalValue: Decimal; + begin + if Arguments.Get('tax_exclusive_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExcl := DecimalValue; + if Arguments.Get('vat_rate', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then VATRate := DecimalValue; + if Arguments.Get('tax_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxAmt := DecimalValue; + + if VerifyTools.VerifyVAT(TaxExcl, VATRate, TaxAmt, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} +``` + +- [ ] **Step 4: Create EDocMLLMVerifyDatesTool.Codeunit.al (6238)** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6238 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_dates'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); PropObj.Add('description', 'issue_date in YYYY-MM-DD format'); + PropsObj.Add('issue_date', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'due_date in YYYY-MM-DD format, or empty string if not present'); + PropsObj.Add('due_date', PropObj); + RequiredArr.Add('issue_date'); RequiredArr.Add('due_date'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that issue_date and due_date are valid XML dates (YYYY-MM-DD), year 1900-2100, and due_date >= issue_date if present.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + IssueDate: Text; + DueDate: Text; + Token: JsonToken; + begin + if Arguments.Get('issue_date', Token) then IssueDate := Token.AsValue().AsText(); + if Arguments.Get('due_date', Token) then DueDate := Token.AsValue().AsText(); + + if VerifyTools.VerifyDates(IssueDate, DueDate, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} +``` + +- [ ] **Step 5: Create EDocMLLMVerifyRequiredTool.Codeunit.al (6239)** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6239 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_required_fields'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); PropObj.Add('description', 'Supplier/vendor company name'); + PropsObj.Add('vendor_name', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Invoice number / id'); + PropsObj.Add('invoice_no', PropObj); Clear(PropObj); + PropObj.Add('type', 'integer'); PropObj.Add('description', 'Number of invoice lines extracted'); + PropsObj.Add('line_count', PropObj); + RequiredArr.Add('vendor_name'); RequiredArr.Add('invoice_no'); RequiredArr.Add('line_count'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that vendor name, invoice number, and at least one invoice line are present.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + VendorName: Text; + InvoiceNo: Text; + LineCount: Integer; + Token: JsonToken; + DecimalValue: Decimal; + begin + if Arguments.Get('vendor_name', Token) then VendorName := Token.AsValue().AsText(); + if Arguments.Get('invoice_no', Token) then InvoiceNo := Token.AsValue().AsText(); + if Arguments.Get('line_count', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then + LineCount := Round(DecimalValue, 1); + + if VerifyTools.VerifyRequiredFields(VendorName, InvoiceNo, LineCount, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} +``` + +- [ ] **Step 6: Create EDocMLLMVerifyRangesTool.Codeunit.al (6243)** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6243 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_ranges'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + ItemsObj: JsonObject; + RequiredArr: JsonArray; + begin + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line quantities'); + PropsObj.Add('quantities', PropObj); Clear(PropObj); Clear(ItemsObj); + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line unit prices'); + PropsObj.Add('prices', PropObj); Clear(PropObj); Clear(ItemsObj); + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line VAT rates (0-100)'); + PropsObj.Add('vat_rates', PropObj); Clear(PropObj); Clear(ItemsObj); + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line discount percentages (0-100)'); + PropsObj.Add('discount_pcts', PropObj); + RequiredArr.Add('quantities'); RequiredArr.Add('prices'); RequiredArr.Add('vat_rates'); RequiredArr.Add('discount_pcts'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that quantities > 0, unit prices > 0, VAT rates 0-100, discount percentages 0-100 for all lines.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + Quantities: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscountPcts: List of [Decimal]; + begin + ParseDecimalArray(Arguments, 'quantities', Quantities); + ParseDecimalArray(Arguments, 'prices', Prices); + ParseDecimalArray(Arguments, 'vat_rates', VATRates); + ParseDecimalArray(Arguments, 'discount_pcts', DiscountPcts); + + if VerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; + + local procedure ParseDecimalArray(Arguments: JsonObject; PropertyName: Text; var Values: List of [Decimal]) + var + ArrayToken: JsonToken; + ItemToken: JsonToken; + DecimalValue: Decimal; + begin + if not Arguments.Get(PropertyName, ArrayToken) then + exit; + foreach ItemToken in ArrayToken.AsArray() do + if Evaluate(DecimalValue, ItemToken.AsValue().AsText(), 9) then + Values.Add(DecimalValue); + end; +} +``` + +- [ ] **Step 7: Compile and verify no errors** + +- [ ] **Step 8: Commit** + +```bash +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al +git commit -m "Add 6 AOAI Function tool adapters for MLLM V2 verification" +``` + +--- + +## Task 4: Create the V2 system prompt + +**Files:** +- Create: `src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md` + +- [ ] **Step 1: Create the prompt file** + +```markdown +You are an invoice data extraction agent with access to verification tools. + +PHASE 1 — IDENTIFY STRUCTURE (reason before extracting): +Before extracting any values, describe in your reasoning: +- Document type (invoice, credit memo, etc.) +- Language and locale (e.g. Swedish: comma decimal, space thousands) +- Decimal separator and thousands separator used on this document +- Line item table: list each column header and its role (gross unit price, net/post-discount unit price, discount percentage, quantity, line total) +- Which column to use as price_amount (use the GROSS/pre-discount price if both gross and net prices are shown — put the discount in allowance_charge instead) +- Header region location (supplier details, invoice number, dates) +- Totals region location + +PHASE 2 — EXTRACT FROM IDENTIFIED REGIONS (not left-to-right): +Extract data guided by your structure analysis above. Rules: +- Numbers: XML decimal format — period (.) as decimal separator, no thousands separators +- Dates: YYYY-MM-DD +- price_amount must be the GROSS unit price (before line discounts). If a discount percentage is also present, set allowance_charge.percent (0-100). Do NOT combine a post-discount unit price with a discount percentage. +- allowance_charge.percent (0-100) when invoice shows a discount %; allowance_charge.amount.value when invoice shows a monetary discount amount; leave allowance_charge empty if no discount +- Output valid UBL JSON matching the schema provided + +PHASE 3 — VERIFY YOUR OUTPUT: +After producing the UBL JSON, call the verification tools: +- Call verify_line_math once per invoice line +- Call verify_invoice_totals with all line amounts +- Call verify_vat for the tax total +- Call verify_dates with issue_date and due_date +- Call verify_required_fields with vendor name, invoice number, and line count +- Call verify_ranges with all quantities, prices, VAT rates, and discount percentages + +If any tool returns { "pass": false }, read the error message carefully, correct the affected fields, and call the tools again. Only output the final UBL JSON when all tools return { "pass": true }. + +Output ONLY valid JSON. No markdown, no explanation. +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +git commit -m "Add MLLM V2 system prompt with plan-act-verify instructions" +``` + +--- + +## Task 5: Create EDocumentMLLMHandlerV2 + +This is the main handler. It implements the same three interfaces as V1 (`IStructureReceivedEDocument`, `IStructuredFormatReader`, `IStructuredDataType`) and uses the existing `EDocMLLMSchemaHelper` for JSON→draft mapping (unchanged from V1). The key difference is `StructureReceivedEDocument()` which runs the agentic loop. + +**Files:** +- Create: `src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al` + +- [ ] **Step 1: Create the handler codeunit** + +```al +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Format; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.eServices.EDocument.Processing.Interfaces; +using System.AI; +using System.Azure.KeyVault; +using System.Telemetry; +using System.Utilities; + +codeunit 6244 "E-Document MLLM Handler V2" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + var + Telemetry: Codeunit Telemetry; + StructuredData: Text; + FileFormat: Enum "E-Doc. File Format"; + FeatureNameLbl: Label 'E-Document MLLM Extraction V2', Locked = true; + FileDataLbl: Label 'data:application/pdf;base64,%1', Locked = true; + SystemPromptV2ResourceTok: Label 'Prompts/EDocMLLMExtractionV2-SystemPrompt.md', Locked = true; + UserPromptLbl: Label 'Extract invoice data into this UBL JSON structure: %1. \n\nExtract ONLY visible values. Return JSON only. %2', Locked = true; + SecurityPromptAKVKeyTok: Label 'EDocMLLMExtraction-SecurityPromptV281', Locked = true; + MaxToolCallsTok: Integer; + BudgetExhaustedErr: Label 'The document could not be verified after %1 tool calls. The extraction was inconsistent.', Comment = '%1 = tool call count'; + DocumentNotProcessedErr: Label 'The document could not be processed.'; + InappropriateContentErr: Label 'The document could not be processed because it contains inappropriate content.'; + + procedure StructureReceivedEDocument(EDocumentDataStorage: Record "E-Doc. Data Storage"): Interface IStructuredDataType + var + ResponseJson: JsonObject; + ResponseText: Text; + begin + MaxToolCallsTok := 200; + + RegisterCopilotCapabilityIfNeeded(); + + ResponseText := CallMLLMV2(EDocumentDataStorage); + + if IsInappropriateContentResponse(ResponseText) then + Error(InappropriateContentErr); + + if not ValidateAndUnwrapResponse(ResponseText, ResponseJson) then + exit(FallbackToADI(EDocumentDataStorage)); + + StructuredData := ResponseText; + FileFormat := "E-Doc. File Format"::JSON; + exit(this); + end; + + [NonDebuggable] + local procedure CallMLLMV2(EDocumentDataStorage: Record "E-Doc. Data Storage"): Text + var + Base64Convert: Codeunit "Base64 Convert"; + AzureOpenAI: Codeunit "Azure OpenAI"; + AOAIChatMessages: Codeunit "AOAI Chat Messages"; + AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params"; + AOAIUserMessage: Codeunit "AOAI User Message"; + AOAIOperationResponse: Codeunit "AOAI Operation Response"; + AOAIDeployments: Codeunit "AOAI Deployments"; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + VerifyLineMathTool: Codeunit "E-Doc. MLLM VL Math Tool"; + VerifyTotalsTool: Codeunit "E-Doc. MLLM VL Totals Tool"; + VerifyVATTool: Codeunit "E-Doc. MLLM VL VAT Tool"; + VerifyDatesTool: Codeunit "E-Doc. MLLM VL Dates Tool"; + VerifyRequiredTool: Codeunit "E-Doc. MLLM VL Required Tool"; + VerifyRangesTool: Codeunit "E-Doc. MLLM VL Ranges Tool"; + FromTempBlob: Codeunit "Temp Blob"; + InStream: InStream; + Base64Data: Text; + ToolCallCount: Integer; + begin + // Load PDF as base64 + FromTempBlob := EDocumentDataStorage.GetTempBlob(); + FromTempBlob.CreateInStream(InStream, TextEncoding::UTF8); + Base64Data := Base64Convert.ToBase64(InStream); + + // Configure AOAI + AzureOpenAI.SetAuthorization(Enum::"AOAI Model Type"::"Chat Completions", AOAIDeployments.GetGPT41MiniPreview()); + AzureOpenAI.SetCopilotCapability(Enum::"Copilot Capability"::"E-Document MLLM Analysis"); + AOAIChatCompletionParams.SetTemperature(0); + // Do NOT set JSON mode — tool-calling and JSON mode cannot be combined. + // The system prompt instructs the model to output UBL JSON as its final response. + + // System prompt + AOAIChatMessages.SetPrimarySystemMessage(NavApp.GetResourceAsText(SystemPromptV2ResourceTok, TextEncoding::UTF8)); + + // Register 6 verification tools + AOAIChatMessages.AddTool(VerifyLineMathTool); + AOAIChatMessages.AddTool(VerifyTotalsTool); + AOAIChatMessages.AddTool(VerifyVATTool); + AOAIChatMessages.AddTool(VerifyDatesTool); + AOAIChatMessages.AddTool(VerifyRequiredTool); + AOAIChatMessages.AddTool(VerifyRangesTool); + AOAIChatMessages.SetToolChoice('auto'); + + // User message: PDF + UBL schema + security clause + AOAIUserMessage.AddFilePart(StrSubstNo(FileDataLbl, Base64Data)); + AOAIUserMessage.AddTextPart(SecretText.SecretStrSubstNo(UserPromptLbl, EDocMLLMSchemaHelper.GetDefaultSchema(), GetSecurityClause()).Unwrap()); + AOAIChatMessages.AddUserMessage(AOAIUserMessage); + + // Agentic dispatch loop + repeat + AzureOpenAI.GenerateChatCompletion(AOAIChatMessages, AOAIChatCompletionParams, AOAIOperationResponse); + + if not AOAIOperationResponse.IsSuccess() then + exit(''); + + if AOAIOperationResponse.IsFunctionCall() then begin + ToolCallCount += AOAIOperationResponse.GetFunctionResponses().Count(); + if ToolCallCount > MaxToolCallsTok then + Error(BudgetExhaustedErr, ToolCallCount); + AOAIOperationResponse.AppendFunctionResponsesToChatMessages(AOAIChatMessages); + end; + until not AOAIOperationResponse.IsFunctionCall(); + + exit(AOAIOperationResponse.GetResult()); + end; + + [NonDebuggable] + local procedure GetSecurityClause() Result: SecretText + var + AzureKeyVault: Codeunit "Azure Key Vault"; + begin + if not AzureKeyVault.GetAzureKeyVaultSecret(SecurityPromptAKVKeyTok, Result) then + Error(DocumentNotProcessedErr); + end; + + // The following methods are identical to V1 (EDocumentMLLMHandler) -------- + + local procedure IsInappropriateContentResponse(ResponseText: Text): Boolean + var + ResponseJson: JsonObject; + ContentToken: JsonToken; + ErrorToken: JsonToken; + InnerText: Text; + begin + if ResponseText = '' then + exit(false); + if not ResponseJson.ReadFrom(ResponseText) then + exit(false); + if ResponseJson.Get('content', ContentToken) and ContentToken.IsValue() then begin + InnerText := ContentToken.AsValue().AsText(); + Clear(ResponseJson); + if not ResponseJson.ReadFrom(InnerText) then + exit(false); + end; + exit(ResponseJson.Get('error', ErrorToken)); + end; + + local procedure ValidateAndUnwrapResponse(var ResponseText: Text; var ResponseJson: JsonObject): Boolean + var + ContentToken: JsonToken; + begin + if ResponseText = '' then + exit(false); + if not ResponseJson.ReadFrom(ResponseText) then + exit(false); + if ResponseJson.Get('content', ContentToken) then begin + ResponseText := ContentToken.AsValue().AsText(); + if not ResponseJson.ReadFrom(ResponseText) then + exit(false); + end; + exit(ValidateMLLMResponse(ResponseJson)); + end; + + local procedure ValidateMLLMResponse(ResponseJson: JsonObject): Boolean + var + SupplierToken: JsonToken; + PartyToken: JsonToken; + NameToken: JsonToken; + AddressToken: JsonToken; + SupplierObj: JsonObject; + PartyObj: JsonObject; + NameObj: JsonObject; + VendorName: Text; + begin + if not ResponseJson.Get('accounting_supplier_party', SupplierToken) then exit(false); + if not SupplierToken.IsObject() then exit(false); + SupplierObj := SupplierToken.AsObject(); + if not SupplierObj.Get('party', PartyToken) then exit(false); + if not PartyToken.IsObject() then exit(false); + PartyObj := PartyToken.AsObject(); + if not PartyObj.Get('party_name', NameToken) then exit(false); + if not NameToken.IsObject() then exit(false); + NameObj := NameToken.AsObject(); + if not NameObj.Get('name', NameToken) then exit(false); + VendorName := NameToken.AsValue().AsText(); + if VendorName = '' then exit(false); + if not PartyObj.Get('postal_address', AddressToken) then exit(false); + if not AddressToken.IsObject() then exit(false); + exit(true); + end; + + local procedure FallbackToADI(EDocumentDataStorage: Record "E-Doc. Data Storage"): Interface IStructuredDataType + var + ADIHandler: Codeunit "E-Document ADI Handler"; + begin + exit(ADIHandler.StructureReceivedEDocument(EDocumentDataStorage)); + end; + + local procedure GetInvoiceLineCount(ResponseJson: JsonObject): Integer + var + LinesToken: JsonToken; + begin + if ResponseJson.Get('invoice_line', LinesToken) then + if LinesToken.IsArray() then + exit(LinesToken.AsArray().Count()); + exit(0); + end; + + procedure RegisterCopilotCapabilityIfNeeded() + var + CopilotCapability: Codeunit "Copilot Capability"; + begin + if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"E-Document MLLM Analysis") then + CopilotCapability.RegisterCapability(Enum::"Copilot Capability"::"E-Document MLLM Analysis", ''); + end; + + // IStructuredFormatReader + IStructuredDataType (identical to V1) ---------- + + procedure GetFileFormat(): Enum "E-Doc. File Format" + begin + exit(this.FileFormat); + end; + + procedure GetContent(): Text + begin + exit(this.StructuredData); + end; + + procedure GetReadIntoDraftImpl(): Enum "E-Doc. Read into Draft" + begin + exit("E-Doc. Read into Draft"::MLLM); + end; + +#pragma warning disable AA0139 + procedure ReadIntoDraft(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob"): Enum "E-Doc. Process Draft" + var + TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; + TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary; + EDocPurchaseDraftUtility: Codeunit "E-Doc. Purchase Draft Utility"; + begin + ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); + EDocPurchaseDraftUtility.PersistDraft(EDocument, TempEDocPurchaseHeader, TempEDocPurchaseLine); + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); + end; + + local procedure ReadIntoBuffer( + EDocument: Record "E-Document"; + TempBlob: Codeunit "Temp Blob"; + var TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; + var TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary) + var + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + InStream: InStream; + SourceJsonObject: JsonObject; + LinesToken: JsonToken; + LinesArray: JsonArray; + BlobAsText: Text; + begin + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + InStream.Read(BlobAsText); + SourceJsonObject.ReadFrom(BlobAsText); + EDocMLLMSchemaHelper.MapHeaderFromJson(SourceJsonObject, TempEDocPurchaseHeader); + TempEDocPurchaseHeader."E-Document Entry No." := EDocument."Entry No"; + if SourceJsonObject.Get('invoice_line', LinesToken) then + if LinesToken.IsArray() then begin + LinesArray := LinesToken.AsArray(); + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, EDocument."Entry No", TempEDocPurchaseLine); + end; + end; + + procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") + var + TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; + TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary; + EDocReadablePurchaseDoc: Page "E-Doc. Readable Purchase Doc."; + begin + ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); + EDocReadablePurchaseDoc.SetBuffer(TempEDocPurchaseHeader, TempEDocPurchaseLine); + EDocReadablePurchaseDoc.Run(); + end; +#pragma warning restore AA0139 +} +``` + +- [ ] **Step 2: Compile — verify Task 1's enum value now resolves** + +The `StructureReceivedEDoc.Enum.al` references `"E-Document MLLM Handler V2"` which is now defined. The file should compile without errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al +git commit -m "Add EDocumentMLLMHandlerV2 with agentic plan-act-verify loop" +``` + +--- + +## Task 6: Wire up app.json and verify full compilation + +The new codeunit files must be included in the app's file list (if the project uses explicit file lists) and the app version should be bumped. + +**Files:** +- Modify: `src/Apps/W1/EDocument/App/app.json` + +- [ ] **Step 1: Bump app version patch number** + +In `app.json`, increment the 4th version segment (e.g. `29.0.0.1` → `29.0.0.2`). + +- [ ] **Step 2: Full compile of EDocument app** + +Run the compile command for the EDocument app. Expected: 0 errors, 0 warnings introduced by new code. + +Use: `dispatch 'Build-Application -AppName "E-Document Core" -CountryCode W1'` + +Expected output: build succeeds. + +- [ ] **Step 3: Run existing MLLM tests to verify nothing regressed** + +Run: `dispatch 'Run-Tests -AppName "E-Document Core Tests" -TestCodeunit "EDoc MLLM Tests"'` + +Expected: all tests pass (V1 tests are unaffected). + +- [ ] **Step 4: Run new verify tools tests** + +Run: `dispatch 'Run-Tests -AppName "E-Document Core Tests" -TestCodeunit "EDoc MLLM Verify Tools Tests"'` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/Apps/W1/EDocument/App/app.json +git commit -m "Bump version and verify V2 compiles cleanly" +``` From 986c0ebe1a9d23d0a68dc52033e4079193917a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:43:57 +0200 Subject: [PATCH 50/85] Redesign Task 4 prompt: agent understands invoice structure, no hardcoded discount rules --- .../2026-05-26-mllm-v2-implementation.md | 106 +++++------------- 1 file changed, 30 insertions(+), 76 deletions(-) diff --git a/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md b/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md index 08b6bb3dbf..a73b481db7 100644 --- a/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md +++ b/docs/superpowers/plans/2026-05-26-mllm-v2-implementation.md @@ -4,7 +4,7 @@ **Goal:** Replace the single-pass MLLM extraction with an agentic plan-act-verify loop where the model identifies document structure, extracts from identified regions, and self-corrects by calling AL-implemented verification tools. -**Architecture:** One agentic AOAI call (GPT-4.1 Mini) with 6 verification tools registered as AOAI Functions. The model plans (chain-of-thought), extracts, calls verify tools, sees failures with error details, corrects, and repeats. AL drives the tool dispatch loop; the model decides when to call what. New handler registered as `"MLLM V2"` enum value alongside V1. +**Architecture:** One agentic AOAI call loop (GPT-4.1 Mini) with 6 verification tools registered as AOAI Functions. The model plans (chain-of-thought), extracts, calls verify tools, sees failures with error details, corrects, and repeats. AL drives the tool dispatch loop; the model decides when to call what. New handler registered as `"MLLM V2"` enum value alongside V1. **Tech Stack:** AL (Business Central), System.AI (AOAI SDK), `AOAI Function` interface for tool adapters, `AOAIChatMessages.AddTool()` + `AppendFunctionResponsesToChatMessages()` for the loop. @@ -423,11 +423,7 @@ codeunit 135648 "EDoc MLLM Verify Tools Tests" } ``` -- [ ] **Step 3: Compile and run tests — expect FAIL (stubs return false)** - -All tests that assert `IsTrue(...)` will fail because stubs return `false`. - -- [ ] **Step 4: Implement the 6 methods in EDocMLLMVerifyTools** +- [ ] **Step 3: Implement the 6 methods in EDocMLLMVerifyTools** Replace the stub body of each method with the real implementation: @@ -567,17 +563,6 @@ begin end; ``` -- [ ] **Step 5: Run tests — expect all PASS** - -- [ ] **Step 6: Commit** - -```bash -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al -git add src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al -git commit -m "Add EDocMLLMVerifyTools with 6 verification methods and unit tests" -``` - ---- ## Task 3: Create the 6 AOAI Function tool adapters @@ -1061,21 +1046,6 @@ codeunit 6243 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" } ``` -- [ ] **Step 7: Compile and verify no errors** - -- [ ] **Step 8: Commit** - -```bash -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al -git commit -m "Add 6 AOAI Function tool adapters for MLLM V2 verification" -``` - ---- ## Task 4: Create the V2 system prompt @@ -1087,46 +1057,42 @@ git commit -m "Add 6 AOAI Function tool adapters for MLLM V2 verification" ```markdown You are an invoice data extraction agent with access to verification tools. -PHASE 1 — IDENTIFY STRUCTURE (reason before extracting): -Before extracting any values, describe in your reasoning: -- Document type (invoice, credit memo, etc.) -- Language and locale (e.g. Swedish: comma decimal, space thousands) -- Decimal separator and thousands separator used on this document -- Line item table: list each column header and its role (gross unit price, net/post-discount unit price, discount percentage, quantity, line total) -- Which column to use as price_amount (use the GROSS/pre-discount price if both gross and net prices are shown — put the discount in allowance_charge instead) -- Header region location (supplier details, invoice number, dates) -- Totals region location - -PHASE 2 — EXTRACT FROM IDENTIFIED REGIONS (not left-to-right): -Extract data guided by your structure analysis above. Rules: -- Numbers: XML decimal format — period (.) as decimal separator, no thousands separators +PHASE 1 — UNDERSTAND THE DOCUMENT: +Before extracting any values, reason through the document's structure out loud. Cover: +- What type of document is this and in what language? +- What number format does this document use? (decimal separator, thousands separator — these vary by country) +- What columns appear in the line item table? For each column, what does it represent? Some invoices show only a unit price; others show a gross price, one or more discount columns, and a net price. Some discounts are percentages, others are monetary amounts. Some apply sequentially. Describe exactly what you see. +- Where are the header fields (supplier, buyer, invoice number, dates)? +- Where is the totals section? +- Is there anything unusual about this invoice's layout? + +Your analysis determines how you extract. Two invoices from different vendors may look completely different — your job is to understand each one on its own terms. + +PHASE 2 — EXTRACT FROM THE REGIONS YOU IDENTIFIED: +Use your analysis from Phase 1 to extract values. Do not sweep left-to-right across the full text. Extract from the specific regions and columns you identified. + +Format rules (non-negotiable): +- Numbers: XML decimal format — period (.) as decimal separator, no thousands separators (e.g. 1083 not "1 083", 2.34 not "2,34") - Dates: YYYY-MM-DD -- price_amount must be the GROSS unit price (before line discounts). If a discount percentage is also present, set allowance_charge.percent (0-100). Do NOT combine a post-discount unit price with a discount percentage. -- allowance_charge.percent (0-100) when invoice shows a discount %; allowance_charge.amount.value when invoice shows a monetary discount amount; leave allowance_charge empty if no discount -- Output valid UBL JSON matching the schema provided -PHASE 3 — VERIFY YOUR OUTPUT: -After producing the UBL JSON, call the verification tools: -- Call verify_line_math once per invoice line -- Call verify_invoice_totals with all line amounts -- Call verify_vat for the tax total -- Call verify_dates with issue_date and due_date -- Call verify_required_fields with vendor name, invoice number, and line count -- Call verify_ranges with all quantities, prices, VAT rates, and discount percentages +For everything else — how to represent the price, how to represent discounts, which column maps to which UBL field — let your Phase 1 analysis guide you. The verify tools in Phase 3 will tell you if your extraction is mathematically inconsistent. -If any tool returns { "pass": false }, read the error message carefully, correct the affected fields, and call the tools again. Only output the final UBL JSON when all tools return { "pass": true }. +Output valid UBL JSON matching the schema provided. -Output ONLY valid JSON. No markdown, no explanation. -``` +PHASE 3 — VERIFY YOUR OWN OUTPUT: +Call the verification tools on what you extracted: +- verify_line_math for each invoice line +- verify_invoice_totals with all line amounts +- verify_vat for the tax total +- verify_dates with issue_date and due_date +- verify_required_fields with vendor name, invoice number, line count +- verify_ranges with all quantities, prices, VAT rates, and discount percentages -- [ ] **Step 2: Commit** +If a tool returns { "pass": false }, read its error message. It will tell you specifically what does not add up. Reconsider your Phase 1 analysis if needed — the error may reveal that you misidentified a column role or misread a discount structure. Correct and call the tools again. Only finalise when all tools return { "pass": true }. -```bash -git add src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md -git commit -m "Add MLLM V2 system prompt with plan-act-verify instructions" +Output ONLY valid JSON. No markdown, no explanation. ``` ---- ## Task 5: Create EDocumentMLLMHandlerV2 @@ -1432,18 +1398,6 @@ codeunit 6244 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen } ``` -- [ ] **Step 2: Compile — verify Task 1's enum value now resolves** - -The `StructureReceivedEDoc.Enum.al` references `"E-Document MLLM Handler V2"` which is now defined. The file should compile without errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al -git add src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al -git commit -m "Add EDocumentMLLMHandlerV2 with agentic plan-act-verify loop" -``` - --- ## Task 6: Wire up app.json and verify full compilation From d72555941615db2ec43e324d16da698efd94a3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:46:24 +0200 Subject: [PATCH 51/85] Add "MLLM V2" enum value for agentic extraction handler --- .../App/src/Processing/Import/StructureReceivedEDoc.Enum.al | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al index 194b0d9adf..138a838f85 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDoc.Enum.al @@ -31,4 +31,9 @@ enum 6103 "Structure Received E-Doc." implements IStructureReceivedEDocument Caption = 'MLLM Extraction'; Implementation = IStructureReceivedEDocument = "E-Document MLLM Handler"; } + value(4; "MLLM V2") + { + Caption = 'MLLM Extraction V2'; + Implementation = IStructureReceivedEDocument = "E-Document MLLM Handler V2"; + } } From cdf68ab218f78bc3adcfdd4e984749175577a99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:49:39 +0200 Subject: [PATCH 52/85] Add EDocMLLMVerifyTools with 6 verification methods and unit tests Codeunit 6233 "E-Doc. MLLM Verify Tools" provides math and structural validation helpers (line math, invoice totals, VAT, dates, required fields, ranges) used by the MLLM V2 agentic extraction pipeline to self-verify AI-extracted invoice data before it enters the import draft. Codeunit 135648 covers all 6 methods plus the IsWithinTolerance helper with 20 unit tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMVerifyTools.Codeunit.al | 193 ++++++++++ .../EDocMLLMVerifyToolsTests.Codeunit.al | 364 ++++++++++++++++++ 2 files changed, 557 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al create mode 100644 src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al new file mode 100644 index 0000000000..dd3ee0ef1a --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al @@ -0,0 +1,193 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +codeunit 6233 "E-Doc. MLLM Verify Tools" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure VerifyLineMath(UnitPrice: Decimal; Quantity: Decimal; DiscountPct: Decimal; LineExtensionAmount: Decimal; var ErrorText: Text): Boolean + var + Expected: Decimal; + LineMathErrLbl: Label '%1 × %2 × (1 − %3/100) = %4, but line_extension_amount = %5. Re-check which price column is the gross (pre-discount) unit price.', Comment = '%1=UnitPrice, %2=Quantity, %3=DiscountPct, %4=Expected, %5=LineExtensionAmount'; + begin + if LineExtensionAmount = 0 then + exit(true); + + Expected := UnitPrice * Quantity * (1 - DiscountPct / 100); + + if IsWithinTolerance(Expected, LineExtensionAmount) then + exit(true); + + ErrorText := StrSubstNo(LineMathErrLbl, UnitPrice, Quantity, DiscountPct, Expected, LineExtensionAmount); + exit(false); + end; + + procedure VerifyInvoiceTotals(LineAmounts: List of [Decimal]; TaxExclusiveAmount: Decimal; var ErrorText: Text): Boolean + var + LineAmount: Decimal; + SumOfLines: Decimal; + InvoiceTotalsErrLbl: Label 'Sum of line_extension_amounts = %1, but tax_exclusive_amount = %2. Check for missing or duplicated lines.', Comment = '%1=SumOfLines, %2=TaxExclusiveAmount'; + begin + if TaxExclusiveAmount = 0 then + exit(true); + + SumOfLines := 0; + foreach LineAmount in LineAmounts do + SumOfLines += LineAmount; + + if IsWithinTolerance(SumOfLines, TaxExclusiveAmount) then + exit(true); + + ErrorText := StrSubstNo(InvoiceTotalsErrLbl, SumOfLines, TaxExclusiveAmount); + exit(false); + end; + + procedure VerifyVAT(TaxExclusiveAmount: Decimal; VATRate: Decimal; TaxAmount: Decimal; var ErrorText: Text): Boolean + var + Expected: Decimal; + VATErrLbl: Label '%1 × %2% = %3, but tax_amount = %4. Re-check the VAT rate.', Comment = '%1=TaxExclusiveAmount, %2=VATRate, %3=Expected, %4=TaxAmount'; + begin + if TaxAmount = 0 then + exit(true); + + Expected := TaxExclusiveAmount * VATRate / 100; + + if IsWithinTolerance(Expected, TaxAmount) then + exit(true); + + ErrorText := StrSubstNo(VATErrLbl, TaxExclusiveAmount, VATRate, Expected, TaxAmount); + exit(false); + end; + + procedure VerifyDates(IssueDateText: Text; DueDateText: Text; var ErrorText: Text): Boolean + var + IssueDate: Date; + DueDate: Date; + MissingIssueDateErrLbl: Label 'issue_date is missing.'; + InvalidIssueDateErrLbl: Label 'issue_date ''%1'' is not a valid date.', Comment = '%1=IssueDateText'; + IssueDateYearErrLbl: Label 'issue_date ''%1'' has year %2 which is outside the expected range 1900–2100.', Comment = '%1=IssueDateText, %2=Year'; + InvalidDueDateErrLbl: Label 'due_date ''%1'' is not a valid date.', Comment = '%1=DueDateText'; + DueDateBeforeIssueDateErrLbl: Label 'due_date %1 is before issue_date %2.', Comment = '%1=DueDate, %2=IssueDate'; + begin + if IssueDateText = '' then begin + ErrorText := MissingIssueDateErrLbl; + exit(false); + end; + + if not Evaluate(IssueDate, IssueDateText, 9) then begin + ErrorText := StrSubstNo(InvalidIssueDateErrLbl, IssueDateText); + exit(false); + end; + + if (Date2DMY(IssueDate, 3) < 1900) or (Date2DMY(IssueDate, 3) > 2100) then begin + ErrorText := StrSubstNo(IssueDateYearErrLbl, IssueDateText, Date2DMY(IssueDate, 3)); + exit(false); + end; + + if DueDateText <> '' then begin + if not Evaluate(DueDate, DueDateText, 9) then begin + ErrorText := StrSubstNo(InvalidDueDateErrLbl, DueDateText); + exit(false); + end; + + if DueDate < IssueDate then begin + ErrorText := StrSubstNo(DueDateBeforeIssueDateErrLbl, DueDate, IssueDate); + exit(false); + end; + end; + + exit(true); + end; + + procedure VerifyRequiredFields(VendorName: Text; InvoiceNo: Text; LineCount: Integer; var ErrorText: Text): Boolean + var + Missing: Text; + MissingFieldsErrLbl: Label 'Missing required fields: %1', Comment = '%1=comma-separated list of missing fields'; + VendorNameLbl: Label 'vendor name', Locked = true; + InvoiceNumberLbl: Label 'invoice number', Locked = true; + InvoiceLineCountLbl: Label 'invoice lines (line_count = 0)', Locked = true; + begin + Missing := ''; + + if VendorName = '' then + AppendMissing(Missing, VendorNameLbl); + + if InvoiceNo = '' then + AppendMissing(Missing, InvoiceNumberLbl); + + if LineCount = 0 then + AppendMissing(Missing, InvoiceLineCountLbl); + + if Missing = '' then + exit(true); + + ErrorText := StrSubstNo(MissingFieldsErrLbl, Missing); + exit(false); + end; + + procedure VerifyRanges(Quantities: List of [Decimal]; Prices: List of [Decimal]; VATRates: List of [Decimal]; DiscountPcts: List of [Decimal]; var ErrorText: Text): Boolean + var + i: Integer; + Value: Decimal; + NegativeQtyErrLbl: Label 'Line %1: quantity %2 must be greater than 0.', Comment = '%1=LineIndex, %2=Value'; + NonPositivePriceErrLbl: Label 'Line %1: unit price %2 must be greater than 0.', Comment = '%1=LineIndex, %2=Value'; + VATRateRangeErrLbl: Label 'Line %1: VAT rate %2 is outside the range 0–100.', Comment = '%1=LineIndex, %2=Value'; + DiscountRangeErrLbl: Label 'Line %1: discount %2 is outside the range 0–100.', Comment = '%1=LineIndex, %2=Value'; + begin + for i := 1 to Quantities.Count() do begin + Quantities.Get(i, Value); + if Value <= 0 then begin + ErrorText := StrSubstNo(NegativeQtyErrLbl, i, Value); + exit(false); + end; + end; + + for i := 1 to Prices.Count() do begin + Prices.Get(i, Value); + if Value <= 0 then begin + ErrorText := StrSubstNo(NonPositivePriceErrLbl, i, Value); + exit(false); + end; + end; + + for i := 1 to VATRates.Count() do begin + VATRates.Get(i, Value); + if (Value < 0) or (Value > 100) then begin + ErrorText := StrSubstNo(VATRateRangeErrLbl, i, Value); + exit(false); + end; + end; + + for i := 1 to DiscountPcts.Count() do begin + DiscountPcts.Get(i, Value); + if (Value < 0) or (Value > 100) then begin + ErrorText := StrSubstNo(DiscountRangeErrLbl, i, Value); + exit(false); + end; + end; + + exit(true); + end; + + procedure IsWithinTolerance(Expected: Decimal; Actual: Decimal): Boolean + var + Denominator: Decimal; + begin + Denominator := Abs(Actual); + if Denominator < 1 then + Denominator := 1; + exit(Abs(Expected - Actual) / Denominator < 0.01); + end; + + local procedure AppendMissing(var Missing: Text; Field: Text) + begin + if Missing <> '' then + Missing += ', '; + Missing += Field; + end; +} diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al new file mode 100644 index 0000000000..ad2f9cd239 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al @@ -0,0 +1,364 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Test; + +using Microsoft.eServices.EDocument.Processing.Import; + +codeunit 135648 "EDoc MLLM Verify Tools Tests" +{ + Subtype = Test; + + var + Assert: Codeunit Assert; + + // VerifyLineMath tests + + [Test] + procedure VerifyLineMath_Pass_NoDiscount() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] 40 × 5 × 1.0 = 200, line_extension_amount = 200 → pass + Result := EDocMLLMVerifyTools.VerifyLineMath(40, 5, 0, 200, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyLineMath to pass for 40 × 5 = 200'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyLineMath_Pass_WithDiscount() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] Swedish invoice case: 3.65 × 1083 × (1 - 36/100) ≈ 2529.89 → pass + // 3.65 × 1083 = 3952.95; 3952.95 × 0.64 = 2529.888 ≈ 2529.89 + Result := EDocMLLMVerifyTools.VerifyLineMath(3.65, 1083, 36, 2529.89, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyLineMath to pass for Swedish invoice case'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyLineMath_Pass_WithinOnePct() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] 10 × 10 × 1.0 = 100, actual = 100.9 → within 1% tolerance → pass + Result := EDocMLLMVerifyTools.VerifyLineMath(10, 10, 0, 100.9, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyLineMath to pass when within 1% tolerance'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyLineMath_Fail_WrongPrice() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] Wrong unit price: 2.34 × 1083 × 0.80 ≈ 2027, but actual = 2529.89 → fail + Result := EDocMLLMVerifyTools.VerifyLineMath(2.34, 1083, 20, 2529.89, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyLineMath to fail for wrong price'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + end; + + [Test] + procedure VerifyLineMath_Pass_ZeroLineAmount() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] line_extension_amount = 0 → skip check → pass + Result := EDocMLLMVerifyTools.VerifyLineMath(0, 0, 0, 0, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyLineMath to pass when line_extension_amount = 0'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + // VerifyInvoiceTotals tests + + [Test] + procedure VerifyInvoiceTotals_Pass() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + LineAmounts: List of [Decimal]; + Result: Boolean; + begin + // [SCENARIO] [200, 30, 20] sums to 250, tax_exclusive_amount = 250 → pass + LineAmounts.Add(200); + LineAmounts.Add(30); + LineAmounts.Add(20); + Result := EDocMLLMVerifyTools.VerifyInvoiceTotals(LineAmounts, 250, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyInvoiceTotals to pass when sum matches'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyInvoiceTotals_Fail_MissingLine() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + LineAmounts: List of [Decimal]; + Result: Boolean; + begin + // [SCENARIO] [200] sums to 200, tax_exclusive_amount = 250 → fail (missing lines) + LineAmounts.Add(200); + Result := EDocMLLMVerifyTools.VerifyInvoiceTotals(LineAmounts, 250, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyInvoiceTotals to fail when sum does not match'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + end; + + // VerifyVAT tests + + [Test] + procedure VerifyVAT_Pass() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] 250 × 15% = 37.5, tax_amount = 37.5 → pass + Result := EDocMLLMVerifyTools.VerifyVAT(250, 15, 37.5, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyVAT to pass for 250 × 15% = 37.5'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyVAT_Fail() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] 250 × 15% = 37.5, but tax_amount = 100 → fail + Result := EDocMLLMVerifyTools.VerifyVAT(250, 15, 100, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyVAT to fail when tax_amount does not match'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + end; + + [Test] + procedure VerifyVAT_Skip_ZeroTax() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] tax_amount = 0 → skip check → pass + Result := EDocMLLMVerifyTools.VerifyVAT(250, 15, 0, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyVAT to pass when tax_amount = 0 (skip)'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + // VerifyDates tests + + [Test] + procedure VerifyDates_Pass_ValidDates() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] Valid issue and due dates where due >= issue → pass + Result := EDocMLLMVerifyTools.VerifyDates('2024-03-15', '2024-04-15', ErrorText); + Assert.IsTrue(Result, 'Expected VerifyDates to pass for valid dates'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyDates_Pass_NoDueDate() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] Valid issue date, no due date → pass + Result := EDocMLLMVerifyTools.VerifyDates('2024-03-15', '', ErrorText); + Assert.IsTrue(Result, 'Expected VerifyDates to pass when due_date is empty'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyDates_Fail_DueDateBeforeIssue() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] due_date 2024-03-15 is before issue_date 2024-04-15 → fail + Result := EDocMLLMVerifyTools.VerifyDates('2024-04-15', '2024-03-15', ErrorText); + Assert.IsFalse(Result, 'Expected VerifyDates to fail when due_date is before issue_date'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + end; + + [Test] + procedure VerifyDates_Fail_InvalidDate() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] issue_date = 'not-a-date' cannot be parsed → fail + Result := EDocMLLMVerifyTools.VerifyDates('not-a-date', '', ErrorText); + Assert.IsFalse(Result, 'Expected VerifyDates to fail for unparseable issue_date'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + end; + + [Test] + procedure VerifyDates_Fail_MissingIssueDate() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] issue_date = '' → fail with missing date error + Result := EDocMLLMVerifyTools.VerifyDates('', '', ErrorText); + Assert.IsFalse(Result, 'Expected VerifyDates to fail when issue_date is empty'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + end; + + // VerifyRequiredFields tests + + [Test] + procedure VerifyRequiredFields_Pass() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] All required fields present → pass + Result := EDocMLLMVerifyTools.VerifyRequiredFields('Contoso Ltd', 'INV-001', 2, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyRequiredFields to pass when all fields are present'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyRequiredFields_Fail_MissingVendor() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] VendorName = '' → fail, ErrorText contains 'vendor' + Result := EDocMLLMVerifyTools.VerifyRequiredFields('', 'INV-001', 2, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyRequiredFields to fail when vendor name is missing'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + Assert.IsTrue(ErrorText.ToLower().Contains('vendor'), 'ErrorText should mention vendor'); + end; + + [Test] + procedure VerifyRequiredFields_Fail_NoLines() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Result: Boolean; + begin + // [SCENARIO] LineCount = 0 → fail, ErrorText contains 'line' + Result := EDocMLLMVerifyTools.VerifyRequiredFields('Contoso Ltd', 'INV-001', 0, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyRequiredFields to fail when line count is 0'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + Assert.IsTrue(ErrorText.ToLower().Contains('line'), 'ErrorText should mention line'); + end; + + // VerifyRanges tests + + [Test] + procedure VerifyRanges_Pass() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Quantities: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscountPcts: List of [Decimal]; + Result: Boolean; + begin + // [SCENARIO] qty=5, price=40, vat=15, disc=0 → all in range → pass + Quantities.Add(5); + Prices.Add(40); + VATRates.Add(15); + DiscountPcts.Add(0); + Result := EDocMLLMVerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText); + Assert.IsTrue(Result, 'Expected VerifyRanges to pass for valid values'); + Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); + end; + + [Test] + procedure VerifyRanges_Fail_NegativeQty() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Quantities: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscountPcts: List of [Decimal]; + Result: Boolean; + begin + // [SCENARIO] qty=-1 → fail, ErrorText contains 'quantity' + Quantities.Add(-1); + Prices.Add(40); + VATRates.Add(15); + DiscountPcts.Add(0); + Result := EDocMLLMVerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyRanges to fail for negative quantity'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + Assert.IsTrue(ErrorText.ToLower().Contains('quantity'), 'ErrorText should mention quantity'); + end; + + [Test] + procedure VerifyRanges_Fail_DiscountOver100() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + Quantities: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscountPcts: List of [Decimal]; + Result: Boolean; + begin + // [SCENARIO] disc=150 → fail, ErrorText contains 'discount' + Quantities.Add(5); + Prices.Add(40); + VATRates.Add(15); + DiscountPcts.Add(150); + Result := EDocMLLMVerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText); + Assert.IsFalse(Result, 'Expected VerifyRanges to fail for discount > 100'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); + Assert.IsTrue(ErrorText.ToLower().Contains('discount'), 'ErrorText should mention discount'); + end; + + // IsWithinTolerance tests (testing internal method via public exposure) + + [Test] + procedure IsWithinTolerance_Pass_ExactMatch() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + begin + // [SCENARIO] 100 vs 100 → within tolerance → true + Assert.IsTrue(EDocMLLMVerifyTools.IsWithinTolerance(100, 100), 'Expected IsWithinTolerance to return true for exact match'); + end; + + [Test] + procedure IsWithinTolerance_Pass_SmallDelta() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + begin + // [SCENARIO] 2529.888 vs 2529.89 → tiny delta, well within 1% → true + Assert.IsTrue(EDocMLLMVerifyTools.IsWithinTolerance(2529.888, 2529.89), 'Expected IsWithinTolerance to return true for small delta'); + end; + + [Test] + procedure IsWithinTolerance_Fail_LargeDelta() + var + EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + begin + // [SCENARIO] 2027 vs 2529.89 → ~20% difference → false + Assert.IsFalse(EDocMLLMVerifyTools.IsWithinTolerance(2027, 2529.89), 'Expected IsWithinTolerance to return false for large delta'); + end; +} From 8e351d2d91a01ed94a37e816994c4a1690ba593b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:55:05 +0200 Subject: [PATCH 53/85] Add 6 AOAI Function tool adapters for MLLM V2 verification Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMVerifyDatesTool.Codeunit.al | 61 +++++++++++++ .../EDocMLLMVerifyLineMathTool.Codeunit.al | 81 +++++++++++++++++ .../EDocMLLMVerifyRangesTool.Codeunit.al | 86 +++++++++++++++++++ .../EDocMLLMVerifyRequiredTool.Codeunit.al | 68 +++++++++++++++ .../EDocMLLMVerifyTotalsTool.Codeunit.al | 74 ++++++++++++++++ .../EDocMLLMVerifyVATTool.Codeunit.al | 69 +++++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al new file mode 100644 index 0000000000..d61ac30766 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6238 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_dates'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); PropObj.Add('description', 'issue_date in YYYY-MM-DD format'); + PropsObj.Add('issue_date', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'due_date in YYYY-MM-DD format, or empty string if not present'); + PropsObj.Add('due_date', PropObj); + RequiredArr.Add('issue_date'); RequiredArr.Add('due_date'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that issue_date and due_date are valid XML dates (YYYY-MM-DD), year 1900-2100, and due_date >= issue_date if present.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + IssueDate: Text; + DueDate: Text; + Token: JsonToken; + begin + if Arguments.Get('issue_date', Token) then IssueDate := Token.AsValue().AsText(); + if Arguments.Get('due_date', Token) then DueDate := Token.AsValue().AsText(); + if VerifyTools.VerifyDates(IssueDate, DueDate, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al new file mode 100644 index 0000000000..6c2d003caf --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6235 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_line_math'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'number'); PropObj.Add('description', 'Gross unit price before discounts'); + PropsObj.Add('unit_price', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'Quantity of units'); + PropsObj.Add('quantity', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'Combined discount percentage 0-100 (use 0 if no discount)'); + PropsObj.Add('discount_pct', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'line_extension_amount from the invoice'); + PropsObj.Add('line_extension_amount', PropObj); + RequiredArr.Add('unit_price'); RequiredArr.Add('quantity'); RequiredArr.Add('discount_pct'); RequiredArr.Add('line_extension_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that gross_unit_price × quantity × (1 − discount_pct/100) matches line_extension_amount within 1%. Call once per invoice line.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + UnitPrice: Decimal; + Quantity: Decimal; + DiscountPct: Decimal; + LineExtAmt: Decimal; + begin + GetDecimalArg(Arguments, 'unit_price', UnitPrice); + GetDecimalArg(Arguments, 'quantity', Quantity); + GetDecimalArg(Arguments, 'discount_pct', DiscountPct); + GetDecimalArg(Arguments, 'line_extension_amount', LineExtAmt); + if VerifyTools.VerifyLineMath(UnitPrice, Quantity, DiscountPct, LineExtAmt, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; + + local procedure GetDecimalArg(Arguments: JsonObject; PropertyName: Text; var Value: Decimal) + var + Token: JsonToken; + DecimalValue: Decimal; + begin + if not Arguments.Get(PropertyName, Token) then + exit; + if Token.AsValue().IsNull() then + exit; + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then + Value := DecimalValue; + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al new file mode 100644 index 0000000000..4419ee8e79 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6243 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_ranges'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + ItemsObj: JsonObject; + RequiredArr: JsonArray; + begin + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line quantities'); + PropsObj.Add('quantities', PropObj); Clear(PropObj); Clear(ItemsObj); + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line unit prices'); + PropsObj.Add('prices', PropObj); Clear(PropObj); Clear(ItemsObj); + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line VAT rates (0-100)'); + PropsObj.Add('vat_rates', PropObj); Clear(PropObj); Clear(ItemsObj); + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line discount percentages (0-100)'); + PropsObj.Add('discount_pcts', PropObj); + RequiredArr.Add('quantities'); RequiredArr.Add('prices'); RequiredArr.Add('vat_rates'); RequiredArr.Add('discount_pcts'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that quantities > 0, unit prices > 0, VAT rates 0-100, discount percentages 0-100 for all lines.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + Quantities: List of [Decimal]; + Prices: List of [Decimal]; + VATRates: List of [Decimal]; + DiscountPcts: List of [Decimal]; + begin + ParseDecimalArray(Arguments, 'quantities', Quantities); + ParseDecimalArray(Arguments, 'prices', Prices); + ParseDecimalArray(Arguments, 'vat_rates', VATRates); + ParseDecimalArray(Arguments, 'discount_pcts', DiscountPcts); + if VerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; + + local procedure ParseDecimalArray(Arguments: JsonObject; PropertyName: Text; var Values: List of [Decimal]) + var + ArrayToken: JsonToken; + ItemToken: JsonToken; + DecimalValue: Decimal; + begin + if not Arguments.Get(PropertyName, ArrayToken) then + exit; + foreach ItemToken in ArrayToken.AsArray() do + if Evaluate(DecimalValue, ItemToken.AsValue().AsText(), 9) then + Values.Add(DecimalValue); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al new file mode 100644 index 0000000000..d9614e9b02 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6239 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_required_fields'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); PropObj.Add('description', 'Supplier/vendor company name'); + PropsObj.Add('vendor_name', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Invoice number / id'); + PropsObj.Add('invoice_no', PropObj); Clear(PropObj); + PropObj.Add('type', 'integer'); PropObj.Add('description', 'Number of invoice lines extracted'); + PropsObj.Add('line_count', PropObj); + RequiredArr.Add('vendor_name'); RequiredArr.Add('invoice_no'); RequiredArr.Add('line_count'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that vendor name, invoice number, and at least one invoice line are present.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + VendorName: Text; + InvoiceNo: Text; + LineCount: Integer; + Token: JsonToken; + DecimalValue: Decimal; + begin + if Arguments.Get('vendor_name', Token) then VendorName := Token.AsValue().AsText(); + if Arguments.Get('invoice_no', Token) then InvoiceNo := Token.AsValue().AsText(); + if Arguments.Get('line_count', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then + LineCount := Round(DecimalValue, 1); + if VerifyTools.VerifyRequiredFields(VendorName, InvoiceNo, LineCount, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al new file mode 100644 index 0000000000..860e0503a4 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6236 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_invoice_totals'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + ItemsObj: JsonObject; + RequiredArr: JsonArray; + begin + ItemsObj.Add('type', 'number'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line_extension_amount values'); + PropsObj.Add('line_amounts', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_exclusive_amount from legal_monetary_total'); + PropsObj.Add('tax_exclusive_amount', PropObj); + RequiredArr.Add('line_amounts'); RequiredArr.Add('tax_exclusive_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that the sum of all line_extension_amounts matches tax_exclusive_amount within 1%.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + LineAmountsToken: JsonToken; + LineAmountsArray: JsonArray; + LineToken: JsonToken; + LineAmounts: List of [Decimal]; + TaxExclusiveAmount: Decimal; + DecimalValue: Decimal; + Token: JsonToken; + begin + if Arguments.Get('line_amounts', LineAmountsToken) then begin + LineAmountsArray := LineAmountsToken.AsArray(); + foreach LineToken in LineAmountsArray do + if Evaluate(DecimalValue, LineToken.AsValue().AsText(), 9) then + LineAmounts.Add(DecimalValue); + end; + if Arguments.Get('tax_exclusive_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then + TaxExclusiveAmount := DecimalValue; + if VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al new file mode 100644 index 0000000000..308037983d --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6237 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_vat'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj: JsonObject; + FunctionObj: JsonObject; + ParamsObj: JsonObject; + PropsObj: JsonObject; + PropObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_exclusive_amount'); + PropsObj.Add('tax_exclusive_amount', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'VAT rate percentage 0-100'); + PropsObj.Add('vat_rate', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_amount'); + PropsObj.Add('tax_amount', PropObj); + RequiredArr.Add('tax_exclusive_amount'); RequiredArr.Add('vat_rate'); RequiredArr.Add('tax_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that tax_exclusive_amount × vat_rate/100 ≈ tax_amount within 1%.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ResultObj: JsonObject; + ErrorText: Text; + TaxExcl: Decimal; + VATRate: Decimal; + TaxAmt: Decimal; + Token: JsonToken; + DecimalValue: Decimal; + begin + if Arguments.Get('tax_exclusive_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExcl := DecimalValue; + if Arguments.Get('vat_rate', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then VATRate := DecimalValue; + if Arguments.Get('tax_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxAmt := DecimalValue; + if VerifyTools.VerifyVAT(TaxExcl, VATRate, TaxAmt, ErrorText) then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + exit(ResultObj); + end; +} From 6f8b95fc051b1c161a2248f696688d6ace2374db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:55:31 +0200 Subject: [PATCH 54/85] Add MLLM V2 system prompt Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMExtractionV2-SystemPrompt.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md new file mode 100644 index 0000000000..d11540a4bc --- /dev/null +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -0,0 +1,36 @@ +You are an invoice data extraction agent with access to verification tools. + +PHASE 1 — UNDERSTAND THE DOCUMENT: +Before extracting any values, reason through the document's structure out loud. Cover: +- What type of document is this and in what language? +- What number format does this document use? (decimal separator, thousands separator — these vary by country) +- What columns appear in the line item table? For each column, what does it represent? Some invoices show only a unit price; others show a gross price, one or more discount columns, and a net price. Some discounts are percentages, others are monetary amounts. Some apply sequentially. Describe exactly what you see. +- Where are the header fields (supplier, buyer, invoice number, dates)? +- Where is the totals section? +- Is there anything unusual about this invoice's layout? + +Your analysis determines how you extract. Two invoices from different vendors may look completely different — your job is to understand each one on its own terms. + +PHASE 2 — EXTRACT FROM THE REGIONS YOU IDENTIFIED: +Use your analysis from Phase 1 to extract values. Do not sweep left-to-right across the full text. Extract from the specific regions and columns you identified. + +Format rules (non-negotiable): +- Numbers: XML decimal format — period (.) as decimal separator, no thousands separators (e.g. 1083 not "1 083", 2.34 not "2,34") +- Dates: YYYY-MM-DD + +For everything else — how to represent the price, how to represent discounts, which column maps to which UBL field — let your Phase 1 analysis guide you. The verify tools in Phase 3 will tell you if your extraction is mathematically inconsistent. + +Output valid UBL JSON matching the schema provided. + +PHASE 3 — VERIFY YOUR OWN OUTPUT: +Call the verification tools on what you extracted: +- verify_line_math for each invoice line +- verify_invoice_totals with all line amounts +- verify_vat for the tax total +- verify_dates with issue_date and due_date +- verify_required_fields with vendor name, invoice number, line count +- verify_ranges with all quantities, prices, VAT rates, and discount percentages + +If a tool returns { "pass": false }, read its error message. It will tell you specifically what does not add up. Reconsider your Phase 1 analysis if needed — the error may reveal that you misidentified a column role or misread a discount structure. Correct and call the tools again. Only finalise when all tools return { "pass": true }. + +Output ONLY valid JSON. No markdown, no explanation. From 93d7b7cda23322223faaebe7828274fe0d24842c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:56:33 +0200 Subject: [PATCH 55/85] Add EDocumentMLLMHandlerV2 with agentic plan-act-verify loop Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocumentMLLMHandlerV2.Codeunit.al | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al new file mode 100644 index 0000000000..6c3514fec6 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -0,0 +1,288 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Format; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.eServices.EDocument.Processing.Interfaces; +using System.AI; +using System.Azure.KeyVault; +using System.Telemetry; +using System.Utilities; + +codeunit 6244 "E-Document MLLM Handler V2" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + var + Telemetry: Codeunit Telemetry; + StructuredData: Text; + FileFormat: Enum "E-Doc. File Format"; + FeatureNameLbl: Label 'E-Document MLLM Extraction V2', Locked = true; + FileDataLbl: Label 'data:application/pdf;base64,%1', Locked = true; + SystemPromptV2ResourceTok: Label 'Prompts/EDocMLLMExtractionV2-SystemPrompt.md', Locked = true; + UserPromptLbl: Label 'Extract invoice data into this UBL JSON structure: %1. \n\nExtract ONLY visible values. Return JSON only. %2', Locked = true; + SecurityPromptAKVKeyTok: Label 'EDocMLLMExtraction-SecurityPromptV281', Locked = true; + MaxToolCallsTok: Integer; + BudgetExhaustedErr: Label 'The document could not be verified after %1 tool calls. The extraction was inconsistent.', Comment = '%1 = tool call count'; + DocumentNotProcessedErr: Label 'The document could not be processed.'; + InappropriateContentErr: Label 'The document could not be processed because it contains inappropriate content.'; + + procedure StructureReceivedEDocument(EDocumentDataStorage: Record "E-Doc. Data Storage"): Interface IStructuredDataType + var + ResponseJson: JsonObject; + ResponseText: Text; + begin + MaxToolCallsTok := 200; + + RegisterCopilotCapabilityIfNeeded(); + + ResponseText := CallMLLMV2(EDocumentDataStorage); + + if IsInappropriateContentResponse(ResponseText) then + Error(InappropriateContentErr); + + if not ValidateAndUnwrapResponse(ResponseText, ResponseJson) then + exit(FallbackToADI(EDocumentDataStorage)); + + StructuredData := ResponseText; + FileFormat := "E-Doc. File Format"::JSON; + exit(this); + end; + + [NonDebuggable] + local procedure CallMLLMV2(EDocumentDataStorage: Record "E-Doc. Data Storage"): Text + var + Base64Convert: Codeunit "Base64 Convert"; + AzureOpenAI: Codeunit "Azure OpenAI"; + AOAIChatMessages: Codeunit "AOAI Chat Messages"; + AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params"; + AOAIUserMessage: Codeunit "AOAI User Message"; + AOAIOperationResponse: Codeunit "AOAI Operation Response"; + AOAIDeployments: Codeunit "AOAI Deployments"; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + VerifyLineMathTool: Codeunit "E-Doc. MLLM VL Math Tool"; + VerifyTotalsTool: Codeunit "E-Doc. MLLM VL Totals Tool"; + VerifyVATTool: Codeunit "E-Doc. MLLM VL VAT Tool"; + VerifyDatesTool: Codeunit "E-Doc. MLLM VL Dates Tool"; + VerifyRequiredTool: Codeunit "E-Doc. MLLM VL Required Tool"; + VerifyRangesTool: Codeunit "E-Doc. MLLM VL Ranges Tool"; + FromTempBlob: Codeunit "Temp Blob"; + InStream: InStream; + Base64Data: Text; + ToolCallCount: Integer; + begin + // Load PDF as base64 + FromTempBlob := EDocumentDataStorage.GetTempBlob(); + FromTempBlob.CreateInStream(InStream, TextEncoding::UTF8); + Base64Data := Base64Convert.ToBase64(InStream); + + // Configure AOAI + AzureOpenAI.SetAuthorization(Enum::"AOAI Model Type"::"Chat Completions", AOAIDeployments.GetGPT41MiniPreview()); + AzureOpenAI.SetCopilotCapability(Enum::"Copilot Capability"::"E-Document MLLM Analysis"); + AOAIChatCompletionParams.SetTemperature(0); + // Do NOT set JSON mode — tool-calling and JSON mode cannot be combined. + // The system prompt instructs the model to output UBL JSON as its final response. + + // System prompt + AOAIChatMessages.SetPrimarySystemMessage(NavApp.GetResourceAsText(SystemPromptV2ResourceTok, TextEncoding::UTF8)); + + // Register 6 verification tools + AOAIChatMessages.AddTool(VerifyLineMathTool); + AOAIChatMessages.AddTool(VerifyTotalsTool); + AOAIChatMessages.AddTool(VerifyVATTool); + AOAIChatMessages.AddTool(VerifyDatesTool); + AOAIChatMessages.AddTool(VerifyRequiredTool); + AOAIChatMessages.AddTool(VerifyRangesTool); + AOAIChatMessages.SetToolChoice('auto'); + + // User message: PDF + UBL schema + security clause + AOAIUserMessage.AddFilePart(StrSubstNo(FileDataLbl, Base64Data)); + AOAIUserMessage.AddTextPart(SecretText.SecretStrSubstNo(UserPromptLbl, EDocMLLMSchemaHelper.GetDefaultSchema(), GetSecurityClause()).Unwrap()); + AOAIChatMessages.AddUserMessage(AOAIUserMessage); + + // Agentic dispatch loop + repeat + AzureOpenAI.GenerateChatCompletion(AOAIChatMessages, AOAIChatCompletionParams, AOAIOperationResponse); + + if not AOAIOperationResponse.IsSuccess() then + exit(''); + + if AOAIOperationResponse.IsFunctionCall() then begin + ToolCallCount += AOAIOperationResponse.GetFunctionResponses().Count(); + if ToolCallCount > MaxToolCallsTok then + Error(BudgetExhaustedErr, ToolCallCount); + AOAIOperationResponse.AppendFunctionResponsesToChatMessages(AOAIChatMessages); + end; + until not AOAIOperationResponse.IsFunctionCall(); + + exit(AOAIOperationResponse.GetResult()); + end; + + [NonDebuggable] + local procedure GetSecurityClause() Result: SecretText + var + AzureKeyVault: Codeunit "Azure Key Vault"; + begin + if not AzureKeyVault.GetAzureKeyVaultSecret(SecurityPromptAKVKeyTok, Result) then + Error(DocumentNotProcessedErr); + end; + + local procedure IsInappropriateContentResponse(ResponseText: Text): Boolean + var + ResponseJson: JsonObject; + ContentToken: JsonToken; + ErrorToken: JsonToken; + InnerText: Text; + begin + if ResponseText = '' then + exit(false); + if not ResponseJson.ReadFrom(ResponseText) then + exit(false); + if ResponseJson.Get('content', ContentToken) and ContentToken.IsValue() then begin + InnerText := ContentToken.AsValue().AsText(); + Clear(ResponseJson); + if not ResponseJson.ReadFrom(InnerText) then + exit(false); + end; + exit(ResponseJson.Get('error', ErrorToken)); + end; + + local procedure ValidateAndUnwrapResponse(var ResponseText: Text; var ResponseJson: JsonObject): Boolean + var + ContentToken: JsonToken; + begin + if ResponseText = '' then + exit(false); + if not ResponseJson.ReadFrom(ResponseText) then + exit(false); + if ResponseJson.Get('content', ContentToken) then begin + ResponseText := ContentToken.AsValue().AsText(); + if not ResponseJson.ReadFrom(ResponseText) then + exit(false); + end; + exit(ValidateMLLMResponse(ResponseJson)); + end; + + local procedure ValidateMLLMResponse(ResponseJson: JsonObject): Boolean + var + SupplierToken: JsonToken; + PartyToken: JsonToken; + NameToken: JsonToken; + AddressToken: JsonToken; + SupplierObj: JsonObject; + PartyObj: JsonObject; + NameObj: JsonObject; + VendorName: Text; + begin + if not ResponseJson.Get('accounting_supplier_party', SupplierToken) then exit(false); + if not SupplierToken.IsObject() then exit(false); + SupplierObj := SupplierToken.AsObject(); + if not SupplierObj.Get('party', PartyToken) then exit(false); + if not PartyToken.IsObject() then exit(false); + PartyObj := PartyToken.AsObject(); + if not PartyObj.Get('party_name', NameToken) then exit(false); + if not NameToken.IsObject() then exit(false); + NameObj := NameToken.AsObject(); + if not NameObj.Get('name', NameToken) then exit(false); + VendorName := NameToken.AsValue().AsText(); + if VendorName = '' then exit(false); + if not PartyObj.Get('postal_address', AddressToken) then exit(false); + if not AddressToken.IsObject() then exit(false); + exit(true); + end; + + local procedure FallbackToADI(EDocumentDataStorage: Record "E-Doc. Data Storage"): Interface IStructuredDataType + var + ADIHandler: Codeunit "E-Document ADI Handler"; + begin + exit(ADIHandler.StructureReceivedEDocument(EDocumentDataStorage)); + end; + + local procedure GetInvoiceLineCount(ResponseJson: JsonObject): Integer + var + LinesToken: JsonToken; + begin + if ResponseJson.Get('invoice_line', LinesToken) then + if LinesToken.IsArray() then + exit(LinesToken.AsArray().Count()); + exit(0); + end; + + procedure RegisterCopilotCapabilityIfNeeded() + var + CopilotCapability: Codeunit "Copilot Capability"; + begin + if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"E-Document MLLM Analysis") then + CopilotCapability.RegisterCapability(Enum::"Copilot Capability"::"E-Document MLLM Analysis", ''); + end; + + procedure GetFileFormat(): Enum "E-Doc. File Format" + begin + exit(this.FileFormat); + end; + + procedure GetContent(): Text + begin + exit(this.StructuredData); + end; + + procedure GetReadIntoDraftImpl(): Enum "E-Doc. Read into Draft" + begin + exit("E-Doc. Read into Draft"::MLLM); + end; + +#pragma warning disable AA0139 + procedure ReadIntoDraft(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob"): Enum "E-Doc. Process Draft" + var + TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; + TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary; + EDocPurchaseDraftUtility: Codeunit "E-Doc. Purchase Draft Utility"; + begin + ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); + EDocPurchaseDraftUtility.PersistDraft(EDocument, TempEDocPurchaseHeader, TempEDocPurchaseLine); + exit(Enum::"E-Doc. Process Draft"::"Purchase Invoice"); + end; + + local procedure ReadIntoBuffer( + EDocument: Record "E-Document"; + TempBlob: Codeunit "Temp Blob"; + var TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; + var TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary) + var + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + InStream: InStream; + SourceJsonObject: JsonObject; + LinesToken: JsonToken; + LinesArray: JsonArray; + BlobAsText: Text; + begin + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + InStream.Read(BlobAsText); + SourceJsonObject.ReadFrom(BlobAsText); + EDocMLLMSchemaHelper.MapHeaderFromJson(SourceJsonObject, TempEDocPurchaseHeader); + TempEDocPurchaseHeader."E-Document Entry No." := EDocument."Entry No"; + if SourceJsonObject.Get('invoice_line', LinesToken) then + if LinesToken.IsArray() then begin + LinesArray := LinesToken.AsArray(); + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, EDocument."Entry No", TempEDocPurchaseLine); + end; + end; + + procedure View(EDocument: Record "E-Document"; TempBlob: Codeunit "Temp Blob") + var + TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; + TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary; + EDocReadablePurchaseDoc: Page "E-Doc. Readable Purchase Doc."; + begin + ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); + EDocReadablePurchaseDoc.SetBuffer(TempEDocPurchaseHeader, TempEDocPurchaseLine); + EDocReadablePurchaseDoc.Run(); + end; +#pragma warning restore AA0139 +} From 9bf70c5cf29607e47a895126904ed34257d62de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Tue, 26 May 2026 15:56:44 +0200 Subject: [PATCH 56/85] Bump E-Document Core version for MLLM V2 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Apps/W1/EDocument/App/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/EDocument/App/app.json b/src/Apps/W1/EDocument/App/app.json index 4ed9bcb273..f806a03046 100644 --- a/src/Apps/W1/EDocument/App/app.json +++ b/src/Apps/W1/EDocument/App/app.json @@ -4,7 +4,7 @@ "publisher": "Microsoft", "brief": "The Dynamics 365 Business Central E-Documents module enables different models of electronic invoicing, available for additional localizations.", "description": "Business Central's E-Documents module is the foundation layer for all e-invoicing standards covering most common processes, but it can be used for other electronic documents. The module is easily extendable with the country-based e-invoicing apps. The E-Documents app covers both sales and purchase processes and can have different lifecycles from standard invoices in Business Central.", - "version": "29.0.0.0", + "version": "29.0.0.2", "privacyStatement": "https://go.microsoft.com/fwlink/?LinkId=724009", "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120", "help": "https://go.microsoft.com/fwlink/?linkid=2204541", @@ -76,4 +76,4 @@ "features": [ "TranslationFile" ] -} +} \ No newline at end of file From 4f60c63ba0d41557ba26e1d0429745957b107e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 08:31:50 +0200 Subject: [PATCH 57/85] Fix codeunit IDs (6110-6114, 6153-6154, 6405) and add missing System.Text using --- .../EDocMLLMSchemaHelper.Codeunit.al | 14 ++++++++++++-- .../EDocMLLMVerifyDatesTool.Codeunit.al | 2 +- .../EDocMLLMVerifyLineMathTool.Codeunit.al | 2 +- .../EDocMLLMVerifyRangesTool.Codeunit.al | 2 +- .../EDocMLLMVerifyRequiredTool.Codeunit.al | 2 +- .../EDocMLLMVerifyTools.Codeunit.al | 2 +- .../EDocMLLMVerifyTotalsTool.Codeunit.al | 2 +- .../EDocMLLMVerifyVATTool.Codeunit.al | 2 +- .../EDocumentMLLMHandlerV2.Codeunit.al | 3 ++- 9 files changed, 21 insertions(+), 10 deletions(-) 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..b33023a5c1 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 @@ -133,6 +133,7 @@ codeunit 6232 "E-Doc. MLLM Schema Helper" NestedObj: JsonObject; NestedObj2: JsonObject; LineNumber: Integer; + DiscountPct: Decimal; begin TempLine.DeleteAll(); @@ -164,9 +165,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" := TempLine."Unit Price" * TempLine.Quantity * DiscountPct / 100; + end; + end; TempLine.Insert(); end; @@ -208,12 +216,14 @@ codeunit 6232 "E-Doc. MLLM Schema Helper" local procedure GetDecimal(JsonObj: JsonObject; PropertyName: Text; var FieldValue: Decimal) var JsonToken: JsonToken; + DecimalValue: Decimal; 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; end; local procedure GetNestedObject(JsonObj: JsonObject; PropertyName: Text; var NestedObj: JsonObject): Boolean diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al index d61ac30766..c547fce48c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6238 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" +codeunit 6114 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al index 6c2d003caf..d153f1b4d2 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6235 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" +codeunit 6111 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al index 4419ee8e79..57b6449af7 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6243 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" +codeunit 6154 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al index d9614e9b02..65efd60292 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6239 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" +codeunit 6153 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al index dd3ee0ef1a..356ce03e65 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al @@ -4,7 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; -codeunit 6233 "E-Doc. MLLM Verify Tools" +codeunit 6110 "E-Doc. MLLM Verify Tools" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al index 860e0503a4..9f8a3b3fba 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6236 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" +codeunit 6112 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al index 308037983d..2c6eb9c3ce 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6237 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" +codeunit 6113 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 6c3514fec6..922adc8c97 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -11,9 +11,10 @@ using Microsoft.eServices.EDocument.Processing.Interfaces; using System.AI; using System.Azure.KeyVault; using System.Telemetry; +using System.Text; using System.Utilities; -codeunit 6244 "E-Document MLLM Handler V2" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType +codeunit 6405 "E-Document MLLM Handler V2" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType { Access = Internal; InherentEntitlements = X; From 2e6656a693031e4f68231645601bae975a32b556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 08:37:04 +0200 Subject: [PATCH 58/85] Renumber MLLM V2 codeunits to 6311-6318 (within idRanges [6311..6331]) --- .../EDocMLLMVerifyDatesTool.Codeunit.al | 2 +- .../EDocMLLMVerifyLineMathTool.Codeunit.al | 2 +- .../EDocMLLMVerifyRangesTool.Codeunit.al | 2 +- .../EDocMLLMVerifyRequiredTool.Codeunit.al | 2 +- .../StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al | 2 +- .../EDocMLLMVerifyTotalsTool.Codeunit.al | 2 +- .../EDocMLLMVerifyVATTool.Codeunit.al | 2 +- .../EDocumentMLLMHandlerV2.Codeunit.al | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al index c547fce48c..f72d5d5139 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6114 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" +codeunit 6315 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al index d153f1b4d2..473c9f821e 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6111 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" +codeunit 6312 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al index 57b6449af7..86de69f4e6 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6154 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" +codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al index 65efd60292..50f044c3e1 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6153 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" +codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al index 356ce03e65..f31998114b 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al @@ -4,7 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; -codeunit 6110 "E-Doc. MLLM Verify Tools" +codeunit 6311 "E-Doc. MLLM Verify Tools" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al index 9f8a3b3fba..dc5fa2a740 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6112 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" +codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al index 2c6eb9c3ce..eb9c50214c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6113 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" +codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 922adc8c97..b2fc985a9f 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -14,7 +14,7 @@ using System.Telemetry; using System.Text; using System.Utilities; -codeunit 6405 "E-Document MLLM Handler V2" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType +codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType { Access = Internal; InherentEntitlements = X; From 0bcbd49f7add52405b1130f42f54e5de3cf3432a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 08:52:49 +0200 Subject: [PATCH 59/85] Fix AA0215 file renames, remove unused Telemetry/FeatureNameLbl/GetInvoiceLineCount --- ...l.Codeunit.al => EDocMLLMVLDatesTool.Codeunit.al} | 0 ...ol.Codeunit.al => EDocMLLMVLMathTool.Codeunit.al} | 0 ....Codeunit.al => EDocMLLMVLRangesTool.Codeunit.al} | 0 ...odeunit.al => EDocMLLMVLRequiredTool.Codeunit.al} | 0 ....Codeunit.al => EDocMLLMVLTotalsTool.Codeunit.al} | 0 ...ool.Codeunit.al => EDocMLLMVLVATTool.Codeunit.al} | 0 .../EDocumentMLLMHandlerV2.Codeunit.al | 12 ------------ 7 files changed, 12 deletions(-) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocMLLMVerifyDatesTool.Codeunit.al => EDocMLLMVLDatesTool.Codeunit.al} (100%) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocMLLMVerifyLineMathTool.Codeunit.al => EDocMLLMVLMathTool.Codeunit.al} (100%) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocMLLMVerifyRangesTool.Codeunit.al => EDocMLLMVLRangesTool.Codeunit.al} (100%) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocMLLMVerifyRequiredTool.Codeunit.al => EDocMLLMVLRequiredTool.Codeunit.al} (100%) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocMLLMVerifyTotalsTool.Codeunit.al => EDocMLLMVLTotalsTool.Codeunit.al} (100%) rename src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/{EDocMLLMVerifyVATTool.Codeunit.al => EDocMLLMVLVATTool.Codeunit.al} (100%) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyDatesTool.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyLineMathTool.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRangesTool.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyRequiredTool.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTotalsTool.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyVATTool.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index b2fc985a9f..59cf51441a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -10,7 +10,6 @@ using Microsoft.eServices.EDocument.Processing.Import.Purchase; using Microsoft.eServices.EDocument.Processing.Interfaces; using System.AI; using System.Azure.KeyVault; -using System.Telemetry; using System.Text; using System.Utilities; @@ -21,10 +20,8 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen InherentPermissions = X; var - Telemetry: Codeunit Telemetry; StructuredData: Text; FileFormat: Enum "E-Doc. File Format"; - FeatureNameLbl: Label 'E-Document MLLM Extraction V2', Locked = true; FileDataLbl: Label 'data:application/pdf;base64,%1', Locked = true; SystemPromptV2ResourceTok: Label 'Prompts/EDocMLLMExtractionV2-SystemPrompt.md', Locked = true; UserPromptLbl: Label 'Extract invoice data into this UBL JSON structure: %1. \n\nExtract ONLY visible values. Return JSON only. %2', Locked = true; @@ -205,15 +202,6 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen exit(ADIHandler.StructureReceivedEDocument(EDocumentDataStorage)); end; - local procedure GetInvoiceLineCount(ResponseJson: JsonObject): Integer - var - LinesToken: JsonToken; - begin - if ResponseJson.Get('invoice_line', LinesToken) then - if LinesToken.IsArray() then - exit(LinesToken.AsArray().Count()); - exit(0); - end; procedure RegisterCopilotCapabilityIfNeeded() var From b10838e3101098df1fe6920e8dbb43ea97abcd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 09:23:00 +0200 Subject: [PATCH 60/85] Remove [NonDebuggable] from all System Application AI module procedures --- .../AzureDIImpl.Codeunit.al | 3 +- .../AOAIAuthorization.Codeunit.al | 16 +------- .../src/Azure OpenAI/AzureOpenAI.Codeunit.al | 15 +------ .../Azure OpenAI/AzureOpenAIImpl.Codeunit.al | 21 +--------- .../AOAIChatComplParamsImpl.Codeunit.al | 3 +- .../AOAIChatCompletionParams.Codeunit.al | 3 +- .../AOAIChatMessages.Codeunit.al | 30 +------------- .../AOAIChatMessagesImpl.Codeunit.al | 39 +------------------ .../Chat Completion/AOAIToolsImpl.Codeunit.al | 7 +--- .../AOAIUserMessageImpl.Codeunit.al | 4 -- .../AOAITextCompletionParams.Codeunit.al | 3 +- .../AOAITextCompletionParamsImpl.Codeunit.al | 3 +- .../Copilot/CopilotCapabilityImpl.Codeunit.al | 3 +- 13 files changed, 12 insertions(+), 138 deletions(-) diff --git a/src/System Application/App/AI/src/Azure AI Document Intelligence/AzureDIImpl.Codeunit.al b/src/System Application/App/AI/src/Azure AI Document Intelligence/AzureDIImpl.Codeunit.al index 455373199d..18b950da4e 100644 --- a/src/System Application/App/AI/src/Azure AI Document Intelligence/AzureDIImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure AI Document Intelligence/AzureDIImpl.Codeunit.al @@ -89,7 +89,6 @@ codeunit 7779 "Azure DI Impl." implements "AI Service Name" end; [TryFunction] - [NonDebuggable] local procedure SendRequest(Base64Data: Text; ModelType: Enum "ADI Model Type"; CallerModuleInfo: ModuleInfo; var Result: Text) var ALCopilotFunctions: DotNet ALCopilotFunctions; @@ -147,4 +146,4 @@ codeunit 7779 "Azure DI Impl." implements "AI Service Name" exit(AzureAiDocumentIntelligenceTxt); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al index 40386b243f..4e9d049461 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al @@ -16,19 +16,13 @@ codeunit 7767 "AOAI Authorization" InherentPermissions = X; var - [NonDebuggable] Endpoint: Text; - [NonDebuggable] Deployment: Text; - [NonDebuggable] ApiKey: SecretText; - [NonDebuggable] ManagedResourceDeployment: Text; - [NonDebuggable] AOAIAccountName: Text; ResourceUtilization: Enum "AOAI Resource Utilization"; - [NonDebuggable] procedure IsConfigured(CallerModule: ModuleInfo): Boolean var CurrentModule: ModuleInfo; @@ -49,7 +43,6 @@ codeunit 7767 "AOAI Authorization" end; #if not CLEAN26 - [NonDebuggable] procedure SetMicrosoftManagedAuthorization(NewEndpoint: Text; NewDeployment: Text; NewApiKey: SecretText; NewManagedResourceDeployment: Text) begin ClearVariables(); @@ -62,7 +55,6 @@ codeunit 7767 "AOAI Authorization" end; #endif - [NonDebuggable] procedure SetMicrosoftManagedAuthorization(NewManagedResourceDeployment: Text) begin ClearVariables(); @@ -71,7 +63,6 @@ codeunit 7767 "AOAI Authorization" ManagedResourceDeployment := NewManagedResourceDeployment; end; - [NonDebuggable] procedure SetSelfManagedAuthorization(NewEndpoint: Text; NewDeployment: Text; NewApiKey: SecretText) begin ClearVariables(); @@ -82,7 +73,6 @@ codeunit 7767 "AOAI Authorization" ApiKey := NewApiKey; end; - [NonDebuggable] procedure SetFirstPartyAuthorization(NewDeployment: Text) begin ClearVariables(); @@ -91,25 +81,21 @@ codeunit 7767 "AOAI Authorization" ManagedResourceDeployment := NewDeployment; end; - [NonDebuggable] procedure GetEndpoint(): SecretText begin exit(Endpoint); end; - [NonDebuggable] procedure GetDeployment(): SecretText begin exit(Deployment); end; - [NonDebuggable] procedure GetApiKey(): SecretText begin exit(ApiKey); end; - [NonDebuggable] procedure GetManagedResourceDeployment(): SecretText begin exit(ManagedResourceDeployment); @@ -129,4 +115,4 @@ codeunit 7767 "AOAI Authorization" Clear(ManagedResourceDeployment); Clear(ResourceUtilization); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al index d8ef80a9f7..3138248018 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al @@ -100,7 +100,6 @@ codeunit 7771 "Azure OpenAI" /// Endpoint would look like: https://resource-name.openai.azure.com/ /// Deployment would look like: gpt-35-turbo-16k /// - [NonDebuggable] [Obsolete('Using Managed AI resources now requires different input parameters. Use the other overload for SetManagedResourceAuthorization instead.', '26.0')] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; Endpoint: Text; Deployment: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) begin @@ -117,7 +116,6 @@ codeunit 7771 "Azure OpenAI" /// Name of the Azure Open AI resource like "MyAzureOpenAIResource" /// The API key to access the Azure Open AI resource. This is used only for verification of access, not for actual Azure Open AI calls. /// The managed deployment to use for the model type. - [NonDebuggable] [Obsolete('Using Managed AI resources now requires only Model Type & Deployment Text as input parameters. Use the other overload for SetManagedResourceAuthorization instead. AOAIAccountName & ApiKey are ignored in the current version of SetManagedResourceAuthorization.', '28.0')] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; AOAIAccountName: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) begin @@ -131,7 +129,6 @@ codeunit 7771 "Azure OpenAI" /// /// The model type to set authorization for. /// The managed deployment to use for the model type. - [NonDebuggable] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; ManagedResourceDeployment: Text) begin AzureOpenAIImpl.SetManagedResourceAuthorization(ModelType, ManagedResourceDeployment); @@ -147,7 +144,6 @@ codeunit 7771 "Azure OpenAI" /// Endpoint would look like: https://resource-name.openai.azure.com/ /// Deployment would look like: gpt-35-turbo-16k /// - [NonDebuggable] procedure SetAuthorization(ModelType: Enum "AOAI Model Type"; Endpoint: Text; Deployment: Text; ApiKey: SecretText) begin AzureOpenAIImpl.SetAuthorization(ModelType, Endpoint, Deployment, ApiKey); @@ -159,7 +155,6 @@ codeunit 7771 "Azure OpenAI" /// The model type to set authorization for. /// The deployment name to use for the model type. /// Deployment would look like: gpt-35-turbo-16k - [NonDebuggable] procedure SetAuthorization(ModelType: Enum "AOAI Model Type"; Deployment: Text) begin AzureOpenAIImpl.SetAuthorization(ModelType, Deployment); @@ -173,7 +168,6 @@ codeunit 7771 "Azure OpenAI" /// The generated completion. /// The completion authentication was not configured. /// The completion generation failed with status code %1. - [NonDebuggable] procedure GenerateTextCompletion(Prompt: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"): Text var CallerModuleInfo: ModuleInfo; @@ -191,7 +185,6 @@ codeunit 7771 "Azure OpenAI" /// The generated completion. /// The completion authentication was not configured. /// The completion generation failed with status code %1. - [NonDebuggable] procedure GenerateTextCompletion(Prompt: SecretText; AOAICompletionParams: Codeunit "AOAI Text Completion Params"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"): Text var CallerModuleInfo: ModuleInfo; @@ -209,7 +202,6 @@ codeunit 7771 "Azure OpenAI" /// The generated completion. /// The completion authentication was not configured. /// The completion generation failed with status code %1. - [NonDebuggable] procedure GenerateTextCompletion(Metaprompt: SecretText; Prompt: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"): Text var CallerModuleInfo: ModuleInfo; @@ -228,7 +220,6 @@ codeunit 7771 "Azure OpenAI" /// The generated completion. /// The completion authentication was not configured. /// The completion generation failed with status code %1. - [NonDebuggable] procedure GenerateTextCompletion(Metaprompt: SecretText; Prompt: SecretText; AOAICompletionParams: Codeunit "AOAI Text Completion Params"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"): Text var CallerModuleInfo: ModuleInfo; @@ -246,7 +237,6 @@ codeunit 7771 "Azure OpenAI" /// The generated list of embeddings. /// The embedding authentication was not configured. /// The embedding generation failed with status code %1. - [NonDebuggable] procedure GenerateEmbeddings(Input: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"): List of [Decimal] var CallerModuleInfo: ModuleInfo; @@ -263,7 +253,6 @@ codeunit 7771 "Azure OpenAI" /// The generated chat completion. /// The chat completion authentication was not configured. /// The chat completion generation failed with status code %1. - [NonDebuggable] procedure GenerateChatCompletion(var AOAIChatMessages: Codeunit "AOAI Chat Messages"; var AOAIOperationResponse: Codeunit "AOAI Operation Response") var CallerModuleInfo: ModuleInfo; @@ -281,7 +270,6 @@ codeunit 7771 "Azure OpenAI" /// The generated chat completion. /// The chat completion authentication was not configured. /// The chat completion generation failed with status code %1. - [NonDebuggable] procedure GenerateChatCompletion(var AOAIChatMessages: Codeunit "AOAI Chat Messages"; AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params"; var AOAIOperationResponse: Codeunit "AOAI Operation Response") var CallerModuleInfo: ModuleInfo; @@ -294,7 +282,6 @@ codeunit 7771 "Azure OpenAI" /// Sets the copilot capability that the API is running for. /// /// The copilot capability to set. - [NonDebuggable] procedure SetCopilotCapability(CopilotCapability: Enum "Copilot Capability") var CallerModuleInfo: ModuleInfo; @@ -304,4 +291,4 @@ codeunit 7771 "Azure OpenAI" end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al index 71731dc20f..97ac761794 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al @@ -83,7 +83,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(IsEnabled(Capability, CallerModuleInfo) and IsAuthorizationConfigured(ModelType, CallerModuleInfo)); end; - [NonDebuggable] procedure SetAuthorization(ModelType: Enum "AOAI Model Type"; Deployment: Text) begin case ModelType of @@ -98,7 +97,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" end; end; - [NonDebuggable] procedure SetAuthorization(ModelType: Enum "AOAI Model Type"; Endpoint: Text; Deployment: Text; ApiKey: SecretText) begin case ModelType of @@ -114,7 +112,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" end; #if not CLEAN26 - [NonDebuggable] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; Endpoint: Text; Deployment: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) begin case ModelType of @@ -132,7 +129,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" #if not CLEAN28 #pragma warning disable AA0137 - [NonDebuggable] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; AOAIAccountName: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) begin SetManagedResourceAuthorization(ModelType, ManagedResourceDeployment); @@ -140,7 +136,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" #pragma warning restore AA0137 #endif - [NonDebuggable] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; ManagedResourceDeployment: Text) begin case ModelType of @@ -155,7 +150,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" end; end; - [NonDebuggable] procedure GenerateTextCompletion(Prompt: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo): Text var AOAICompletionParameters: Codeunit "AOAI Text Completion Params"; @@ -163,13 +157,11 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(GenerateTextCompletion(GetTextMetaprompt(), Prompt, AOAICompletionParameters, AOAIOperationResponse, CallerModuleInfo)); end; - [NonDebuggable] procedure GenerateTextCompletion(Prompt: SecretText; AOAICompletionParameters: Codeunit "AOAI Text Completion Params"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo) Result: Text begin exit(GenerateTextCompletion(GetTextMetaprompt(), Prompt, AOAICompletionParameters, AOAIOperationResponse, CallerModuleInfo)); end; - [NonDebuggable] procedure GenerateTextCompletion(Metaprompt: SecretText; Prompt: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo): Text var AOAICompletionParameters: Codeunit "AOAI Text Completion Params"; @@ -177,7 +169,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(GenerateTextCompletion(Metaprompt, Prompt, AOAICompletionParameters, AOAIOperationResponse, CallerModuleInfo)); end; - [NonDebuggable] procedure GenerateTextCompletion(Metaprompt: SecretText; Prompt: SecretText; AOAICompletionParameters: Codeunit "AOAI Text Completion Params"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo) Result: Text var CustomDimensions: Dictionary of [Text, Text]; @@ -212,7 +203,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" Result := AOAIOperationResponse.GetResult(); end; - [NonDebuggable] procedure GenerateEmbeddings(Input: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo): List of [Decimal] var CustomDimensions: Dictionary of [Text, Text]; @@ -239,7 +229,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(ProcessEmbeddingResponse(AOAIOperationResponse)); end; - [NonDebuggable] local procedure ProcessEmbeddingResponse(AOAIOperationResponse: Codeunit "AOAI Operation Response") Result: List of [Decimal] var Response: JsonObject; @@ -255,7 +244,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" end; end; - [NonDebuggable] procedure GenerateChatCompletion(var ChatMessages: Codeunit "AOAI Chat Messages"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo) var AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params"; @@ -263,7 +251,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" GenerateChatCompletion(ChatMessages, AOAIChatCompletionParams, AOAIOperationResponse, CallerModuleInfo); end; - [NonDebuggable] procedure GenerateChatCompletion(var ChatMessages: Codeunit "AOAI Chat Messages"; AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo) var AOAIPolicyParams: Codeunit "AOAI Policy Params"; @@ -328,7 +315,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" GenerateChatCompletion(ChatMessages, AOAIChatCompletionParams, AOAIOperationResponse, CallerModuleInfo); end; - [NonDebuggable] local procedure CheckJsonModeCompatibility(Payload: JsonObject) var ResponseFormatToken: JsonToken; @@ -354,7 +340,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" Error(MessagesMustContainJsonWordWhenResponseFormatIsJsonErr); end; - [NonDebuggable] [TryFunction] local procedure ProcessChatCompletionResponse(var ChatMessages: Codeunit "AOAI Chat Messages"; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo) var @@ -470,7 +455,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" end; [TryFunction] - [NonDebuggable] local procedure SendRequest(ModelType: Enum "AOAI Model Type"; AOAIAuthorization: Codeunit "AOAI Authorization"; Payload: Text; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo; AzureOpenAIPolicy: Text) var CopilotNotifications: Codeunit "Copilot Notifications"; @@ -549,7 +533,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" Error(AuthenticationNotConfiguredErr); end; - [NonDebuggable] procedure RemoveProhibitedCharacters(Prompt: Text) Result: Text begin Result := Prompt.Replace('<|end>', ''); @@ -563,7 +546,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(Result); end; - [NonDebuggable] internal procedure GetTextMetaprompt() Metaprompt: SecretText; var AzureKeyVault: Codeunit "Azure Key Vault"; @@ -583,7 +565,6 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" Metaprompt := KVSecret; end; - [NonDebuggable] local procedure CheckTextCompletionMetaprompt(Metaprompt: SecretText; CustomDimensions: Dictionary of [Text, Text]) var ModuleInfo: ModuleInfo; @@ -663,4 +644,4 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(true); end; end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatComplParamsImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatComplParamsImpl.Codeunit.al index 978a505362..009841f0a0 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatComplParamsImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatComplParamsImpl.Codeunit.al @@ -146,7 +146,6 @@ codeunit 7762 "AOAI Chat Compl Params Impl" FrequencyPenalty := NewFrequencyPenalty; end; - [NonDebuggable] procedure AddChatCompletionsParametersToPayload(var Payload: JsonObject) begin if GetMaxTokens() > 0 then @@ -181,4 +180,4 @@ codeunit 7762 "AOAI Chat Compl Params Impl" DefaultPolicyParams.InitializeDefaults(); SetAOAIPolicyParams(DefaultPolicyParams); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatCompletionParams.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatCompletionParams.Codeunit.al index 31db11e7cd..9719d628a7 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatCompletionParams.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatCompletionParams.Codeunit.al @@ -149,9 +149,8 @@ codeunit 7761 "AOAI Chat Completion Params" /// Adds the Chat Completion parameters to the payload. /// /// JsonObject to add parameters to. - [NonDebuggable] internal procedure AddChatCompletionsParametersToPayload(var Payload: JsonObject) begin AOAIChatComplParamsImpl.AddChatCompletionsParametersToPayload(Payload); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessages.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessages.Codeunit.al index 8c9b3b1f21..f4d1e2b51b 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessages.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessages.Codeunit.al @@ -22,7 +22,6 @@ codeunit 7763 "AOAI Chat Messages" /// Sets the system message which is always at the top of the chat messages history provided to the model. /// /// The primary system message. - [NonDebuggable] procedure SetPrimarySystemMessage(Message: SecretText) begin AOAIChatMessagesImpl.SetPrimarySystemMessage(Message); @@ -32,7 +31,6 @@ codeunit 7763 "AOAI Chat Messages" /// Adds a system message to the chat messages history. /// /// The message to add. - [NonDebuggable] procedure AddSystemMessage(NewMessage: Text) begin AOAIChatMessagesImpl.AddSystemMessage(NewMessage); @@ -42,7 +40,6 @@ codeunit 7763 "AOAI Chat Messages" /// Adds a user message to the chat messages history. /// /// The message to add. - [NonDebuggable] procedure AddUserMessage(NewMessage: Text) begin AOAIChatMessagesImpl.AddUserMessage(NewMessage); @@ -53,7 +50,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// The message to add. /// The name of the user. - [NonDebuggable] procedure AddUserMessage(NewMessage: Text; NewName: Text[2048]) begin AOAIChatMessagesImpl.AddUserMessage(NewMessage, NewName); @@ -63,7 +59,6 @@ codeunit 7763 "AOAI Chat Messages" /// Adds a user message with structured content parts to the chat messages history. /// /// The user message builder containing content parts (e.g. text, file). - [NonDebuggable] procedure AddUserMessage(AOAIUserMessage: Codeunit "AOAI User Message") begin AOAIChatMessagesImpl.AddUserMessage(AOAIUserMessage); @@ -74,7 +69,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// The user message builder containing content parts (e.g. text, file). /// The name of the user. - [NonDebuggable] procedure AddUserMessage(AOAIUserMessage: Codeunit "AOAI User Message"; NewName: Text[2048]) begin AOAIChatMessagesImpl.AddUserMessage(AOAIUserMessage, NewName); @@ -84,7 +78,6 @@ codeunit 7763 "AOAI Chat Messages" /// Adds a assistant message to the chat messages history. /// /// The message to add. - [NonDebuggable] procedure AddAssistantMessage(NewMessage: Text) begin AOAIChatMessagesImpl.AddAssistantMessage(NewMessage); @@ -94,7 +87,6 @@ codeunit 7763 "AOAI Chat Messages" /// Adds a assistant message containing the tool calls returned from the model to the chat messages history. /// /// The tool calls to add. - [NonDebuggable] internal procedure AddToolCalls(ToolCalls: JsonArray) begin AOAIChatMessagesImpl.AddToolCalls(ToolCalls); @@ -106,7 +98,6 @@ codeunit 7763 "AOAI Chat Messages" /// The id of the tool call. /// The name of the called function. /// The result of the tool call. - [NonDebuggable] procedure AddToolMessage(ToolCallId: Text; FunctionName: Text; FunctionResult: Text) begin AOAIChatMessagesImpl.AddToolMessage(ToolCallId, FunctionName, FunctionResult); @@ -120,7 +111,6 @@ codeunit 7763 "AOAI Chat Messages" /// The new role. /// The new name. /// Message id does not exist. - [NonDebuggable] procedure ModifyMessage(Id: Integer; NewMessage: Text; NewRole: Enum "AOAI Chat Roles"; NewName: Text[2048]) begin AOAIChatMessagesImpl.ModifyMessage(Id, NewMessage, NewRole, NewName); @@ -131,7 +121,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// Id of the message. /// Message id does not exist. - [NonDebuggable] procedure DeleteMessage(Id: Integer) begin AOAIChatMessagesImpl.DeleteMessage(Id); @@ -141,7 +130,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the history of chat messages. /// /// List of chat messages. - [NonDebuggable] procedure GetHistory(): List of [Text] begin exit(AOAIChatMessagesImpl.GetHistory()); @@ -151,7 +139,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the history names of chat messages. /// /// List of names of chat messages. - [NonDebuggable] procedure GetHistoryNames(): List of [Text[2048]] begin exit(AOAIChatMessagesImpl.GetHistoryNames()); @@ -161,7 +148,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the history roles of chat messages. /// /// List of roles of chat messages. - [NonDebuggable] procedure GetHistoryRoles(): List of [Enum "AOAI Chat Roles"] begin exit(AOAIChatMessagesImpl.GetHistoryRoles()); @@ -171,7 +157,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the last chat message. /// /// The last chat message. - [NonDebuggable] procedure GetLastMessage(): Text begin exit(AOAIChatMessagesImpl.GetLastMessage()); @@ -181,7 +166,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the last chat message role. /// /// The last chat message role. - [NonDebuggable] procedure GetLastRole(): Enum "AOAI Chat Roles" begin exit(AOAIChatMessagesImpl.GetLastRole()); @@ -191,7 +175,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the last chat message name. /// /// The last chat message name. - [NonDebuggable] procedure GetLastName(): Text[2048] begin exit(AOAIChatMessagesImpl.GetLastName()); @@ -201,7 +184,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets a copy of the last chat message tool calls array. /// /// The last tool calls array. - [NonDebuggable] procedure GetLastToolCalls(): JsonArray begin exit(AOAIChatMessagesImpl.GetLastToolCalls()); @@ -212,7 +194,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// The new length. /// History length must be greater than 0. - [NonDebuggable] procedure SetHistoryLength(NewLength: Integer) begin AOAIChatMessagesImpl.SetHistoryLength(NewLength); @@ -225,7 +206,6 @@ codeunit 7763 "AOAI Chat Messages" /// The number tokens used by all other messages. /// History of messages in a JsonArray. /// Use this after adding messages, to construct a json array of all messages. - [NonDebuggable] internal procedure AssembleHistory(var SystemMessageTokenCount: Integer; var MessagesTokenCount: Integer): JsonArray begin exit(AOAIChatMessagesImpl.PrepareHistory(SystemMessageTokenCount, MessagesTokenCount)); @@ -234,7 +214,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// Gets the number of tokens used by the primary system messages and all other messages. /// - [NonDebuggable] procedure GetHistoryTokenCount(): Integer begin exit(AOAIChatMessagesImpl.GetHistoryTokenCount()); @@ -312,7 +291,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// The Tool choice parameter. /// See more details here: https://go.microsoft.com/fwlink/?linkid=2254538 - [NonDebuggable] procedure SetToolChoice(ToolChoice: Text) begin AOAIToolsImpl.SetToolChoice(ToolChoice); @@ -323,7 +301,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// The function name parameter. /// See more details here: https://go.microsoft.com/fwlink/?linkid=2254538 - [NonDebuggable] procedure SetFunctionAsToolChoice(FunctionName: Text) begin AOAIToolsImpl.SetFunctionAsToolChoice(FunctionName); @@ -334,7 +311,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// The function codeunit. /// See more details here: https://go.microsoft.com/fwlink/?linkid=2254538 - [NonDebuggable] procedure SetFunctionAsToolChoice(Function: Interface "AOAI Function") begin AOAIToolsImpl.SetFunctionAsToolChoice(Function); @@ -344,7 +320,6 @@ codeunit 7763 "AOAI Chat Messages" /// Gets the Tool choice parameter. /// /// The Tool choice parameter. - [NonDebuggable] procedure GetToolChoice(): Text begin exit(AOAIToolsImpl.GetToolChoice()); @@ -372,7 +347,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// Input text with XPIA Detection tags added. /// Please make sure to configure the AOAI policy to support XPIA detection before using this method. Also, remember to add the 'Input text with XPIA detection tags added' to a message. - [NonDebuggable] procedure AddXPIADetectionTags(var Input: Text) begin AOAIChatMessagesImpl.AddXPIADetectionTags(Input); @@ -383,7 +357,6 @@ codeunit 7763 "AOAI Chat Messages" /// /// Tools in a JsonArray. /// Use this after adding Tools, to construct a json array of all Tools. - [NonDebuggable] internal procedure AssembleTools(): JsonArray begin exit(AOAIToolsImpl.PrepareTools()); @@ -393,10 +366,9 @@ codeunit 7763 "AOAI Chat Messages" /// Checks if the current chat messages history is compatible with the deployment model /// /// True if compatible, false otherwise. - [NonDebuggable] internal procedure CheckCompatibilityWithModel(Deployment: SecretText) begin AOAIChatMessagesImpl.CheckCompatibilityWithModel(Deployment); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al index afafb15579..7feb72e543 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al @@ -21,17 +21,11 @@ codeunit 7764 "AOAI Chat Messages Impl" Initialized: Boolean; HistoryLength: Integer; SystemMessage: SecretText; - [NonDebuggable] History: List of [Text]; - [NonDebuggable] HistoryRoles: List of [Enum "AOAI Chat Roles"]; - [NonDebuggable] HistoryNames: List of [Text[2048]]; - [NonDebuggable] HistoryToolCallIds: List of [Text]; - [NonDebuggable] HistoryToolCalls: List of [JsonArray]; - [NonDebuggable] HistoryUserMessages: List of [Codeunit "AOAI User Message"]; IsSystemMessageSet: Boolean; MessageIdDoesNotExistErr: Label 'Message id does not exist.'; @@ -46,63 +40,54 @@ codeunit 7764 "AOAI Chat Messages Impl" IncompatibleModelErr: Label 'The current message history contains file content which is only compatible with the GPT-4.1 mini preview deployment.'; - [NonDebuggable] procedure SetPrimarySystemMessage(NewPrimaryMessage: SecretText) begin SystemMessage := NewPrimaryMessage; IsSystemMessageSet := true; end; - [NonDebuggable] procedure AddSystemMessage(NewMessage: Text) begin Initialize(); AddMessage(NewMessage, '', Enum::"AOAI Chat Roles"::System); end; - [NonDebuggable] procedure AddUserMessage(NewMessage: Text) begin Initialize(); AddMessage(NewMessage, '', Enum::"AOAI Chat Roles"::User); end; - [NonDebuggable] procedure AddUserMessage(NewMessage: Text; NewName: Text[2048]) begin Initialize(); AddMessage(NewMessage, NewName, Enum::"AOAI Chat Roles"::User); end; - [NonDebuggable] procedure AddUserMessage(AOAIUserMessage: Codeunit "AOAI User Message") begin Initialize(); AddMessage(AOAIUserMessage, '', Enum::"AOAI Chat Roles"::User); end; - [NonDebuggable] procedure AddUserMessage(AOAIUserMessage: Codeunit "AOAI User Message"; NewName: Text[2048]) begin Initialize(); AddMessage(AOAIUserMessage, NewName, Enum::"AOAI Chat Roles"::User); end; - [NonDebuggable] procedure AddAssistantMessage(NewMessage: Text) begin Initialize(); AddMessage(NewMessage, '', Enum::"AOAI Chat Roles"::Assistant); end; - [NonDebuggable] procedure AddToolCalls(ToolCalls: JsonArray) begin Initialize(); AddMessage(ToolCalls); end; - [NonDebuggable] procedure AddToolMessage(ToolCallId: Text; FunctionName: Text; FunctionResult: Text) var FunctionNameTruncated: Text[2048]; @@ -115,7 +100,6 @@ codeunit 7764 "AOAI Chat Messages Impl" end; - [NonDebuggable] procedure ModifyMessage(Id: Integer; NewMessage: Text; NewRole: Enum "AOAI Chat Roles"; NewName: Text[2048]) begin if (Id < 1) or (Id > History.Count) then @@ -126,7 +110,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryNames.Set(Id, NewName); end; - [NonDebuggable] procedure DeleteMessage(Id: Integer) begin if (Id < 1) or (Id > History.Count) then @@ -139,49 +122,41 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryUserMessages.RemoveAt(Id); end; - [NonDebuggable] procedure GetHistory(): List of [Text] begin exit(History); end; - [NonDebuggable] procedure GetHistoryNames(): List of [Text[2048]] begin exit(HistoryNames); end; - [NonDebuggable] procedure GetHistoryRoles(): List of [Enum "AOAI Chat Roles"] begin exit(HistoryRoles); end; - [NonDebuggable] procedure GetHistoryToolCallIds(): List of [Text] begin exit(HistoryToolCallIds); end; - [NonDebuggable] procedure GetLastMessage() LastMessage: Text begin History.Get(History.Count, LastMessage); end; - [NonDebuggable] procedure GetLastRole() LastRole: Enum "AOAI Chat Roles" begin HistoryRoles.Get(HistoryRoles.Count, LastRole); end; - [NonDebuggable] procedure GetLastName() LastName: Text[2048] begin HistoryNames.Get(HistoryNames.Count, LastName); end; - [NonDebuggable] procedure GetLastToolCalls() LastToolCalls: JsonArray var LastToolCallsRef: JsonArray; @@ -190,13 +165,11 @@ codeunit 7764 "AOAI Chat Messages Impl" LastToolCalls := LastToolCallsRef.Clone().AsArray(); // avoid modifications to the chat message end; - [NonDebuggable] procedure GetLastToolCallId() LastToolCall: Text begin HistoryToolCallIds.Get(HistoryToolCallIds.Count, LastToolCall); end; - [NonDebuggable] procedure SetHistoryLength(NewHistoryLength: Integer) begin if NewHistoryLength < 1 then @@ -205,7 +178,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryLength := NewHistoryLength; end; - [NonDebuggable] procedure GetHistoryTokenCount(): Integer var SystemMessageTokenCount: Integer; @@ -215,7 +187,6 @@ codeunit 7764 "AOAI Chat Messages Impl" exit(SystemMessageTokenCount + MessagesTokenCount); end; - [NonDebuggable] procedure PrepareHistory(var SystemMessageTokenCount: Integer; var MessagesTokenCount: Integer) HistoryResult: JsonArray var AOAIUserMessage: Codeunit "AOAI User Message"; @@ -321,7 +292,6 @@ codeunit 7764 "AOAI Chat Messages Impl" Initialized := true; end; - [NonDebuggable] local procedure PrepareMessage(WrapMessage: Boolean; MessageVariant: Variant): Variant var AzureOpenAIImpl: Codeunit "Azure OpenAI Impl"; @@ -373,7 +343,6 @@ codeunit 7764 "AOAI Chat Messages Impl" Error(WrongTypeErr); end; - [NonDebuggable] local procedure AddMessage(ToolCalls: JsonArray) var AOAIUserMessage: Codeunit "AOAI User Message"; @@ -386,7 +355,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryUserMessages.Add(AOAIUserMessage); end; - [NonDebuggable] local procedure AddMessage(NewMessage: Text; NewName: Text[2048]; NewRole: Enum "AOAI Chat Roles") var AOAIUserMessage: Codeunit "AOAI User Message"; @@ -400,7 +368,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryUserMessages.Add(AOAIUserMessage); end; - [NonDebuggable] local procedure AddMessage(NewMessage: Text; NewName: Text[2048]; NewToolCallId: Text; NewRole: Enum "AOAI Chat Roles") var AOAIUserMessage: Codeunit "AOAI User Message"; @@ -414,7 +381,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryUserMessages.Add(AOAIUserMessage); end; - [NonDebuggable] local procedure AddMessage(AOAIUserMessage: Codeunit "AOAI User Message"; NewName: Text[2048]; NewRole: Enum "AOAI Chat Roles") var ToolCalls: JsonArray; @@ -427,7 +393,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryUserMessages.Add(AOAIUserMessage); end; - [NonDebuggable] local procedure GetChatMetaprompt(var UsingMicrosoftMetaprompt: Boolean) Metaprompt: SecretText; var AzureKeyVault: Codeunit "Azure Key Vault"; @@ -449,7 +414,6 @@ codeunit 7764 "AOAI Chat Messages Impl" Metaprompt := KVSecret; end; - [NonDebuggable] local procedure CheckandAddMetaprompt(var UsingMicrosoftMetaprompt: Boolean) begin if SystemMessage.Unwrap().Trim() = '' then begin @@ -461,7 +425,6 @@ codeunit 7764 "AOAI Chat Messages Impl" end; end; - [NonDebuggable] procedure CheckCompatibilityWithModel(Deployment: SecretText) var AOAIDeployments: Codeunit "AOAI Deployments"; @@ -477,4 +440,4 @@ codeunit 7764 "AOAI Chat Messages Impl" end; end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIToolsImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIToolsImpl.Codeunit.al index bf08eda527..58559307f0 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIToolsImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIToolsImpl.Codeunit.al @@ -17,7 +17,6 @@ codeunit 7778 "AOAI Tools Impl" FunctionNames: Dictionary of [Text, Integer]; Initialized: Boolean; AddToolToPayload: Boolean; - [NonDebuggable] ToolChoice: Text; ToolObjectInvalidErr: Label '%1 object does not contain %2 property.', Comment = '%1 is the object name and %2 is the property that is missing.'; ToolTypeErr: Label 'Tool type must be of function type.'; @@ -82,7 +81,6 @@ codeunit 7778 "AOAI Tools Impl" Clear(FunctionNames); end; - [NonDebuggable] procedure PrepareTools() ToolsResult: JsonArray var Counter: Integer; @@ -116,7 +114,6 @@ codeunit 7778 "AOAI Tools Impl" AddToolToPayload := AddToolsToPayload; end; - [NonDebuggable] procedure SetToolChoice(NewToolChoice: Text) begin Initialize(); @@ -142,7 +139,6 @@ codeunit 7778 "AOAI Tools Impl" ToolChoiceObject.WriteTo(ToolChoice); end; - [NonDebuggable] procedure GetToolChoice(): Text begin exit(ToolChoice); @@ -172,7 +168,6 @@ codeunit 7778 "AOAI Tools Impl" Initialized := true; end; - [NonDebuggable] local procedure ValidateTool(ToolObject: JsonObject): Boolean var AzureOpenAIImpl: Codeunit "Azure OpenAI Impl"; @@ -212,4 +207,4 @@ codeunit 7778 "AOAI Tools Impl" end; exit(true); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIUserMessageImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIUserMessageImpl.Codeunit.al index 92cd084937..5d8f1851e3 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIUserMessageImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIUserMessageImpl.Codeunit.al @@ -12,12 +12,10 @@ codeunit 7784 "AOAI User Message Impl" var CopilotCapabilityImpl: Codeunit "Copilot Capability Impl"; - [NonDebuggable] ContentParts: JsonArray; HasFileContent, HasTextContent : Boolean; NotMicrosoftPublisherErr: Label 'This functionality is only available to Microsoft published apps.'; - [NonDebuggable] procedure AddTextPart(TextContent: Text; CallerModuleInfo: ModuleInfo) var TextPartObject: JsonObject; @@ -30,7 +28,6 @@ codeunit 7784 "AOAI User Message Impl" HasTextContent := true; end; - [NonDebuggable] procedure AddFilePart(FileData: Text; CallerModuleInfo: ModuleInfo) var FilePartObject: JsonObject; @@ -45,7 +42,6 @@ codeunit 7784 "AOAI User Message Impl" HasFileContent := true; end; - [NonDebuggable] procedure GetContentParts(): JsonArray begin exit(ContentParts); diff --git a/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParams.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParams.Codeunit.al index 1dc5c4b30b..d4587bc74a 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParams.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParams.Codeunit.al @@ -135,9 +135,8 @@ codeunit 7765 "AOAI Text Completion Params" /// Add the completion parameters to the payload. /// /// The JsonObject payload to add the completion parameters to. - [NonDebuggable] internal procedure AddCompletionsParametersToPayload(var Payload: JsonObject) begin AOAITextCompletionParamsImpl.AddCompletionsParametersToPayload(Payload); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParamsImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParamsImpl.Codeunit.al index b3e5ba4aa6..aea173684b 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParamsImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Text Completion/AOAITextCompletionParamsImpl.Codeunit.al @@ -127,7 +127,6 @@ codeunit 7766 "AOAI TextCompletionParams Impl" FrequencyPenalty := NewFrequencyPenalty; end; - [NonDebuggable] procedure AddCompletionsParametersToPayload(var Payload: JsonObject) begin if GetMaxTokens() > 0 then @@ -152,4 +151,4 @@ codeunit 7766 "AOAI TextCompletionParams Impl" SetPresencePenalty(0); SetFrequencyPenalty(0); end; -} \ No newline at end of file +} diff --git a/src/System Application/App/AI/src/Copilot/CopilotCapabilityImpl.Codeunit.al b/src/System Application/App/AI/src/Copilot/CopilotCapabilityImpl.Codeunit.al index dacbc0e3e3..af30c1418c 100644 --- a/src/System Application/App/AI/src/Copilot/CopilotCapabilityImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Copilot/CopilotCapabilityImpl.Codeunit.al @@ -305,7 +305,6 @@ codeunit 7774 "Copilot Capability Impl" exit(CheckPrivacyNoticeState(Silent, Capability)); end; - [NonDebuggable] local procedure IsTenantAllowedToUseAOAI(): Boolean var EnvironmentInformation: Codeunit "Environment Information"; @@ -560,4 +559,4 @@ codeunit 7774 "Copilot Capability Impl" begin exit(EnvironmentInformation.IsProduction()); end; -} \ No newline at end of file +} From 6920aa4f37c5af1b8fd701059d1de4d586c368d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 09:26:43 +0200 Subject: [PATCH 61/85] Scope [NonDebuggable] to GetUserPromptText only; CallMLLMV2 is fully debuggable --- .../EDocumentMLLMHandlerV2.Codeunit.al | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 59cf51441a..12db38bfad 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -53,7 +53,6 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen exit(this); end; - [NonDebuggable] local procedure CallMLLMV2(EDocumentDataStorage: Record "E-Doc. Data Storage"): Text var Base64Convert: Codeunit "Base64 Convert"; @@ -101,7 +100,7 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen // User message: PDF + UBL schema + security clause AOAIUserMessage.AddFilePart(StrSubstNo(FileDataLbl, Base64Data)); - AOAIUserMessage.AddTextPart(SecretText.SecretStrSubstNo(UserPromptLbl, EDocMLLMSchemaHelper.GetDefaultSchema(), GetSecurityClause()).Unwrap()); + AOAIUserMessage.AddTextPart(GetUserPromptText(EDocMLLMSchemaHelper.GetDefaultSchema())); AOAIChatMessages.AddUserMessage(AOAIUserMessage); // Agentic dispatch loop @@ -123,12 +122,14 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen end; [NonDebuggable] - local procedure GetSecurityClause() Result: SecretText + local procedure GetUserPromptText(Schema: Text): Text var AzureKeyVault: Codeunit "Azure Key Vault"; + SecurityClause: SecretText; begin - if not AzureKeyVault.GetAzureKeyVaultSecret(SecurityPromptAKVKeyTok, Result) then + if not AzureKeyVault.GetAzureKeyVaultSecret(SecurityPromptAKVKeyTok, SecurityClause) then Error(DocumentNotProcessedErr); + exit(SecretText.SecretStrSubstNo(UserPromptLbl, Schema, SecurityClause).Unwrap()); end; local procedure IsInappropriateContentResponse(ResponseText: Text): Boolean From 0be0ddf830a427979bdab9e9a8997ad2c704c1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 09:30:12 +0200 Subject: [PATCH 62/85] Isolate .Unwrap() to tiny [NonDebuggable] UnwrapSecret helper; all other AI logic stays debuggable --- .../AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al | 12 +++++++++--- .../AOAIChatMessagesImpl.Codeunit.al | 13 +++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al index 97ac761794..dcb4b7a831 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al @@ -185,7 +185,7 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" CopilotCapabilityImpl.AddTelemetryCustomDimensions(CustomDimensions, CallerModuleInfo); CheckTextCompletionMetaprompt(Metaprompt, CustomDimensions); - UnwrappedPrompt := Metaprompt.Unwrap() + Prompt.Unwrap(); + UnwrappedPrompt := UnwrapSecret(Metaprompt) + UnwrapSecret(Prompt); UnwrappedPrompt := RemoveProhibitedCharacters(UnwrappedPrompt); AOAICompletionParameters.AddCompletionsParametersToPayload(Payload); @@ -215,7 +215,7 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" CopilotCapabilityImpl.CheckEnabled(CallerModuleInfo); CheckAuthorizationEnabled(EmbeddingsAOAIAuthorization, CallerModuleInfo); - Payload.Add('input', Input.Unwrap()); + Payload.Add('input', UnwrapSecret(Input)); Payload.WriteTo(PayloadText); CopilotCapabilityImpl.AddTelemetryCustomDimensions(CustomDimensions, CallerModuleInfo); @@ -569,7 +569,7 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" var ModuleInfo: ModuleInfo; begin - if Metaprompt.Unwrap().Trim() = '' then begin + if UnwrapSecret(Metaprompt).Trim() = '' then begin FeatureTelemetry.LogError('0000LO8', GetAzureOpenAICategory(), TelemetryGenerateTextCompletionLbl, EmptyMetapromptErr, '', Enum::"AL Telemetry Scope"::All, CustomDimensions); NavApp.GetCurrentModuleInfo(ModuleInfo); @@ -644,4 +644,10 @@ codeunit 7772 "Azure OpenAI Impl" implements "AI Service Name" exit(true); end; end; + + [NonDebuggable] + local procedure UnwrapSecret(Secret: SecretText): Text + begin + exit(Secret.Unwrap()); + end; } diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al index 7feb72e543..63a03552d4 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al @@ -209,9 +209,9 @@ codeunit 7764 "AOAI Chat Messages Impl" Initialize(); CheckandAddMetaprompt(UsingMicrosoftMetaprompt); - if SystemMessage.Unwrap() <> '' then begin + if UnwrapSecret(SystemMessage) <> '' then begin MessageJsonObject.Add('role', Format(Enum::"AOAI Chat Roles"::System)); - MessageJsonObject.Add('content', SystemMessage.Unwrap()); + MessageJsonObject.Add('content', UnwrapSecret(SystemMessage)); HistoryResult.Add(MessageJsonObject); SystemMessageTokenCount := AOAIToken.GetGPT4TokenCount(SystemMessage); @@ -416,7 +416,7 @@ codeunit 7764 "AOAI Chat Messages Impl" local procedure CheckandAddMetaprompt(var UsingMicrosoftMetaprompt: Boolean) begin - if SystemMessage.Unwrap().Trim() = '' then begin + if UnwrapSecret(SystemMessage).Trim() = '' then begin if IsSystemMessageSet then Telemetry.LogMessage('0000LO9', TelemetryMetapromptSetbutEmptyTxt, Verbosity::Normal, DataClassification::SystemMetadata) else @@ -435,9 +435,14 @@ codeunit 7764 "AOAI Chat Messages Impl" for Counter := 1 to HistoryUserMessages.Count() do begin HistoryUserMessages.Get(Counter, AOAIUserMessage); if AOAIUserMessage.HasFilePart() then - if Deployment.Unwrap() <> AOAIDeployments.GetGPT41MiniPreview() then + if UnwrapSecret(Deployment) <> AOAIDeployments.GetGPT41MiniPreview() then Error(IncompatibleModelErr); end; end; + [NonDebuggable] + local procedure UnwrapSecret(Secret: SecretText): Text + begin + exit(Secret.Unwrap()); + end; } From d0c7cee07f3b5a04db35270ddcae26d2ed5033ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 10:44:15 +0200 Subject: [PATCH 63/85] Fix agentic loop: remove redundant AppendFunctionResponsesToChatMessages (already called by SDK internally) --- .../EDocumentMLLMHandlerV2.Codeunit.al | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 12db38bfad..4944c9d109 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -114,7 +114,9 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen ToolCallCount += AOAIOperationResponse.GetFunctionResponses().Count(); if ToolCallCount > MaxToolCallsTok then Error(BudgetExhaustedErr, ToolCallCount); - AOAIOperationResponse.AppendFunctionResponsesToChatMessages(AOAIChatMessages); + // Tool results are already appended to AOAIChatMessages by GenerateChatCompletion + // internally (ProcessChatCompletionResponse calls AppendFunctionResponsesToChatMessages + // for "Invoke Tools Only" preference). We just loop to get the next model response. end; until not AOAIOperationResponse.IsFunctionCall(); From 3f4a524279111ef313f3377791afc80d1c158501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 10:59:10 +0200 Subject: [PATCH 64/85] Improve verify feedback: implied gross price hint + explicit correction reasoning in prompt --- .../Prompts/EDocMLLMExtractionV2-SystemPrompt.md | 8 +++++++- .../EDocMLLMVerifyTools.Codeunit.al | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index d11540a4bc..cf7d01546f 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -31,6 +31,12 @@ Call the verification tools on what you extracted: - verify_required_fields with vendor name, invoice number, line count - verify_ranges with all quantities, prices, VAT rates, and discount percentages -If a tool returns { "pass": false }, read its error message. It will tell you specifically what does not add up. Reconsider your Phase 1 analysis if needed — the error may reveal that you misidentified a column role or misread a discount structure. Correct and call the tools again. Only finalise when all tools return { "pass": true }. +If a tool returns { "pass": false }: +1. State out loud what the error tells you: which value is wrong and what the tool says it should be. +2. State which specific field in your extraction you are changing, and to what value, and why. +3. Output the corrected UBL JSON with ONLY that field changed. +4. Call the tools again. + +Do not silently re-extract the whole document. Change exactly what the error points to. Only finalise when all tools return { "pass": true }. Output ONLY valid JSON. No markdown, no explanation. diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al index f31998114b..065fbdd00f 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al @@ -13,7 +13,12 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" procedure VerifyLineMath(UnitPrice: Decimal; Quantity: Decimal; DiscountPct: Decimal; LineExtensionAmount: Decimal; var ErrorText: Text): Boolean var Expected: Decimal; - LineMathErrLbl: Label '%1 × %2 × (1 − %3/100) = %4, but line_extension_amount = %5. Re-check which price column is the gross (pre-discount) unit price.', Comment = '%1=UnitPrice, %2=Quantity, %3=DiscountPct, %4=Expected, %5=LineExtensionAmount'; + ImpliedGrossPrice: Decimal; + LineMathErrLbl: Label '%1 × %2 × (1 − %3/100) = %4, but line_extension_amount = %5. ' + + 'To fix: if discount_pct %3 is correct, the gross unit_price should be ≈ %6 (= %5 / (%2 × (1 − %3/100))). ' + + 'Look for a higher unit price column on the document and use that as price_amount. ' + + 'If the document shows chained discounts (e.g. two columns both showing 20%%), combine them: effective_discount = 1 − (1−d1/100) × (1−d2/100).', + Comment = '%1=UnitPrice, %2=Quantity, %3=DiscountPct, %4=Expected, %5=LineExtensionAmount, %6=ImpliedGrossPrice'; begin if LineExtensionAmount = 0 then exit(true); @@ -23,7 +28,12 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" if IsWithinTolerance(Expected, LineExtensionAmount) then exit(true); - ErrorText := StrSubstNo(LineMathErrLbl, UnitPrice, Quantity, DiscountPct, Expected, LineExtensionAmount); + if (Quantity <> 0) and (DiscountPct < 100) then + ImpliedGrossPrice := Round(LineExtensionAmount / (Quantity * (1 - DiscountPct / 100)), 0.001) + else + ImpliedGrossPrice := 0; + + ErrorText := StrSubstNo(LineMathErrLbl, UnitPrice, Quantity, DiscountPct, Round(Expected, 0.01), LineExtensionAmount, ImpliedGrossPrice); exit(false); end; From 20517683cf0665652d66a26be3285da97a217b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 11:05:01 +0200 Subject: [PATCH 65/85] Return Text from Execute() so Format() produces readable JSON for the model --- .../EDocMLLMVLDatesTool.Codeunit.al | 4 +- .../EDocMLLMVLMathTool.Codeunit.al | 39 +++++++++++++------ .../EDocMLLMVLRangesTool.Codeunit.al | 4 +- .../EDocMLLMVLRequiredTool.Codeunit.al | 4 +- .../EDocMLLMVLTotalsTool.Codeunit.al | 4 +- .../EDocMLLMVLVATTool.Codeunit.al | 4 +- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al index f72d5d5139..37ab4be51a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al @@ -44,6 +44,7 @@ codeunit 6315 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; ResultObj: JsonObject; ErrorText: Text; + ResultText: Text; IssueDate: Text; DueDate: Text; Token: JsonToken; @@ -56,6 +57,7 @@ codeunit 6315 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" ResultObj.Add('pass', false); ResultObj.Add('error', ErrorText); end; - exit(ResultObj); + ResultObj.WriteTo(ResultText); + exit(ResultText); end; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al index 473c9f821e..d3dc07eaa7 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6312 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" +codeunit 6339 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; @@ -26,20 +26,33 @@ codeunit 6312 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" PropObj: JsonObject; RequiredArr: JsonArray; begin - PropObj.Add('type', 'number'); PropObj.Add('description', 'Gross unit price before discounts'); - PropsObj.Add('unit_price', PropObj); Clear(PropObj); - PropObj.Add('type', 'number'); PropObj.Add('description', 'Quantity of units'); - PropsObj.Add('quantity', PropObj); Clear(PropObj); - PropObj.Add('type', 'number'); PropObj.Add('description', 'Combined discount percentage 0-100 (use 0 if no discount)'); - PropsObj.Add('discount_pct', PropObj); Clear(PropObj); - PropObj.Add('type', 'number'); PropObj.Add('description', 'line_extension_amount from the invoice'); + PropObj.Add('type', 'number'); + PropObj.Add('description', 'Gross unit price before discounts'); + PropsObj.Add('unit_price', PropObj); + Clear(PropObj); + PropObj.Add('type', 'number'); + PropObj.Add('description', 'Quantity of units'); + PropsObj.Add('quantity', PropObj); + Clear(PropObj); + PropObj.Add('type', 'number'); + PropObj.Add('description', 'Combined discount percentage 0-100 (use 0 if no discount)'); + PropsObj.Add('discount_pct', PropObj); + Clear(PropObj); + PropObj.Add('type', 'number'); + PropObj.Add('description', 'line_extension_amount from the invoice'); PropsObj.Add('line_extension_amount', PropObj); - RequiredArr.Add('unit_price'); RequiredArr.Add('quantity'); RequiredArr.Add('discount_pct'); RequiredArr.Add('line_extension_amount'); - ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + RequiredArr.Add('unit_price'); + RequiredArr.Add('quantity'); + RequiredArr.Add('discount_pct'); + RequiredArr.Add('line_extension_amount'); + ParamsObj.Add('type', 'object'); + ParamsObj.Add('properties', PropsObj); + ParamsObj.Add('required', RequiredArr); FunctionObj.Add('name', GetName()); FunctionObj.Add('description', 'Verify that gross_unit_price × quantity × (1 − discount_pct/100) matches line_extension_amount within 1%. Call once per invoice line.'); FunctionObj.Add('parameters', ParamsObj); - ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + ToolObj.Add('type', 'function'); + ToolObj.Add('function', FunctionObj); exit(ToolObj); end; @@ -48,6 +61,7 @@ codeunit 6312 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; ResultObj: JsonObject; ErrorText: Text; + ResultText: Text; UnitPrice: Decimal; Quantity: Decimal; DiscountPct: Decimal; @@ -63,7 +77,8 @@ codeunit 6312 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" ResultObj.Add('pass', false); ResultObj.Add('error', ErrorText); end; - exit(ResultObj); + ResultObj.WriteTo(ResultText); + exit(ResultText); end; local procedure GetDecimalArg(Arguments: JsonObject; PropertyName: Text; var Value: Decimal) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al index 86de69f4e6..20e1615dcf 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al @@ -53,6 +53,7 @@ codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; ResultObj: JsonObject; ErrorText: Text; + ResultText: Text; Quantities: List of [Decimal]; Prices: List of [Decimal]; VATRates: List of [Decimal]; @@ -68,7 +69,8 @@ codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" ResultObj.Add('pass', false); ResultObj.Add('error', ErrorText); end; - exit(ResultObj); + ResultObj.WriteTo(ResultText); + exit(ResultText); end; local procedure ParseDecimalArray(Arguments: JsonObject; PropertyName: Text; var Values: List of [Decimal]) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al index 50f044c3e1..21bd2ea6b9 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al @@ -46,6 +46,7 @@ codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; ResultObj: JsonObject; ErrorText: Text; + ResultText: Text; VendorName: Text; InvoiceNo: Text; LineCount: Integer; @@ -63,6 +64,7 @@ codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" ResultObj.Add('pass', false); ResultObj.Add('error', ErrorText); end; - exit(ResultObj); + ResultObj.WriteTo(ResultText); + exit(ResultText); end; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al index dc5fa2a740..12daa03156 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al @@ -46,6 +46,7 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; ResultObj: JsonObject; ErrorText: Text; + ResultText: Text; LineAmountsToken: JsonToken; LineAmountsArray: JsonArray; LineToken: JsonToken; @@ -69,6 +70,7 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" ResultObj.Add('pass', false); ResultObj.Add('error', ErrorText); end; - exit(ResultObj); + ResultObj.WriteTo(ResultText); + exit(ResultText); end; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al index eb9c50214c..231a618151 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al @@ -46,6 +46,7 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; ResultObj: JsonObject; ErrorText: Text; + ResultText: Text; TaxExcl: Decimal; VATRate: Decimal; TaxAmt: Decimal; @@ -64,6 +65,7 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" ResultObj.Add('pass', false); ResultObj.Add('error', ErrorText); end; - exit(ResultObj); + ResultObj.WriteTo(ResultText); + exit(ResultText); end; } From ef1e3475fd84d75eb346e6b77a30e43be8f1667c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 11:11:40 +0200 Subject: [PATCH 66/85] Generalise VerifyLineMath error: remove invoice-specific hints, keep implied price --- .../EDocMLLMVerifyTools.Codeunit.al | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al index 065fbdd00f..956bdc5b89 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al @@ -14,11 +14,7 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" var Expected: Decimal; ImpliedGrossPrice: Decimal; - LineMathErrLbl: Label '%1 × %2 × (1 − %3/100) = %4, but line_extension_amount = %5. ' + - 'To fix: if discount_pct %3 is correct, the gross unit_price should be ≈ %6 (= %5 / (%2 × (1 − %3/100))). ' + - 'Look for a higher unit price column on the document and use that as price_amount. ' + - 'If the document shows chained discounts (e.g. two columns both showing 20%%), combine them: effective_discount = 1 − (1−d1/100) × (1−d2/100).', - Comment = '%1=UnitPrice, %2=Quantity, %3=DiscountPct, %4=Expected, %5=LineExtensionAmount, %6=ImpliedGrossPrice'; + LineMathErrLbl: Label '%1 × %2 × (1 − %3/100) = %4, but line_extension_amount = %5. If discount_pct = %3 is correct, unit_price should be ≈ %6. Verify that unit_price is the pre-discount price and that discount_pct is the combined effective discount.', Comment = '%1=UnitPrice, %2=Quantity, %3=DiscountPct, %4=Expected, %5=LineExtensionAmount, %6=ImpliedGrossPrice'; begin if LineExtensionAmount = 0 then exit(true); From 06e4acfe046a3a4a24224555d73b1800baba44d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 11:30:06 +0200 Subject: [PATCH 67/85] Prefer allowance_charge.percent over amount.value for line discount calculation --- .../EDocMLLMSchemaHelper.Codeunit.al | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 b33023a5c1..eb4b521c3c 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 @@ -166,13 +166,13 @@ codeunit 6232 "E-Doc. MLLM Schema Helper" GetDecimal(LineObj, 'line_extension_amount', TempLine."Sub Total"); 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" := TempLine."Unit Price" * TempLine.Quantity * DiscountPct / 100; + DiscountPct := 0; + GetDecimal(NestedObj, 'percent', DiscountPct); + if DiscountPct <> 0 then + TempLine."Total Discount" := TempLine."Unit Price" * TempLine.Quantity * DiscountPct / 100 + else begin + if GetNestedObject(NestedObj, 'amount', NestedObj2) then + GetDecimal(NestedObj2, 'value', TempLine."Total Discount"); end; end; From 93419634787e484ea7c5fe641974bbfe5f9c3eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 11:38:48 +0200 Subject: [PATCH 68/85] Cap line discount at subtotal instead of erroring; allows draft page to open with MLLM-overestimated discounts --- .../Import/Purchase/EDocPurchaseDraftSubform.Page.al | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al index a49e0ab6aa..4f49042704 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al @@ -386,17 +386,12 @@ page 6183 "E-Doc. Purchase Draft Subform" TotalEDocPurchaseLine: Record "E-Document Purchase Line"; EDocumentImportHelper: Codeunit "E-Document Import Helper"; LineSubtotal: Decimal; - DiscountExceedsSubtotalErr: Label 'Discount should not exceed the subtotal of the line'; begin LineSubtotal := Rec.Quantity * Rec."Unit Price"; - LineAmount := LineSubtotal - Rec."Total Discount"; - if LineSubtotal = 0 then begin - if Rec."Total Discount" > 0 then - Error(DiscountExceedsSubtotalErr) - end + if Rec."Total Discount" > LineSubtotal then + LineAmount := 0 else - if Rec."Total Discount" / LineSubtotal > 1 then - Error(DiscountExceedsSubtotalErr); + LineAmount := LineSubtotal - Rec."Total Discount"; if not UpdateParentRecord then exit; if not EDocumentPurchaseHeader.Get(Rec."E-Document Entry No.") then From b46b6eb9c9910becd90ea901f8e6ad8e644cc2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:50:17 +0200 Subject: [PATCH 69/85] Add EDocMLLMExtractionPlan state codeunit and plan tools (analyze_invoice, get_checklist) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMExtractionPlan.Codeunit.al | 88 +++++++++++++++++++ .../EDocMLLMPlanAnalyzeTool.Codeunit.al | 85 ++++++++++++++++++ .../EDocMLLMPlanStatusTool.Codeunit.al | 38 ++++++++ 3 files changed, 211 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanAnalyzeTool.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al new file mode 100644 index 0000000000..4a68c2d05f --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +codeunit 6319 "E-Doc. MLLM Extraction Plan" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + SingleInstance = true; + + var + ItemStatus: Dictionary of [Text, Text]; + ItemErrors: Dictionary of [Text, Text]; + AnalysisPayload: Text; + FixedItemsTok: Label 'verify_invoice_totals,verify_vat,verify_dates,verify_required_fields,verify_ranges', Locked = true; + + procedure Reset() + begin + Clear(ItemStatus); + Clear(ItemErrors); + AnalysisPayload := ''; + end; + + procedure InitializePlan(LineIds: List of [Text]; Analysis: Text) + var + LineId: Text; + FixedIds: List of [Text]; + FixedId: Text; + begin + Reset(); + AnalysisPayload := Analysis; + ItemStatus.Add('analyze_invoice', 'passed'); + foreach LineId in LineIds do + ItemStatus.Add('verify_line_' + LineId, 'pending'); + FixedIds.AddRange(FixedItemsTok.Split(',')); + foreach FixedId in FixedIds do + ItemStatus.Add(FixedId, 'pending'); + end; + + procedure MarkItem(ItemId: Text; Passed: Boolean; ErrorMsg: Text) + var + Status: Text; + begin + if Passed then Status := 'passed' else Status := 'failed'; + if ItemStatus.ContainsKey(ItemId) then + ItemStatus.Set(ItemId, Status) + else + ItemStatus.Add(ItemId, Status); + if not Passed then begin + if ItemErrors.ContainsKey(ItemId) then + ItemErrors.Set(ItemId, ErrorMsg) + else + ItemErrors.Add(ItemId, ErrorMsg); + end else + if ItemErrors.ContainsKey(ItemId) then + ItemErrors.Remove(ItemId); + end; + + procedure GetChecklistJson(): Text + var + ResultArr: JsonArray; + ItemObj: JsonObject; + ItemId, Status, ErrorMsg : Text; + ResultText: Text; + begin + foreach ItemId in ItemStatus.Keys() do begin + Clear(ItemObj); + ItemStatus.Get(ItemId, Status); + ItemObj.Add('id', ItemId); + ItemObj.Add('status', Status); + if (Status = 'failed') and ItemErrors.ContainsKey(ItemId) then begin + ItemErrors.Get(ItemId, ErrorMsg); + ItemObj.Add('error', ErrorMsg); + end; + ResultArr.Add(ItemObj); + end; + ResultArr.WriteTo(ResultText); + exit(ResultText); + end; + + procedure IsInitialized(): Boolean + begin + exit(ItemStatus.Count() > 0); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanAnalyzeTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanAnalyzeTool.Codeunit.al new file mode 100644 index 0000000000..339bde2339 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanAnalyzeTool.Codeunit.al @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6320 "E-Doc. MLLM Plan Analyze Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('analyze_invoice'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj, FunctionObj, ParamsObj, PropsObj, PropObj : JsonObject; + ItemsObj: JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); PropObj.Add('description', 'Document type (invoice, credit_memo, etc.)'); + PropsObj.Add('doc_type', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Document language code (e.g. sv, en, de)'); + PropsObj.Add('language', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Decimal separator used in this document (. or ,)'); + PropsObj.Add('decimal_sep', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Thousands separator used in this document (space, . or ,)'); + PropsObj.Add('thousands_sep', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Description of each line item column and its role (e.g. which column is gross price, discount %, net price, quantity, line total)'); + PropsObj.Add('line_columns', PropObj); Clear(PropObj); + PropObj.Add('type', 'string'); PropObj.Add('description', 'Any other observations about the document layout or unusual features'); + PropsObj.Add('notes', PropObj); Clear(PropObj); + ItemsObj.Add('type', 'string'); + PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'IDs of all invoice lines visible on the document (e.g. ["1","2","3"])'); + PropsObj.Add('line_ids', PropObj); + RequiredArr.Add('doc_type'); RequiredArr.Add('language'); RequiredArr.Add('decimal_sep'); + RequiredArr.Add('thousands_sep'); RequiredArr.Add('line_columns'); RequiredArr.Add('line_ids'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'CALL THIS FIRST before extracting any values. Records your structural analysis of the document and initializes the verification checklist. Returns the full checklist of items to verify.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; + ResultObj: JsonObject; + LineIdsToken: JsonToken; + LineIdToken: JsonToken; + LineIds: List of [Text]; + AnalysisObj: JsonObject; + Token: JsonToken; + AnalysisText, ResultText : Text; + DocType, Language, DecimalSep, ThousandsSep, LineColumns, Notes : Text; + begin + if Arguments.Get('doc_type', Token) then DocType := Token.AsValue().AsText(); + if Arguments.Get('language', Token) then Language := Token.AsValue().AsText(); + if Arguments.Get('decimal_sep', Token) then DecimalSep := Token.AsValue().AsText(); + if Arguments.Get('thousands_sep', Token) then ThousandsSep := Token.AsValue().AsText(); + if Arguments.Get('line_columns', Token) then LineColumns := Token.AsValue().AsText(); + if Arguments.Get('notes', Token) then Notes := Token.AsValue().AsText(); + if Arguments.Get('line_ids', LineIdsToken) then + foreach LineIdToken in LineIdsToken.AsArray() do + LineIds.Add(LineIdToken.AsValue().AsText()); + + AnalysisObj.Add('doc_type', DocType); AnalysisObj.Add('language', Language); + AnalysisObj.Add('decimal_sep', DecimalSep); AnalysisObj.Add('thousands_sep', ThousandsSep); + AnalysisObj.Add('line_columns', LineColumns); AnalysisObj.Add('notes', Notes); + AnalysisObj.WriteTo(AnalysisText); + + ExtractionPlan.InitializePlan(LineIds, AnalysisText); + + ResultObj.Add('status', 'analysis_recorded'); + ResultObj.Add('checklist', ExtractionPlan.GetChecklistJson()); + ResultObj.WriteTo(ResultText); + exit(ResultText); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al new file mode 100644 index 0000000000..e690fe9904 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6321 "E-Doc. MLLM Plan Status Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('get_checklist'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj, FunctionObj, ParamsObj : JsonObject; + begin + ParamsObj.Add('type', 'object'); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Returns the current verification checklist showing status (pending/passed/failed) for each item. Call this to see what verifications remain before finalising.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; + begin + exit(ExtractionPlan.GetChecklistJson()); + end; +} From 4133a2b160bf8fd4b8baec2bd29b40044717b380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:50:28 +0200 Subject: [PATCH 70/85] Wire verify tools to auto-mark extraction plan; register plan tools in handler Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMVLDatesTool.Codeunit.al | 7 ++++- .../EDocMLLMVLMathTool.Codeunit.al | 26 ++++++++++++++----- .../EDocMLLMVLRangesTool.Codeunit.al | 7 ++++- .../EDocMLLMVLRequiredTool.Codeunit.al | 7 ++++- .../EDocMLLMVLTotalsTool.Codeunit.al | 7 ++++- .../EDocMLLMVLVATTool.Codeunit.al | 7 ++++- .../EDocumentMLLMHandlerV2.Codeunit.al | 7 +++++ 7 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al index 37ab4be51a..8c5ab9302a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al @@ -42,16 +42,21 @@ codeunit 6315 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; IssueDate: Text; DueDate: Text; Token: JsonToken; + Passed: Boolean; begin if Arguments.Get('issue_date', Token) then IssueDate := Token.AsValue().AsText(); if Arguments.Get('due_date', Token) then DueDate := Token.AsValue().AsText(); - if VerifyTools.VerifyDates(IssueDate, DueDate, ErrorText) then + Passed := VerifyTools.VerifyDates(IssueDate, DueDate, ErrorText); + if ExtractionPlan.IsInitialized() then + ExtractionPlan.MarkItem('verify_dates', Passed, ErrorText); + if Passed then ResultObj.Add('pass', true) else begin ResultObj.Add('pass', false); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al index d3dc07eaa7..a7794d46d3 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al @@ -26,6 +26,10 @@ codeunit 6339 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" PropObj: JsonObject; RequiredArr: JsonArray; begin + PropObj.Add('type', 'string'); + PropObj.Add('description', 'The id field of the invoice line being verified'); + PropsObj.Add('line_id', PropObj); + Clear(PropObj); PropObj.Add('type', 'number'); PropObj.Add('description', 'Gross unit price before discounts'); PropsObj.Add('unit_price', PropObj); @@ -41,6 +45,7 @@ codeunit 6339 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" PropObj.Add('type', 'number'); PropObj.Add('description', 'line_extension_amount from the invoice'); PropsObj.Add('line_extension_amount', PropObj); + RequiredArr.Add('line_id'); RequiredArr.Add('unit_price'); RequiredArr.Add('quantity'); RequiredArr.Add('discount_pct'); @@ -59,19 +64,26 @@ codeunit 6339 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; - ErrorText: Text; - ResultText: Text; - UnitPrice: Decimal; - Quantity: Decimal; - DiscountPct: Decimal; - LineExtAmt: Decimal; + ErrorText, ResultText, LineId : Text; + UnitPrice, Quantity, DiscountPct, LineExtAmt : Decimal; + Token: JsonToken; + Passed: Boolean; begin GetDecimalArg(Arguments, 'unit_price', UnitPrice); GetDecimalArg(Arguments, 'quantity', Quantity); GetDecimalArg(Arguments, 'discount_pct', DiscountPct); GetDecimalArg(Arguments, 'line_extension_amount', LineExtAmt); - if VerifyTools.VerifyLineMath(UnitPrice, Quantity, DiscountPct, LineExtAmt, ErrorText) then + if Arguments.Get('line_id', Token) then + LineId := Token.AsValue().AsText(); + + Passed := VerifyTools.VerifyLineMath(UnitPrice, Quantity, DiscountPct, LineExtAmt, ErrorText); + + if ExtractionPlan.IsInitialized() then + ExtractionPlan.MarkItem('verify_line_' + LineId, Passed, ErrorText); + + if Passed then ResultObj.Add('pass', true) else begin ResultObj.Add('pass', false); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al index 20e1615dcf..b5b9ae72e5 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al @@ -51,6 +51,7 @@ codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -58,12 +59,16 @@ codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" Prices: List of [Decimal]; VATRates: List of [Decimal]; DiscountPcts: List of [Decimal]; + Passed: Boolean; begin ParseDecimalArray(Arguments, 'quantities', Quantities); ParseDecimalArray(Arguments, 'prices', Prices); ParseDecimalArray(Arguments, 'vat_rates', VATRates); ParseDecimalArray(Arguments, 'discount_pcts', DiscountPcts); - if VerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText) then + Passed := VerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText); + if ExtractionPlan.IsInitialized() then + ExtractionPlan.MarkItem('verify_ranges', Passed, ErrorText); + if Passed then ResultObj.Add('pass', true) else begin ResultObj.Add('pass', false); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al index 21bd2ea6b9..eac30e7947 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al @@ -44,6 +44,7 @@ codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -52,13 +53,17 @@ codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" LineCount: Integer; Token: JsonToken; DecimalValue: Decimal; + Passed: Boolean; begin if Arguments.Get('vendor_name', Token) then VendorName := Token.AsValue().AsText(); if Arguments.Get('invoice_no', Token) then InvoiceNo := Token.AsValue().AsText(); if Arguments.Get('line_count', Token) then if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then LineCount := Round(DecimalValue, 1); - if VerifyTools.VerifyRequiredFields(VendorName, InvoiceNo, LineCount, ErrorText) then + Passed := VerifyTools.VerifyRequiredFields(VendorName, InvoiceNo, LineCount, ErrorText); + if ExtractionPlan.IsInitialized() then + ExtractionPlan.MarkItem('verify_required_fields', Passed, ErrorText); + if Passed then ResultObj.Add('pass', true) else begin ResultObj.Add('pass', false); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al index 12daa03156..860cecb5fb 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al @@ -44,6 +44,7 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -54,6 +55,7 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" TaxExclusiveAmount: Decimal; DecimalValue: Decimal; Token: JsonToken; + Passed: Boolean; begin if Arguments.Get('line_amounts', LineAmountsToken) then begin LineAmountsArray := LineAmountsToken.AsArray(); @@ -64,7 +66,10 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" if Arguments.Get('tax_exclusive_amount', Token) then if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExclusiveAmount := DecimalValue; - if VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, ErrorText) then + Passed := VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, ErrorText); + if ExtractionPlan.IsInitialized() then + ExtractionPlan.MarkItem('verify_invoice_totals', Passed, ErrorText); + if Passed then ResultObj.Add('pass', true) else begin ResultObj.Add('pass', false); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al index 231a618151..23cf6ae4b5 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al @@ -44,6 +44,7 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -52,6 +53,7 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" TaxAmt: Decimal; Token: JsonToken; DecimalValue: Decimal; + Passed: Boolean; begin if Arguments.Get('tax_exclusive_amount', Token) then if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExcl := DecimalValue; @@ -59,7 +61,10 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then VATRate := DecimalValue; if Arguments.Get('tax_amount', Token) then if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxAmt := DecimalValue; - if VerifyTools.VerifyVAT(TaxExcl, VATRate, TaxAmt, ErrorText) then + Passed := VerifyTools.VerifyVAT(TaxExcl, VATRate, TaxAmt, ErrorText); + if ExtractionPlan.IsInitialized() then + ExtractionPlan.MarkItem('verify_vat', Passed, ErrorText); + if Passed then ResultObj.Add('pass', true) else begin ResultObj.Add('pass', false); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 4944c9d109..5886747134 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -69,11 +69,16 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen VerifyDatesTool: Codeunit "E-Doc. MLLM VL Dates Tool"; VerifyRequiredTool: Codeunit "E-Doc. MLLM VL Required Tool"; VerifyRangesTool: Codeunit "E-Doc. MLLM VL Ranges Tool"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; + PlanAnalyzeTool: Codeunit "E-Doc. MLLM Plan Analyze Tool"; + PlanStatusTool: Codeunit "E-Doc. MLLM Plan Status Tool"; FromTempBlob: Codeunit "Temp Blob"; InStream: InStream; Base64Data: Text; ToolCallCount: Integer; begin + ExtractionPlan.Reset(); + // Load PDF as base64 FromTempBlob := EDocumentDataStorage.GetTempBlob(); FromTempBlob.CreateInStream(InStream, TextEncoding::UTF8); @@ -96,6 +101,8 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen AOAIChatMessages.AddTool(VerifyDatesTool); AOAIChatMessages.AddTool(VerifyRequiredTool); AOAIChatMessages.AddTool(VerifyRangesTool); + AOAIChatMessages.AddTool(PlanAnalyzeTool); + AOAIChatMessages.AddTool(PlanStatusTool); AOAIChatMessages.SetToolChoice('auto'); // User message: PDF + UBL schema + security clause From f6411aca1229260ec28d1b0435bca104b0b0c403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:50:42 +0200 Subject: [PATCH 71/85] Update V2 system prompt: enforce analyze_invoice first, checklist-driven Phase 3 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMExtractionV2-SystemPrompt.md | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index cf7d01546f..3fb18b8ebc 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -1,15 +1,13 @@ You are an invoice data extraction agent with access to verification tools. -PHASE 1 — UNDERSTAND THE DOCUMENT: -Before extracting any values, reason through the document's structure out loud. Cover: -- What type of document is this and in what language? -- What number format does this document use? (decimal separator, thousands separator — these vary by country) -- What columns appear in the line item table? For each column, what does it represent? Some invoices show only a unit price; others show a gross price, one or more discount columns, and a net price. Some discounts are percentages, others are monetary amounts. Some apply sequentially. Describe exactly what you see. -- Where are the header fields (supplier, buyer, invoice number, dates)? -- Where is the totals section? -- Is there anything unusual about this invoice's layout? +PHASE 1 — UNDERSTAND AND RECORD: +Call `analyze_invoice` FIRST. Pass your structural analysis of the document: +- doc_type, language, decimal_sep, thousands_sep +- line_columns: describe each column in the line table and its role +- line_ids: the id values of all invoice lines you see +- notes: anything unusual -Your analysis determines how you extract. Two invoices from different vendors may look completely different — your job is to understand each one on its own terms. +This call initialises your verification checklist. You will receive the full list of items to verify. PHASE 2 — EXTRACT FROM THE REGIONS YOU IDENTIFIED: Use your analysis from Phase 1 to extract values. Do not sweep left-to-right across the full text. Extract from the specific regions and columns you identified. @@ -23,20 +21,20 @@ For everything else — how to represent the price, how to represent discounts, Output valid UBL JSON matching the schema provided. PHASE 3 — VERIFY YOUR OWN OUTPUT: -Call the verification tools on what you extracted: -- verify_line_math for each invoice line -- verify_invoice_totals with all line amounts -- verify_vat for the tax total -- verify_dates with issue_date and due_date -- verify_required_fields with vendor name, invoice number, line count -- verify_ranges with all quantities, prices, VAT rates, and discount percentages +Work through the checklist returned by analyze_invoice. For each pending item call the matching tool: +- verify_line_math(line_id, unit_price, quantity, discount_pct, line_extension_amount) — once per line +- verify_invoice_totals(line_amounts[], tax_exclusive_amount) +- verify_vat(tax_exclusive_amount, vat_rate, tax_amount) +- verify_dates(issue_date, due_date) +- verify_required_fields(vendor_name, invoice_no, line_count) +- verify_ranges(quantities[], prices[], vat_rates[], discount_pcts[]) + +Call get_checklist() at any time to see what remains. Only finalise when all items show "passed". If a tool returns { "pass": false }: -1. State out loud what the error tells you: which value is wrong and what the tool says it should be. -2. State which specific field in your extraction you are changing, and to what value, and why. +1. State out loud what the error tells you: which value is wrong and what it should be. +2. State which specific field you are changing, to what value, and why. 3. Output the corrected UBL JSON with ONLY that field changed. -4. Call the tools again. - -Do not silently re-extract the whole document. Change exactly what the error points to. Only finalise when all tools return { "pass": true }. +4. Re-call the verify tool for that item. Output ONLY valid JSON. No markdown, no explanation. From 8ff221a51ed5af7a72e0bd00970d5b0fb7bd0041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:54:16 +0200 Subject: [PATCH 72/85] Prompt: make checklist the explicit driver of Phase 3, not just guidance --- .../EDocMLLMExtractionV2-SystemPrompt.md | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index 3fb18b8ebc..8bce9c8c6e 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -21,20 +21,24 @@ For everything else — how to represent the price, how to represent discounts, Output valid UBL JSON matching the schema provided. PHASE 3 — VERIFY YOUR OWN OUTPUT: -Work through the checklist returned by analyze_invoice. For each pending item call the matching tool: -- verify_line_math(line_id, unit_price, quantity, discount_pct, line_extension_amount) — once per line -- verify_invoice_totals(line_amounts[], tax_exclusive_amount) -- verify_vat(tax_exclusive_amount, vat_rate, tax_amount) -- verify_dates(issue_date, due_date) -- verify_required_fields(vendor_name, invoice_no, line_count) -- verify_ranges(quantities[], prices[], vat_rates[], discount_pcts[]) - -Call get_checklist() at any time to see what remains. Only finalise when all items show "passed". - -If a tool returns { "pass": false }: +The checklist is your source of truth. Follow it strictly: + +1. Call get_checklist() to see all pending items. +2. For each item with status "pending", call the matching verify tool: + - verify_line_math(line_id, unit_price, quantity, discount_pct, line_extension_amount) — for each verify_line_* item + - verify_invoice_totals(line_amounts[], tax_exclusive_amount) — for verify_invoice_totals + - verify_vat(tax_exclusive_amount, vat_rate, tax_amount) — for verify_vat + - verify_dates(issue_date, due_date) — for verify_dates + - verify_required_fields(vendor_name, invoice_no, line_count) — for verify_required_fields + - verify_ranges(quantities[], prices[], vat_rates[], discount_pcts[]) — for verify_ranges +3. After working through the pending items, call get_checklist() again. +4. If any items are still "pending" or "failed", repeat from step 2. +5. Only output the final UBL JSON when get_checklist() shows ALL items as "passed". + +If a verify tool returns { "pass": false }: 1. State out loud what the error tells you: which value is wrong and what it should be. 2. State which specific field you are changing, to what value, and why. 3. Output the corrected UBL JSON with ONLY that field changed. -4. Re-call the verify tool for that item. +4. Re-call the verify tool for that item, then call get_checklist() to confirm. Output ONLY valid JSON. No markdown, no explanation. From f92141b85864dd68f9d4a2a32d5acc1e20066ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:59:39 +0200 Subject: [PATCH 73/85] Add mark_item tool: model explicitly tracks checklist state --- .../EDocMLLMPlanMarkTool.Codeunit.al | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanMarkTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanMarkTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanMarkTool.Codeunit.al new file mode 100644 index 0000000000..4d04fc50ba --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanMarkTool.Codeunit.al @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6344 "E-Doc. MLLM Plan Mark Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('mark_item'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj, FunctionObj, ParamsObj, PropsObj, PropObj : JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); + PropObj.Add('description', 'The checklist item id to mark (e.g. "verify_line_1", "verify_invoice_totals")'); + PropsObj.Add('item_id', PropObj); + Clear(PropObj); + PropObj.Add('type', 'boolean'); + PropObj.Add('description', 'true if the verification passed, false if it failed'); + PropsObj.Add('passed', PropObj); + Clear(PropObj); + PropObj.Add('type', 'string'); + PropObj.Add('description', 'Error message if passed=false, empty string if passed=true'); + PropsObj.Add('error', PropObj); + RequiredArr.Add('item_id'); + RequiredArr.Add('passed'); + RequiredArr.Add('error'); + ParamsObj.Add('type', 'object'); + ParamsObj.Add('properties', PropsObj); + ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Record the result of a verification on the checklist. Call this after every verify tool call to mark the item as passed or failed.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); + ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; + ResultObj: JsonObject; + Token: JsonToken; + ItemId, ErrorMsg, ResultText : Text; + Passed: Boolean; + begin + if Arguments.Get('item_id', Token) then ItemId := Token.AsValue().AsText(); + if Arguments.Get('passed', Token) then Passed := Token.AsValue().AsBoolean(); + if Arguments.Get('error', Token) then ErrorMsg := Token.AsValue().AsText(); + + ExtractionPlan.MarkItem(ItemId, Passed, ErrorMsg); + + ResultObj.Add('item_id', ItemId); + if Passed then + ResultObj.Add('status', 'passed') + else + ResultObj.Add('status', 'failed'); + ResultObj.WriteTo(ResultText); + exit(ResultText); + end; +} From f5921956e3329c50513c1749a5289637926120df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:59:49 +0200 Subject: [PATCH 74/85] Remove auto-marking from verify tools: they are now pure validators --- .../EDocMLLMVLDatesTool.Codeunit.al | 3 --- .../StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al | 4 ---- .../EDocMLLMVLRangesTool.Codeunit.al | 3 --- .../EDocMLLMVLRequiredTool.Codeunit.al | 3 --- .../EDocMLLMVLTotalsTool.Codeunit.al | 3 --- .../StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al | 3 --- 6 files changed, 19 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al index 8c5ab9302a..15688ee0ee 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLDatesTool.Codeunit.al @@ -42,7 +42,6 @@ codeunit 6315 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; - ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -54,8 +53,6 @@ codeunit 6315 "E-Doc. MLLM VL Dates Tool" implements "AOAI Function" if Arguments.Get('issue_date', Token) then IssueDate := Token.AsValue().AsText(); if Arguments.Get('due_date', Token) then DueDate := Token.AsValue().AsText(); Passed := VerifyTools.VerifyDates(IssueDate, DueDate, ErrorText); - if ExtractionPlan.IsInitialized() then - ExtractionPlan.MarkItem('verify_dates', Passed, ErrorText); if Passed then ResultObj.Add('pass', true) else begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al index a7794d46d3..cf404e79a2 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLMathTool.Codeunit.al @@ -64,7 +64,6 @@ codeunit 6339 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; - ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText, ResultText, LineId : Text; UnitPrice, Quantity, DiscountPct, LineExtAmt : Decimal; @@ -80,9 +79,6 @@ codeunit 6339 "E-Doc. MLLM VL Math Tool" implements "AOAI Function" Passed := VerifyTools.VerifyLineMath(UnitPrice, Quantity, DiscountPct, LineExtAmt, ErrorText); - if ExtractionPlan.IsInitialized() then - ExtractionPlan.MarkItem('verify_line_' + LineId, Passed, ErrorText); - if Passed then ResultObj.Add('pass', true) else begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al index b5b9ae72e5..d5d5b018bf 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRangesTool.Codeunit.al @@ -51,7 +51,6 @@ codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; - ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -66,8 +65,6 @@ codeunit 6317 "E-Doc. MLLM VL Ranges Tool" implements "AOAI Function" ParseDecimalArray(Arguments, 'vat_rates', VATRates); ParseDecimalArray(Arguments, 'discount_pcts', DiscountPcts); Passed := VerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText); - if ExtractionPlan.IsInitialized() then - ExtractionPlan.MarkItem('verify_ranges', Passed, ErrorText); if Passed then ResultObj.Add('pass', true) else begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al index eac30e7947..3c600be871 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLRequiredTool.Codeunit.al @@ -44,7 +44,6 @@ codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; - ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -61,8 +60,6 @@ codeunit 6316 "E-Doc. MLLM VL Required Tool" implements "AOAI Function" if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then LineCount := Round(DecimalValue, 1); Passed := VerifyTools.VerifyRequiredFields(VendorName, InvoiceNo, LineCount, ErrorText); - if ExtractionPlan.IsInitialized() then - ExtractionPlan.MarkItem('verify_required_fields', Passed, ErrorText); if Passed then ResultObj.Add('pass', true) else begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al index 860cecb5fb..bb901749a4 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al @@ -44,7 +44,6 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; - ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -67,8 +66,6 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExclusiveAmount := DecimalValue; Passed := VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, ErrorText); - if ExtractionPlan.IsInitialized() then - ExtractionPlan.MarkItem('verify_invoice_totals', Passed, ErrorText); if Passed then ResultObj.Add('pass', true) else begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al index 23cf6ae4b5..fcc7118abd 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLVATTool.Codeunit.al @@ -44,7 +44,6 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" procedure Execute(Arguments: JsonObject): Variant var VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; - ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; ResultObj: JsonObject; ErrorText: Text; ResultText: Text; @@ -62,8 +61,6 @@ codeunit 6314 "E-Doc. MLLM VL VAT Tool" implements "AOAI Function" if Arguments.Get('tax_amount', Token) then if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxAmt := DecimalValue; Passed := VerifyTools.VerifyVAT(TaxExcl, VATRate, TaxAmt, ErrorText); - if ExtractionPlan.IsInitialized() then - ExtractionPlan.MarkItem('verify_vat', Passed, ErrorText); if Passed then ResultObj.Add('pass', true) else begin From c338a07769c06901b2c196abce01b48b2fd0a625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 12:59:57 +0200 Subject: [PATCH 75/85] Register mark_item tool; update prompt to require explicit mark_item after each verify --- .../Prompts/EDocMLLMExtractionV2-SystemPrompt.md | 16 ++++++++-------- .../EDocumentMLLMHandlerV2.Codeunit.al | 2 ++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index 8bce9c8c6e..3df4a23c1d 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -24,13 +24,13 @@ PHASE 3 — VERIFY YOUR OWN OUTPUT: The checklist is your source of truth. Follow it strictly: 1. Call get_checklist() to see all pending items. -2. For each item with status "pending", call the matching verify tool: - - verify_line_math(line_id, unit_price, quantity, discount_pct, line_extension_amount) — for each verify_line_* item - - verify_invoice_totals(line_amounts[], tax_exclusive_amount) — for verify_invoice_totals - - verify_vat(tax_exclusive_amount, vat_rate, tax_amount) — for verify_vat - - verify_dates(issue_date, due_date) — for verify_dates - - verify_required_fields(vendor_name, invoice_no, line_count) — for verify_required_fields - - verify_ranges(quantities[], prices[], vat_rates[], discount_pcts[]) — for verify_ranges +2. For each item with status "pending", call the matching verify tool then immediately call mark_item with the result: + - verify_line_math(line_id, unit_price, quantity, discount_pct, line_extension_amount) → mark_item(item_id="verify_line_", passed=..., error=...) + - verify_invoice_totals(line_amounts[], tax_exclusive_amount) → mark_item(item_id="verify_invoice_totals", ...) + - verify_vat(tax_exclusive_amount, vat_rate, tax_amount) → mark_item(item_id="verify_vat", ...) + - verify_dates(issue_date, due_date) → mark_item(item_id="verify_dates", ...) + - verify_required_fields(vendor_name, invoice_no, line_count) → mark_item(item_id="verify_required_fields", ...) + - verify_ranges(quantities[], prices[], vat_rates[], discount_pcts[]) → mark_item(item_id="verify_ranges", ...) 3. After working through the pending items, call get_checklist() again. 4. If any items are still "pending" or "failed", repeat from step 2. 5. Only output the final UBL JSON when get_checklist() shows ALL items as "passed". @@ -39,6 +39,6 @@ If a verify tool returns { "pass": false }: 1. State out loud what the error tells you: which value is wrong and what it should be. 2. State which specific field you are changing, to what value, and why. 3. Output the corrected UBL JSON with ONLY that field changed. -4. Re-call the verify tool for that item, then call get_checklist() to confirm. +4. Re-call the verify tool for that item, call mark_item with the new result, then call get_checklist() to confirm. Output ONLY valid JSON. No markdown, no explanation. diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 5886747134..01f5f9c0c3 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -72,6 +72,7 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; PlanAnalyzeTool: Codeunit "E-Doc. MLLM Plan Analyze Tool"; PlanStatusTool: Codeunit "E-Doc. MLLM Plan Status Tool"; + PlanMarkTool: Codeunit "E-Doc. MLLM Plan Mark Tool"; FromTempBlob: Codeunit "Temp Blob"; InStream: InStream; Base64Data: Text; @@ -103,6 +104,7 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen AOAIChatMessages.AddTool(VerifyRangesTool); AOAIChatMessages.AddTool(PlanAnalyzeTool); AOAIChatMessages.AddTool(PlanStatusTool); + AOAIChatMessages.AddTool(PlanMarkTool); AOAIChatMessages.SetToolChoice('auto'); // User message: PDF + UBL schema + security clause From ba43f15e54f919ff27a1ae4a73d06170957b3f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 13:06:26 +0200 Subject: [PATCH 76/85] Fix null in get_checklist GetPrompt: add empty properties to parameters schema --- .../EDocMLLMPlanStatusTool.Codeunit.al | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al index e690fe9904..7aeef4921a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanStatusTool.Codeunit.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Processing.Import; using System.AI; -codeunit 6321 "E-Doc. MLLM Plan Status Tool" implements "AOAI Function" +codeunit 6341 "E-Doc. MLLM Plan Status Tool" implements "AOAI Function" { Access = Internal; InherentEntitlements = X; @@ -19,13 +19,15 @@ codeunit 6321 "E-Doc. MLLM Plan Status Tool" implements "AOAI Function" procedure GetPrompt(): JsonObject var - ToolObj, FunctionObj, ParamsObj : JsonObject; + ToolObj, FunctionObj, ParamsObj, PropsObj : JsonObject; begin ParamsObj.Add('type', 'object'); + ParamsObj.Add('properties', PropsObj); FunctionObj.Add('name', GetName()); FunctionObj.Add('description', 'Returns the current verification checklist showing status (pending/passed/failed) for each item. Call this to see what verifications remain before finalising.'); FunctionObj.Add('parameters', ParamsObj); - ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + ToolObj.Add('type', 'function'); + ToolObj.Add('function', FunctionObj); exit(ToolObj); end; From 2df9aed64131811dc81209050de28c1a3157390c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 13:15:02 +0200 Subject: [PATCH 77/85] Fix history window: expand HistoryLength for assistant tool-call messages to match tool results --- .../Chat Completion/AOAIChatMessagesImpl.Codeunit.al | 1 + 1 file changed, 1 insertion(+) diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al index 63a03552d4..60e5eb41cd 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al @@ -353,6 +353,7 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryNames.Add(''); HistoryToolCallIds.Add(''); HistoryUserMessages.Add(AOAIUserMessage); + HistoryLength += 1; // Keep assistant tool-call messages in window alongside their tool results end; local procedure AddMessage(NewMessage: Text; NewName: Text[2048]; NewRole: Enum "AOAI Chat Roles") From db925c500eee678f8b8b044442aa41f88688db80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 13:16:44 +0200 Subject: [PATCH 78/85] Revert "Fix history window: expand HistoryLength for assistant tool-call messages to match tool results" This reverts commit 2df9aed64131811dc81209050de28c1a3157390c. --- .../Chat Completion/AOAIChatMessagesImpl.Codeunit.al | 1 - 1 file changed, 1 deletion(-) diff --git a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al index 60e5eb41cd..63a03552d4 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/Chat Completion/AOAIChatMessagesImpl.Codeunit.al @@ -353,7 +353,6 @@ codeunit 7764 "AOAI Chat Messages Impl" HistoryNames.Add(''); HistoryToolCallIds.Add(''); HistoryUserMessages.Add(AOAIUserMessage); - HistoryLength += 1; // Keep assistant tool-call messages in window alongside their tool results end; local procedure AddMessage(NewMessage: Text; NewName: Text[2048]; NewRole: Enum "AOAI Chat Roles") From cfbb8a9c9faf9a9c2a59c2f308d6a929305be98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 13:59:33 +0200 Subject: [PATCH 79/85] Prompt: explicitly instruct model to read totals section from document in Phase 1 and 2 --- .../EDocMLLMExtractionV2-SystemPrompt.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index 3df4a23c1d..8de9baa3b9 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -1,11 +1,11 @@ You are an invoice data extraction agent with access to verification tools. PHASE 1 — UNDERSTAND AND RECORD: -Call `analyze_invoice` FIRST. Pass your structural analysis of the document: +Call `analyze_invoice` FIRST. Before extracting any values, study the document carefully and record: - doc_type, language, decimal_sep, thousands_sep -- line_columns: describe each column in the line table and its role -- line_ids: the id values of all invoice lines you see -- notes: anything unusual +- line_columns: describe each column in the line table and its role (gross price, discount %, net price, quantity, line total, etc.) +- line_ids: the id values of all invoice lines you see on the document +- notes: describe the totals section at the bottom of the invoice — what labelled amounts are shown? Look for: subtotal (sum of lines before header discount), header-level discount (a deduction applied to the whole invoice, not a line), VAT amount, and the final payable/total amount. Record the label and value of each field you find. Also note whether the invoice shows both a pre-discount and post-discount line price. This call initialises your verification checklist. You will receive the full list of items to verify. @@ -16,9 +16,15 @@ Format rules (non-negotiable): - Numbers: XML decimal format — period (.) as decimal separator, no thousands separators (e.g. 1083 not "1 083", 2.34 not "2,34") - Dates: YYYY-MM-DD -For everything else — how to represent the price, how to represent discounts, which column maps to which UBL field — let your Phase 1 analysis guide you. The verify tools in Phase 3 will tell you if your extraction is mathematically inconsistent. +For the totals section, read each labelled amount directly from the document: +- legal_monetary_total.line_extension_amount: the sum-of-lines subtotal as printed +- legal_monetary_total.allowance_total_amount: the header discount amount as printed (0 if none) +- legal_monetary_total.tax_exclusive_amount: the pre-VAT total as printed (= line_extension_amount − allowance_total_amount) +- tax_total.tax_amount: the VAT amount as printed +- legal_monetary_total.payable_amount: the final amount due as printed +These are read from the document — do not calculate them from other fields. -Output valid UBL JSON matching the schema provided. +For everything else — how to represent line prices and discounts — let your Phase 1 analysis guide you. The verify tools in Phase 3 will tell you if your extraction is mathematically inconsistent. PHASE 3 — VERIFY YOUR OWN OUTPUT: The checklist is your source of truth. Follow it strictly: From 69c744d9ab7d57f825a9eee3f2caa34ab0f31d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 14:07:14 +0200 Subject: [PATCH 80/85] Add verify_payable tool; fix verify_invoice_totals for header discounts; remove price/qty > 0 from verify_ranges Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMVLPayableTool.Codeunit.al | 69 +++++++++++++++++++ .../EDocMLLMVLTotalsTool.Codeunit.al | 11 ++- .../EDocMLLMVerifyTools.Codeunit.al | 40 +++++------ .../EDocMLLMVerifyToolsTests.Codeunit.al | 69 ++++++++++++++----- 4 files changed, 146 insertions(+), 43 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLPayableTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLPayableTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLPayableTool.Codeunit.al new file mode 100644 index 0000000000..945b4497dc --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLPayableTool.Codeunit.al @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6345 "E-Doc. MLLM VL Payable Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('verify_payable'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj, FunctionObj, ParamsObj, PropsObj, PropObj : JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_exclusive_amount from legal_monetary_total'); + PropsObj.Add('tax_exclusive_amount', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_amount from tax_total'); + PropsObj.Add('tax_amount', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'payable_amount from legal_monetary_total'); + PropsObj.Add('payable_amount', PropObj); + RequiredArr.Add('tax_exclusive_amount'); RequiredArr.Add('tax_amount'); RequiredArr.Add('payable_amount'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Verify that tax_exclusive_amount + tax_amount ≈ payable_amount within 1%.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; + ResultObj: JsonObject; + Token: JsonToken; + DecimalValue: Decimal; + TaxExcl, TaxAmt, Payable : Decimal; + ErrorText, ResultText : Text; + Passed: Boolean; + begin + if Arguments.Get('tax_exclusive_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExcl := DecimalValue; + if Arguments.Get('tax_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxAmt := DecimalValue; + if Arguments.Get('payable_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then Payable := DecimalValue; + + Passed := VerifyTools.VerifyPayable(TaxExcl, TaxAmt, Payable, ErrorText); + + if Passed then + ResultObj.Add('pass', true) + else begin + ResultObj.Add('pass', false); + ResultObj.Add('error', ErrorText); + end; + ResultObj.WriteTo(ResultText); + exit(ResultText); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al index bb901749a4..ebc815cb66 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVLTotalsTool.Codeunit.al @@ -31,8 +31,10 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" PropObj.Add('type', 'array'); PropObj.Add('items', ItemsObj); PropObj.Add('description', 'All line_extension_amount values'); PropsObj.Add('line_amounts', PropObj); Clear(PropObj); PropObj.Add('type', 'number'); PropObj.Add('description', 'tax_exclusive_amount from legal_monetary_total'); - PropsObj.Add('tax_exclusive_amount', PropObj); - RequiredArr.Add('line_amounts'); RequiredArr.Add('tax_exclusive_amount'); + PropsObj.Add('tax_exclusive_amount', PropObj); Clear(PropObj); + PropObj.Add('type', 'number'); PropObj.Add('description', 'allowance_total_amount from legal_monetary_total (header-level discount, 0 if none)'); + PropsObj.Add('allowance_total_amount', PropObj); + RequiredArr.Add('line_amounts'); RequiredArr.Add('tax_exclusive_amount'); RequiredArr.Add('allowance_total_amount'); ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); FunctionObj.Add('name', GetName()); FunctionObj.Add('description', 'Verify that the sum of all line_extension_amounts matches tax_exclusive_amount within 1%.'); @@ -52,6 +54,7 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" LineToken: JsonToken; LineAmounts: List of [Decimal]; TaxExclusiveAmount: Decimal; + AllowanceTotalAmount: Decimal; DecimalValue: Decimal; Token: JsonToken; Passed: Boolean; @@ -65,7 +68,9 @@ codeunit 6313 "E-Doc. MLLM VL Totals Tool" implements "AOAI Function" if Arguments.Get('tax_exclusive_amount', Token) then if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then TaxExclusiveAmount := DecimalValue; - Passed := VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, ErrorText); + if Arguments.Get('allowance_total_amount', Token) then + if Evaluate(DecimalValue, Token.AsValue().AsText(), 9) then AllowanceTotalAmount := DecimalValue; + Passed := VerifyTools.VerifyInvoiceTotals(LineAmounts, TaxExclusiveAmount, AllowanceTotalAmount, ErrorText); if Passed then ResultObj.Add('pass', true) else begin diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al index 956bdc5b89..7ad456fa4e 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMVerifyTools.Codeunit.al @@ -33,11 +33,11 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" exit(false); end; - procedure VerifyInvoiceTotals(LineAmounts: List of [Decimal]; TaxExclusiveAmount: Decimal; var ErrorText: Text): Boolean + procedure VerifyInvoiceTotals(LineAmounts: List of [Decimal]; TaxExclusiveAmount: Decimal; AllowanceTotalAmount: Decimal; var ErrorText: Text): Boolean var LineAmount: Decimal; SumOfLines: Decimal; - InvoiceTotalsErrLbl: Label 'Sum of line_extension_amounts = %1, but tax_exclusive_amount = %2. Check for missing or duplicated lines.', Comment = '%1=SumOfLines, %2=TaxExclusiveAmount'; + InvoiceTotalsErrLbl: Label 'Sum of lines (%1) minus header discount (%2) = %3, but tax_exclusive_amount = %4. Check for missing lines or incorrect header discount.', Comment = '%1=SumOfLines, %2=AllowanceTotalAmount, %3=Net, %4=TaxExclusiveAmount'; begin if TaxExclusiveAmount = 0 then exit(true); @@ -46,10 +46,10 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" foreach LineAmount in LineAmounts do SumOfLines += LineAmount; - if IsWithinTolerance(SumOfLines, TaxExclusiveAmount) then + if IsWithinTolerance(SumOfLines - AllowanceTotalAmount, TaxExclusiveAmount) then exit(true); - ErrorText := StrSubstNo(InvoiceTotalsErrLbl, SumOfLines, TaxExclusiveAmount); + ErrorText := StrSubstNo(InvoiceTotalsErrLbl, Round(SumOfLines, 0.01), AllowanceTotalAmount, Round(SumOfLines - AllowanceTotalAmount, 0.01), TaxExclusiveAmount); exit(false); end; @@ -140,27 +140,9 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" var i: Integer; Value: Decimal; - NegativeQtyErrLbl: Label 'Line %1: quantity %2 must be greater than 0.', Comment = '%1=LineIndex, %2=Value'; - NonPositivePriceErrLbl: Label 'Line %1: unit price %2 must be greater than 0.', Comment = '%1=LineIndex, %2=Value'; VATRateRangeErrLbl: Label 'Line %1: VAT rate %2 is outside the range 0–100.', Comment = '%1=LineIndex, %2=Value'; DiscountRangeErrLbl: Label 'Line %1: discount %2 is outside the range 0–100.', Comment = '%1=LineIndex, %2=Value'; begin - for i := 1 to Quantities.Count() do begin - Quantities.Get(i, Value); - if Value <= 0 then begin - ErrorText := StrSubstNo(NegativeQtyErrLbl, i, Value); - exit(false); - end; - end; - - for i := 1 to Prices.Count() do begin - Prices.Get(i, Value); - if Value <= 0 then begin - ErrorText := StrSubstNo(NonPositivePriceErrLbl, i, Value); - exit(false); - end; - end; - for i := 1 to VATRates.Count() do begin VATRates.Get(i, Value); if (Value < 0) or (Value > 100) then begin @@ -180,6 +162,20 @@ codeunit 6311 "E-Doc. MLLM Verify Tools" exit(true); end; + procedure VerifyPayable(TaxExclusiveAmount: Decimal; TaxAmount: Decimal; PayableAmount: Decimal; var ErrorText: Text): Boolean + var + Expected: Decimal; + PayableErrLbl: Label '%1 (tax_exclusive) + %2 (tax_amount) = %3, but payable_amount = %4.', Comment = '%1=TaxExclusiveAmount, %2=TaxAmount, %3=Expected, %4=PayableAmount'; + begin + if PayableAmount = 0 then + exit(true); + Expected := TaxExclusiveAmount + TaxAmount; + if IsWithinTolerance(Expected, PayableAmount) then + exit(true); + ErrorText := StrSubstNo(PayableErrLbl, TaxExclusiveAmount, TaxAmount, Round(Expected, 0.01), PayableAmount); + exit(false); + end; + procedure IsWithinTolerance(Expected: Decimal; Actual: Decimal): Boolean var Denominator: Decimal; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al index ad2f9cd239..2995a8e712 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMVerifyToolsTests.Codeunit.al @@ -95,7 +95,7 @@ codeunit 135648 "EDoc MLLM Verify Tools Tests" LineAmounts.Add(200); LineAmounts.Add(30); LineAmounts.Add(20); - Result := EDocMLLMVerifyTools.VerifyInvoiceTotals(LineAmounts, 250, ErrorText); + Result := EDocMLLMVerifyTools.VerifyInvoiceTotals(LineAmounts, 250, 0, ErrorText); Assert.IsTrue(Result, 'Expected VerifyInvoiceTotals to pass when sum matches'); Assert.AreEqual('', ErrorText, 'ErrorText should be empty on pass'); end; @@ -110,11 +110,23 @@ codeunit 135648 "EDoc MLLM Verify Tools Tests" begin // [SCENARIO] [200] sums to 200, tax_exclusive_amount = 250 → fail (missing lines) LineAmounts.Add(200); - Result := EDocMLLMVerifyTools.VerifyInvoiceTotals(LineAmounts, 250, ErrorText); + Result := EDocMLLMVerifyTools.VerifyInvoiceTotals(LineAmounts, 250, 0, ErrorText); Assert.IsFalse(Result, 'Expected VerifyInvoiceTotals to fail when sum does not match'); Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); end; + [Test] + procedure VerifyInvoiceTotals_Pass_WithHeaderDiscount() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Lines: List of [Decimal]; + ErrorText: Text; + begin + // [200, 30, 20] = 250, minus 10 header discount = 240 = tax_exclusive_amount + Lines.Add(200); Lines.Add(30); Lines.Add(20); + Assert.IsTrue(VerifyTools.VerifyInvoiceTotals(Lines, 240, 10, ErrorText), ErrorText); + end; + // VerifyVAT tests [Test] @@ -290,25 +302,15 @@ codeunit 135648 "EDoc MLLM Verify Tools Tests" end; [Test] - procedure VerifyRanges_Fail_NegativeQty() + procedure VerifyRanges_Pass_NegativeQty() var - EDocMLLMVerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + Qtys, Prices, VATRates, DiscPcts : List of [Decimal]; ErrorText: Text; - Quantities: List of [Decimal]; - Prices: List of [Decimal]; - VATRates: List of [Decimal]; - DiscountPcts: List of [Decimal]; - Result: Boolean; begin - // [SCENARIO] qty=-1 → fail, ErrorText contains 'quantity' - Quantities.Add(-1); - Prices.Add(40); - VATRates.Add(15); - DiscountPcts.Add(0); - Result := EDocMLLMVerifyTools.VerifyRanges(Quantities, Prices, VATRates, DiscountPcts, ErrorText); - Assert.IsFalse(Result, 'Expected VerifyRanges to fail for negative quantity'); - Assert.AreNotEqual('', ErrorText, 'ErrorText should be non-empty on fail'); - Assert.IsTrue(ErrorText.ToLower().Contains('quantity'), 'ErrorText should mention quantity'); + // Negative qty is valid for credit/return lines + Qtys.Add(-1); Prices.Add(40); VATRates.Add(15); DiscPcts.Add(0); + Assert.IsTrue(VerifyTools.VerifyRanges(Qtys, Prices, VATRates, DiscPcts, ErrorText), ErrorText); end; [Test] @@ -333,6 +335,37 @@ codeunit 135648 "EDoc MLLM Verify Tools Tests" Assert.IsTrue(ErrorText.ToLower().Contains('discount'), 'ErrorText should mention discount'); end; + // VerifyPayable tests + + [Test] + procedure VerifyPayable_Pass() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + // 250 + 37.5 = 287.5 + Assert.IsTrue(VerifyTools.VerifyPayable(250, 37.5, 287.5, ErrorText), ErrorText); + end; + + [Test] + procedure VerifyPayable_Fail() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsFalse(VerifyTools.VerifyPayable(250, 37.5, 300, ErrorText), 'Should fail'); + Assert.AreNotEqual('', ErrorText, 'ErrorText should be set'); + end; + + [Test] + procedure VerifyPayable_Skip_ZeroPayable() + var + VerifyTools: Codeunit "E-Doc. MLLM Verify Tools"; + ErrorText: Text; + begin + Assert.IsTrue(VerifyTools.VerifyPayable(250, 37.5, 0, ErrorText), ErrorText); + end; + // IsWithinTolerance tests (testing internal method via public exposure) [Test] From 80d62afa7da55e54f5a669b8de277ef029149e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 14:07:25 +0200 Subject: [PATCH 81/85] Add submit_extraction tool; store verified JSON in plan state Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocMLLMExtractionPlan.Codeunit.al | 31 ++++++++++- .../EDocMLLMPlanSubmitTool.Codeunit.al | 51 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al index 4a68c2d05f..74b3429514 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMExtractionPlan.Codeunit.al @@ -4,7 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument.Processing.Import; -codeunit 6319 "E-Doc. MLLM Extraction Plan" +codeunit 6340 "E-Doc. MLLM Extraction Plan" { Access = Internal; InherentEntitlements = X; @@ -15,13 +15,15 @@ codeunit 6319 "E-Doc. MLLM Extraction Plan" ItemStatus: Dictionary of [Text, Text]; ItemErrors: Dictionary of [Text, Text]; AnalysisPayload: Text; - FixedItemsTok: Label 'verify_invoice_totals,verify_vat,verify_dates,verify_required_fields,verify_ranges', Locked = true; + CurrentJson: Text; + FixedItemsTok: Label 'verify_invoice_totals,verify_vat,verify_dates,verify_required_fields,verify_ranges,verify_payable', Locked = true; procedure Reset() begin Clear(ItemStatus); Clear(ItemErrors); AnalysisPayload := ''; + CurrentJson := ''; end; procedure InitializePlan(LineIds: List of [Text]; Analysis: Text) @@ -59,6 +61,31 @@ codeunit 6319 "E-Doc. MLLM Extraction Plan" ItemErrors.Remove(ItemId); end; + procedure SetCurrentJson(Json: Text) + var + ItemId, Status : Text; + begin + CurrentJson := Json; + // Reset failed items to pending so the model re-verifies after correction + foreach ItemId in ItemStatus.Keys() do begin + ItemStatus.Get(ItemId, Status); + if Status = 'failed' then + ItemStatus.Set(ItemId, 'pending'); + end; + if ItemErrors.Count() > 0 then + Clear(ItemErrors); + end; + + procedure GetCurrentJson(): Text + begin + exit(CurrentJson); + end; + + procedure HasCurrentJson(): Boolean + begin + exit(CurrentJson <> ''); + end; + procedure GetChecklistJson(): Text var ResultArr: JsonArray; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al new file mode 100644 index 0000000000..a436e24040 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Import; + +using System.AI; + +codeunit 6346 "E-Doc. MLLM Plan Submit Tool" implements "AOAI Function" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetName(): Text + begin + exit('submit_extraction'); + end; + + procedure GetPrompt(): JsonObject + var + ToolObj, FunctionObj, ParamsObj, PropsObj, PropObj : JsonObject; + RequiredArr: JsonArray; + begin + PropObj.Add('type', 'string'); + PropObj.Add('description', 'The complete UBL JSON you have extracted. Must be the full document, not a snippet.'); + PropsObj.Add('json', PropObj); + RequiredArr.Add('json'); + ParamsObj.Add('type', 'object'); ParamsObj.Add('properties', PropsObj); ParamsObj.Add('required', RequiredArr); + FunctionObj.Add('name', GetName()); + FunctionObj.Add('description', 'Save the current complete UBL JSON extraction. Call this after Phase 2 and after every correction. The saved JSON is the final result — do not output JSON as a text response.'); + FunctionObj.Add('parameters', ParamsObj); + ToolObj.Add('type', 'function'); ToolObj.Add('function', FunctionObj); + exit(ToolObj); + end; + + procedure Execute(Arguments: JsonObject): Variant + var + ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; + ResultObj: JsonObject; + Token: JsonToken; + Json, ResultText : Text; + begin + if Arguments.Get('json', Token) then Json := Token.AsValue().AsText(); + ExtractionPlan.SetCurrentJson(Json); + ResultObj.Add('status', 'saved'); + ResultObj.Add('checklist', ExtractionPlan.GetChecklistJson()); + ResultObj.WriteTo(ResultText); + exit(ResultText); + end; +} From ddcf7fd5e87c66e5517be4d6de6bbacf52bd2bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 14:07:30 +0200 Subject: [PATCH 82/85] Register new tools in handler; SetHistoryLength 500; use plan JSON as result Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../EDocumentMLLMHandlerV2.Codeunit.al | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al index 01f5f9c0c3..b1b3ad7734 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandlerV2.Codeunit.al @@ -69,6 +69,8 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen VerifyDatesTool: Codeunit "E-Doc. MLLM VL Dates Tool"; VerifyRequiredTool: Codeunit "E-Doc. MLLM VL Required Tool"; VerifyRangesTool: Codeunit "E-Doc. MLLM VL Ranges Tool"; + PayableTool: Codeunit "E-Doc. MLLM VL Payable Tool"; + PlanSubmitTool: Codeunit "E-Doc. MLLM Plan Submit Tool"; ExtractionPlan: Codeunit "E-Doc. MLLM Extraction Plan"; PlanAnalyzeTool: Codeunit "E-Doc. MLLM Plan Analyze Tool"; PlanStatusTool: Codeunit "E-Doc. MLLM Plan Status Tool"; @@ -89,6 +91,7 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen AzureOpenAI.SetAuthorization(Enum::"AOAI Model Type"::"Chat Completions", AOAIDeployments.GetGPT41MiniPreview()); AzureOpenAI.SetCopilotCapability(Enum::"Copilot Capability"::"E-Document MLLM Analysis"); AOAIChatCompletionParams.SetTemperature(0); + AOAIChatMessages.SetHistoryLength(500); // Do NOT set JSON mode — tool-calling and JSON mode cannot be combined. // The system prompt instructs the model to output UBL JSON as its final response. @@ -105,6 +108,8 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen AOAIChatMessages.AddTool(PlanAnalyzeTool); AOAIChatMessages.AddTool(PlanStatusTool); AOAIChatMessages.AddTool(PlanMarkTool); + AOAIChatMessages.AddTool(PayableTool); + AOAIChatMessages.AddTool(PlanSubmitTool); AOAIChatMessages.SetToolChoice('auto'); // User message: PDF + UBL schema + security clause @@ -129,6 +134,8 @@ codeunit 6318 "E-Document MLLM Handler V2" implements IStructureReceivedEDocumen end; until not AOAIOperationResponse.IsFunctionCall(); + if ExtractionPlan.HasCurrentJson() then + exit(ExtractionPlan.GetCurrentJson()); exit(AOAIOperationResponse.GetResult()); end; From 6961ab957159fb8829da75980d33f3ca7ba6d4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 14:07:35 +0200 Subject: [PATCH 83/85] Update prompt: submit_extraction replaces JSON output; add verify_payable to checklist Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Prompts/EDocMLLMExtractionV2-SystemPrompt.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index 8de9baa3b9..06513f4240 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -26,17 +26,20 @@ These are read from the document — do not calculate them from other fields. For everything else — how to represent line prices and discounts — let your Phase 1 analysis guide you. The verify tools in Phase 3 will tell you if your extraction is mathematically inconsistent. +After extracting, call submit_extraction(json) with the complete UBL JSON. Do NOT output JSON as a text response — always save it via submit_extraction. + PHASE 3 — VERIFY YOUR OWN OUTPUT: The checklist is your source of truth. Follow it strictly: 1. Call get_checklist() to see all pending items. 2. For each item with status "pending", call the matching verify tool then immediately call mark_item with the result: - verify_line_math(line_id, unit_price, quantity, discount_pct, line_extension_amount) → mark_item(item_id="verify_line_", passed=..., error=...) - - verify_invoice_totals(line_amounts[], tax_exclusive_amount) → mark_item(item_id="verify_invoice_totals", ...) + - verify_invoice_totals(line_amounts[], tax_exclusive_amount, allowance_total_amount) → mark_item(item_id="verify_invoice_totals", ...) - verify_vat(tax_exclusive_amount, vat_rate, tax_amount) → mark_item(item_id="verify_vat", ...) - verify_dates(issue_date, due_date) → mark_item(item_id="verify_dates", ...) - verify_required_fields(vendor_name, invoice_no, line_count) → mark_item(item_id="verify_required_fields", ...) - verify_ranges(quantities[], prices[], vat_rates[], discount_pcts[]) → mark_item(item_id="verify_ranges", ...) + - verify_payable(tax_exclusive_amount, tax_amount, payable_amount) → mark_item(item_id="verify_payable", ...) 3. After working through the pending items, call get_checklist() again. 4. If any items are still "pending" or "failed", repeat from step 2. 5. Only output the final UBL JSON when get_checklist() shows ALL items as "passed". @@ -44,7 +47,7 @@ The checklist is your source of truth. Follow it strictly: If a verify tool returns { "pass": false }: 1. State out loud what the error tells you: which value is wrong and what it should be. 2. State which specific field you are changing, to what value, and why. -3. Output the corrected UBL JSON with ONLY that field changed. +3. Call submit_extraction with the complete corrected UBL JSON (not just the changed field). 4. Re-call the verify tool for that item, call mark_item with the new result, then call get_checklist() to confirm. Output ONLY valid JSON. No markdown, no explanation. From 121aeeb94d9091a324bd889408fd5a47415d8b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 14:33:55 +0200 Subject: [PATCH 84/85] Fix submit_extraction: handle JSON passed as object token, not just string --- .../EDocMLLMPlanSubmitTool.Codeunit.al | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al index a436e24040..4e814d7129 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMPlanSubmitTool.Codeunit.al @@ -41,7 +41,11 @@ codeunit 6346 "E-Doc. MLLM Plan Submit Tool" implements "AOAI Function" Token: JsonToken; Json, ResultText : Text; begin - if Arguments.Get('json', Token) then Json := Token.AsValue().AsText(); + if Arguments.Get('json', Token) then + if Token.IsObject() then + Token.WriteTo(Json) // model passed the JSON as an object — serialize it + else + Json := Token.AsValue().AsText(); // model passed it as a string — use as-is ExtractionPlan.SetCurrentJson(Json); ResultObj.Add('status', 'saved'); ResultObj.Add('checklist', ExtractionPlan.GetChecklistJson()); From 302a530064a69dbd271805c5ff17975e36160108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 27 May 2026 14:42:05 +0200 Subject: [PATCH 85/85] Fix prompt contradiction: model outputs JSON as final response AND calls submit_extraction --- .../.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md index 06513f4240..e283a516fa 100644 --- a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtractionV2-SystemPrompt.md @@ -26,7 +26,7 @@ These are read from the document — do not calculate them from other fields. For everything else — how to represent line prices and discounts — let your Phase 1 analysis guide you. The verify tools in Phase 3 will tell you if your extraction is mathematically inconsistent. -After extracting, call submit_extraction(json) with the complete UBL JSON. Do NOT output JSON as a text response — always save it via submit_extraction. +After extracting, call submit_extraction(json) with the complete UBL JSON to save it. PHASE 3 — VERIFY YOUR OWN OUTPUT: The checklist is your source of truth. Follow it strictly: @@ -42,7 +42,7 @@ The checklist is your source of truth. Follow it strictly: - verify_payable(tax_exclusive_amount, tax_amount, payable_amount) → mark_item(item_id="verify_payable", ...) 3. After working through the pending items, call get_checklist() again. 4. If any items are still "pending" or "failed", repeat from step 2. -5. Only output the final UBL JSON when get_checklist() shows ALL items as "passed". +5. When get_checklist() shows ALL items as "passed", call submit_extraction(json) one final time with the complete corrected UBL JSON, then output the same JSON as your final response. If a verify tool returns { "pass": false }: 1. State out loud what the error tells you: which value is wrong and what it should be.