From 8eb97d31743744eedc1212358e316174cf3763a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 11 Mar 2026 17:04:59 +0100 Subject: [PATCH] E-Document: Add MLLM extraction with ADI fallback (#6893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add MLLM (Multimodal LLM) extraction as a new structured data handler for e-document PDF processing, with automatic fallback to ADI (Azure Document Intelligence) when MLLM fails - Introduce experiment gate in `EDocPDFFileFormat` with `OnOverrideStructureDataImpl` integration event for cross-app override - Add dedicated `"E-Document MLLM Analysis"` Copilot Capability enum value - Prefill `accounting_customer_party` from Company Information to help the LLM distinguish buyer from vendor - Add system prompt instructions for customer vs vendor identification [AB#618414](https://dynamicssmb2.visualstudio.com/1fcb79e7-ab07-432a-a3c6-6cf5a88ba4a5/_workitems/edit/618414) --------- Co-authored-by: Magnus Hartvig Grønbech Co-authored-by: Claude Opus 4.6 --- .../App/.resources/AITools/ubl_example.json | 186 +++++++++++ .../EDocMLLMExtraction-SystemPrompt.md | 24 ++ src/Apps/W1/EDocument/App/app.json | 8 + .../EDocumentCopilotCapability.EnumExt.al | 4 + .../EDocumentSubscribers.Codeunit.al | 10 + .../Import/EDocReadIntoDraft.Enum.al | 5 + .../FileFormat/EDocPDFFileFormat.Codeunit.al | 22 +- .../Import/StructureReceivedEDoc.Enum.al | 5 + .../EDocMLLMSchemaHelper.Codeunit.al | 281 +++++++++++++++++ .../EDocPurchaseDraftUtility.Codeunit.al | 53 ++++ .../EDocumentADIHandler.Codeunit.al | 38 +-- .../EDocumentMLLMHandler.Codeunit.al | 294 ++++++++++++++++++ .../.resources/mllm/mllm-header-full.json | 81 +++++ .../.resources/mllm/mllm-invoice-empty.json | 3 + .../.resources/mllm/mllm-invoice-valid-0.json | 155 +++++++++ .../.resources/mllm/mllm-lines-three.json | 74 +++++ src/Apps/W1/EDocument/Test/app.json | 4 + .../CAPIStructuredValidations.Codeunit.al | 107 ------- .../src/Processing/EDocMLLMTests.Codeunit.al | 293 +++++++++++++++++ .../EDocStructuredValidations.Codeunit.al | 222 +++++++++++++ .../EDocumentStructuredTests.Codeunit.al | 111 ++++++- .../PEPPOLStructuredValidations.Codeunit.al | 62 ---- 22 files changed, 1833 insertions(+), 209 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json create mode 100644 src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMSchemaHelper.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPurchaseDraftUtility.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandler.Codeunit.al create mode 100644 src/Apps/W1/EDocument/Test/.resources/mllm/mllm-header-full.json create mode 100644 src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-empty.json create mode 100644 src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-valid-0.json create mode 100644 src/Apps/W1/EDocument/Test/.resources/mllm/mllm-lines-three.json delete mode 100644 src/Apps/W1/EDocument/Test/src/Processing/CAPIStructuredValidations.Codeunit.al create mode 100644 src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al create mode 100644 src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al delete mode 100644 src/Apps/W1/EDocument/Test/src/Processing/PEPPOLStructuredValidations.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json b/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json new file mode 100644 index 0000000000..d96c56d613 --- /dev/null +++ b/src/Apps/W1/EDocument/App/.resources/AITools/ubl_example.json @@ -0,0 +1,186 @@ +{ + "customization_id": "urn:cen.eu:en16931:2017", + "profile_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0", + "id": "", + "issue_date": "", + "due_date": "", + "document_currency_code": "", + "order_reference": { + "id": "" + }, + "accounting_supplier_party": { + "party": { + "party_name": { + "name": "" + }, + "postal_address": { + "street_name": "", + "additional_street_name": "", + "city_name": "", + "postal_zone": "", + "country": { + "identification_code": "" + } + }, + "party_tax_scheme": { + "company_id": "", + "tax_scheme": { + "id": "" + } + }, + "contact": { + "name": "", + "telephone": "", + "electronic_mail": "" + } + } + }, + "accounting_customer_party": { + "party": { + "party_name": { + "name": "" + }, + "postal_address": { + "street_name": "", + "additional_street_name": "", + "city_name": "", + "postal_zone": "", + "country": { + "identification_code": "" + } + }, + "party_tax_scheme": { + "company_id": "", + "tax_scheme": { + "id": "" + } + }, + "contact": { + "name": "", + "telephone": "", + "electronic_mail": "" + } + } + }, + "delivery": { + "actual_delivery_date": "", + "delivery_location": { + "id": { + "scheme_id": "", + "value": "" + }, + "address": { + "street_name": "", + "additional_street_name": "", + "city_name": "", + "postal_zone": "", + "country": { + "identification_code": "" + } + } + }, + "delivery_party": { + "party_name": { + "name": "" + } + } + }, + "payment_means": { + "payment_means_code": "", + "payment_id": "", + "payee_financial_account": { + "id": "", + "name": "", + "financial_institution_branch": { + "id": "" + } + } + }, + "payment_terms": { + "note": "" + }, + "allowance_charge": [ + { + "charge_indicator": false, + "allowance_charge_reason_code": 0, + "allowance_charge_reason": "", + "amount": { + "currency_id": "", + "value": "0" + }, + "tax_category": { + "id": "", + "percent": "0", + "tax_scheme": { + "id": "" + } + } + } + ], + "tax_total": { + "tax_amount": "0", + "tax_subtotal": [ + { + "taxable_amount": "0", + "tax_amount": "0", + "tax_category": { + "id": "", + "percent": "0", + "tax_scheme": { + "id": "" + } + } + } + ] + }, + "legal_monetary_total": { + "line_extension_amount": "0", + "tax_exclusive_amount": "0", + "tax_inclusive_amount": "0", + "allowance_total_amount": "0", + "charge_total_amount": "0", + "payable_amount": "0" + }, + "invoice_line": [ + { + "id": "", + "invoiced_quantity": { + "unit_code": "", + "value": "0" + }, + "line_extension_amount": "0", + "allowance_charge": { + "charge_indicator": false, + "allowance_charge_reason_code": 0, + "allowance_charge_reason": "", + "amount": { + "currency_id": "", + "value": "0" + }, + "tax_category": { + "id": "", + "percent": "0", + "tax_scheme": { + "id": "" + } + } + }, + "item": { + "name": "", + "sellers_item_identification": { + "id": "" + }, + "classified_tax_category": { + "id": "", + "percent": "0", + "tax_scheme": { + "id": "" + } + } + }, + "price": { + "price_amount": "0" + } + } + ] +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md new file mode 100644 index 0000000000..c8ae18e094 --- /dev/null +++ b/src/Apps/W1/EDocument/App/.resources/Prompts/EDocMLLMExtraction-SystemPrompt.md @@ -0,0 +1,24 @@ +You are an data extraction system. Extract ONLY what is explicitly visible on the document into UBL (Universal Business Language) JSON format. + +EXTRACTION RULES: +1. NEVER invent, calculate, or assume values - extract only what you see +2. Use "" for missing text fields +3. Dates: YYYY-MM-DD format +4. Extract ALL invoice lines with sequential IDs starting from "1" +5. Quantity: use "1" only if no quantity column exists on the document + +CUSTOMER vs VENDOR IDENTIFICATION: +The JSON structure includes pre-filled accounting_customer_party data. This is OUR company — the buyer receiving the invoice. Use this to distinguish between customer and vendor on the document: +- The accounting_customer_party (buyer) is already filled in. Keep these values as provided unless the document clearly shows different buyer details. +- The accounting_supplier_party (vendor/seller) is the OTHER party on the invoice — the one sending the invoice and requesting payment. Extract their details from the document. + +CRITICAL FORMAT RULES: +- Country codes: Use ISO 3166-1 alpha-2 (2 letters) +- VAT IDs: Extract only the number with country prefix, no labels (e.g., "DK29399700", NOT "SE. Nr. 31 89 26 86") +- Tax scheme ID: Always use "VAT" +- Tax category ID: Use standard codes: S=Standard rate, Z=Zero rate, E=Exempt, AE=Reverse charge +- Unit codes: Use UN/ECE codes +- Allowance Charge: Leave allowance_charge section empty if no discount/charge exists on the document + + +Output ONLY valid JSON. No markdown, no explanation. \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/app.json b/src/Apps/W1/EDocument/App/app.json index ea4f52d5fc..1a0d822acb 100644 --- a/src/Apps/W1/EDocument/App/app.json +++ b/src/Apps/W1/EDocument/App/app.json @@ -49,6 +49,14 @@ { "from": 6208, "to": 6209 + }, + { + "from": 6231, + "to": 6232 + }, + { + "from": 6234, + "to": 6234 } ], "resourceExposurePolicy": { diff --git a/src/Apps/W1/EDocument/App/src/Processing/EDocumentCopilotCapability.EnumExt.al b/src/Apps/W1/EDocument/App/src/Processing/EDocumentCopilotCapability.EnumExt.al index 55394a7171..a0e9743c8a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/EDocumentCopilotCapability.EnumExt.al +++ b/src/Apps/W1/EDocument/App/src/Processing/EDocumentCopilotCapability.EnumExt.al @@ -12,4 +12,8 @@ enumextension 6164 "E-Document Copilot Capability" extends "Copilot Capability" { Caption = 'E-Document analysis'; } + value(6166; "E-Document MLLM Analysis") + { + Caption = 'E-Document MLLM analysis'; + } } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/EDocumentSubscribers.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/EDocumentSubscribers.Codeunit.al index a11862a36e..45adc17822 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/EDocumentSubscribers.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/EDocumentSubscribers.Codeunit.al @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Format; using Microsoft.eServices.EDocument.IO.Peppol; using Microsoft.eServices.EDocument.OrderMatch; using Microsoft.EServices.EDocument.Processing; @@ -30,6 +31,7 @@ using Microsoft.Service.Document; using Microsoft.Service.History; using Microsoft.Service.Posting; using Microsoft.Utilities; +using System.AI; using System.Automation; using System.Reflection; using System.Telemetry; @@ -48,6 +50,14 @@ codeunit 6103 "E-Document Subscribers" DeleteDocumentQst: Label 'This document is linked to E-Document %1. Do you want to continue?', Comment = '%1 - E-Document Entry No.'; + [EventSubscriber(ObjectType::Page, Page::"Copilot AI Capabilities", OnRegisterCopilotCapability, '', false, false)] + local procedure HandleOnRegisterCopilotCapability() + var + EDocumentMLLMHandler: Codeunit "E-Document MLLM Handler"; + begin + EDocumentMLLMHandler.RegisterCopilotCapabilityIfNeeded(); + end; + #region Draft page user edits [EventSubscriber(ObjectType::Page, Page::"E-Document Purchase Draft", OnAfterValidateEvent, "Vendor No.", false, false)] 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 ea1ccaea1d..fa0fe2fb0b 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 @@ -35,4 +35,9 @@ enum 6109 "E-Doc. Read into Draft" implements IStructuredFormatReader Caption = 'PEPPOL'; Implementation = IStructuredFormatReader = "E-Document PEPPOL Handler"; } + value(4; "MLLM") + { + Caption = 'MLLM'; + Implementation = IStructuredFormatReader = "E-Document MLLM Handler"; + } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/FileFormat/EDocPDFFileFormat.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/FileFormat/EDocPDFFileFormat.Codeunit.al index 38d777f721..62be1b354a 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/FileFormat/EDocPDFFileFormat.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/FileFormat/EDocPDFFileFormat.Codeunit.al @@ -6,6 +6,7 @@ namespace Microsoft.EServices.EDocument.Format; using Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument.Processing.Interfaces; +using System.Config; using System.Utilities; codeunit 6191 "E-Doc. PDF File Format" implements IEDocFileFormat @@ -20,12 +21,31 @@ codeunit 6191 "E-Doc. PDF File Format" implements IEDocFileFormat end; procedure PreferredStructureDataImplementation(): Enum "Structure Received E-Doc." + var + FeatureConfiguration: Codeunit "Feature Configuration"; + Result: Enum "Structure Received E-Doc."; + IsExperiment: Boolean; + begin + IsExperiment := FeatureConfiguration.GetConfiguration(MLLMExperimentTok) = 'mllm'; + Result := IsExperiment ? "Structure Received E-Doc."::MLLM : "Structure Received E-Doc."::ADI; + OnAfterSetIStructureReceivedEDocumentForPdf(Result); + exit(Result); + end; + + /// + /// Allows subscribers to override which structure data implementation is used for PDF processing. + /// This is specifically used by the Payables Agent to force MLLM processing on, regardless of the experiment setting. + /// + [IntegrationEvent(false, false)] + local procedure OnAfterSetIStructureReceivedEDocumentForPdf(var Result: Enum "Structure Received E-Doc.") begin - exit("Structure Received E-Doc."::ADI); end; procedure FileExtension(): Text begin exit('pdf'); end; + + var + MLLMExperimentTok: Label 'EDocMLLMExtraction', Locked = true; } 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 6cbb0a83a6..194b0d9adf 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 @@ -26,4 +26,9 @@ enum 6103 "Structure Received E-Doc." implements IStructureReceivedEDocument Caption = 'Azure Document Intelligence'; Implementation = IStructureReceivedEDocument = "E-Document ADI Handler"; } + value(3; "MLLM") + { + Caption = 'MLLM Extraction'; + Implementation = IStructureReceivedEDocument = "E-Document MLLM Handler"; + } } 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 new file mode 100644 index 0000000000..93468d3d62 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocMLLMSchemaHelper.Codeunit.al @@ -0,0 +1,281 @@ +// ------------------------------------------------------------------------------------------------ +// 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.Processing.Import.Purchase; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Foundation.Company; + +codeunit 6232 "E-Doc. MLLM Schema Helper" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure GetDefaultSchema() Value: Text + var + DefaultSchemaFileLbl: Label 'AITools/ubl_example.json', Locked = true; + SchemaJson: JsonObject; + begin + SchemaJson := NavApp.GetResourceAsJson(DefaultSchemaFileLbl, TextEncoding::UTF8); + PrefillCustomerFromCompanyInfo(SchemaJson); + SchemaJson.WriteTo(Value); + end; + + local procedure PrefillCustomerFromCompanyInfo(var SchemaJson: JsonObject) + var + CompanyInformation: Record "Company Information"; + CustomerPartyObj: JsonObject; + PartyObj: JsonObject; + PartyNameObj: JsonObject; + PostalAddressObj: JsonObject; + CountryObj: JsonObject; + TaxSchemeObj: JsonObject; + PartyTaxSchemeObj: JsonObject; + begin + if not CompanyInformation.Get() then + exit; + + PartyNameObj.Add('name', CompanyInformation.Name); + + CountryObj.Add('identification_code', CompanyInformation."Country/Region Code"); + PostalAddressObj.Add('street_name', CompanyInformation.Address); + PostalAddressObj.Add('additional_street_name', CompanyInformation."Address 2"); + PostalAddressObj.Add('city_name', CompanyInformation.City); + PostalAddressObj.Add('postal_zone', CompanyInformation."Post Code"); + PostalAddressObj.Add('country', CountryObj); + + TaxSchemeObj.Add('id', 'VAT'); + PartyTaxSchemeObj.Add('company_id', CompanyInformation."VAT Registration No."); + PartyTaxSchemeObj.Add('tax_scheme', TaxSchemeObj); + + PartyObj.Add('party_name', PartyNameObj); + PartyObj.Add('postal_address', PostalAddressObj); + PartyObj.Add('party_tax_scheme', PartyTaxSchemeObj); + + CustomerPartyObj.Add('party', PartyObj); + SchemaJson.Replace('accounting_customer_party', CustomerPartyObj); + end; + +#pragma warning disable AA0139 + procedure MapHeaderFromJson(HeaderObj: JsonObject; var TempHeader: Record "E-Document Purchase Header" temporary) + var + NestedObj: JsonObject; + NestedObj2: JsonObject; + NestedObj3: JsonObject; + CurrencyText: Text; + begin + GetString(HeaderObj, 'id', MaxStrLen(TempHeader."Sales Invoice No."), TempHeader."Sales Invoice No."); + GetDate(HeaderObj, 'issue_date', TempHeader."Document Date"); + GetDate(HeaderObj, 'due_date', TempHeader."Due Date"); + + GetString(HeaderObj, 'document_currency_code', MaxStrLen(TempHeader."Currency Code"), CurrencyText); + SetCurrencyCode(CurrencyText, TempHeader."Currency Code"); + + if GetNestedObject(HeaderObj, 'order_reference', NestedObj) then + GetString(NestedObj, 'id', MaxStrLen(TempHeader."Purchase Order No."), TempHeader."Purchase Order No."); + + if GetNestedObject(HeaderObj, 'payment_terms', NestedObj) then + GetString(NestedObj, 'note', MaxStrLen(TempHeader."Payment Terms"), TempHeader."Payment Terms"); + + if GetNestedObject(HeaderObj, 'accounting_supplier_party', NestedObj) then + if GetNestedObject(NestedObj, 'party', NestedObj2) then begin + if GetNestedObject(NestedObj2, 'party_name', NestedObj3) then + GetString(NestedObj3, 'name', MaxStrLen(TempHeader."Vendor Company Name"), TempHeader."Vendor Company Name"); + if GetNestedObject(NestedObj2, 'postal_address', NestedObj3) then + BuildAddress(NestedObj3, MaxStrLen(TempHeader."Vendor Address"), TempHeader."Vendor Address"); + if GetNestedObject(NestedObj2, 'party_tax_scheme', NestedObj3) then + GetString(NestedObj3, 'company_id', MaxStrLen(TempHeader."Vendor VAT Id"), TempHeader."Vendor VAT Id"); + if GetNestedObject(NestedObj2, 'contact', NestedObj3) then + GetString(NestedObj3, 'name', MaxStrLen(TempHeader."Vendor Contact Name"), TempHeader."Vendor Contact Name"); + end; + + if GetNestedObject(HeaderObj, 'accounting_customer_party', NestedObj) then + if GetNestedObject(NestedObj, 'party', NestedObj2) then begin + if GetNestedObject(NestedObj2, 'party_name', NestedObj3) then + GetString(NestedObj3, 'name', MaxStrLen(TempHeader."Customer Company Name"), TempHeader."Customer Company Name"); + if GetNestedObject(NestedObj2, 'postal_address', NestedObj3) then + BuildAddress(NestedObj3, MaxStrLen(TempHeader."Customer Address"), TempHeader."Customer Address"); + if GetNestedObject(NestedObj2, 'party_tax_scheme', NestedObj3) then + GetString(NestedObj3, 'company_id', MaxStrLen(TempHeader."Customer VAT Id"), TempHeader."Customer VAT Id"); + end; + + if GetNestedObject(HeaderObj, 'delivery', NestedObj) then begin + if GetNestedObject(NestedObj, 'delivery_location', NestedObj2) then + if GetNestedObject(NestedObj2, 'address', NestedObj3) then + BuildAddress(NestedObj3, MaxStrLen(TempHeader."Shipping Address"), TempHeader."Shipping Address"); + if GetNestedObject(NestedObj, 'delivery_party', NestedObj2) then + if GetNestedObject(NestedObj2, 'party_name', NestedObj3) then + GetString(NestedObj3, 'name', MaxStrLen(TempHeader."Shipping Address Recipient"), TempHeader."Shipping Address Recipient"); + end; + + if GetNestedObject(HeaderObj, 'payment_means', NestedObj) then + if GetNestedObject(NestedObj, 'payee_financial_account', NestedObj2) then + GetString(NestedObj2, 'name', MaxStrLen(TempHeader."Remittance Address Recipient"), TempHeader."Remittance Address Recipient"); + + if GetNestedObject(HeaderObj, 'tax_total', NestedObj) then + GetDecimal(NestedObj, 'tax_amount', TempHeader."Total VAT"); + + if GetNestedObject(HeaderObj, 'legal_monetary_total', NestedObj) then begin + GetDecimal(NestedObj, 'tax_exclusive_amount', TempHeader."Sub Total"); + GetDecimal(NestedObj, 'allowance_total_amount', TempHeader."Total Discount"); + GetDecimal(NestedObj, 'payable_amount', TempHeader.Total); + GetDecimal(NestedObj, 'payable_amount', TempHeader."Amount Due"); + end; + end; + + procedure MapLinesFromJson(LinesArray: JsonArray; EDocEntryNo: Integer; var TempLine: Record "E-Document Purchase Line" temporary) + var + LineToken: JsonToken; + LineObj: JsonObject; + NestedObj: JsonObject; + NestedObj2: JsonObject; + LineNumber: Integer; + begin + TempLine.DeleteAll(); + + for LineNumber := 0 to LinesArray.Count() - 1 do + if LinesArray.Get(LineNumber, LineToken) then begin + Clear(TempLine); + TempLine."E-Document Entry No." := EDocEntryNo; + TempLine."Line No." := 10000 + (LineNumber * 10000); + + LineObj := LineToken.AsObject(); + + if GetNestedObject(LineObj, 'item', NestedObj) then begin + GetString(NestedObj, 'name', MaxStrLen(TempLine.Description), TempLine.Description); + if GetNestedObject(NestedObj, 'sellers_item_identification', NestedObj2) then + GetString(NestedObj2, 'id', MaxStrLen(TempLine."Product Code"), TempLine."Product Code"); + if GetNestedObject(NestedObj, 'classified_tax_category', NestedObj2) then + GetDecimal(NestedObj2, 'percent', TempLine."VAT Rate"); + end; + + if GetNestedObject(LineObj, 'invoiced_quantity', NestedObj) then begin + GetDecimal(NestedObj, 'value', TempLine.Quantity); + GetString(NestedObj, 'unit_code', MaxStrLen(TempLine."Unit of Measure"), TempLine."Unit of Measure"); + end; + if TempLine.Quantity <= 0 then + TempLine.Quantity := 1; + + if GetNestedObject(LineObj, 'price', NestedObj) then + GetDecimal(NestedObj, 'price_amount', TempLine."Unit Price"); + + GetDecimal(LineObj, 'line_extension_amount', TempLine."Sub Total"); + + if GetNestedObject(LineObj, 'allowance_charge', NestedObj) then + if GetNestedObject(NestedObj, 'amount', NestedObj2) then + GetDecimal(NestedObj2, 'value', TempLine."Total Discount"); + + TempLine.Insert(); + end; + end; +#pragma warning restore AA0139 + + local procedure GetString(JsonObj: JsonObject; PropertyName: Text; MaxLen: Integer; var FieldValue: Text) + var + JsonToken: JsonToken; + TextValue: Text; + begin + if not JsonObj.Get(PropertyName, JsonToken) then + exit; + if JsonToken.AsValue().IsNull() then + exit; + TextValue := JsonToken.AsValue().AsText(); + if StrLen(TextValue) > MaxLen then + TextValue := CopyStr(TextValue, 1, MaxLen); + FieldValue := TextValue; + end; + + local procedure GetDate(JsonObj: JsonObject; PropertyName: Text; var FieldValue: Date) + var + JsonToken: JsonToken; + DateText: Text; + DateValue: Date; + begin + if not JsonObj.Get(PropertyName, JsonToken) then + exit; + if JsonToken.AsValue().IsNull() then + exit; + DateText := JsonToken.AsValue().AsText(); + if DateText = '' then + exit; + if Evaluate(DateValue, DateText, 9) then + FieldValue := DateValue; + end; + + local procedure GetDecimal(JsonObj: JsonObject; PropertyName: Text; var FieldValue: Decimal) + var + JsonToken: JsonToken; + begin + if not JsonObj.Get(PropertyName, JsonToken) then + exit; + if JsonToken.AsValue().IsNull() then + exit; + FieldValue := JsonToken.AsValue().AsDecimal(); + end; + + local procedure GetNestedObject(JsonObj: JsonObject; PropertyName: Text; var NestedObj: JsonObject): Boolean + var + JsonToken: JsonToken; + begin + if not JsonObj.Get(PropertyName, JsonToken) then + exit(false); + if not JsonToken.IsObject() then + exit(false); + NestedObj := JsonToken.AsObject(); + exit(true); + end; + + local procedure BuildAddress(PostalAddressObj: JsonObject; MaxLen: Integer; var FieldValue: Text) + var + CountryObj: JsonObject; + Street: Text; + AdditionalStreet: Text; + City: Text; + PostalZone: Text; + CountryCode: Text; + Address: Text; + begin + GetString(PostalAddressObj, 'street_name', 250, Street); + GetString(PostalAddressObj, 'additional_street_name', 250, AdditionalStreet); + GetString(PostalAddressObj, 'city_name', 250, City); + GetString(PostalAddressObj, 'postal_zone', 250, PostalZone); + if GetNestedObject(PostalAddressObj, 'country', CountryObj) then + GetString(CountryObj, 'identification_code', 250, CountryCode); + + Address := Street; + AppendToAddress(Address, AdditionalStreet, ', '); + AppendToAddress(Address, City, ', '); + AppendToAddress(Address, PostalZone, ' '); + AppendToAddress(Address, CountryCode, ', '); + + if StrLen(Address) > MaxLen then + Address := CopyStr(Address, 1, MaxLen); + FieldValue := Address; + end; + + local procedure AppendToAddress(var Address: Text; Part: Text; Separator: Text) + begin + if Part = '' then + exit; + if Address <> '' then + Address += Separator; + Address += Part; + end; + + local procedure SetCurrencyCode(CurrencyText: Text; var CurrencyCode: Code[10]) + var + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + if CurrencyText = '' then + exit; + + GeneralLedgerSetup.Get(); + if UpperCase(CurrencyText) = GeneralLedgerSetup."LCY Code" then + exit; + + CurrencyCode := CopyStr(UpperCase(CurrencyText), 1, MaxStrLen(CurrencyCode)); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPurchaseDraftUtility.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPurchaseDraftUtility.Codeunit.al new file mode 100644 index 0000000000..70c9f96a07 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocPurchaseDraftUtility.Codeunit.al @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +codeunit 6234 "E-Doc. Purchase Draft Utility" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure PersistDraft(EDocument: Record "E-Document"; var TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; var TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + // Clean up old data, since we are re-reading data + EDocumentPurchaseHeader.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocumentPurchaseHeader.DeleteAll(); + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocumentPurchaseLine.DeleteAll(); + + EDocumentPurchaseHeader := TempEDocPurchaseHeader; + EDocumentPurchaseHeader."E-Document Entry No." := EDocument."Entry No"; + EDocumentPurchaseHeader.Insert(); + OnInsertedEDocumentPurchaseHeader(EDocument, EDocumentPurchaseHeader); + + if TempEDocPurchaseLine.FindSet() then begin + repeat + EDocumentPurchaseLine := TempEDocPurchaseLine; + EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; + EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); + EDocumentPurchaseLine.Insert(); + until TempEDocPurchaseLine.Next() = 0; + + OnInsertedEDocumentPurchaseLines(EDocument, EDocumentPurchaseHeader, EDocumentPurchaseLine); + end; + end; + + [InternalEvent(false, false)] + local procedure OnInsertedEDocumentPurchaseHeader(EDocument: Record "E-Document"; EDocumentPurchaseHeader: Record "E-Document Purchase Header") + begin + end; + + [InternalEvent(false, false)] + local procedure OnInsertedEDocumentPurchaseLines(EDocument: Record "E-Document"; EDocumentPurchaseHeader: Record "E-Document Purchase Header"; EDocumentPurchaseLine: Record "E-Document Purchase Line") + begin + end; +} 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 c7a2b995ac..1e2c79bca8 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 @@ -17,6 +17,8 @@ using System.Utilities; codeunit 6174 "E-Document ADI Handler" implements IStructureReceivedEDocument, IStructuredFormatReader, IStructuredDataType { Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; var EDocumentJsonHelper: Codeunit "EDocument Json Helper"; @@ -73,32 +75,10 @@ codeunit 6174 "E-Document ADI Handler" implements IStructureReceivedEDocument, I var TempEDocPurchaseHeader: Record "E-Document Purchase Header" temporary; TempEDocPurchaseLine: Record "E-Document Purchase Line" temporary; - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocPurchaseDraftUtility: Codeunit "E-Doc. Purchase Draft Utility"; begin - // Clean up old data, since we are re-reading data - EDocumentPurchaseHeader.SetRange("E-Document Entry No.", EDocument."Entry No"); - EDocumentPurchaseHeader.DeleteAll(); - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocument."Entry No"); - EDocumentPurchaseLine.DeleteAll(); - ReadIntoBuffer(EDocument, TempBlob, TempEDocPurchaseHeader, TempEDocPurchaseLine); - EDocumentPurchaseHeader := TempEDocPurchaseHeader; - EDocumentPurchaseHeader."E-Document Entry No." := EDocument."Entry No"; - EDocumentPurchaseHeader.Insert(); - OnInsertedEDocumentPurchaseHeader(EDocument, EDocumentPurchaseHeader); - - if TempEDocPurchaseLine.FindSet() then begin - repeat - EDocumentPurchaseLine := TempEDocPurchaseLine; - EDocumentPurchaseLine."E-Document Entry No." := EDocument."Entry No"; - EDocumentPurchaseLine."Line No." := EDocumentPurchaseLine.GetNextLineNo(EDocument."Entry No"); - EDocumentPurchaseLine.Insert(); - until TempEDocPurchaseLine.Next() = 0; - - OnInsertedEDocumentPurchaseLines(EDocument, EDocumentPurchaseHeader, EDocumentPurchaseLine); - end; - + EDocPurchaseDraftUtility.PersistDraft(EDocument, TempEDocPurchaseHeader, TempEDocPurchaseLine); exit(Enum::"E-Doc. Process Draft"::"Purchase Document"); end; @@ -207,14 +187,4 @@ codeunit 6174 "E-Document ADI Handler" implements IStructureReceivedEDocument, I TempEDocPurchaseLine."Total Discount" := (TempEDocPurchaseLine."Unit Price" * TempEDocPurchaseLine.Quantity) - TempEDocPurchaseLine."Sub Total"; end; #pragma warning restore AA0139 - - [InternalEvent(false, false)] - local procedure OnInsertedEDocumentPurchaseHeader(EDocument: Record "E-Document"; EDocumentPurchaseHeader: Record "E-Document Purchase Header") - begin - end; - - [InternalEvent(false, false)] - local procedure OnInsertedEDocumentPurchaseLines(EDocument: Record "E-Document"; EDocumentPurchaseHeader: Record "E-Document Purchase Header"; EDocumentPurchaseLine: Record "E-Document Purchase Line") - begin - end; } 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 new file mode 100644 index 0000000000..613913c963 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/StructureReceivedEDocument/EDocumentMLLMHandler.Codeunit.al @@ -0,0 +1,294 @@ +// ------------------------------------------------------------------------------------------------ +// 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.Telemetry; +using System.Text; +using System.Utilities; + +codeunit 6231 "E-Document MLLM Handler" 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', Locked = true; + FileDataLbl: Label 'data:application/pdf;base64,%1', Locked = true; + SystemPromptResourceTok: Label 'Prompts/EDocMLLMExtraction-SystemPrompt.md', Locked = true; + UserPromptLbl: Label 'Extract invoice data into this UBL JSON structure: %1. \n\nExtract ONLY visible values. Return JSON only.', Locked = true; + MLLMExtractionStartedMsg: Label 'MLLM extraction started.', Locked = true; + MLLMExtractionSucceededMsg: Label 'MLLM extraction succeeded.', Locked = true; + MLLMApiCallSucceededMsg: Label 'MLLM API call succeeded.', Locked = true; + MLLMApiCallFailedMsg: Label 'MLLM API call failed, falling back to ADI.', Locked = true; + MLLMEmptyResponseMsg: Label 'MLLM returned empty response, falling back to ADI.', Locked = true; + MLLMJsonParseFailedMsg: Label 'MLLM response is not valid JSON, falling back to ADI.', Locked = true; + MLLMSchemaValidationFailedMsg: Label 'MLLM response missing required vendor fields (name or address), falling back to ADI.', Locked = true; + ADIFallbackSucceededMsg: Label 'ADI fallback produced structured data.', Locked = true; + ADIFallbackFailedMsg: Label 'ADI fallback returned empty result.', Locked = true; + + procedure StructureReceivedEDocument(EDocumentDataStorage: Record "E-Doc. Data Storage"): Interface IStructuredDataType + var + ResponseJson: JsonObject; + CustomDimensions: Dictionary of [Text, Text]; + ResponseText: Text; + begin + Telemetry.LogMessage('0000SGQ', MLLMExtractionStartedMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()); + + RegisterCopilotCapabilityIfNeeded(); + + ResponseText := CallMLLM(EDocumentDataStorage); + + if not ValidateAndUnwrapResponse(ResponseText, ResponseJson) then + exit(FallbackToADI(EDocumentDataStorage)); + + StructuredData := ResponseText; + FileFormat := "E-Doc. File Format"::JSON; + + CustomDimensions := GetCustomDimensions(); + CustomDimensions.Add('LineCount', Format(GetInvoiceLineCount(ResponseJson))); + Telemetry.LogMessage('0000SGR', MLLMExtractionSucceededMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, CustomDimensions); + + exit(this); + end; + + local procedure CallMLLM(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"; + FromTempBlob: Codeunit "Temp Blob"; + CustomDimensions: Dictionary of [Text, Text]; + InStream: InStream; + Base64Data: Text; + StartTime: DateTime; + DurationMs: Integer; + begin + // Load schema and convert PDF to base64 + FromTempBlob := EDocumentDataStorage.GetTempBlob(); + FromTempBlob.CreateInStream(InStream, TextEncoding::UTF8); + Base64Data := Base64Convert.ToBase64(InStream); + + // Build AOAI call + AzureOpenAI.SetAuthorization(Enum::"AOAI Model Type"::"Chat Completions", AOAIDeployments.GetGPT41MiniPreview()); + AzureOpenAI.SetCopilotCapability(Enum::"Copilot Capability"::"E-Document MLLM Analysis"); + + AOAIChatCompletionParams.SetTemperature(0); + AOAIChatCompletionParams.SetJsonMode(true); + + AOAIChatMessages.SetPrimarySystemMessage(NavApp.GetResourceAsText(SystemPromptResourceTok, TextEncoding::UTF8)); + + AOAIUserMessage.AddFilePart(StrSubstNo(FileDataLbl, Base64Data)); + AOAIUserMessage.AddTextPart(StrSubstNo(UserPromptLbl, EDocMLLMSchemaHelper.GetDefaultSchema())); + AOAIChatMessages.AddUserMessage(AOAIUserMessage); + + StartTime := CurrentDateTime(); + AzureOpenAI.GenerateChatCompletion(AOAIChatMessages, AOAIChatCompletionParams, AOAIOperationResponse); + DurationMs := CurrentDateTime() - StartTime; + + CustomDimensions := GetCustomDimensions(); + CustomDimensions.Add('DurationMs', Format(DurationMs)); + + if not AOAIOperationResponse.IsSuccess() then begin + Telemetry.LogMessage('0000SGS', MLLMApiCallFailedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, CustomDimensions); + exit(''); + end; + + Telemetry.LogMessage('0000SGT', MLLMApiCallSucceededMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, CustomDimensions); + exit(AOAIOperationResponse.GetResult()); + end; + + local procedure ValidateAndUnwrapResponse(var ResponseText: Text; var ResponseJson: JsonObject): Boolean + var + ContentToken: JsonToken; + begin + if ResponseText = '' then begin + Telemetry.LogMessage('0000SGU', MLLMEmptyResponseMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()); + exit(false); + end; + + if not ResponseJson.ReadFrom(ResponseText) then begin + Telemetry.LogMessage('0000SGV', MLLMJsonParseFailedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()); + exit(false); + end; + + // Unwrap 'content' wrapper if AOAI wrapped the response + if ResponseJson.Get('content', ContentToken) then begin + ResponseText := ContentToken.AsValue().AsText(); + if not ResponseJson.ReadFrom(ResponseText) then begin + Telemetry.LogMessage('0000SGW', MLLMJsonParseFailedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()); + exit(false); + end; + end; + + if not ValidateMLLMResponse(ResponseJson) then begin + Telemetry.LogMessage('0000SGX', MLLMSchemaValidationFailedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()); + exit(false); + end; + + exit(true); + end; + + local procedure FallbackToADI(EDocumentDataStorage: Record "E-Doc. Data Storage"): Interface IStructuredDataType + var + ADIHandler: Codeunit "E-Document ADI Handler"; + ADIResult: Interface IStructuredDataType; + begin + ADIResult := ADIHandler.StructureReceivedEDocument(EDocumentDataStorage); + + if ADIResult.GetContent() <> '' then + Telemetry.LogMessage('0000SGY', ADIFallbackSucceededMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()) + else + Telemetry.LogMessage('0000SGZ', ADIFallbackFailedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, GetCustomDimensions()); + + exit(ADIResult); + 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 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 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; + + 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 Document"); + 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; + + local procedure GetCustomDimensions(): Dictionary of [Text, Text] + var + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('Category', FeatureNameLbl); + exit(CustomDimensions); + 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; +} diff --git a/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-header-full.json b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-header-full.json new file mode 100644 index 0000000000..38e4584d94 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-header-full.json @@ -0,0 +1,81 @@ +{ + "id": "MLLM-INV-001", + "issue_date": "2024-03-15", + "due_date": "2024-04-15", + "document_currency_code": "XYZ", + "order_reference": { + "id": "PO-5678" + }, + "payment_terms": { + "note": "Net 30" + }, + "accounting_supplier_party": { + "party": { + "party_name": { + "name": "Contoso Supplies Ltd." + }, + "postal_address": { + "street_name": "123 Bill Ave", + "city_name": "Seattle", + "postal_zone": "98101", + "country": { + "identification_code": "US" + } + }, + "party_tax_scheme": { + "company_id": "US-VAT-12345" + }, + "contact": { + "name": "John Doe" + } + } + }, + "accounting_customer_party": { + "party": { + "party_name": { + "name": "Microsoft Corporation" + }, + "postal_address": { + "street_name": "456 Main St", + "city_name": "Redmond", + "postal_zone": "98052", + "country": { + "identification_code": "US" + } + }, + "party_tax_scheme": { + "company_id": "US-VAT-67890" + } + } + }, + "delivery": { + "delivery_location": { + "address": { + "street_name": "789 Ship Rd", + "city_name": "Bellevue", + "postal_zone": "98004", + "country": { + "identification_code": "US" + } + } + }, + "delivery_party": { + "party_name": { + "name": "Warehouse Team" + } + } + }, + "payment_means": { + "payee_financial_account": { + "name": "Contoso Billing Dept" + } + }, + "tax_total": { + "tax_amount": 37.5 + }, + "legal_monetary_total": { + "tax_exclusive_amount": 250.0, + "allowance_total_amount": 5.0, + "payable_amount": 287.5 + } +} diff --git a/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-empty.json b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-empty.json new file mode 100644 index 0000000000..05641b2c6c --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-empty.json @@ -0,0 +1,3 @@ +{ + "invoice_line": [] +} diff --git a/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-valid-0.json b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-valid-0.json new file mode 100644 index 0000000000..89a3b10fe9 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-invoice-valid-0.json @@ -0,0 +1,155 @@ +{ + "id": "MLLM-INV-001", + "issue_date": "2024-03-15", + "due_date": "2024-04-15", + "document_currency_code": "XYZ", + "order_reference": { + "id": "PO-5678" + }, + "payment_terms": { + "note": "Net 30" + }, + "accounting_supplier_party": { + "party": { + "party_name": { + "name": "Contoso Supplies Ltd." + }, + "postal_address": { + "street_name": "123 Bill Ave", + "city_name": "Seattle", + "postal_zone": "98101", + "country": { + "identification_code": "US" + } + }, + "party_tax_scheme": { + "company_id": "US-VAT-12345" + }, + "contact": { + "name": "John Doe" + } + } + }, + "accounting_customer_party": { + "party": { + "party_name": { + "name": "Microsoft Corporation" + }, + "postal_address": { + "street_name": "456 Main St", + "city_name": "Redmond", + "postal_zone": "98052", + "country": { + "identification_code": "US" + } + }, + "party_tax_scheme": { + "company_id": "US-VAT-67890" + } + } + }, + "delivery": { + "delivery_location": { + "address": { + "street_name": "789 Ship Rd", + "city_name": "Bellevue", + "postal_zone": "98004", + "country": { + "identification_code": "US" + } + } + }, + "delivery_party": { + "party_name": { + "name": "Warehouse Team" + } + } + }, + "payment_means": { + "payee_financial_account": { + "name": "Contoso Billing Dept" + } + }, + "tax_total": { + "tax_amount": 37.50 + }, + "legal_monetary_total": { + "tax_exclusive_amount": 250.00, + "allowance_total_amount": 5.00, + "payable_amount": 287.50 + }, + "invoice_line": [ + { + "item": { + "name": "Consulting Services", + "sellers_item_identification": { + "id": "SVC-001" + }, + "classified_tax_category": { + "percent": 15.0 + } + }, + "invoiced_quantity": { + "value": 5, + "unit_code": "HRS" + }, + "price": { + "price_amount": 40.00 + }, + "line_extension_amount": 200.00, + "allowance_charge": { + "amount": { + "value": 5.00 + } + } + }, + { + "item": { + "name": "Office Supplies", + "sellers_item_identification": { + "id": "MAT-002" + }, + "classified_tax_category": { + "percent": 10.0 + } + }, + "invoiced_quantity": { + "value": 10, + "unit_code": "PCS" + }, + "price": { + "price_amount": 3.00 + }, + "line_extension_amount": 30.00, + "allowance_charge": { + "amount": { + "value": 0.00 + } + } + }, + { + "item": { + "name": "Express Delivery", + "sellers_item_identification": { + "id": "DLV-003" + }, + "classified_tax_category": { + "percent": 15.0 + } + }, + "invoiced_quantity": { + "value": 1, + "unit_code": "EA" + }, + "price": { + "price_amount": 20.00 + }, + "line_extension_amount": 20.00, + "allowance_charge": { + "amount": { + "value": 0.00 + } + } + } + ] +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-lines-three.json b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-lines-three.json new file mode 100644 index 0000000000..b08b843850 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/.resources/mllm/mllm-lines-three.json @@ -0,0 +1,74 @@ +[ + { + "item": { + "name": "Consulting Services", + "sellers_item_identification": { + "id": "SVC-001" + }, + "classified_tax_category": { + "percent": 15.0 + } + }, + "invoiced_quantity": { + "value": 5, + "unit_code": "HRS" + }, + "price": { + "price_amount": 40.0 + }, + "line_extension_amount": 200.0, + "allowance_charge": { + "amount": { + "value": 5.0 + } + } + }, + { + "item": { + "name": "Office Supplies", + "sellers_item_identification": { + "id": "MAT-002" + }, + "classified_tax_category": { + "percent": 10.0 + } + }, + "invoiced_quantity": { + "value": 10, + "unit_code": "PCS" + }, + "price": { + "price_amount": 3.0 + }, + "line_extension_amount": 30.0, + "allowance_charge": { + "amount": { + "value": 0.0 + } + } + }, + { + "item": { + "name": "Express Delivery", + "sellers_item_identification": { + "id": "DLV-003" + }, + "classified_tax_category": { + "percent": 15.0 + } + }, + "invoiced_quantity": { + "value": 1, + "unit_code": "EA" + }, + "price": { + "price_amount": 20.0 + }, + "line_extension_amount": 20.0, + "allowance_charge": { + "amount": { + "value": 0.0 + } + } + } +] diff --git a/src/Apps/W1/EDocument/Test/app.json b/src/Apps/W1/EDocument/Test/app.json index fb4da654e3..9f20675994 100644 --- a/src/Apps/W1/EDocument/Test/app.json +++ b/src/Apps/W1/EDocument/Test/app.json @@ -75,6 +75,10 @@ { "from": 135575, "to": 135575 + }, + { + "from": 135647, + "to": 135649 } ], diff --git a/src/Apps/W1/EDocument/Test/src/Processing/CAPIStructuredValidations.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/CAPIStructuredValidations.Codeunit.al deleted file mode 100644 index 55ff412073..0000000000 --- a/src/Apps/W1/EDocument/Test/src/Processing/CAPIStructuredValidations.Codeunit.al +++ /dev/null @@ -1,107 +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.Test; - -using Microsoft.eServices.EDocument.Processing.Import.Purchase; - -codeunit 139894 "CAPI Structured Validations" -{ - - var - Assert: Codeunit Assert; - - internal procedure AssertFullEDocumentContentExtracted(EDocumentEntryNo: Integer) - var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - begin - EDocumentPurchaseHeader.Get(EDocumentEntryNo); - - Assert.AreEqual('MICROSOFT CORPORATION', EDocumentPurchaseHeader."Customer Company Name", 'The customer company name does not allign with the mock data.'); - Assert.AreEqual('CID-12345', EDocumentPurchaseHeader."Customer Company Id", 'The customer company id does not allign with the mock data.'); - Assert.AreEqual('PO-3333', EDocumentPurchaseHeader."Purchase Order No.", 'The purchase order number does not allign with the mock data.'); - Assert.AreEqual('INV-100', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(15, 12, 2019), EDocumentPurchaseHeader."Due Date", 'The due date does not allign with the mock data.'); - Assert.AreEqual('CONTOSO LTD.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not allign with the mock data.'); - Assert.AreEqual('123 456th St New York, NY, 10001', EDocumentPurchaseHeader."Vendor Address", 'The vendor address does not allign with the mock data.'); - Assert.AreEqual('Contoso Headquarters', EDocumentPurchaseHeader."Vendor Address Recipient", 'The vendor address recipient does not allign with the mock data.'); - Assert.AreEqual('123 Other St, Redmond WA, 98052', EDocumentPurchaseHeader."Customer Address", 'The customer address does not allign with the mock data.'); - Assert.AreEqual('Microsoft Corp', EDocumentPurchaseHeader."Customer Address Recipient", 'The customer address recipient does not allign with the mock data.'); - Assert.AreEqual('123 Bill St, Redmond WA, 98052', EDocumentPurchaseHeader."Billing Address", 'The billing address does not allign with the mock data.'); - Assert.AreEqual('Microsoft Finance', EDocumentPurchaseHeader."Billing Address Recipient", 'The billing address recipient does not allign with the mock data.'); - Assert.AreEqual('123 Ship St, Redmond WA, 98052', EDocumentPurchaseHeader."Shipping Address", 'The shipping address does not allign with the mock data.'); - Assert.AreEqual('Microsoft Delivery', EDocumentPurchaseHeader."Shipping Address Recipient", 'The shipping address recipient does not allign with the mock data.'); - Assert.AreEqual(100, EDocumentPurchaseHeader."Sub Total", 'The sub total does not allign with the mock data.'); - Assert.AreEqual(10, EDocumentPurchaseHeader."Total VAT", 'The total tax does not allign with the mock data.'); - Assert.AreEqual(110, EDocumentPurchaseHeader.Total, 'The total does not allign with the mock data.'); - Assert.AreEqual(610, EDocumentPurchaseHeader."Amount Due", 'The amount due does not allign with the mock data.'); - Assert.AreEqual(500, EDocumentPurchaseHeader."Previous Unpaid Balance", 'The previous unpaid balance does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not allign with the mock data.'); - Assert.AreEqual('123 Remit St New York, NY, 10001', EDocumentPurchaseHeader."Remittance Address", 'The remittance address does not allign with the mock data.'); - Assert.AreEqual('Contoso Billing', EDocumentPurchaseHeader."Remittance Address Recipient", 'The remittance address recipient does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(14, 10, 2019), EDocumentPurchaseHeader."Service Start Date", 'The service start date does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(14, 11, 2019), EDocumentPurchaseHeader."Service End Date", 'The service end date does not allign with the mock data.'); - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.FindSet(); - Assert.AreEqual(60, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); - Assert.AreEqual('Consulting Services', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); - Assert.AreEqual(30, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); - Assert.AreEqual(2, EDocumentPurchaseLine.Quantity, 'The quantity in the purchase line does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); - Assert.AreEqual('A123', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); - Assert.AreEqual('hours', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(4, 3, 2021), EDocumentPurchaseLine.Date, 'The date in the purchase line does not allign with the mock data.'); - Assert.AreEqual(6, EDocumentPurchaseLine."VAT Rate", 'The amount in the purchase line does not allign with the mock data.'); - - EDocumentPurchaseLine.Next(); - Assert.AreEqual(30, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); - Assert.AreEqual('Document Fee', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); - Assert.AreEqual(10, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); - Assert.AreEqual(3, EDocumentPurchaseLine.Quantity, 'The quantity in the purchase line does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); - Assert.AreEqual('B456', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); - Assert.AreEqual('', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(5, 3, 2021), EDocumentPurchaseLine.Date, 'The date in the purchase line does not allign with the mock data.'); - Assert.AreEqual(3, EDocumentPurchaseLine."VAT Rate", 'The amount in the purchase line does not allign with the mock data.'); - - EDocumentPurchaseLine.Next(); - Assert.AreEqual(10, EDocumentPurchaseLine."Sub Total", 'The amount does not allign with the mock data.'); - Assert.AreEqual('Printing Fee', EDocumentPurchaseLine.Description, 'The description does not allign with the mock data.'); - Assert.AreEqual(1, EDocumentPurchaseLine."Unit Price", 'The unit price does not allign with the mock data.'); - Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'The quantity does not allign with the mock data.'); - Assert.AreEqual('C789', EDocumentPurchaseLine."Product Code", 'The product code does not allign with the mock data.'); - Assert.AreEqual('pages', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(6, 3, 2021), EDocumentPurchaseLine.Date, 'The date does not allign with the mock data.'); - Assert.AreEqual(1, EDocumentPurchaseLine."VAT Rate", 'The amount does not allign with the mock data.'); - end; - - internal procedure AssertMinimalEDocumentContentParsed(EDocumentEntryNo: Integer) - var - EDocumentPurchaseHeader: Record "E-Document Purchase Header"; - EDocumentPurchaseLine: Record "E-Document Purchase Line"; - begin - EDocumentPurchaseHeader.Get(EDocumentEntryNo); - - Assert.AreEqual('INV-100', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(15, 12, 2019), EDocumentPurchaseHeader."Due Date", 'The due date does not allign with the mock data.'); - Assert.AreEqual('CONTOSO LTD.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not allign with the mock data.'); - Assert.AreEqual(110, EDocumentPurchaseHeader.Total, 'The total does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not allign with the mock data.'); - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.FindSet(); - Assert.AreEqual(60, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); - Assert.AreEqual('Consulting Services', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); - - EDocumentPurchaseLine.Next(); - Assert.AreEqual(30, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); - Assert.AreEqual('Document Fee', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); - - EDocumentPurchaseLine.Next(); - Assert.AreEqual(10, EDocumentPurchaseLine."Sub Total", 'The amount does not allign with the mock data.'); - Assert.AreEqual('Printing Fee', EDocumentPurchaseLine.Description, 'The description does not allign with the mock data.'); - end; -} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al new file mode 100644 index 0000000000..f3daf6bae7 --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMLLMTests.Codeunit.al @@ -0,0 +1,293 @@ +// ------------------------------------------------------------------------------------------------ +// 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.Format; +using Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.Finance.GeneralLedger.Setup; +using System.TestLibraries.Config; + +codeunit 135647 "EDoc MLLM Tests" +{ + Subtype = Test; + EventSubscriberInstance = Manual; + + var + Assert: Codeunit Assert; + LibraryLowerPermission: Codeunit "Library - Lower Permissions"; + + [Test] + procedure MapHeader_FullInvoice() + var + TempHeader: Record "E-Document Purchase Header" temporary; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + HeaderObj: JsonObject; + begin + // [SCENARIO] MapHeaderFromJson maps all fields from a complete UBL invoice JSON + LibraryLowerPermission.SetOutsideO365Scope(); + EnsureGLSetup(); + + HeaderObj := BuildFullHeaderJson(); + + EDocMLLMSchemaHelper.MapHeaderFromJson(HeaderObj, TempHeader); + + Assert.AreEqual('MLLM-INV-001', TempHeader."Sales Invoice No.", 'Sales Invoice No.'); + Assert.AreEqual(DMY2Date(15, 3, 2024), TempHeader."Document Date", 'Document Date'); + Assert.AreEqual(DMY2Date(15, 4, 2024), TempHeader."Due Date", 'Due Date'); + Assert.AreEqual('XYZ', TempHeader."Currency Code", 'Currency Code'); + Assert.AreEqual('PO-5678', TempHeader."Purchase Order No.", 'Purchase Order No.'); + Assert.AreEqual('Net 30', TempHeader."Payment Terms", 'Payment Terms'); + Assert.AreEqual('Contoso Supplies Ltd.', TempHeader."Vendor Company Name", 'Vendor Company Name'); + Assert.AreEqual('123 Bill Ave, Seattle 98101, US', TempHeader."Vendor Address", 'Vendor Address'); + Assert.AreEqual('US-VAT-12345', TempHeader."Vendor VAT Id", 'Vendor VAT Id'); + Assert.AreEqual('John Doe', TempHeader."Vendor Contact Name", 'Vendor Contact Name'); + Assert.AreEqual('Microsoft Corporation', TempHeader."Customer Company Name", 'Customer Company Name'); + Assert.AreEqual('456 Main St, Redmond 98052, US', TempHeader."Customer Address", 'Customer Address'); + Assert.AreEqual('US-VAT-67890', TempHeader."Customer VAT Id", 'Customer VAT Id'); + Assert.AreEqual('789 Ship Rd, Bellevue 98004, US', TempHeader."Shipping Address", 'Shipping Address'); + Assert.AreEqual('Warehouse Team', TempHeader."Shipping Address Recipient", 'Shipping Address Recipient'); + Assert.AreEqual('Contoso Billing Dept', TempHeader."Remittance Address Recipient", 'Remittance Address Recipient'); + Assert.AreEqual(37.5, TempHeader."Total VAT", 'Total VAT'); + Assert.AreEqual(250.0, TempHeader."Sub Total", 'Sub Total'); + Assert.AreEqual(5.0, TempHeader."Total Discount", 'Total Discount'); + Assert.AreEqual(287.5, TempHeader.Total, 'Total'); + Assert.AreEqual(287.5, TempHeader."Amount Due", 'Amount Due'); + end; + + [Test] + procedure MapHeader_MissingOptionalFields() + var + TempHeader: Record "E-Document Purchase Header" temporary; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + HeaderObj: JsonObject; + begin + // [SCENARIO] Missing nested objects (delivery, payment_means) leave fields empty without error + LibraryLowerPermission.SetOutsideO365Scope(); + EnsureGLSetup(); + + HeaderObj.Add('id', 'INV-MINIMAL'); + HeaderObj.Add('issue_date', '2024-01-01'); + + EDocMLLMSchemaHelper.MapHeaderFromJson(HeaderObj, TempHeader); + + Assert.AreEqual('INV-MINIMAL', TempHeader."Sales Invoice No.", 'Sales Invoice No.'); + Assert.AreEqual(DMY2Date(1, 1, 2024), TempHeader."Document Date", 'Document Date'); + Assert.AreEqual(0D, TempHeader."Due Date", 'Due Date should be empty'); + Assert.AreEqual('', TempHeader."Currency Code", 'Currency Code should be empty'); + Assert.AreEqual('', TempHeader."Purchase Order No.", 'Purchase Order No. should be empty'); + Assert.AreEqual('', TempHeader."Payment Terms", 'Payment Terms should be empty'); + Assert.AreEqual('', TempHeader."Vendor Company Name", 'Vendor Company Name should be empty'); + Assert.AreEqual('', TempHeader."Vendor Address", 'Vendor Address should be empty'); + Assert.AreEqual('', TempHeader."Customer Company Name", 'Customer Company Name should be empty'); + Assert.AreEqual('', TempHeader."Shipping Address", 'Shipping Address should be empty'); + Assert.AreEqual('', TempHeader."Shipping Address Recipient", 'Shipping Address Recipient should be empty'); + Assert.AreEqual('', TempHeader."Remittance Address Recipient", 'Remittance Address Recipient should be empty'); + Assert.AreEqual(0, TempHeader."Total VAT", 'Total VAT should be zero'); + Assert.AreEqual(0, TempHeader."Sub Total", 'Sub Total should be zero'); + Assert.AreEqual(0, TempHeader.Total, 'Total should be zero'); + end; + + [Test] + procedure MapHeader_EmptyObject() + var + TempHeader: Record "E-Document Purchase Header" temporary; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + HeaderObj: JsonObject; + begin + // [SCENARIO] Empty JSON object leaves all fields empty/zero without error + LibraryLowerPermission.SetOutsideO365Scope(); + EnsureGLSetup(); + + EDocMLLMSchemaHelper.MapHeaderFromJson(HeaderObj, TempHeader); + + Assert.AreEqual('', TempHeader."Sales Invoice No.", 'Sales Invoice No. should be empty'); + Assert.AreEqual(0D, TempHeader."Document Date", 'Document Date should be empty'); + Assert.AreEqual(0D, TempHeader."Due Date", 'Due Date should be empty'); + Assert.AreEqual('', TempHeader."Currency Code", 'Currency Code should be empty'); + Assert.AreEqual('', TempHeader."Vendor Company Name", 'Vendor Company Name should be empty'); + Assert.AreEqual('', TempHeader."Customer Company Name", 'Customer Company Name should be empty'); + Assert.AreEqual(0, TempHeader."Sub Total", 'Sub Total should be zero'); + Assert.AreEqual(0, TempHeader.Total, 'Total should be zero'); + Assert.AreEqual(0, TempHeader."Amount Due", 'Amount Due should be zero'); + end; + + [Test] + procedure MapLines_MultipleLines() + var + TempLine: Record "E-Document Purchase Line" temporary; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + LinesArray: JsonArray; + begin + // [SCENARIO] Three invoice lines produce correct line numbers and field values + LibraryLowerPermission.SetOutsideO365Scope(); + + LinesArray := BuildThreeLineArray(); + + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine); + + TempLine.FindSet(); + Assert.AreEqual(10000, TempLine."Line No.", 'First line number'); + Assert.AreEqual('Consulting Services', TempLine.Description, 'Line 1 Description'); + Assert.AreEqual('SVC-001', TempLine."Product Code", 'Line 1 Product Code'); + Assert.AreEqual(5, TempLine.Quantity, 'Line 1 Quantity'); + Assert.AreEqual('HRS', TempLine."Unit of Measure", 'Line 1 Unit of Measure'); + Assert.AreEqual(40, TempLine."Unit Price", 'Line 1 Unit Price'); + Assert.AreEqual(200, TempLine."Sub Total", 'Line 1 Sub Total'); + Assert.AreEqual(15, TempLine."VAT Rate", 'Line 1 VAT Rate'); + Assert.AreEqual(5, TempLine."Total Discount", 'Line 1 Total Discount'); + + TempLine.Next(); + Assert.AreEqual(20000, TempLine."Line No.", 'Second line number'); + Assert.AreEqual('Office Supplies', TempLine.Description, 'Line 2 Description'); + Assert.AreEqual('MAT-002', TempLine."Product Code", 'Line 2 Product Code'); + Assert.AreEqual(10, TempLine.Quantity, 'Line 2 Quantity'); + Assert.AreEqual('PCS', TempLine."Unit of Measure", 'Line 2 Unit of Measure'); + Assert.AreEqual(3, TempLine."Unit Price", 'Line 2 Unit Price'); + Assert.AreEqual(30, TempLine."Sub Total", 'Line 2 Sub Total'); + Assert.AreEqual(10, TempLine."VAT Rate", 'Line 2 VAT Rate'); + + TempLine.Next(); + Assert.AreEqual(30000, TempLine."Line No.", 'Third line number'); + Assert.AreEqual('Express Delivery', TempLine.Description, 'Line 3 Description'); + Assert.AreEqual('DLV-003', TempLine."Product Code", 'Line 3 Product Code'); + Assert.AreEqual(1, TempLine.Quantity, 'Line 3 Quantity'); + Assert.AreEqual('EA', TempLine."Unit of Measure", 'Line 3 Unit of Measure'); + Assert.AreEqual(20, TempLine."Unit Price", 'Line 3 Unit Price'); + Assert.AreEqual(20, TempLine."Sub Total", 'Line 3 Sub Total'); + Assert.AreEqual(15, TempLine."VAT Rate", 'Line 3 VAT Rate'); + end; + + [Test] + procedure MapLines_ZeroQuantity() + var + TempLine: Record "E-Document Purchase Line" temporary; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + LinesArray: JsonArray; + LineObj: JsonObject; + ItemObj: JsonObject; + QuantityObj: JsonObject; + begin + // [SCENARIO] Line with quantity 0 defaults to 1 + LibraryLowerPermission.SetOutsideO365Scope(); + + ItemObj.Add('name', 'Zero Qty Item'); + QuantityObj.Add('value', 0); + QuantityObj.Add('unit_code', 'PCS'); + LineObj.Add('item', ItemObj); + LineObj.Add('invoiced_quantity', QuantityObj); + LinesArray.Add(LineObj); + + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine); + + TempLine.FindFirst(); + Assert.AreEqual(1, TempLine.Quantity, 'Zero quantity should default to 1'); + Assert.AreEqual('Zero Qty Item', TempLine.Description, 'Description'); + end; + + [Test] + procedure MapLines_EmptyArray() + var + TempLine: Record "E-Document Purchase Line" temporary; + EDocMLLMSchemaHelper: Codeunit "E-Doc. MLLM Schema Helper"; + LinesArray: JsonArray; + begin + // [SCENARIO] Empty lines array produces no line records + LibraryLowerPermission.SetOutsideO365Scope(); + + EDocMLLMSchemaHelper.MapLinesFromJson(LinesArray, 1, TempLine); + + Assert.IsTrue(TempLine.IsEmpty(), 'No lines should be inserted for empty array'); + end; + + [Test] + procedure PreferredImpl_ControlAllocation_ReturnsADI() + var + EDocPDFFileFormat: Codeunit "E-Doc. PDF File Format"; + FeatureConfigTestLib: Codeunit "Feature Config Test Lib."; + begin + // [SCENARIO] With control allocation, PreferredStructureDataImplementation returns ADI + LibraryLowerPermission.SetOutsideO365Scope(); + + FeatureConfigTestLib.UseControlAllocation(); + + Assert.AreEqual( + "Structure Received E-Doc."::ADI, + EDocPDFFileFormat.PreferredStructureDataImplementation(), + 'Control allocation should return ADI'); + end; + + [Test] + procedure PreferredImpl_TreatmentAllocation_ReturnsMLLM() + // var + // EDocPDFFileFormat: Codeunit "E-Doc. PDF File Format"; + // FeatureConfigTestLib: Codeunit "Feature Config Test Lib."; + begin + // Bug #624677: ECS must be enabled for this test to pass. See wiki for ECS configuration. + // [SCENARIO] With treatment allocation, PreferredStructureDataImplementation returns MLLM + // LibraryLowerPermission.SetOutsideO365Scope(); + + // FeatureConfigTestLib.UseTreatmentAllocation(); + + // Assert.AreEqual( + // "Structure Received E-Doc."::MLLM, + // EDocPDFFileFormat.PreferredStructureDataImplementation(), + // 'Treatment allocation should return MLLM'); + end; + + [Test] + procedure PreferredImpl_EventOverride_TakesPrecedence() + var + EDocPDFFileFormat: Codeunit "E-Doc. PDF File Format"; + FeatureConfigTestLib: Codeunit "Feature Config Test Lib."; + EDocMLLMTests: Codeunit "EDoc MLLM Tests"; + begin + // [SCENARIO] An event subscriber can override the result regardless of experiment allocation + LibraryLowerPermission.SetOutsideO365Scope(); + + FeatureConfigTestLib.UseControlAllocation(); // Would normally return ADI + BindSubscription(EDocMLLMTests); + + Assert.AreEqual( + "Structure Received E-Doc."::MLLM, + EDocPDFFileFormat.PreferredStructureDataImplementation(), + 'Event override should take precedence over experiment allocation'); + + UnbindSubscription(EDocMLLMTests); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"E-Doc. PDF File Format", OnAfterSetIStructureReceivedEDocumentForPdf, '', false, false)] + local procedure OverrideToMLLM(var Result: Enum "Structure Received E-Doc.") + begin + Result := "Structure Received E-Doc."::MLLM; + end; + + local procedure EnsureGLSetup() + var + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + if not GeneralLedgerSetup.Get() then begin + GeneralLedgerSetup.Init(); + GeneralLedgerSetup."LCY Code" := 'USD'; + GeneralLedgerSetup.Insert(); + end; + end; + + local procedure BuildFullHeaderJson(): JsonObject + var + Result: JsonObject; + begin + Result.ReadFrom(NavApp.GetResourceAsText('mllm/mllm-header-full.json')); + exit(Result); + end; + + local procedure BuildThreeLineArray(): JsonArray + var + Result: JsonArray; + begin + Result.ReadFrom(NavApp.GetResourceAsText('mllm/mllm-lines-three.json')); + exit(Result); + 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 new file mode 100644 index 0000000000..076298e65c --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocStructuredValidations.Codeunit.al @@ -0,0 +1,222 @@ +// ------------------------------------------------------------------------------------------------ +// 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.Purchase; +using Microsoft.Finance.GeneralLedger.Setup; + +codeunit 139894 "EDoc Structured Validations" +{ + + var + Assert: Codeunit Assert; + + #region CAPI + internal procedure AssertFullCAPIDocumentExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + + Assert.AreEqual('MICROSOFT CORPORATION', EDocumentPurchaseHeader."Customer Company Name", 'The customer company name does not allign with the mock data.'); + Assert.AreEqual('CID-12345', EDocumentPurchaseHeader."Customer Company Id", 'The customer company id does not allign with the mock data.'); + Assert.AreEqual('PO-3333', EDocumentPurchaseHeader."Purchase Order No.", 'The purchase order number does not allign with the mock data.'); + Assert.AreEqual('INV-100', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(15, 12, 2019), EDocumentPurchaseHeader."Due Date", 'The due date does not allign with the mock data.'); + Assert.AreEqual('CONTOSO LTD.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not allign with the mock data.'); + Assert.AreEqual('123 456th St New York, NY, 10001', EDocumentPurchaseHeader."Vendor Address", 'The vendor address does not allign with the mock data.'); + Assert.AreEqual('Contoso Headquarters', EDocumentPurchaseHeader."Vendor Address Recipient", 'The vendor address recipient does not allign with the mock data.'); + Assert.AreEqual('123 Other St, Redmond WA, 98052', EDocumentPurchaseHeader."Customer Address", 'The customer address does not allign with the mock data.'); + Assert.AreEqual('Microsoft Corp', EDocumentPurchaseHeader."Customer Address Recipient", 'The customer address recipient does not allign with the mock data.'); + Assert.AreEqual('123 Bill St, Redmond WA, 98052', EDocumentPurchaseHeader."Billing Address", 'The billing address does not allign with the mock data.'); + Assert.AreEqual('Microsoft Finance', EDocumentPurchaseHeader."Billing Address Recipient", 'The billing address recipient does not allign with the mock data.'); + Assert.AreEqual('123 Ship St, Redmond WA, 98052', EDocumentPurchaseHeader."Shipping Address", 'The shipping address does not allign with the mock data.'); + Assert.AreEqual('Microsoft Delivery', EDocumentPurchaseHeader."Shipping Address Recipient", 'The shipping address recipient does not allign with the mock data.'); + Assert.AreEqual(100, EDocumentPurchaseHeader."Sub Total", 'The sub total does not allign with the mock data.'); + Assert.AreEqual(10, EDocumentPurchaseHeader."Total VAT", 'The total tax does not allign with the mock data.'); + Assert.AreEqual(110, EDocumentPurchaseHeader.Total, 'The total does not allign with the mock data.'); + Assert.AreEqual(610, EDocumentPurchaseHeader."Amount Due", 'The amount due does not allign with the mock data.'); + Assert.AreEqual(500, EDocumentPurchaseHeader."Previous Unpaid Balance", 'The previous unpaid balance does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not allign with the mock data.'); + Assert.AreEqual('123 Remit St New York, NY, 10001', EDocumentPurchaseHeader."Remittance Address", 'The remittance address does not allign with the mock data.'); + Assert.AreEqual('Contoso Billing', EDocumentPurchaseHeader."Remittance Address Recipient", 'The remittance address recipient does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(14, 10, 2019), EDocumentPurchaseHeader."Service Start Date", 'The service start date does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(14, 11, 2019), EDocumentPurchaseHeader."Service End Date", 'The service end date does not allign with the mock data.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.FindSet(); + Assert.AreEqual(60, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); + Assert.AreEqual('Consulting Services', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); + Assert.AreEqual(30, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); + Assert.AreEqual(2, EDocumentPurchaseLine.Quantity, 'The quantity in the purchase line does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); + Assert.AreEqual('A123', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); + Assert.AreEqual('hours', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(4, 3, 2021), EDocumentPurchaseLine.Date, 'The date in the purchase line does not allign with the mock data.'); + Assert.AreEqual(6, EDocumentPurchaseLine."VAT Rate", 'The amount in the purchase line does not allign with the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual(30, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); + Assert.AreEqual('Document Fee', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); + Assert.AreEqual(10, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); + Assert.AreEqual(3, EDocumentPurchaseLine.Quantity, 'The quantity in the purchase line does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); + Assert.AreEqual('B456', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); + Assert.AreEqual('', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(5, 3, 2021), EDocumentPurchaseLine.Date, 'The date in the purchase line does not allign with the mock data.'); + Assert.AreEqual(3, EDocumentPurchaseLine."VAT Rate", 'The amount in the purchase line does not allign with the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual(10, EDocumentPurchaseLine."Sub Total", 'The amount does not allign with the mock data.'); + Assert.AreEqual('Printing Fee', EDocumentPurchaseLine.Description, 'The description does not allign with the mock data.'); + Assert.AreEqual(1, EDocumentPurchaseLine."Unit Price", 'The unit price does not allign with the mock data.'); + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'The quantity does not allign with the mock data.'); + Assert.AreEqual('C789', EDocumentPurchaseLine."Product Code", 'The product code does not allign with the mock data.'); + Assert.AreEqual('pages', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(6, 3, 2021), EDocumentPurchaseLine.Date, 'The date does not allign with the mock data.'); + Assert.AreEqual(1, EDocumentPurchaseLine."VAT Rate", 'The amount does not allign with the mock data.'); + end; + + internal procedure AssertMinimalCAPIDocumentParsed(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + + Assert.AreEqual('INV-100', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(15, 12, 2019), EDocumentPurchaseHeader."Due Date", 'The due date does not allign with the mock data.'); + Assert.AreEqual('CONTOSO LTD.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not allign with the mock data.'); + Assert.AreEqual(110, EDocumentPurchaseHeader.Total, 'The total does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not allign with the mock data.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.FindSet(); + Assert.AreEqual(60, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); + Assert.AreEqual('Consulting Services', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual(30, EDocumentPurchaseLine."Sub Total", 'The amount in the purchase line does not allign with the mock data.'); + Assert.AreEqual('Document Fee', EDocumentPurchaseLine.Description, 'The description in the purchase line does not allign with the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual(10, EDocumentPurchaseLine."Sub Total", 'The amount does not allign with the mock data.'); + Assert.AreEqual('Printing Fee', EDocumentPurchaseLine.Description, 'The description does not allign with the mock data.'); + end; + #endregion + + #region PEPPOL + internal procedure AssertFullPEPPOLDocumentExtracted(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('103033', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(22, 01, 2026), EDocumentPurchaseHeader."Document Date", 'The invoice date does not allign with the mock data.'); + Assert.AreEqual(DMY2Date(22, 02, 2026), EDocumentPurchaseHeader."Due Date", 'The due date does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not allign with the mock data.'); + Assert.AreEqual('2', EDocumentPurchaseHeader."Purchase Order No.", 'The purchase order number does not allign with the mock data.'); + Assert.AreEqual('CRONUS International', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not allign with the mock data.'); + Assert.AreEqual('Main Street, 14', EDocumentPurchaseHeader."Vendor Address", 'The vendor street does not allign with the mock data.'); + Assert.AreEqual('GB123456789', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not allign with the mock data.'); + Assert.AreEqual('Jim Olive', EDocumentPurchaseHeader."Vendor Contact Name", 'The vendor contact name does not allign with the mock data.'); + Assert.AreEqual('The Cannon Group PLC', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not allign with the mock data.'); + Assert.AreEqual('GB789456278', EDocumentPurchaseHeader."Customer VAT Id", 'The customer VAT id does not allign with the mock data.'); + Assert.AreEqual('192 Market Square', EDocumentPurchaseHeader."Customer Address", 'The customer address does not allign with the mock data.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.FindSet(); + Assert.AreEqual(1, EDocumentPurchaseLine."Quantity", 'The quantity in the purchase line does not allign with the mock data.'); + Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); + Assert.AreEqual(4000, EDocumentPurchaseLine."Sub Total", 'The total amount before taxes in the purchase line does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); + Assert.AreEqual(0, EDocumentPurchaseLine."Total Discount", 'The total discount in the purchase line does not allign with the mock data.'); + Assert.AreEqual('Bicycle', EDocumentPurchaseLine.Description, 'The product description in the purchase line does not allign with the mock data.'); + Assert.AreEqual('1000', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in the purchase line does not allign with the mock data.'); + Assert.AreEqual(4000, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual(2, EDocumentPurchaseLine."Quantity", 'The quantity in the purchase line does not allign with the mock data.'); + Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); + Assert.AreEqual(10000, EDocumentPurchaseLine."Sub Total", 'The total amount before taxes in the purchase line does not allign with the mock data.'); + Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); + Assert.AreEqual(0, EDocumentPurchaseLine."Total Discount", 'The total discount in the purchase line does not allign with the mock data.'); + Assert.AreEqual('Bicycle v2', EDocumentPurchaseLine.Description, 'The product description in the purchase line does not allign with the mock data.'); + Assert.AreEqual('2000', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); + Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in the purchase line does not allign with the mock data.'); + Assert.AreEqual(5000, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); + + end; + #endregion + + #region MLLM + internal procedure AssertFullMLLMDocumentExtracted(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + EDocumentPurchaseHeader.Get(EDocumentEntryNo); + + Assert.AreEqual('MLLM-INV-001', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not match the mock data.'); + Assert.AreEqual(DMY2Date(15, 3, 2024), EDocumentPurchaseHeader."Document Date", 'The document date does not match the mock data.'); + Assert.AreEqual(DMY2Date(15, 4, 2024), 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('PO-5678', EDocumentPurchaseHeader."Purchase Order No.", 'The purchase order number does not match the mock data.'); + Assert.AreEqual('Net 30', EDocumentPurchaseHeader."Payment Terms", 'The payment terms do not match the mock data.'); + Assert.AreEqual('Contoso Supplies Ltd.', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not match the mock data.'); + Assert.AreEqual('123 Bill Ave, Seattle 98101, US', EDocumentPurchaseHeader."Vendor Address", 'The vendor address does not match the mock data.'); + Assert.AreEqual('US-VAT-12345', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not match the mock data.'); + Assert.AreEqual('John Doe', EDocumentPurchaseHeader."Vendor Contact Name", 'The vendor contact name does not match the mock data.'); + Assert.AreEqual('Microsoft Corporation', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not match the mock data.'); + Assert.AreEqual('456 Main St, Redmond 98052, US', EDocumentPurchaseHeader."Customer Address", 'The customer address does not match the mock data.'); + Assert.AreEqual('US-VAT-67890', EDocumentPurchaseHeader."Customer VAT Id", 'The customer VAT id does not match the mock data.'); + Assert.AreEqual('789 Ship Rd, Bellevue 98004, US', EDocumentPurchaseHeader."Shipping Address", 'The shipping address does not match the mock data.'); + Assert.AreEqual('Warehouse Team', EDocumentPurchaseHeader."Shipping Address Recipient", 'The shipping address recipient does not match the mock data.'); + Assert.AreEqual('Contoso Billing Dept', EDocumentPurchaseHeader."Remittance Address Recipient", 'The remittance address recipient does not match the mock data.'); + Assert.AreEqual(37.5, EDocumentPurchaseHeader."Total VAT", 'The total VAT does not match the mock data.'); + Assert.AreEqual(250, EDocumentPurchaseHeader."Sub Total", 'The sub total does not match the mock data.'); + Assert.AreEqual(5, EDocumentPurchaseHeader."Total Discount", 'The total discount does not match the mock data.'); + Assert.AreEqual(287.5, EDocumentPurchaseHeader.Total, 'The total does not match the mock data.'); + Assert.AreEqual(287.5, EDocumentPurchaseHeader."Amount Due", 'The amount due does not match the mock data.'); + + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentPurchaseLine.FindSet(); + Assert.AreEqual('Consulting Services', EDocumentPurchaseLine.Description, 'The description in line 1 does not match the mock data.'); + Assert.AreEqual('SVC-001', EDocumentPurchaseLine."Product Code", 'The product code in line 1 does not match the mock data.'); + Assert.AreEqual(5, EDocumentPurchaseLine.Quantity, 'The quantity in line 1 does not match the mock data.'); + Assert.AreEqual('HRS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in line 1 does not match the mock data.'); + Assert.AreEqual(40, EDocumentPurchaseLine."Unit Price", 'The unit price in line 1 does not match the mock data.'); + Assert.AreEqual(200, EDocumentPurchaseLine."Sub Total", 'The sub total in line 1 does not match the mock data.'); + Assert.AreEqual(15, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in line 1 does not match the mock data.'); + Assert.AreEqual(5, EDocumentPurchaseLine."Total Discount", 'The total discount in line 1 does not match the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual('Office Supplies', EDocumentPurchaseLine.Description, 'The description in line 2 does not match the mock data.'); + Assert.AreEqual('MAT-002', EDocumentPurchaseLine."Product Code", 'The product code in line 2 does not match the mock data.'); + Assert.AreEqual(10, EDocumentPurchaseLine.Quantity, 'The quantity in line 2 does not match the mock data.'); + Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in line 2 does not match the mock data.'); + Assert.AreEqual(3, EDocumentPurchaseLine."Unit Price", 'The unit price in line 2 does not match the mock data.'); + Assert.AreEqual(30, EDocumentPurchaseLine."Sub Total", 'The sub total in line 2 does not match the mock data.'); + Assert.AreEqual(10, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in line 2 does not match the mock data.'); + Assert.AreEqual(0, EDocumentPurchaseLine."Total Discount", 'The total discount in line 2 does not match the mock data.'); + + EDocumentPurchaseLine.Next(); + Assert.AreEqual('Express Delivery', EDocumentPurchaseLine.Description, 'The description in line 3 does not match the mock data.'); + Assert.AreEqual('DLV-003', EDocumentPurchaseLine."Product Code", 'The product code in line 3 does not match the mock data.'); + Assert.AreEqual(1, EDocumentPurchaseLine.Quantity, 'The quantity in line 3 does not match the mock data.'); + Assert.AreEqual('EA', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in line 3 does not match the mock data.'); + Assert.AreEqual(20, EDocumentPurchaseLine."Unit Price", 'The unit price in line 3 does not match the mock data.'); + Assert.AreEqual(20, EDocumentPurchaseLine."Sub Total", 'The sub total in line 3 does not match the mock data.'); + Assert.AreEqual(15, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in line 3 does not match the mock data.'); + Assert.AreEqual(0, EDocumentPurchaseLine."Total Discount", 'The total discount in line 3 does not match the mock data.'); + end; + #endregion + +} 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 98e4cd174b..eafe28e4e6 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocumentStructuredTests.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.eServices.EDocument.Test; using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Format; using Microsoft.eServices.EDocument.Integration; using Microsoft.eServices.EDocument.Processing.Import; using Microsoft.eServices.EDocument.Processing.Import.Purchase; @@ -13,6 +14,7 @@ using Microsoft.Foundation.Attachment; using Microsoft.Purchases.Vendor; using Microsoft.Sales.Customer; using System.IO; +using System.TestLibraries.Config; using System.TestLibraries.Utilities; codeunit 139891 "E-Document Structured Tests" @@ -29,8 +31,7 @@ codeunit 139891 "E-Document Structured Tests" LibraryEDoc: Codeunit "Library - E-Document"; EDocImplState: Codeunit "E-Doc. Impl. State"; LibraryLowerPermission: Codeunit "Library - Lower Permissions"; - CAPIStructuredValidations: Codeunit "CAPI Structured Validations"; - PEPPOLStructuredValidations: Codeunit "PEPPOL Structured Validations"; + StructuredValidations: Codeunit "EDoc Structured Validations"; IsInitialized: Boolean; EDocumentStatusNotUpdatedErr: Label 'The status of the EDocument was not updated to the expected status after the step was executed.'; @@ -44,7 +45,7 @@ codeunit 139891 "E-Document Structured Tests" SetupCAPIEDocumentService(); CreateInboundEDocumentFromJSON(EDocument, 'capi/capi-invoice-valid-0.json'); if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then - CAPIStructuredValidations.AssertFullEDocumentContentExtracted(EDocument."Entry No") + StructuredValidations.AssertFullCAPIDocumentExtracted(EDocument."Entry No") else Assert.Fail(EDocumentStatusNotUpdatedErr); end; @@ -59,7 +60,7 @@ codeunit 139891 "E-Document Structured Tests" SetupCAPIEDocumentService(); CreateInboundEDocumentFromJSON(EDocument, 'capi/capi-invoice-unexpected-values-0.json'); if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin - CAPIStructuredValidations.AssertMinimalEDocumentContentParsed(EDocument."Entry No"); + StructuredValidations.AssertMinimalCAPIDocumentParsed(EDocument."Entry No"); EDocumentPurchaseHeader.Get(EDocument."Entry No"); // "value_text": null Assert.AreEqual('', EDocumentPurchaseHeader."Shipping Address", 'Text field should be empty when JSON value is null'); @@ -93,7 +94,101 @@ codeunit 139891 "E-Document Structured Tests" SetupPEPPOLEDocumentService(); CreateInboundEDocumentFromXML(EDocument, 'peppol/peppol-invoice-0.xml'); if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then - PEPPOLStructuredValidations.AssertFullEDocumentContentExtracted(EDocument."Entry No") + StructuredValidations.AssertFullPEPPOLDocumentExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + #endregion + + #region MLLM JSON + [Test] + procedure TestMLLMInvoice_ValidDocument() + var + EDocument: Record "E-Document"; + begin + Initialize(Enum::"Service Integration"::"Mock"); + SetupMLLMEDocumentService(); + CreateInboundEDocumentFromJSON(EDocument, 'mllm/mllm-invoice-valid-0.json'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + StructuredValidations.AssertFullMLLMDocumentExtracted(EDocument."Entry No") + else + Assert.Fail(EDocumentStatusNotUpdatedErr); + end; + #endregion + + #region Experiment Configuration + [Test] + procedure TestExperiment_ControlAllocation_PreferredImplIsADI() + var + EDocPDFFileFormat: Codeunit "E-Doc. PDF File Format"; + FeatureConfigTestLib: Codeunit "Feature Config Test Lib."; + begin + // [SCENARIO] With control allocation, the PDF file format returns ADI as the preferred implementation + LibraryLowerPermission.SetOutsideO365Scope(); + + FeatureConfigTestLib.UseControlAllocation(); + + Assert.AreEqual( + "Structure Received E-Doc."::ADI, + EDocPDFFileFormat.PreferredStructureDataImplementation(), + 'Control allocation should prefer ADI for PDF processing'); + end; + + // Todo: Reenable once #624677 is fixed + // [Test] + // procedure TestExperiment_TreatmentAllocation_PreferredImplIsMLLM() + // var + // EDocPDFFileFormat: Codeunit "E-Doc. PDF File Format"; + // FeatureConfigTestLib: Codeunit "Feature Config Test Lib."; + // begin + // // [SCENARIO] With treatment allocation, the PDF file format returns MLLM as the preferred implementation + // LibraryLowerPermission.SetOutsideO365Scope(); + + // FeatureConfigTestLib.UseTreatmentAllocation(); + + // Assert.AreEqual( + // "Structure Received E-Doc."::MLLM, + // EDocPDFFileFormat.PreferredStructureDataImplementation(), + // 'Treatment allocation should prefer MLLM for PDF processing'); + // end; + + // Todo: Reenable once #624677 is fixed + // [Test] + // procedure TestExperiment_TreatmentAllocation_MLLMProcessesValidDocument() + // var + // EDocument: Record "E-Document"; + // FeatureConfigTestLib: Codeunit "Feature Config Test Lib."; + // begin + // // [SCENARIO] With treatment allocation active, MLLM is used to process a valid UBL invoice E2E + // Initialize(Enum::"Service Integration"::"Mock"); + // SetupMLLMEDocumentService(); + + // FeatureConfigTestLib.UseTreatmentAllocation(); + + // CreateInboundEDocumentFromJSON(EDocument, 'mllm/mllm-invoice-valid-0.json'); + // if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then + // StructuredValidations.AssertFullMLLMDocumentExtracted(EDocument."Entry No") + // else + // Assert.Fail(EDocumentStatusNotUpdatedErr); + // end; + #endregion + + #region Fallback + [Test] + procedure TestMLLM_InvalidJson_ProducesEmptyDraft() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + // [SCENARIO] When MLLM produces invalid/empty JSON, ReadIntoDraft creates a minimal draft without error + Initialize(Enum::"Service Integration"::"Mock"); + SetupMLLMEDocumentService(); + CreateInboundEDocumentFromJSON(EDocument, 'mllm/mllm-invoice-empty.json'); + if ProcessEDocumentToStep(EDocument, "Import E-Document Steps"::"Read into Draft") then begin + EDocumentPurchaseHeader.Get(EDocument."Entry No"); + Assert.AreEqual('', EDocumentPurchaseHeader."Sales Invoice No.", 'Empty JSON should produce empty header fields'); + Assert.AreEqual(0, EDocumentPurchaseHeader.Total, 'Empty JSON should produce zero totals'); + end else Assert.Fail(EDocumentStatusNotUpdatedErr); end; @@ -156,6 +251,12 @@ codeunit 139891 "E-Document Structured Tests" EDocumentService.Modify(); end; + local procedure SetupMLLMEDocumentService() + begin + EDocumentService."Read into Draft Impl." := "E-Doc. Read into Draft"::MLLM; + EDocumentService.Modify(); + end; + local procedure CreateInboundEDocumentFromJSON(var EDocument: Record "E-Document"; FilePath: Text) var EDocLogRecord: Record "E-Document Log"; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/PEPPOLStructuredValidations.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/PEPPOLStructuredValidations.Codeunit.al deleted file mode 100644 index 7510acaea3..0000000000 --- a/src/Apps/W1/EDocument/Test/src/Processing/PEPPOLStructuredValidations.Codeunit.al +++ /dev/null @@ -1,62 +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.Test; - -using Microsoft.eServices.EDocument.Processing.Import.Purchase; -using Microsoft.Finance.GeneralLedger.Setup; - -codeunit 139896 "PEPPOL Structured Validations" -{ - var - Assert: Codeunit Assert; - - internal procedure AssertFullEDocumentContentExtracted(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('103033', EDocumentPurchaseHeader."Sales Invoice No.", 'The sales invoice number does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(22, 01, 2026), EDocumentPurchaseHeader."Document Date", 'The invoice date does not allign with the mock data.'); - Assert.AreEqual(DMY2Date(22, 02, 2026), EDocumentPurchaseHeader."Due Date", 'The due date does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseHeader."Currency Code", 'The currency code does not allign with the mock data.'); - Assert.AreEqual('2', EDocumentPurchaseHeader."Purchase Order No.", 'The purchase order number does not allign with the mock data.'); - // Assert.AreEqual('', EDocumentPurchaseHeader."Vendor GLN", 'The endpoint schema is not provided to populate the GLN.'); - Assert.AreEqual('CRONUS International', EDocumentPurchaseHeader."Vendor Company Name", 'The vendor name does not allign with the mock data.'); - Assert.AreEqual('Main Street, 14', EDocumentPurchaseHeader."Vendor Address", 'The vendor street does not allign with the mock data.'); - Assert.AreEqual('GB123456789', EDocumentPurchaseHeader."Vendor VAT Id", 'The vendor VAT id does not allign with the mock data.'); - Assert.AreEqual('Jim Olive', EDocumentPurchaseHeader."Vendor Contact Name", 'The vendor contact name does not allign with the mock data.'); - Assert.AreEqual('The Cannon Group PLC', EDocumentPurchaseHeader."Customer Company Name", 'The customer name does not allign with the mock data.'); - Assert.AreEqual('GB789456278', EDocumentPurchaseHeader."Customer VAT Id", 'The customer VAT id does not allign with the mock data.'); - Assert.AreEqual('192 Market Square', EDocumentPurchaseHeader."Customer Address", 'The customer address does not allign with the mock data.'); - - EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentPurchaseLine.FindSet(); - Assert.AreEqual(1, EDocumentPurchaseLine."Quantity", 'The quantity in the purchase line does not allign with the mock data.'); - Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); - Assert.AreEqual(4000, EDocumentPurchaseLine."Sub Total", 'The total amount before taxes in the purchase line does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); - Assert.AreEqual(0, EDocumentPurchaseLine."Total Discount", 'The total discount in the purchase line does not allign with the mock data.'); - Assert.AreEqual('Bicycle', EDocumentPurchaseLine.Description, 'The product description in the purchase line does not allign with the mock data.'); - Assert.AreEqual('1000', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); - Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in the purchase line does not allign with the mock data.'); - Assert.AreEqual(4000, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); - - EDocumentPurchaseLine.Next(); - Assert.AreEqual(2, EDocumentPurchaseLine."Quantity", 'The quantity in the purchase line does not allign with the mock data.'); - Assert.AreEqual('PCS', EDocumentPurchaseLine."Unit of Measure", 'The unit of measure in the purchase line does not allign with the mock data.'); - Assert.AreEqual(10000, EDocumentPurchaseLine."Sub Total", 'The total amount before taxes in the purchase line does not allign with the mock data.'); - Assert.AreEqual('XYZ', EDocumentPurchaseLine."Currency Code", 'The currency code in the purchase line does not allign with the mock data.'); - Assert.AreEqual(0, EDocumentPurchaseLine."Total Discount", 'The total discount in the purchase line does not allign with the mock data.'); - Assert.AreEqual('Bicycle v2', EDocumentPurchaseLine.Description, 'The product description in the purchase line does not allign with the mock data.'); - Assert.AreEqual('2000', EDocumentPurchaseLine."Product Code", 'The product code in the purchase line does not allign with the mock data.'); - Assert.AreEqual(25, EDocumentPurchaseLine."VAT Rate", 'The VAT rate in the purchase line does not allign with the mock data.'); - Assert.AreEqual(5000, EDocumentPurchaseLine."Unit Price", 'The unit price in the purchase line does not allign with the mock data.'); - - end; - -}