From 8552212fbe71dda07a314dee78d4e746a623460a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 5 Sep 2026 11:41:33 +0200 Subject: [PATCH 01/22] [Bug] Disable customer asset conversion for CRM products during synchronization --- .../FSIntTableSubscriber.Codeunit.al | 59 ++++------ .../src/FSIntegrationTestLibrary.Codeunit.al | 5 +- .../test/src/FSIntegrationTest.Codeunit.al | 110 ++++-------------- 3 files changed, 50 insertions(+), 124 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 60d8d99d3dd..f757872eff0 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -290,6 +290,7 @@ codeunit 6610 "FS Int. Table Subscriber" FSWorkOrderProduct: Record "FS Work Order Product"; FSWorkOrderService: Record "FS Work Order Service"; FSBookableResourceBooking: Record "FS Bookable Resource Booking"; + CRMProduct: Record "CRM Product"; ServiceLine: Record "Service Line"; SourceDestCode: Text; begin @@ -299,6 +300,12 @@ codeunit 6610 "FS Int. Table Subscriber" SourceDestCode := GetSourceDestCode(SourceRecordRef, DestinationRecordRef); case SourceDestCode of + 'Item-CRM Product': + begin + DestinationRecordRef.SetTable(CRMProduct); + DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + DestinationRecordRef.GetTable(CRMProduct); + end; 'FS Work Order Product-Service Line': begin SourceRecordRef.SetTable(FSWorkOrderProduct); @@ -358,6 +365,12 @@ codeunit 6610 "FS Int. Table Subscriber" end; end; + internal procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) + begin + CRMProduct.ConvertToCustomerAsset := false; + AdditionalFieldsWereModified := true; + end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Record Synch.", 'OnTransferFieldData', '', true, false)] local procedure OnTransferFieldData(SourceFieldRef: FieldRef; DestinationFieldRef: FieldRef; var NewValue: Variant; var IsValueFound: Boolean; var NeedsConversion: Boolean) var @@ -1444,7 +1457,7 @@ codeunit 6610 "FS Int. Table Subscriber" end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"CRM Setup Defaults", 'OnResetItemProductMappingOnAfterInsertFieldsMapping', '', false, false)] - local procedure AddFieldServiceProductTypeFieldMapping(var Sender: Codeunit "CRM Setup Defaults"; IntegrationTableMappingName: Code[20]) + local procedure AddFieldServiceProductMappings(var Sender: Codeunit "CRM Setup Defaults"; IntegrationTableMappingName: Code[20]) var FSConnectionSetup: Record "FS Connection Setup"; Item: Record Item; @@ -1461,6 +1474,14 @@ codeunit 6610 "FS Int. Table Subscriber" CRMProduct.FieldNo(FieldServiceProductType), IntegrationFieldMapping.Direction::ToIntegrationTable, '', false, false); + + // Business Central service items are the source for Field Service customer assets. + Sender.InsertIntegrationFieldMapping( + IntegrationTableMappingName, + 0, + CRMProduct.FieldNo(ConvertToCustomerAsset), + IntegrationFieldMapping.Direction::ToIntegrationTable, + 'false', false, false); end; local procedure UpdateCorrelatedJobJournalLine(var SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef) @@ -2518,8 +2539,6 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreArchievedServiceOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); Database::"FS Work Order": IgnoreArchievedCRMWorkOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); - Database::"Service Item": - IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef, IgnoreRecord); end; if FSConnectionSetup.IsEnabled() then @@ -2685,40 +2704,6 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; - internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) - var - FSConnectionSetup: Record "FS Connection Setup"; - ServiceItem: Record "Service Item"; - Item: Record Item; - CRMIntegrationRecord: Record "CRM Integration Record"; - CRMProduct: Record "CRM Product"; - begin - if not FSConnectionSetup.IsEnabled() then - exit; - - if IgnoreRecord then - exit; - - SourceRecordRef.SetTable(ServiceItem); - if ServiceItem."Item No." = '' then - exit; - - if CRMIntegrationRecord.FindByRecordID(ServiceItem.RecordId) then - exit; - - if not Item.Get(ServiceItem."Item No.") then - exit; - - if not CRMIntegrationRecord.FindByRecordID(Item.RecordId) then - exit; - - if not CRMProduct.Get(CRMIntegrationRecord."CRM ID") then - exit; - - if not CRMProduct.ConvertToCustomerAsset then - IgnoreRecord := true; - end; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) var diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index fbb03b04162..7b3718b9e8f 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.TestLibraries.DynamicsFieldService; +using Microsoft.Integration.D365Sales; using Microsoft.Integration.DynamicsFieldService; using Microsoft.Service.Archive; using Microsoft.Service.Document; @@ -79,11 +80,11 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.IgnoreArchievedCRMWorkOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); end; - procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) + procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) var FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; begin - FSIntTableSubscriber.IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef, IgnoreRecord); + FSIntTableSubscriber.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); end; procedure MarkArchivedServiceOrder(ServiceHeader: Record "Service Header") diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 94c69e79ff9..e34187db4e7 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1578,109 +1578,49 @@ codeunit 139204 "FS Integration Test" end; [Test] - procedure IgnoreServiceItemWhenConvertToCustomerAssetIsFalse() + procedure ItemSynchronizationDisablesCustomerAssetConversion() var - Item: Record Item; - TempServiceItem: Record "Service Item" temporary; - CRMProduct: Record "CRM Product"; - CRMIntegrationRecord: Record "CRM Integration Record"; - RecordRef: RecordRef; - IgnoreRecord: Boolean; - ProductId: Guid; - begin - // [FEATURE] [Service Item Mapping] - // [SCENARIO] Service Item is skipped when linked CRM Product has Convert to Customer Asset = No. - Initialize(); - InitSetup(true, ''); - - Item.Get(CreateItem()); - TempServiceItem."Item No." := Item."No."; - RecordRef.GetTable(TempServiceItem); - - ProductId := CreateGuid(); - CRMProduct.ProductId := ProductId; - CRMProduct.ConvertToCustomerAsset := false; - CRMProduct.Insert(false); - - CRMIntegrationRecord.CoupleCRMIDToRecordID(ProductId, Item.RecordId()); - - FSIntegrationTestLibrary.IgnoreServiceItemsByConvertToCustomerAssetFlag(RecordRef, IgnoreRecord); - - Assert.IsTrue(IgnoreRecord, 'Service Item should be ignored when Convert to Customer Asset is false.'); - end; - - [Test] - procedure DoNotIgnoreServiceItemWhenConvertToCustomerAssetIsTrue() - var - Item: Record Item; - TempServiceItem: Record "Service Item" temporary; CRMProduct: Record "CRM Product"; - CRMIntegrationRecord: Record "CRM Integration Record"; - RecordRef: RecordRef; - IgnoreRecord: Boolean; - ProductId: Guid; + AdditionalFieldsWereModified: Boolean; begin - // [FEATURE] [Service Item Mapping] - // [SCENARIO] Service Item is not skipped when linked CRM Product has Convert to Customer Asset = Yes. + // [FEATURE] [Item-Product Mapping] + // [SCENARIO] Synchronizing an item disables native Field Service customer asset creation. Initialize(); InitSetup(true, ''); - Item.Get(CreateItem()); - TempServiceItem."Item No." := Item."No."; - RecordRef.GetTable(TempServiceItem); - - ProductId := CreateGuid(); - CRMProduct.ProductId := ProductId; + // [GIVEN] A Field Service product where Convert to Customer Asset is Yes. CRMProduct.ConvertToCustomerAsset := true; - CRMProduct.Insert(false); - - CRMIntegrationRecord.CoupleCRMIDToRecordID(ProductId, Item.RecordId()); - - FSIntegrationTestLibrary.IgnoreServiceItemsByConvertToCustomerAssetFlag(RecordRef, IgnoreRecord); - - Assert.IsFalse(IgnoreRecord, 'Service Item should not be ignored when Convert to Customer Asset is true.'); - end; - - [Test] - procedure DoNotIgnoreServiceItemWhenItemNoIsBlank() - var - TempServiceItem: Record "Service Item" temporary; - RecordRef: RecordRef; - IgnoreRecord: Boolean; - begin - // [FEATURE] [Service Item Mapping] - // [SCENARIO] Service Item with blank Item No. is not skipped by this filter. - Initialize(); - InitSetup(true, ''); - - TempServiceItem."Item No." := ''; - RecordRef.GetTable(TempServiceItem); - FSIntegrationTestLibrary.IgnoreServiceItemsByConvertToCustomerAssetFlag(RecordRef, IgnoreRecord); + // [WHEN] The Field Service product is prepared for synchronization from an item. + FSIntegrationTestLibrary.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); - Assert.IsFalse(IgnoreRecord, 'Service Item with blank Item No. should not be ignored by this filter.'); + // [THEN] Native Field Service customer asset creation is disabled. + Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); + Assert.IsTrue(AdditionalFieldsWereModified, 'The synchronization should recognize the modified field.'); end; [Test] - procedure DoNotIgnoreServiceItemWhenItemIsNotCoupled() + procedure ItemProductMappingDisablesCustomerAssetConversion() var - Item: Record Item; - TempServiceItem: Record "Service Item" temporary; - RecordRef: RecordRef; - IgnoreRecord: Boolean; + CRMProduct: Record "CRM Product"; + IntegrationFieldMapping: Record "Integration Field Mapping"; + CRMSetupDefaults: Codeunit "CRM Setup Defaults"; begin - // [FEATURE] [Service Item Mapping] - // [SCENARIO] Service Item with uncoupled Item is not skipped by this filter. + // [FEATURE] [Item-Product Mapping] + // [SCENARIO] The item-product mapping always disables native Field Service customer asset creation. Initialize(); InitSetup(true, ''); - Item.Get(CreateItem()); - TempServiceItem."Item No." := Item."No."; - RecordRef.GetTable(TempServiceItem); - - FSIntegrationTestLibrary.IgnoreServiceItemsByConvertToCustomerAssetFlag(RecordRef, IgnoreRecord); + // [WHEN] The default item-product mapping is reset. + CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); - Assert.IsFalse(IgnoreRecord, 'Service Item with uncoupled Item should not be ignored by this filter.'); + // [THEN] Convert to Customer Asset is mapped to the constant false in the outbound direction. + IntegrationFieldMapping.SetRange("Integration Table Mapping Name", 'ITEM-PRODUCT'); + IntegrationFieldMapping.SetRange("Integration Table Field No.", CRMProduct.FieldNo(ConvertToCustomerAsset)); + Assert.IsTrue(IntegrationFieldMapping.FindFirst(), 'The Convert to Customer Asset mapping should exist.'); + Assert.AreEqual(0, IntegrationFieldMapping."Field No.", 'The mapping should use a constant value.'); + Assert.AreEqual(IntegrationFieldMapping.Direction::ToIntegrationTable, IntegrationFieldMapping.Direction, 'The mapping should be outbound.'); + Assert.AreEqual('false', IntegrationFieldMapping."Constant Value", 'The mapping should disable Convert to Customer Asset.'); end; local procedure Initialize() From 84a99988a861d00b5a70ffd319675684055a0df5 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 5 Sep 2026 21:59:37 +0200 Subject: [PATCH 02/22] [Bug] Remove obsolete Convert to Customer Asset flag and update synchronization logic for service items --- .../FSIntTableSubscriber.Codeunit.al | 35 +++++++++++++++++++ .../src/FSIntegrationTestLibrary.Codeunit.al | 15 ++++++++ .../test/src/FSIntegrationTest.Codeunit.al | 1 - 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index f757872eff0..6eb0240c68e 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2704,6 +2704,41 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; + [Obsolete('Service items are always synchronized to Field Service customer assets. The Convert to Customer Asset flag is no longer used for filtering', '30.0')] + internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) + var + FSConnectionSetup: Record "FS Connection Setup"; + ServiceItem: Record "Service Item"; + Item: Record Item; + CRMIntegrationRecord: Record "CRM Integration Record"; + CRMProduct: Record "CRM Product"; + begin + if not FSConnectionSetup.IsEnabled() then + exit; + + if IgnoreRecord then + exit; + + SourceRecordRef.SetTable(ServiceItem); + if ServiceItem."Item No." = '' then + exit; + + if CRMIntegrationRecord.FindByRecordID(ServiceItem.RecordId) then + exit; + + if not Item.Get(ServiceItem."Item No.") then + exit; + + if not CRMIntegrationRecord.FindByRecordID(Item.RecordId) then + exit; + + if not CRMProduct.Get(CRMIntegrationRecord."CRM ID") then + exit; + + if not CRMProduct.ConvertToCustomerAsset then + IgnoreRecord := true; + end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) var diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index 7b3718b9e8f..efb737ecfc5 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -80,6 +80,21 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.IgnoreArchievedCRMWorkOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); end; + [Obsolete('Service items are always synchronized to Field Service customer assets. The Convert to Customer Asset flag is no longer used for filtering', '30.0')] + procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) + var + FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; + begin +#pragma warning disable AL0432 + FSIntTableSubscriber.IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef, IgnoreRecord); +#pragma warning restore AL0432 + end; + + /// + /// Disables native Field Service customer asset conversion for a CRM product during item synchronization. + /// + /// The CRM product whose Convert to Customer Asset flag is disabled. + /// Set to true to notify synchronization that an additional field was modified. procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) var FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index e34187db4e7..7d1a9960ccb 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -23,7 +23,6 @@ using Microsoft.Purchases.Vendor; using Microsoft.Sales.Customer; using Microsoft.Service.Archive; using Microsoft.Service.Document; -using Microsoft.Service.Item; using Microsoft.Service.Setup; using Microsoft.Service.Test; using Microsoft.TestLibraries.DynamicsFieldService; From 482d3538597af51b80da0c876b65191219d0e713 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sun, 6 Sep 2026 19:02:31 +0200 Subject: [PATCH 03/22] [Bug] Update obsolete service item synchronization logic and enhance test coverage --- .../FSIntTableSubscriber.Codeunit.al | 2 +- .../src/FSIntegrationTestLibrary.Codeunit.al | 53 +++++++++++++------ .../test/src/FSIntegrationTest.Codeunit.al | 23 ++++++-- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 6eb0240c68e..51e90640fef 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2704,7 +2704,7 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; - [Obsolete('Service items are always synchronized to Field Service customer assets. The Convert to Customer Asset flag is no longer used for filtering', '30.0')] + [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) var FSConnectionSetup: Record "FS Connection Setup"; diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index efb737ecfc5..bb6799d13d0 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -5,9 +5,12 @@ namespace Microsoft.TestLibraries.DynamicsFieldService; using Microsoft.Integration.D365Sales; +using Microsoft.Integration.Dataverse; using Microsoft.Integration.DynamicsFieldService; +using Microsoft.Inventory.Item; using Microsoft.Service.Archive; using Microsoft.Service.Document; +using Microsoft.Service.Item; codeunit 139205 "FS Integration Test Library" { @@ -80,26 +83,44 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.IgnoreArchievedCRMWorkOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); end; - [Obsolete('Service items are always synchronized to Field Service customer assets. The Convert to Customer Asset flag is no longer used for filtering', '30.0')] - procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) - var - FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; - begin -#pragma warning disable AL0432 - FSIntTableSubscriber.IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef, IgnoreRecord); -#pragma warning restore AL0432 - end; - /// - /// Disables native Field Service customer asset conversion for a CRM product during item synchronization. + /// Applies the legacy service item filter based on the coupled CRM product's Convert to Customer Asset flag. This filter is obsolete because service items are now always synchronized to Field Service customer assets. /// - /// The CRM product whose Convert to Customer Asset flag is disabled. - /// Set to true to notify synchronization that an additional field was modified. - procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) + /// A reference to the service item to evaluate. + /// Set to true when the service item should be ignored according to the legacy filter. + [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] + procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) var - FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; + FSConnectionSetup: Record "FS Connection Setup"; + ServiceItem: Record "Service Item"; + Item: Record Item; + CRMIntegrationRecord: Record "CRM Integration Record"; + CRMProduct: Record "CRM Product"; begin - FSIntTableSubscriber.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + if not FSConnectionSetup.IsEnabled() then + exit; + + if IgnoreRecord then + exit; + + SourceRecordRef.SetTable(ServiceItem); + if ServiceItem."Item No." = '' then + exit; + + if CRMIntegrationRecord.FindByRecordID(ServiceItem.RecordId) then + exit; + + if not Item.Get(ServiceItem."Item No.") then + exit; + + if not CRMIntegrationRecord.FindByRecordID(Item.RecordId) then + exit; + + if not CRMProduct.Get(CRMIntegrationRecord."CRM ID") then + exit; + + if not CRMProduct.ConvertToCustomerAsset then + IgnoreRecord := true; end; procedure MarkArchivedServiceOrder(ServiceHeader: Record "Service Header") diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 7d1a9960ccb..9194a8fc34e 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1579,23 +1579,36 @@ codeunit 139204 "FS Integration Test" [Test] procedure ItemSynchronizationDisablesCustomerAssetConversion() var + Item: Record Item; CRMProduct: Record "CRM Product"; - AdditionalFieldsWereModified: Boolean; + IntegrationFieldMapping: Record "Integration Field Mapping"; + IntegrationTableMapping: Record "Integration Table Mapping"; + CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; + CRMSetupDefaults: Codeunit "CRM Setup Defaults"; begin // [FEATURE] [Item-Product Mapping] // [SCENARIO] Synchronizing an item disables native Field Service customer asset creation. Initialize(); InitSetup(true, ''); - // [GIVEN] A Field Service product where Convert to Customer Asset is Yes. + // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes. + CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); + LibraryCRMIntegration.CreateCoupledItemAndProduct(Item, CRMProduct); CRMProduct.ConvertToCustomerAsset := true; + CRMProduct.Modify(); + + // [GIVEN] The constant field mapping is removed to isolate the custom synchronization callback. + IntegrationFieldMapping.SetRange("Integration Table Mapping Name", 'ITEM-PRODUCT'); + IntegrationFieldMapping.SetRange("Integration Table Field No.", CRMProduct.FieldNo(ConvertToCustomerAsset)); + IntegrationFieldMapping.DeleteAll(); + IntegrationTableMapping.Get('ITEM-PRODUCT'); - // [WHEN] The Field Service product is prepared for synchronization from an item. - FSIntegrationTestLibrary.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + // [WHEN] The item is synchronized to the Field Service product. + CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); // [THEN] Native Field Service customer asset creation is disabled. + CRMProduct.Get(CRMProduct.ProductId); Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); - Assert.IsTrue(AdditionalFieldsWereModified, 'The synchronization should recognize the modified field.'); end; [Test] From a5191e7dfdaaee01e5e0376ceb74c93c5de86a59 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sun, 6 Sep 2026 21:03:13 +0200 Subject: [PATCH 04/22] [Bug] Remove obsolete IgnoreServiceItemsByConvertToCustomerAssetFlag procedure to streamline service item synchronization --- .../FSIntTableSubscriber.Codeunit.al | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 51e90640fef..f757872eff0 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2704,41 +2704,6 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; - [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] - internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) - var - FSConnectionSetup: Record "FS Connection Setup"; - ServiceItem: Record "Service Item"; - Item: Record Item; - CRMIntegrationRecord: Record "CRM Integration Record"; - CRMProduct: Record "CRM Product"; - begin - if not FSConnectionSetup.IsEnabled() then - exit; - - if IgnoreRecord then - exit; - - SourceRecordRef.SetTable(ServiceItem); - if ServiceItem."Item No." = '' then - exit; - - if CRMIntegrationRecord.FindByRecordID(ServiceItem.RecordId) then - exit; - - if not Item.Get(ServiceItem."Item No.") then - exit; - - if not CRMIntegrationRecord.FindByRecordID(Item.RecordId) then - exit; - - if not CRMProduct.Get(CRMIntegrationRecord."CRM ID") then - exit; - - if not CRMProduct.ConvertToCustomerAsset then - IgnoreRecord := true; - end; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) var From e3d1c2add41b733b92faf6c6df5c13469db10a82 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Mon, 7 Sep 2026 12:04:07 +0200 Subject: [PATCH 05/22] [Bug] Refactor IgnoreServiceItemsByConvertToCustomerAssetFlag procedure and update test cases for service item synchronization --- .../FSIntTableSubscriber.Codeunit.al | 35 ++++++++++++++++++ .../src/FSIntegrationTestLibrary.Codeunit.al | 37 ++----------------- .../test/src/FSIntegrationTest.Codeunit.al | 3 ++ 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index f757872eff0..51e90640fef 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2704,6 +2704,41 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; + [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] + internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) + var + FSConnectionSetup: Record "FS Connection Setup"; + ServiceItem: Record "Service Item"; + Item: Record Item; + CRMIntegrationRecord: Record "CRM Integration Record"; + CRMProduct: Record "CRM Product"; + begin + if not FSConnectionSetup.IsEnabled() then + exit; + + if IgnoreRecord then + exit; + + SourceRecordRef.SetTable(ServiceItem); + if ServiceItem."Item No." = '' then + exit; + + if CRMIntegrationRecord.FindByRecordID(ServiceItem.RecordId) then + exit; + + if not Item.Get(ServiceItem."Item No.") then + exit; + + if not CRMIntegrationRecord.FindByRecordID(Item.RecordId) then + exit; + + if not CRMProduct.Get(CRMIntegrationRecord."CRM ID") then + exit; + + if not CRMProduct.ConvertToCustomerAsset then + IgnoreRecord := true; + end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) var diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index bb6799d13d0..1edcd404a82 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -4,13 +4,9 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.TestLibraries.DynamicsFieldService; -using Microsoft.Integration.D365Sales; -using Microsoft.Integration.Dataverse; using Microsoft.Integration.DynamicsFieldService; -using Microsoft.Inventory.Item; using Microsoft.Service.Archive; using Microsoft.Service.Document; -using Microsoft.Service.Item; codeunit 139205 "FS Integration Test Library" { @@ -91,36 +87,11 @@ codeunit 139205 "FS Integration Test Library" [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) var - FSConnectionSetup: Record "FS Connection Setup"; - ServiceItem: Record "Service Item"; - Item: Record Item; - CRMIntegrationRecord: Record "CRM Integration Record"; - CRMProduct: Record "CRM Product"; + FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; begin - if not FSConnectionSetup.IsEnabled() then - exit; - - if IgnoreRecord then - exit; - - SourceRecordRef.SetTable(ServiceItem); - if ServiceItem."Item No." = '' then - exit; - - if CRMIntegrationRecord.FindByRecordID(ServiceItem.RecordId) then - exit; - - if not Item.Get(ServiceItem."Item No.") then - exit; - - if not CRMIntegrationRecord.FindByRecordID(Item.RecordId) then - exit; - - if not CRMProduct.Get(CRMIntegrationRecord."CRM ID") then - exit; - - if not CRMProduct.ConvertToCustomerAsset then - IgnoreRecord := true; +#pragma warning disable AL0432, AS0105 + FSIntTableSubscriber.IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef, IgnoreRecord); +#pragma warning restore AL0432, AS0105 end; procedure MarkArchivedServiceOrder(ServiceHeader: Record "Service Header") diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 9194a8fc34e..a1cda6b17ce 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1577,6 +1577,7 @@ codeunit 139204 "FS Integration Test" end; [Test] + [TransactionModel(TransactionModel::AutoRollback)] procedure ItemSynchronizationDisablesCustomerAssetConversion() var Item: Record Item; @@ -1589,6 +1590,7 @@ codeunit 139204 "FS Integration Test" // [FEATURE] [Item-Product Mapping] // [SCENARIO] Synchronizing an item disables native Field Service customer asset creation. Initialize(); + LibraryCRMIntegration.CreateCRMConnectionSetup('', '@@test@@', true); InitSetup(true, ''); // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes. @@ -1612,6 +1614,7 @@ codeunit 139204 "FS Integration Test" end; [Test] + [TransactionModel(TransactionModel::AutoRollback)] procedure ItemProductMappingDisablesCustomerAssetConversion() var CRMProduct: Record "CRM Product"; From 2378ecc9732acc6e5e63b81b7dbd8eaa8969766a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Mon, 7 Sep 2026 15:36:53 +0200 Subject: [PATCH 06/22] [Bug] Update IgnoreServiceItemsByConvertToCustomerAssetFlag procedure for clarity and compatibility --- .../src/FSIntegrationTestLibrary.Codeunit.al | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index 1edcd404a82..9730996f44b 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -80,18 +80,13 @@ codeunit 139205 "FS Integration Test Library" end; /// - /// Applies the legacy service item filter based on the coupled CRM product's Convert to Customer Asset flag. This filter is obsolete because service items are now always synchronized to Field Service customer assets. + /// Retained for compatibility. Service items are now always synchronized to Field Service customer assets, so this procedure leaves the synchronization decision unchanged. /// /// A reference to the service item to evaluate. - /// Set to true when the service item should be ignored according to the legacy filter. + /// The existing synchronization decision, which is left unchanged. [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) - var - FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; begin -#pragma warning disable AL0432, AS0105 - FSIntTableSubscriber.IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef, IgnoreRecord); -#pragma warning restore AL0432, AS0105 end; procedure MarkArchivedServiceOrder(ServiceHeader: Record "Service Header") From 225f4dd0a1ce143babf44aa99d102c77b86cca42 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Mon, 7 Sep 2026 23:27:47 +0200 Subject: [PATCH 07/22] [Bug] Update IgnoreServiceItemsByConvertToCustomerAssetFlag procedure to clarify synchronization behavior and conditionally compile based on CLEAN30 --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 51e90640fef..0a89e4f15aa 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2704,6 +2704,7 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; +#if not CLEAN30 [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) var @@ -2738,6 +2739,7 @@ codeunit 6610 "FS Int. Table Subscriber" if not CRMProduct.ConvertToCustomerAsset then IgnoreRecord := true; end; + #endif [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) From 1d615d8a8165ac0c8dd9f8d875b2caedfd0b9bee Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 8 Sep 2026 10:35:38 +0200 Subject: [PATCH 08/22] [Bug] Enhance customer asset conversion logic and add tests for modification tracking --- .../FSIntTableSubscriber.Codeunit.al | 12 ++++++--- .../src/FSIntegrationTestLibrary.Codeunit.al | 12 +++++++++ .../test/src/FSIntegrationTest.Codeunit.al | 27 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 0a89e4f15aa..4e11d523e24 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -367,8 +367,10 @@ codeunit 6610 "FS Int. Table Subscriber" internal procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) begin - CRMProduct.ConvertToCustomerAsset := false; - AdditionalFieldsWereModified := true; + if CRMProduct.ConvertToCustomerAsset then begin + CRMProduct.ConvertToCustomerAsset := false; + AdditionalFieldsWereModified := true; + end; end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Record Synch.", 'OnTransferFieldData', '', true, false)] @@ -2704,7 +2706,8 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; -#if not CLEAN30 +#if not CLEAN31 +#pragma warning disable AS0105 [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) var @@ -2739,7 +2742,8 @@ codeunit 6610 "FS Int. Table Subscriber" if not CRMProduct.ConvertToCustomerAsset then IgnoreRecord := true; end; - #endif +#pragma warning restore AS0105 +#endif [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index 9730996f44b..8045404d0fa 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.TestLibraries.DynamicsFieldService; +using Microsoft.Integration.D365Sales; using Microsoft.Integration.DynamicsFieldService; using Microsoft.Service.Archive; using Microsoft.Service.Document; @@ -58,6 +59,13 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.UpdateQuantities(FSBookableResourceBooking, ServiceLine); end; + procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) + var + FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; + begin + FSIntTableSubscriber.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + end; + procedure IgnorePostedJobJournalLinesOnQueryPostFilterIgnoreRecord(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) var FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; @@ -79,15 +87,19 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.IgnoreArchievedCRMWorkOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); end; +#if not CLEAN31 /// /// Retained for compatibility. Service items are now always synchronized to Field Service customer assets, so this procedure leaves the synchronization decision unchanged. /// /// A reference to the service item to evaluate. /// The existing synchronization decision, which is left unchanged. +#pragma warning disable AS0105 [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) begin end; +#pragma warning restore AS0105 +#endif procedure MarkArchivedServiceOrder(ServiceHeader: Record "Service Header") var diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index a1cda6b17ce..587cf28e63f 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1577,7 +1577,7 @@ codeunit 139204 "FS Integration Test" end; [Test] - [TransactionModel(TransactionModel::AutoRollback)] + [TransactionModel(TransactionModel::AutoCommit)] procedure ItemSynchronizationDisablesCustomerAssetConversion() var Item: Record Item; @@ -1615,6 +1615,31 @@ codeunit 139204 "FS Integration Test" [Test] [TransactionModel(TransactionModel::AutoRollback)] + procedure DisableCustomerAssetConversionOnlyMarksActualChanges() + var + CRMProduct: Record "CRM Product"; + AdditionalFieldsWereModified: Boolean; + begin + // [FEATURE] [Item-Product Mapping] + // [SCENARIO] Customer asset conversion only marks the product as modified when its value changes. + + // [WHEN] Customer asset conversion is already disabled. + FSIntegrationTestLibrary.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + + // [THEN] The product is not marked as modified. + Assert.IsFalse(AdditionalFieldsWereModified, 'An unchanged product should not be marked as modified.'); + + // [WHEN] Customer asset conversion is enabled and then disabled. + CRMProduct.ConvertToCustomerAsset := true; + FSIntegrationTestLibrary.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + + // [THEN] Customer asset conversion is disabled and the product is marked as modified. + Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); + Assert.IsTrue(AdditionalFieldsWereModified, 'A changed product should be marked as modified.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure ItemProductMappingDisablesCustomerAssetConversion() var CRMProduct: Record "CRM Product"; From be17c2091147964ea8fb2378749c139734f55a22 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 8 Sep 2026 12:38:52 +0200 Subject: [PATCH 09/22] [Bug] Remove obsolete conditional compilation for service item synchronization procedures --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 2 -- .../test library/src/FSIntegrationTestLibrary.Codeunit.al | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 4e11d523e24..e125ac1429b 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2706,7 +2706,6 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; -#if not CLEAN31 #pragma warning disable AS0105 [Obsolete('Remove calls to this procedure. Service items are always synchronized to Field Service customer assets; item-product synchronization disables customer asset conversion.', '30.0')] internal procedure IgnoreServiceItemsByConvertToCustomerAssetFlag(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) @@ -2743,7 +2742,6 @@ codeunit 6610 "FS Int. Table Subscriber" IgnoreRecord := true; end; #pragma warning restore AS0105 -#endif [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Table Synch.", 'OnAfterInitSynchJob', '', true, true)] local procedure LogTelemetryOnAfterInitSynchJob(ConnectionType: TableConnectionType; IntegrationTableID: Integer) diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index 8045404d0fa..d7a2db47d71 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -87,7 +87,6 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.IgnoreArchievedCRMWorkOrdersOnQueryPostFilterIgnoreRecord(SourceRecordRef, IgnoreRecord); end; -#if not CLEAN31 /// /// Retained for compatibility. Service items are now always synchronized to Field Service customer assets, so this procedure leaves the synchronization decision unchanged. /// @@ -99,7 +98,6 @@ codeunit 139205 "FS Integration Test Library" begin end; #pragma warning restore AS0105 -#endif procedure MarkArchivedServiceOrder(ServiceHeader: Record "Service Header") var From dcf560082035a47f943193a2fe375c520c9cf81a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 8 Sep 2026 22:25:19 +0200 Subject: [PATCH 10/22] [Bug] Enhance DisableCustomerAssetConversion procedure to handle existing CRM products and modify additional fields correctly --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index e125ac1429b..de7fe74ad8b 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -366,11 +366,19 @@ codeunit 6610 "FS Int. Table Subscriber" end; internal procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) + var + ExistingCRMProduct: Record "CRM Product"; begin if CRMProduct.ConvertToCustomerAsset then begin CRMProduct.ConvertToCustomerAsset := false; AdditionalFieldsWereModified := true; + exit; end; + + ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); + if ExistingCRMProduct.Get(CRMProduct.ProductId) then + if ExistingCRMProduct.ConvertToCustomerAsset then + AdditionalFieldsWereModified := true; end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Record Synch.", 'OnTransferFieldData', '', true, false)] From 326579a8aad11b77dcd3ffb190c57143b125c179 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 9 Sep 2026 10:24:51 +0200 Subject: [PATCH 11/22] [Bug] Update OnAfterTransferRecordFields procedure to include DestinationIsInserted parameter and adjust logic for field loading --- .../src/Codeunits/FSIntTableSubscriber.Codeunit.al | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index de7fe74ad8b..567442cd614 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -284,7 +284,7 @@ codeunit 6610 "FS Int. Table Subscriber" end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnAfterTransferRecordFields', '', true, false)] - local procedure OnAfterTransferRecordFields(SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef; var AdditionalFieldsWereModified: Boolean) + local procedure OnAfterTransferRecordFields(SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef; var AdditionalFieldsWereModified: Boolean; DestinationIsInserted: Boolean) var FSConnectionSetup: Record "FS Connection Setup"; FSWorkOrderProduct: Record "FS Work Order Product"; @@ -302,6 +302,8 @@ codeunit 6610 "FS Int. Table Subscriber" case SourceDestCode of 'Item-CRM Product': begin + if not DestinationIsInserted then + DestinationRecordRef.LoadFields(CRMProduct.FieldNo(ConvertToCustomerAsset)); DestinationRecordRef.SetTable(CRMProduct); DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); DestinationRecordRef.GetTable(CRMProduct); @@ -366,19 +368,11 @@ codeunit 6610 "FS Int. Table Subscriber" end; internal procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) - var - ExistingCRMProduct: Record "CRM Product"; begin if CRMProduct.ConvertToCustomerAsset then begin CRMProduct.ConvertToCustomerAsset := false; AdditionalFieldsWereModified := true; - exit; end; - - ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); - if ExistingCRMProduct.Get(CRMProduct.ProductId) then - if ExistingCRMProduct.ConvertToCustomerAsset then - AdditionalFieldsWereModified := true; end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Record Synch.", 'OnTransferFieldData', '', true, false)] From f0b6ce46da3816be44a5c63cc06201afb1be4266 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 9 Sep 2026 13:59:08 +0200 Subject: [PATCH 12/22] [Bug] Fix logic in customer asset conversion to correctly check DestinationIsInserted condition --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 567442cd614..60f39d4eb55 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -302,7 +302,7 @@ codeunit 6610 "FS Int. Table Subscriber" case SourceDestCode of 'Item-CRM Product': begin - if not DestinationIsInserted then + if DestinationIsInserted then DestinationRecordRef.LoadFields(CRMProduct.FieldNo(ConvertToCustomerAsset)); DestinationRecordRef.SetTable(CRMProduct); DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); From bb079469413cbc62074c17dcf16005017201918b Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 9 Sep 2026 17:56:44 +0200 Subject: [PATCH 13/22] [Bug] Add handling for existing CRM products in customer asset conversion logic --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 60f39d4eb55..dcdfea896ee 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -291,6 +291,7 @@ codeunit 6610 "FS Int. Table Subscriber" FSWorkOrderService: Record "FS Work Order Service"; FSBookableResourceBooking: Record "FS Bookable Resource Booking"; CRMProduct: Record "CRM Product"; + ExistingCRMProduct: Record "CRM Product"; ServiceLine: Record "Service Line"; SourceDestCode: Text; begin @@ -302,9 +303,12 @@ codeunit 6610 "FS Int. Table Subscriber" case SourceDestCode of 'Item-CRM Product': begin - if DestinationIsInserted then - DestinationRecordRef.LoadFields(CRMProduct.FieldNo(ConvertToCustomerAsset)); DestinationRecordRef.SetTable(CRMProduct); + if DestinationIsInserted then begin + ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); + ExistingCRMProduct.Get(CRMProduct.ProductId); + CRMProduct.ConvertToCustomerAsset := ExistingCRMProduct.ConvertToCustomerAsset; + end; DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); DestinationRecordRef.GetTable(CRMProduct); end; From 7acb78878afa4b8e279f181ab8b8f0c964f7a799 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Wed, 9 Sep 2026 22:33:49 +0200 Subject: [PATCH 14/22] [Bug] Refactor customer asset conversion logic to correctly handle existing CRM products and modify additional fields --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index dcdfea896ee..16ba1badb06 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -307,10 +307,14 @@ codeunit 6610 "FS Int. Table Subscriber" if DestinationIsInserted then begin ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); ExistingCRMProduct.Get(CRMProduct.ProductId); - CRMProduct.ConvertToCustomerAsset := ExistingCRMProduct.ConvertToCustomerAsset; + if ExistingCRMProduct.ConvertToCustomerAsset then begin + DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); + AdditionalFieldsWereModified := true; + end; + end else begin + DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + DestinationRecordRef.GetTable(CRMProduct); end; - DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); - DestinationRecordRef.GetTable(CRMProduct); end; 'FS Work Order Product-Service Line': begin From b6614f9798b5f6d2db181123d2eee0908bc2666a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Thu, 10 Sep 2026 13:43:05 +0200 Subject: [PATCH 15/22] [Bug] Simplify CRM product handling in customer asset conversion logic by removing redundant checks and directly loading fields --- .../Codeunits/FSIntTableSubscriber.Codeunit.al | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 16ba1badb06..521196d9117 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -291,7 +291,6 @@ codeunit 6610 "FS Int. Table Subscriber" FSWorkOrderService: Record "FS Work Order Service"; FSBookableResourceBooking: Record "FS Bookable Resource Booking"; CRMProduct: Record "CRM Product"; - ExistingCRMProduct: Record "CRM Product"; ServiceLine: Record "Service Line"; SourceDestCode: Text; begin @@ -304,17 +303,10 @@ codeunit 6610 "FS Int. Table Subscriber" 'Item-CRM Product': begin DestinationRecordRef.SetTable(CRMProduct); - if DestinationIsInserted then begin - ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); - ExistingCRMProduct.Get(CRMProduct.ProductId); - if ExistingCRMProduct.ConvertToCustomerAsset then begin - DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); - AdditionalFieldsWereModified := true; - end; - end else begin - DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); - DestinationRecordRef.GetTable(CRMProduct); - end; + if DestinationIsInserted then + CRMProduct.LoadFields(ConvertToCustomerAsset); + DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + DestinationRecordRef.GetTable(CRMProduct); end; 'FS Work Order Product-Service Line': begin From ab1b3a7b5087034ec7b062fad06893b8d0dc7b1e Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Thu, 10 Sep 2026 23:02:32 +0200 Subject: [PATCH 16/22] [Bug] Update OnAfterTransferRecordFields event subscriber to disable multi-company synchronization --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 521196d9117..9cb582dbb69 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -283,7 +283,7 @@ codeunit 6610 "FS Int. Table Subscriber" end; end; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnAfterTransferRecordFields', '', true, false)] + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnAfterTransferRecordFields', '', false, false)] local procedure OnAfterTransferRecordFields(SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef; var AdditionalFieldsWereModified: Boolean; DestinationIsInserted: Boolean) var FSConnectionSetup: Record "FS Connection Setup"; From 890d0ee4a433ca2772a2905efb7f354db7570c6c Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 11 Sep 2026 11:32:54 +0200 Subject: [PATCH 17/22] Enhance FS Int. Table Subscriber event subscription and update integration tests for item variant support --- .../FSIntTableSubscriber.Codeunit.al | 2 +- .../test/src/FSIntegrationTest.Codeunit.al | 5 - .../Multi-Layer-Item-Search-Architecture.pptx | Bin 0 -> 41929 bytes .../app/SOA-ItemVariant-Design.md | 470 ++++++++++++++++++ .../app/src/Integration/app.code-workspace | 11 + 5 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 src/Apps/W1/SalesOrderAgent/app/Multi-Layer-Item-Search-Architecture.pptx create mode 100644 src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md create mode 100644 src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 9cb582dbb69..521196d9117 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -283,7 +283,7 @@ codeunit 6610 "FS Int. Table Subscriber" end; end; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnAfterTransferRecordFields', '', false, false)] + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnAfterTransferRecordFields', '', true, false)] local procedure OnAfterTransferRecordFields(SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef; var AdditionalFieldsWereModified: Boolean; DestinationIsInserted: Boolean) var FSConnectionSetup: Record "FS Connection Setup"; diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 587cf28e63f..a8f80521665 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1582,7 +1582,6 @@ codeunit 139204 "FS Integration Test" var Item: Record Item; CRMProduct: Record "CRM Product"; - IntegrationFieldMapping: Record "Integration Field Mapping"; IntegrationTableMapping: Record "Integration Table Mapping"; CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; CRMSetupDefaults: Codeunit "CRM Setup Defaults"; @@ -1599,10 +1598,6 @@ codeunit 139204 "FS Integration Test" CRMProduct.ConvertToCustomerAsset := true; CRMProduct.Modify(); - // [GIVEN] The constant field mapping is removed to isolate the custom synchronization callback. - IntegrationFieldMapping.SetRange("Integration Table Mapping Name", 'ITEM-PRODUCT'); - IntegrationFieldMapping.SetRange("Integration Table Field No.", CRMProduct.FieldNo(ConvertToCustomerAsset)); - IntegrationFieldMapping.DeleteAll(); IntegrationTableMapping.Get('ITEM-PRODUCT'); // [WHEN] The item is synchronized to the Field Service product. diff --git a/src/Apps/W1/SalesOrderAgent/app/Multi-Layer-Item-Search-Architecture.pptx b/src/Apps/W1/SalesOrderAgent/app/Multi-Layer-Item-Search-Architecture.pptx new file mode 100644 index 0000000000000000000000000000000000000000..412aa6b2c2abdf68d97a1d82a97da4b4ccb78bee GIT binary patch literal 41929 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
akba5g=(1Fz?o;6q|{ux1wQwH}lnjclHt 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 QP?mw8AaEP;b&B-Ue>PQhE&u=k literal 0 HcmV?d00001 diff --git a/src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md b/src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md new file mode 100644 index 00000000000..502171a1df7 --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md @@ -0,0 +1,470 @@ +# Sales Order Agent — Item Variant Support + +## 1. Overview + +This document describes the implemented Item Variant support in the Sales Order Agent (SOA). The agent resolves customer requests to an **item + variant combination** when variant intent is present, checks availability and pricing under that variant, and exposes only safe alternatives. + +### Scope + +| Area | Baseline | Implemented State | +|---|---|---| +| Item Search | Finds items; variant data exists in index but is unused for selection | Resolve to item + variant via Item Selector | +| Availability | Hard-coded `Variant Filter = ''` (item-level only) | Variant-level availability via filter field on header | +| Pricing | No variant code on temp Sales Line | Variant-aware pricing | +| Alternatives | Item-level only | LLM returns alternative variants; pre-checked for availability | + +### Reference + +- TODO 558879 — broader variant test updates; the base `SOA-QUOTE_ITEMS_HANDLING_VARIANTS.yaml` dataset now has active `variantCode` assertions +- Variant Code is visible on the Sales Quote/Order subform page customizations +- Application changes are in the BCApps PR; the accuracy dataset changes are in a separate internal test-app PR + +--- + +## 2. Item Search & Variant Resolution + +### Current Flow + +``` +Email → SOA Dispatcher → SOA Impl + → SOA Multi Items Availability page opens + → OnBeforeFindRecord fires + → SOAItemSearch.FindRecordItem() + → GlobalItemSearch (platform ALSearch.FindItems API) + → or SOABroaderItemSearch (AOAI-assisted) + → SOAItemSelector (AOAI picks matching vs alternative) + → Results: list of Item SystemIds +``` + +The platform item search API returns item candidates with indexed column values. The implementation does not rely on those results containing complete variant data: after exact, standard, or broader candidate collection, `SOAItemSearch` enriches every candidate from authoritative `Item Variant` records (Code, Description, and Description 2). `SOAVariantSearch.Codeunit.al` also exists for cross-column Item Variant search and is bound via `SOASessionEvents`. + +### Design — Unified Resolution via Item Selector (AGREED) + +**Key decision from design review:** Resolve variants through the **existing Item Selector** in a single LLM call, rather than a separate two-step process. + +The Item Selector receives each search candidate's indexed item fields plus an authoritative `Variants` array added by application code. The LLM can distinguish variants from attributes because the payload labels them separately. The Item Selector returns both the best item **and** the best variant in one pass. + +``` +Item Search pipeline: + → GlobalItemSearch / BroaderItemSearch finds candidates + → SOAItemSearch enriches every candidate with authoritative Item Variant data + → Item Selector receives candidates with column_values including the Variants array + → Item Selector returns selected_items entries with item_no, optional variant_code, + overall selection confidence, variant_match, variant_substitution_safety, and reason + → Result: best match item+variant AND alternative item+variants +``` + +**Behavior:** +- Item Selector runs for standard and broader candidates and for exact item matches that have variants. A single exact item match with no variants bypasses the selector +- Candidate enrichment runs after exact, standard, and broader search so selector behavior does not depend on variant data being present in the search index payload +- `variant_code` is optional. When no variant is requested, omit it and set `variant_match` to `not_requested` +- When a returned code fulfills the request, set `variant_match` to `matching`; when it is a safe substitute, set it to `alternative` +- `variant_substitution_safety` independently classifies a substitution as `safe`, `unsafe`, or `not_applicable`. Application code retains an alternative only when it is explicitly `safe`; missing or unsafe decisions are discarded +- Alternative variants must be concrete entries in `selected_items`; application code does not synthesize variants omitted by the model +- If a requested non-interchangeable variant does not exist, return an empty `selected_items` array +- A valid empty result is successful and authoritative. It clears the item filter and never falls back to the original candidates +- Missing or malformed `selected_items`, AOAI failure, and missing function calls remain selector failures and retain the existing fallback behavior +- Returned item numbers are validated against the original candidate set, and variant codes are validated against the selected item before use +- `Variant Mandatory if Exists` is not included in the Item Selector candidate payload. Mandatory-without-request behavior is deferred until that metadata is available to the selector or enforced downstream +- Alternative variants are returned alongside the best match, avoiding extra LLM round-trips + +### Implemented Components + +| File | Change | +|---|---| +| `itemselector-task.md` | Defines item and variant selection behavior, interchangeability rules, safe alternatives, and valid empty results. | +| `itemselector-tool.md` | Defines one required `selected_items` array. Each entry requires `item_no`, `confidence`, `variant_match`, `variant_substitution_safety`, and `reason`; `variant_code` is optional. | +| `SOAItemSelector.Codeunit.al` | Runs AOAI, distinguishes valid empty results from failures, and emits non-sensitive failure telemetry. | +| `SOAItemSelectorFunc.Codeunit.al` | Strictly parses the function result and classifies malformed item, variant, and `variant_match` output. | +| `SOAItemSearch.Codeunit.al` | Enriches every candidate from authoritative Item Variant records, invokes Item Selector, validates candidate ownership, prefers concrete variant alternatives over generic item alternatives, applies variant-specific availability, and stores the resolved `Item SystemId → Variant Code` mapping. | + +### Why this approach (from design review) + +- The LLM receives existing indexed item fields plus authoritative variant data loaded in one batched Item Variant query +- We don't know which keywords match to variants vs. item names — the LLM decides +- Single LLM call for both item and variant selection avoids latency of separate round-trips +- LLM can distinguish between variants (e.g., colors) and attributes because the JSON payload labels each column +- Alternative variants come back in the same response, ready for use if primary is unavailable +- The latest incoming email body and the extracted text of its non-ignored attachments are combined in the `message_content` field of the untrusted-data envelope. The extracted `search_query` remains primary; supporting context may recover omitted same-item intent such as "any color," but must not contribute item or variant intent from unrelated lines in a multi-item email or attachment. + +### Selector follow-up raised in handoff review (NOT YET DECIDED) + +- **Schema-constrained variant values:** Dynamically constraining `variant_code` to candidate variant codes may reduce rejected output. Server-side candidate and variant validation remains mandatory even if a dynamic enum is added. + +--- + +## 3. Availability with Variants (AGREED) + +### Baseline + +`SOA Multi Items Availability` uses **Item** as its source table. Each row = one item. Before this feature, the flow forced `Variant Filter = ''` in four places: + +1. **`OnOpenPage`** — `Rec.SetRange("Variant Filter", '');` +2. **`CalcAvailQuantities`** — `Item.SetRange("Variant Filter", '');` +3. **`SOACreateTaskImpl.CalcItemProjAvailableBalance`** — `Item.SetRange("Variant Filter", '');` +4. **`SOAItemSearch.OnAfterCheckItemAvailable`** — `Item.SetRange("Variant Filter", '');` + +The Item table's availability FlowFields (`Inventory`, `Qty. on Sales Order`, etc.) all respect the `Variant Filter` — so setting it to a specific variant code will correctly calculate variant-level availability. The platform handles this. + +### Constraint: Page source table is Item, not Item Variant + +The page source table is `Item`, meaning: +- **One row per item** — we cannot show the same item twice with different variants +- Variant Filter is a FlowFilter on Item, not a field per row + +### Decision: Add Variant Filter field on page header (Option 1) + +**Agreed and implemented.** The page keeps `Item` as its source table and obtains the resolved variant through its existing event pattern. Availability, pricing, translation, and shipment-date calculations use that resolved code. + +``` +OnAfterGetRecord: + VariantCode := GetResolvedVariant(Rec."No."); // from Item Selector result + → CalcAvailQuantities uses VariantCode instead of '' + → CalcPrice uses VariantCode + → SOAShipmentDateMgt.SetParameters uses VariantCode +``` + +**Why this approach:** +- Minimal change to existing page structure +- All Item FlowFields already calculate correctly when Variant Filter is set — no rework needed +- Pricing naturally picks up variant-specific prices +- Confirmed by Volodymyr: "if we know the exact variant code, it's very easy to calculate price" + +**Accepted limitation:** One variant per item row. For multi-variant same-item requests (e.g., "5 Blue + 3 Red Fairy Dolls"), these are handled as **separate searches** via agent instructions (agreed with Qasim). + +**Future consideration:** May change to support multiple lines per item later, but the first iteration intentionally avoids redesigning the availability page. Ship the single-variant behavior first, gather feedback and telemetry, and revisit an item+variant buffer only if users need all alternatives displayed. + +### Programmatic availability pre-check (AGREED) + +Before showing results to the agent, **programmatically check availability** for the resolved item+variant: + +``` +Item Selector returns: best match (item + variant) + alternatives (item + variant) + → Check: is best match available? + Yes → show only best match to agent + No → show alternatives to agent instead +``` + +This avoids relying on the agent to interpret availability and make decisions. The decision is made in code before the agent sees the results. + +### Implemented Components + +| File | Change | +|---|---| +| `SOAMultiItemsAvailability.Page.al` | Reads the resolved variant, applies it to availability and translation, validates it on the temporary Sales Line, and passes it to shipment-date calculation. | +| `SOAItemSearch.Codeunit.al` | Supplies the resolved variant, applies variant-specific availability filtering, and stores the selected mapping. | +| `SOACreateTaskImpl.Codeunit.al` | Preserves the source item's Variant Filter when calculating projected available balance. | + +--- + +## 4. Pricing with Variants (AGREED) + +### Baseline + +`CalcPrice` previously created a temporary Sales Quote line without Variant Code: +```al +TempSalesLine.Validate(Type, TempSalesLine.Type::Item); +TempSalesLine.Validate("No.", Rec."No."); +TempSalesLine.Validate(Quantity, 1); +// Baseline omitted Variant Code, producing a generic item price +``` + +BC's pricing engine uses Variant Code to find variant-specific prices and discounts. + +### Implemented Behavior + +After validating `"No."`, validate the variant code: + +```al +TempSalesLine.Validate("No.", Rec."No."); +if VariantCode <> '' then + TempSalesLine.Validate("Variant Code", VariantCode); +TempSalesLine.Validate(Quantity, 1); +``` + +**Confirmed straightforward** by Volodymyr: "if we know the exact variant code, it's very easy to calculate price." The BC pricing engine handles the rest. + +### Item Translation with Variants + +Variant-specific Item Translation lookup uses: + +```al +if ItemTranslation.Get(Rec."No.", VariantCode, LanguageCode) then ... +``` + +This ensures variant-specific translated descriptions are shown when available. + +--- + +## 5. Alternative Variant Suggestions (AGREED) + +### Scenario + +Customer requests "5 Blue Fairy Dolls". The Blue variant is out of stock. The agent should suggest other available variants (Red, Green). + +### Design — LLM-driven alternatives with programmatic availability pre-check + +**Agreed in design review:** The Item Selector returns both the best match and alternative variants in a single LLM call. We then programmatically check availability before showing results to the agent. + +``` +Item Selector returns: + - Best match: AItem-0004 + variant BLUE (confidence: matching) + - Alternative: AItem-0004 + variant RED (confidence: alternative) + - Alternative: AItem-0004 + variant GREEN (confidence: alternative) + +Programmatic pre-check: + - BLUE available? → Show only BLUE to agent + - BLUE not available? → Pick the first available same-item alternative variant and show that single variant to the agent +``` + +**Key principles (from design review):** +- No extra LLM round-trip for finding alternatives — they come back in the same Item Selector response +- Availability check is done **programmatically** (using existing helper functions), not by the agent +- Agent only sees items/variants that are already confirmed available (or the best match if availability check is disabled) +- Item Selector runs for exact matches when the item has variants. A single exact item with no variants bypasses the selector + +### Implementation compromise — single displayed alternative variant + +The initial implementation keeps `SOA Multi Items Availability` source table as `Item` and therefore keeps one row per item. To avoid redesigning the availability page as a temporary item+variant buffer, alternative variants are handled internally as an ordered list of candidates for the same item. The programmatic availability check selects the first available alternative variant and stores only that variant in the `Item SystemId → Variant Code` mapping shown on the page. + +This means the agent sees one available alternative variant, not all available alternatives. This preserves the existing item-based page behavior and reduces risk to non-variant item flows. + +**Confirmed for the first iteration in the handoff review:** Andrei selected the simplest current behavior: show one available alternative variant, ship it in the major release, and use feedback and telemetry to decide whether displaying all available variants justifies an availability-page redesign. + +The alternative is a suggestion, not an automatic substitution. When the requested variant is unavailable, do not create a quote until the customer confirms the offered alternative variant. + +For requests where an interchangeable variant does not exist, such as "Yellow MagicToyland Fairy Doll" when only BLUE, GREEN, and RED exist, the Item Selector may return concrete same-item variants. When at least one concrete variant alternative is present, search post-processing suppresses generic variantless item alternatives, availability-checks the concrete alternatives, and exposes the first available same-item variant. It does not synthesize variants omitted by the selector. + +### Smart alternative suggestions via prompting + +Andrei raised that variant interchangeability is context-dependent: +- Shoe sizes (44 vs 42) — **not interchangeable** +- Keyboard colors (black vs white) — **interchangeable** + +**Agreed approach:** Use LLM prompting to handle this. Instruct the Item Selector to only return alternative variants that are "closely related" or "reasonable substitutes." The LLM should use common sense — e.g., don't suggest adult bicycle when kids bicycle is requested, but do suggest white keyboard when black is unavailable. + +Variant values that affect fit, compatibility, or another non-interchangeable requirement are not reasonable substitutes. For example, do not suggest shoe size 42 when the customer requested size 44 unless the customer explicitly permits other sizes. + +### Fallback behavior + +If no matching variant or safe concrete alternative qualifies: +- The selector returns a valid empty result +- Search treats the empty result as authoritative and does not restore the original candidates +- The agent treats the result as a customer-facing unavailability outcome, sends a reply explaining that the requested item/variant and any suitable alternative are unavailable, and does not request internal assistance +- The agent must not create a quote or offer an unsafe substitute + +### Implemented Components + +| File | Change | +|---|---| +| `itemselector-task.md` | Restricts alternatives to interchangeable dimensions and requires concrete alternative entries. | +| `SOAItemSelectorFunc.Codeunit.al` | Parses matching and alternative variant entries and validates their state combinations. | +| `SOAItemSearch.Codeunit.al` | Availability-checks matching entries first, then concrete alternatives, and stores the selected mapping. | +| `SalesOrderAgent-AgentInstructions.md` | Routes an empty result for a variant-specific request to a customer reply instead of internal availability assistance. | + +--- + +## 6. Sales Quote/Order Line Creation (AGREED) + +### Implemented State + +The `Variant Code` field is visible on both Sales Quote Subform and Sales Order Subform page customizations: + +```al +modify("Variant Code") { Visible = true; } +``` + +### Implementation + +1. `Variant Code` is visible on the SOA page customizations +2. When the agent creates a sales line, it sets Variant Code after Item No. when a variant was resolved +3. The orchestration flow carries the resolved variant code from search and availability through line creation + +### Agent behavior with visible Variant Code + +Volodymyr raised: if Variant Code is always visible but some items don't have variants, will the agent try to fill it with something? + +**Agreed approach:** Rely on proper prompting/instructions to guide the agent. If items have variants, the agent uses the resolved variant code from the search phase. If items don't have variants, the field stays empty. Andrei noted companies using variants typically want it selected everywhere — this aligns with making it always visible. + +### Implemented Components + +| File | Change | +|---|---| +| `SOASalesQuoteSubform.PageCust.al` | Shows Variant Code on Sales Quote lines. | +| `SOASalesOrderSubform.PageCust.al` | Shows Variant Code on Sales Order lines. | + +--- + +## 7. End-to-End Flow (Target State) + +``` +1. Email arrives: "5 Blue Fairy Dolls, 3 Red Bicycles" + +2. SOA Dispatcher → SOA Impl → Opens SOA Multi Items Availability + +3. OnBeforeFindRecord fires → SOAItemSearch.FindRecordItem(): + a. Platform search finds candidates including AItem-0004 (Fairy Doll) and AItem-0011 (Bicycle) + b. SOAItemSearch enriches candidates with authoritative Item Variant data + c. Item Selector (LLM) receives candidates with column_values including the Variants array + d. Item Selector returns: + - AItem-0004, variant_code: BLUE, confidence: matching, variant_match: matching + - AItem-0004, variant_code: RED, confidence: alternative, variant_match: alternative + - AItem-0004, variant_code: GREEN, confidence: alternative, variant_match: alternative + - AItem-0011, variant_code: RED, confidence: matching, variant_match: matching + - Each entry also contains a concise reason (omitted here for brevity) + e. Programmatic availability check: + - AItem-0004 + BLUE → available ✓ → use BLUE + - AItem-0011 + RED → available ✓ → use RED + f. Store mapping: {AItem-0004 → BLUE, AItem-0011 → RED} + +4. OnAfterGetRecord for AItem-0004: + a. VariantCode = BLUE (from mapping) + b. CalcAvailQuantities with Variant Filter = 'BLUE' + c. CalcPrice with Variant Code = BLUE on temp Sales Line + +5. OnAfterGetRecord for AItem-0011: + a. VariantCode = RED (from mapping) + b. CalcAvailQuantities with Variant Filter = 'RED' + c. CalcPrice with Variant Code = RED on temp Sales Line + +6. Agent reads availability results → Creates Sales Quote: + a. Line 1: Item AItem-0004, Variant Code BLUE, Qty 5 + b. Line 2: Item AItem-0011, Variant Code RED, Qty 3 + +7. Agent sends reply email mentioning variants in descriptions +``` + +### Alternative scenario (variant unavailable): + +``` +3c. Item Selector returns: + - AItem-0004, variant_code: BLUE, confidence: matching, variant_match: matching + - AItem-0004, variant_code: RED, confidence: alternative, variant_match: alternative + - AItem-0004, variant_code: GREEN, confidence: alternative, variant_match: alternative + +3d. Programmatic availability check: + - AItem-0004 + BLUE → NOT available ✗ + - AItem-0004 + RED → available ✓ → use RED as the selected alternative + - AItem-0004 + GREEN → not shown because the item page displays one variant per item row + +3e. Agent sees RED (not BLUE) on availability page + → Does not create a quote yet + → Reply email says BLUE is unavailable, offers RED, and asks the customer to confirm + → Creates the quote with RED only after customer confirmation +``` + +--- + +## 8. Test Scenarios + +The base `SOA-QUOTE_ITEMS_HANDLING_VARIANTS.yaml` dataset contains ten active scenarios: + +| Test | Required behavior | +|---|---| +| `QUOTE_5_ITEMS_VARIANTS_01` | Resolve explicit variant labels to exact Variant Codes on quote lines. | +| `QUOTE_5_ITEMS_VARIANTS_02` | Resolve variant wording embedded in item descriptions. | +| `QUOTE_3_ITEMS_VARIANTS_03` | Resolve semantic and broad variant wording, including an acceptable "any color" request. | +| `QUOTE_3_ITEMS_VARIANTS_04` | Omit Variant Code when no variant is requested. | +| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_05` | For a missing interchangeable color, offer exactly one available same-item color, do not offer another item, and wait for confirmation. | +| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_06` | When the requested size exists but is unavailable, do not offer other sizes. | +| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_07` | When the requested size does not exist, preserve the authoritative empty selection, offer no other size, and create no quote. | +| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_08` | Honor an explicit customer prohibition against color substitution and create no quote. | +| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_10` | When the requested color exists but is unavailable, offer one safe available same-item color without silently substituting. | +| `QUOTE_2_ITEMS_VARIANT_CONTEXT_ISOLATION_11` | Keep variant context isolated between multiple requested items. | + +The `variantCode` expectations are active. Scenarios `_07`, `_08`, `_10`, and `_11` were specifically verified while simplifying the prompt contract. All ten active scenarios should be rerun after changes to selector output parsing, candidate validation, or availability fallback. + +### Deferred Scenario + +`_09` (variant mandatory but not specified) is intentionally not active. The Item Selector candidate payload does not include `Variant Mandatory if Exists`, so the selector cannot reliably distinguish an optional blank variant from a mandatory missing variant. Add this scenario only after that metadata is supplied to the selector or deterministic downstream enforcement is implemented. + +### Additional Coverage + +- Variant-specific pricing differs from the base item price +- Variant code and semantic variant wording in non-English languages +- Same item requested with multiple variants through separate searches +- PDF or attachment input containing item variants +- Larger item counts and mixed variant requests +- Alternative item whose best candidate also requires an alternative variant + +### Known Runtime Note + +A spaced variant code such as `AGE - 6-8` can trigger an agent-runtime lookup failure and produce a quote line with quantity 0 even when the outgoing message is otherwise correct. Track this separately from application search and availability behavior. + +--- + +## 9. Implemented Architecture + +### Selector Contract + +1. The tool schema requires one `selected_items` array. +2. Every selected entry requires `item_no`, overall selection `confidence`, `variant_match`, `variant_substitution_safety`, and `reason`. +3. `variant_code` is optional and must be omitted when `variant_match` is `not_requested`. +4. `variant_match` is `matching` when the code fulfills the request and `alternative` when the code is a safe substitute. +5. `variant_substitution_safety` is `safe` only for presentation-only changes that preserve suitability or changes explicitly permitted by the customer. It is `unsafe` for suitability-affecting, prohibited, or uncertain changes and `not_applicable` for exact matches or requests without variant intent. +6. The parser discards every alternative that is not explicitly classified `safe`, independently of its code or description. If no safe entry remains, the result is a valid empty selection. +7. A valid empty array is a successful business result. Missing or malformed required output outside the fail-closed alternative-safety handling is a selector failure. +8. There is no separate unresolved-request output. Safe alternatives must be concrete selected entries; unsafe missing variants produce an empty result. + +### Search and Validation + +1. Item Selector runs after candidate collection for standard and broader search paths and for exact item matches that have variants. A single exact item match with no variants bypasses the selector. +2. Candidate payloads preserve indexed item fields and add an authoritative `Variants` array from Item Variant records before entering the untrusted-data envelope with supporting message context. +3. Returned item numbers must belong to the original candidate set. +4. Returned variant codes must exist for the selected item. +5. Matching item+variant pairs are availability-filtered first; concrete alternatives are considered only when no matching pair remains available. +6. The selected mapping is stored as `Item SystemId -> Variant Code` and consumed by the availability page. + +### Availability, Pricing, and Quote Creation + +1. Availability calculations apply the resolved Variant Filter. +2. Shipment date calculations receive the resolved Variant Code. +3. Temporary sales lines validate Variant Code before price calculation. +4. Item Translation lookup uses the resolved variant. +5. Variant Code is visible on SOA sales quote and sales order subforms. +6. Alternative variants are suggestions and require customer confirmation before quote creation. + +### Telemetry + +Telemetry distinguishes valid empty selection, AOAI failure, malformed function output, invalid item number, invalid variant code, rejected `variant_match` combinations, and server-side candidate or variant ownership rejection counts. It records categories, booleans, and aggregate counts only; customer content, item numbers, variant codes, and descriptions are not logged. + +### Validation Workflow + +1. Run focused AL diagnostics after selector, search, availability, or prompt changes. +2. Build the full workspace with CodeCop. +3. Run all ten active variant accuracy scenarios after changes to selector parsing or fallback behavior. +4. Run the regular accuracy suite to detect non-variant regressions. +5. Track agent-runtime issues, including spaced variant codes, separately from application search and availability failures. + +--- + +## 10. Decisions from Design Review + +| Question | Decision | +|---|---| +| No variant specified but item has variants | Omit `variant_code` and set `variant_match` to `not_requested`. Do not choose a variant without a request signal. | +| Variant feature configuration | Do not add a separate SOA setup Boolean. Customers without Item Variant records retain the standard flow. Mandatory-without-request enforcement is deferred because that field is not in the selector payload. | +| Non-existent variant requested | Return concrete alternatives only for interchangeable dimensions. Return a valid empty result when substitution could change suitability or the customer rejects substitutions. | +| Multi-variant same item | Handle as **separate searches** via agent instructions. One variant per item row on availability page. | +| Variant in reply email | Include variant information (code and/or description) in outgoing email. | +| Item Selector contract | Return one `selected_items` array with optional `variant_code`, required `variant_match`, independent `variant_substitution_safety`, overall selection confidence, and reason. Alternatives are retained only when safety is explicitly `safe`. | +| Where to resolve variants | In Item Selector during search phase (not on availability page). | +| Availability page redesign | Not needed for initial implementation. Keep Item source table + Variant Filter field on header. | +| Alternative variant logic | LLM-driven with deterministic post-processing. When the selector returns concrete variant alternatives, code suppresses generic variantless item alternatives without synthesizing variants from unknown terms. Availability is pre-checked programmatically, and the initial implementation shows the first available same-item alternative variant only. | +| Quote creation for an alternative | Offer the available alternative and wait for customer confirmation. Do not silently substitute the variant or create the quote in the initial response. | +| When best match is unavailable | Show the first available concrete safe alternative. If none qualifies, preserve the authoritative empty result, reply to the customer without requesting internal assistance, and create no quote. | +| Valid empty selector result | Treat it as successful and never fall back to original candidates. | +| Release scope | Target the major release. Do not initially backport to version 28 because the cross-cutting behavior change needs broader validation. | + +--- + +## 11. Notes & Future Considerations + +- **Item attributes at variant level:** Andrei noted that item attributes now exist at the variant level (recently added) and may not yet be included in the item index table. Platform could add more columns to the index. Not a blocker for initial implementation but worth exploring. +- **Item Variant Index Table:** Platform created an item variant index table for this purpose, but the implementation does not query it directly. Search still uses the existing item index for discovery, then enriches selected candidates from authoritative Item Variant records before Item Selector runs. +- **Consistency of alternatives:** Item Selector runs whenever variant evaluation may be needed, so concrete alternatives remain available for exact items with variants and for standard and broader search paths. +- **Buffer table approach:** Could be revisited after feedback and telemetry if we need to display multiple variants per item as separate rows on the availability page. +- **Message-context scope:** The latest incoming email body and its non-ignored extracted attachment text are approved as supporting context for same-item intent. Broader use for vague item discovery should be evaluated separately for relevance, token cost, and cross-item interference. +- **Dynamic variant enum:** Potential way to constrain model output to known variant codes, but not approved as a requirement; server-side validation remains necessary either way. +- **Mandatory variant metadata:** Add `Variant Mandatory if Exists` to candidate metadata or enforce it downstream before activating the deferred `_09` scenario. +- **Alternative item + alternative variant:** The handoff review did not confirm this combined fallback scenario as supported. Keep it explicit in tests and scope discussions. diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace b/src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace new file mode 100644 index 00000000000..cbd1819e74f --- /dev/null +++ b/src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../.." + }, + { + "path": "../../../../../../../../master/App/Internal/Apps/SalesOrderAgent/test" + } + ], + "settings": {} +} \ No newline at end of file From bc0d664b7cc8cac7ea610cd128ca06e77909129c Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Thu, 17 Sep 2026 13:39:33 +0200 Subject: [PATCH 18/22] Add handling for existing CRM products in customer asset conversion --- .../Codeunits/FSIntTableSubscriber.Codeunit.al | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 521196d9117..16ba1badb06 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -291,6 +291,7 @@ codeunit 6610 "FS Int. Table Subscriber" FSWorkOrderService: Record "FS Work Order Service"; FSBookableResourceBooking: Record "FS Bookable Resource Booking"; CRMProduct: Record "CRM Product"; + ExistingCRMProduct: Record "CRM Product"; ServiceLine: Record "Service Line"; SourceDestCode: Text; begin @@ -303,10 +304,17 @@ codeunit 6610 "FS Int. Table Subscriber" 'Item-CRM Product': begin DestinationRecordRef.SetTable(CRMProduct); - if DestinationIsInserted then - CRMProduct.LoadFields(ConvertToCustomerAsset); - DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); - DestinationRecordRef.GetTable(CRMProduct); + if DestinationIsInserted then begin + ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); + ExistingCRMProduct.Get(CRMProduct.ProductId); + if ExistingCRMProduct.ConvertToCustomerAsset then begin + DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); + AdditionalFieldsWereModified := true; + end; + end else begin + DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + DestinationRecordRef.GetTable(CRMProduct); + end; end; 'FS Work Order Product-Service Line': begin From 1d1420eaaf374d4d8d8f4bdd9e2db0fe312710c5 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 18 Sep 2026 14:04:41 +0200 Subject: [PATCH 19/22] Refactor customer asset conversion logic to utilize item management status and streamline related procedures --- .../FSIntTableSubscriber.Codeunit.al | 74 ++++++++++++------- .../src/FSIntegrationTestLibrary.Codeunit.al | 4 +- .../test/src/FSIntegrationTest.Codeunit.al | 36 ++++----- 3 files changed, 66 insertions(+), 48 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 16ba1badb06..0159b187f6e 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -290,8 +290,6 @@ codeunit 6610 "FS Int. Table Subscriber" FSWorkOrderProduct: Record "FS Work Order Product"; FSWorkOrderService: Record "FS Work Order Service"; FSBookableResourceBooking: Record "FS Bookable Resource Booking"; - CRMProduct: Record "CRM Product"; - ExistingCRMProduct: Record "CRM Product"; ServiceLine: Record "Service Line"; SourceDestCode: Text; begin @@ -301,21 +299,6 @@ codeunit 6610 "FS Int. Table Subscriber" SourceDestCode := GetSourceDestCode(SourceRecordRef, DestinationRecordRef); case SourceDestCode of - 'Item-CRM Product': - begin - DestinationRecordRef.SetTable(CRMProduct); - if DestinationIsInserted then begin - ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); - ExistingCRMProduct.Get(CRMProduct.ProductId); - if ExistingCRMProduct.ConvertToCustomerAsset then begin - DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); - AdditionalFieldsWereModified := true; - end; - end else begin - DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); - DestinationRecordRef.GetTable(CRMProduct); - end; - end; 'FS Work Order Product-Service Line': begin SourceRecordRef.SetTable(FSWorkOrderProduct); @@ -375,12 +358,23 @@ codeunit 6610 "FS Int. Table Subscriber" end; end; - internal procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Record Synch.", 'OnBeforeIsFieldModified', '', true, false)] + local procedure OnBeforeIsFieldModified(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var Result: Boolean; var IsHandled: Boolean) + var + Item: Record Item; + CRMProduct: Record "CRM Product"; + SourceRecordRef: RecordRef; + DestinationRecordRef: RecordRef; begin - if CRMProduct.ConvertToCustomerAsset then begin - CRMProduct.ConvertToCustomerAsset := false; - AdditionalFieldsWereModified := true; - end; + if not IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then + exit; + + SourceRecordRef := SourceFieldRef.Record(); + SourceRecordRef.SetTable(Item); + DestinationRecordRef := DestinationFieldRef.Record(); + DestinationRecordRef.SetTable(CRMProduct); + Result := CRMProduct.ConvertToCustomerAsset <> GetCustomerAssetConversion(Item."Coupled to Dataverse"); + IsHandled := true; end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Record Synch.", 'OnTransferFieldData', '', true, false)] @@ -396,6 +390,8 @@ codeunit 6610 "FS Int. Table Subscriber" ServiceHeader: Record "Service Header"; ServiceLine: Record "Service Line"; ItemUnitOfMeasure: Record "Item Unit of Measure"; + Item: Record Item; + CRMProduct: Record "CRM Product"; SourceRecordRef: RecordRef; DestinationRecordRef: RecordRef; NAVItemUomRecordId: RecordId; @@ -417,6 +413,15 @@ codeunit 6610 "FS Int. Table Subscriber" if SourceFieldRef.Record().Number() = DestinationFieldRef.Record().Number() then exit; + if IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then begin + SourceRecordRef := SourceFieldRef.Record(); + SourceRecordRef.SetTable(Item); + NewValue := GetCustomerAssetConversion(Item."Coupled to Dataverse"); + IsValueFound := true; + NeedsConversion := false; + exit; + end; + if (SourceFieldRef.Record().Number = Database::"Service Header") and (DestinationFieldRef.Record().Number = Database::"FS Work Order") then case DestinationFieldRef.Name() of @@ -900,6 +905,25 @@ codeunit 6610 "FS Int. Table Subscriber" ClearLastError(); end; end + + local procedure IsItemCouplingToCustomerAssetConversion(SourceFieldRef: FieldRef; + DestinationFieldRef: FieldRef): Boolean + var + Item: Record Item; + CRMProduct: + Record "CRM Product"; + begin + exit( + (SourceFieldRef.Record().Number() = Database::Item) and + (SourceFieldRef.Number() = Item.FieldNo("Coupled to Dataverse")) and + (DestinationFieldRef.Record().Number() = Database::"CRM Product") and + (DestinationFieldRef.Number() = CRMProduct.FieldNo(ConvertToCustomerAsset))); + end; + + internal procedure GetCustomerAssetConversion(ItemIsManaged: Boolean): Boolean + begin + exit(not ItemIsManaged); + end; else if FSWorkorderService.Get(JobUsageLink."External Id") then begin FSWorkorderService.DurationInvoiced += (JobPlanningLineInvoice."Quantity Transferred" * 60); @@ -1487,13 +1511,13 @@ codeunit 6610 "FS Int. Table Subscriber" IntegrationFieldMapping.Direction::ToIntegrationTable, '', false, false); - // Business Central service items are the source for Field Service customer assets. + // Coupled Business Central items are managed by Field Service customer assets. Sender.InsertIntegrationFieldMapping( IntegrationTableMappingName, - 0, + Item.FieldNo("Coupled to Dataverse"), CRMProduct.FieldNo(ConvertToCustomerAsset), IntegrationFieldMapping.Direction::ToIntegrationTable, - 'false', false, false); + '', false, false); end; local procedure UpdateCorrelatedJobJournalLine(var SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef) diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index d7a2db47d71..0a1fa501cd3 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -59,11 +59,11 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.UpdateQuantities(FSBookableResourceBooking, ServiceLine); end; - procedure DisableCustomerAssetConversion(var CRMProduct: Record "CRM Product"; var AdditionalFieldsWereModified: Boolean) + procedure GetCustomerAssetConversion(ItemIsManaged: Boolean): Boolean var FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; begin - FSIntTableSubscriber.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + exit(FSIntTableSubscriber.GetCustomerAssetConversion(ItemIsManaged)); end; procedure IgnorePostedJobJournalLinesOnQueryPostFilterIgnoreRecord(SourceRecordRef: RecordRef; var IgnoreRecord: Boolean) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index a8f80521665..d58586da841 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1599,6 +1599,8 @@ codeunit 139204 "FS Integration Test" CRMProduct.Modify(); IntegrationTableMapping.Get('ITEM-PRODUCT'); + Item.CalcFields("Coupled to Dataverse"); + Assert.IsTrue(Item."Coupled to Dataverse", 'The item should be identified as managed by its Dataverse coupling.'); // [WHEN] The item is synchronized to the Field Service product. CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); @@ -1610,52 +1612,44 @@ codeunit 139204 "FS Integration Test" [Test] [TransactionModel(TransactionModel::AutoRollback)] - procedure DisableCustomerAssetConversionOnlyMarksActualChanges() - var - CRMProduct: Record "CRM Product"; - AdditionalFieldsWereModified: Boolean; + procedure CustomerAssetConversionReflectsItemManagement() begin // [FEATURE] [Item-Product Mapping] - // [SCENARIO] Customer asset conversion only marks the product as modified when its value changes. + // [SCENARIO] Customer asset conversion is disabled only for items managed through synchronization. - // [WHEN] Customer asset conversion is already disabled. - FSIntegrationTestLibrary.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); + // [WHEN] An item is not managed through synchronization. + // [THEN] Native Field Service customer asset conversion remains enabled. + Assert.IsTrue(FSIntegrationTestLibrary.GetCustomerAssetConversion(false), 'An unmanaged item should allow customer asset conversion.'); - // [THEN] The product is not marked as modified. - Assert.IsFalse(AdditionalFieldsWereModified, 'An unchanged product should not be marked as modified.'); - - // [WHEN] Customer asset conversion is enabled and then disabled. - CRMProduct.ConvertToCustomerAsset := true; - FSIntegrationTestLibrary.DisableCustomerAssetConversion(CRMProduct, AdditionalFieldsWereModified); - - // [THEN] Customer asset conversion is disabled and the product is marked as modified. - Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); - Assert.IsTrue(AdditionalFieldsWereModified, 'A changed product should be marked as modified.'); + // [WHEN] An item is managed through synchronization. + // [THEN] Native Field Service customer asset conversion is disabled. + Assert.IsFalse(FSIntegrationTestLibrary.GetCustomerAssetConversion(true), 'A managed item should disable customer asset conversion.'); end; [Test] [TransactionModel(TransactionModel::AutoCommit)] procedure ItemProductMappingDisablesCustomerAssetConversion() var + Item: Record Item; CRMProduct: Record "CRM Product"; IntegrationFieldMapping: Record "Integration Field Mapping"; CRMSetupDefaults: Codeunit "CRM Setup Defaults"; begin // [FEATURE] [Item-Product Mapping] - // [SCENARIO] The item-product mapping always disables native Field Service customer asset creation. + // [SCENARIO] The item-product mapping derives native Field Service customer asset creation from item management. Initialize(); InitSetup(true, ''); // [WHEN] The default item-product mapping is reset. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); - // [THEN] Convert to Customer Asset is mapped to the constant false in the outbound direction. + // [THEN] Convert to Customer Asset is mapped from the item's Dataverse coupling state in the outbound direction. IntegrationFieldMapping.SetRange("Integration Table Mapping Name", 'ITEM-PRODUCT'); IntegrationFieldMapping.SetRange("Integration Table Field No.", CRMProduct.FieldNo(ConvertToCustomerAsset)); Assert.IsTrue(IntegrationFieldMapping.FindFirst(), 'The Convert to Customer Asset mapping should exist.'); - Assert.AreEqual(0, IntegrationFieldMapping."Field No.", 'The mapping should use a constant value.'); + Assert.AreEqual(Item.FieldNo("Coupled to Dataverse"), IntegrationFieldMapping."Field No.", 'The mapping should use the item coupling state.'); Assert.AreEqual(IntegrationFieldMapping.Direction::ToIntegrationTable, IntegrationFieldMapping.Direction, 'The mapping should be outbound.'); - Assert.AreEqual('false', IntegrationFieldMapping."Constant Value", 'The mapping should disable Convert to Customer Asset.'); + Assert.AreEqual('', IntegrationFieldMapping."Constant Value", 'The mapping should not use a constant value.'); end; local procedure Initialize() From e894abdffaf42fd259227831146bb7bc09f954fd Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 18 Sep 2026 14:14:26 +0200 Subject: [PATCH 20/22] Refactor customer asset conversion procedures by removing redundant CRMProduct record and adding new helper methods for item coupling checks --- .../FSIntTableSubscriber.Codeunit.al | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al index 0159b187f6e..bfd34e09e01 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -391,7 +391,6 @@ codeunit 6610 "FS Int. Table Subscriber" ServiceLine: Record "Service Line"; ItemUnitOfMeasure: Record "Item Unit of Measure"; Item: Record Item; - CRMProduct: Record "CRM Product"; SourceRecordRef: RecordRef; DestinationRecordRef: RecordRef; NAVItemUomRecordId: RecordId; @@ -702,6 +701,23 @@ codeunit 6610 "FS Int. Table Subscriber" exit(MaxQuantity); end; + local procedure IsItemCouplingToCustomerAssetConversion(SourceFieldRef: FieldRef; DestinationFieldRef: FieldRef): Boolean + var + Item: Record Item; + CRMProduct: Record "CRM Product"; + begin + exit( + (SourceFieldRef.Record().Number() = Database::Item) and + (SourceFieldRef.Number() = Item.FieldNo("Coupled to Dataverse")) and + (DestinationFieldRef.Record().Number() = Database::"CRM Product") and + (DestinationFieldRef.Number() = CRMProduct.FieldNo(ConvertToCustomerAsset))); + end; + + internal procedure GetCustomerAssetConversion(ItemIsManaged: Boolean): Boolean + begin + exit(not ItemIsManaged); + end; + [EventSubscriber(ObjectType::Codeunit, Codeunit::"CRM Int. Table. Subscriber", 'OnFindNewValueForCoupledRecordPK', '', true, false)] local procedure OnFindNewValueForCoupledRecordPK(IntegrationTableMapping: Record "Integration Table Mapping"; SourceFieldRef: FieldRef; DestinationFieldRef: FieldRef; var NewValue: Variant; var IsValueFound: Boolean) var @@ -905,25 +921,6 @@ codeunit 6610 "FS Int. Table Subscriber" ClearLastError(); end; end - - local procedure IsItemCouplingToCustomerAssetConversion(SourceFieldRef: FieldRef; - DestinationFieldRef: FieldRef): Boolean - var - Item: Record Item; - CRMProduct: - Record "CRM Product"; - begin - exit( - (SourceFieldRef.Record().Number() = Database::Item) and - (SourceFieldRef.Number() = Item.FieldNo("Coupled to Dataverse")) and - (DestinationFieldRef.Record().Number() = Database::"CRM Product") and - (DestinationFieldRef.Number() = CRMProduct.FieldNo(ConvertToCustomerAsset))); - end; - - internal procedure GetCustomerAssetConversion(ItemIsManaged: Boolean): Boolean - begin - exit(not ItemIsManaged); - end; else if FSWorkorderService.Get(JobUsageLink."External Id") then begin FSWorkorderService.DurationInvoiced += (JobPlanningLineInvoice."Quantity Transferred" * 60); From 4d324913c208954aabbe7e739903bd03d8503bae Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 18 Sep 2026 14:26:38 +0200 Subject: [PATCH 21/22] Remove obsolete documentation and workspace files for Sales Order Agent --- .../Multi-Layer-Item-Search-Architecture.pptx | Bin 41929 -> 0 bytes .../app/SOA-ItemVariant-Design.md | 470 ------------------ .../app/src/Integration/app.code-workspace | 11 - 3 files changed, 481 deletions(-) delete mode 100644 src/Apps/W1/SalesOrderAgent/app/Multi-Layer-Item-Search-Architecture.pptx delete mode 100644 src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md delete mode 100644 src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace diff --git a/src/Apps/W1/SalesOrderAgent/app/Multi-Layer-Item-Search-Architecture.pptx b/src/Apps/W1/SalesOrderAgent/app/Multi-Layer-Item-Search-Architecture.pptx deleted file mode 100644 index 412aa6b2c2abdf68d97a1d82a97da4b4ccb78bee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41929 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
akba5g=(1Fz?o;6q|{ux1wQwH}lnjclHt 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 QP?mw8AaEP;b&B-Ue>PQhE&u=k diff --git a/src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md b/src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md deleted file mode 100644 index 502171a1df7..00000000000 --- a/src/Apps/W1/SalesOrderAgent/app/SOA-ItemVariant-Design.md +++ /dev/null @@ -1,470 +0,0 @@ -# Sales Order Agent — Item Variant Support - -## 1. Overview - -This document describes the implemented Item Variant support in the Sales Order Agent (SOA). The agent resolves customer requests to an **item + variant combination** when variant intent is present, checks availability and pricing under that variant, and exposes only safe alternatives. - -### Scope - -| Area | Baseline | Implemented State | -|---|---|---| -| Item Search | Finds items; variant data exists in index but is unused for selection | Resolve to item + variant via Item Selector | -| Availability | Hard-coded `Variant Filter = ''` (item-level only) | Variant-level availability via filter field on header | -| Pricing | No variant code on temp Sales Line | Variant-aware pricing | -| Alternatives | Item-level only | LLM returns alternative variants; pre-checked for availability | - -### Reference - -- TODO 558879 — broader variant test updates; the base `SOA-QUOTE_ITEMS_HANDLING_VARIANTS.yaml` dataset now has active `variantCode` assertions -- Variant Code is visible on the Sales Quote/Order subform page customizations -- Application changes are in the BCApps PR; the accuracy dataset changes are in a separate internal test-app PR - ---- - -## 2. Item Search & Variant Resolution - -### Current Flow - -``` -Email → SOA Dispatcher → SOA Impl - → SOA Multi Items Availability page opens - → OnBeforeFindRecord fires - → SOAItemSearch.FindRecordItem() - → GlobalItemSearch (platform ALSearch.FindItems API) - → or SOABroaderItemSearch (AOAI-assisted) - → SOAItemSelector (AOAI picks matching vs alternative) - → Results: list of Item SystemIds -``` - -The platform item search API returns item candidates with indexed column values. The implementation does not rely on those results containing complete variant data: after exact, standard, or broader candidate collection, `SOAItemSearch` enriches every candidate from authoritative `Item Variant` records (Code, Description, and Description 2). `SOAVariantSearch.Codeunit.al` also exists for cross-column Item Variant search and is bound via `SOASessionEvents`. - -### Design — Unified Resolution via Item Selector (AGREED) - -**Key decision from design review:** Resolve variants through the **existing Item Selector** in a single LLM call, rather than a separate two-step process. - -The Item Selector receives each search candidate's indexed item fields plus an authoritative `Variants` array added by application code. The LLM can distinguish variants from attributes because the payload labels them separately. The Item Selector returns both the best item **and** the best variant in one pass. - -``` -Item Search pipeline: - → GlobalItemSearch / BroaderItemSearch finds candidates - → SOAItemSearch enriches every candidate with authoritative Item Variant data - → Item Selector receives candidates with column_values including the Variants array - → Item Selector returns selected_items entries with item_no, optional variant_code, - overall selection confidence, variant_match, variant_substitution_safety, and reason - → Result: best match item+variant AND alternative item+variants -``` - -**Behavior:** -- Item Selector runs for standard and broader candidates and for exact item matches that have variants. A single exact item match with no variants bypasses the selector -- Candidate enrichment runs after exact, standard, and broader search so selector behavior does not depend on variant data being present in the search index payload -- `variant_code` is optional. When no variant is requested, omit it and set `variant_match` to `not_requested` -- When a returned code fulfills the request, set `variant_match` to `matching`; when it is a safe substitute, set it to `alternative` -- `variant_substitution_safety` independently classifies a substitution as `safe`, `unsafe`, or `not_applicable`. Application code retains an alternative only when it is explicitly `safe`; missing or unsafe decisions are discarded -- Alternative variants must be concrete entries in `selected_items`; application code does not synthesize variants omitted by the model -- If a requested non-interchangeable variant does not exist, return an empty `selected_items` array -- A valid empty result is successful and authoritative. It clears the item filter and never falls back to the original candidates -- Missing or malformed `selected_items`, AOAI failure, and missing function calls remain selector failures and retain the existing fallback behavior -- Returned item numbers are validated against the original candidate set, and variant codes are validated against the selected item before use -- `Variant Mandatory if Exists` is not included in the Item Selector candidate payload. Mandatory-without-request behavior is deferred until that metadata is available to the selector or enforced downstream -- Alternative variants are returned alongside the best match, avoiding extra LLM round-trips - -### Implemented Components - -| File | Change | -|---|---| -| `itemselector-task.md` | Defines item and variant selection behavior, interchangeability rules, safe alternatives, and valid empty results. | -| `itemselector-tool.md` | Defines one required `selected_items` array. Each entry requires `item_no`, `confidence`, `variant_match`, `variant_substitution_safety`, and `reason`; `variant_code` is optional. | -| `SOAItemSelector.Codeunit.al` | Runs AOAI, distinguishes valid empty results from failures, and emits non-sensitive failure telemetry. | -| `SOAItemSelectorFunc.Codeunit.al` | Strictly parses the function result and classifies malformed item, variant, and `variant_match` output. | -| `SOAItemSearch.Codeunit.al` | Enriches every candidate from authoritative Item Variant records, invokes Item Selector, validates candidate ownership, prefers concrete variant alternatives over generic item alternatives, applies variant-specific availability, and stores the resolved `Item SystemId → Variant Code` mapping. | - -### Why this approach (from design review) - -- The LLM receives existing indexed item fields plus authoritative variant data loaded in one batched Item Variant query -- We don't know which keywords match to variants vs. item names — the LLM decides -- Single LLM call for both item and variant selection avoids latency of separate round-trips -- LLM can distinguish between variants (e.g., colors) and attributes because the JSON payload labels each column -- Alternative variants come back in the same response, ready for use if primary is unavailable -- The latest incoming email body and the extracted text of its non-ignored attachments are combined in the `message_content` field of the untrusted-data envelope. The extracted `search_query` remains primary; supporting context may recover omitted same-item intent such as "any color," but must not contribute item or variant intent from unrelated lines in a multi-item email or attachment. - -### Selector follow-up raised in handoff review (NOT YET DECIDED) - -- **Schema-constrained variant values:** Dynamically constraining `variant_code` to candidate variant codes may reduce rejected output. Server-side candidate and variant validation remains mandatory even if a dynamic enum is added. - ---- - -## 3. Availability with Variants (AGREED) - -### Baseline - -`SOA Multi Items Availability` uses **Item** as its source table. Each row = one item. Before this feature, the flow forced `Variant Filter = ''` in four places: - -1. **`OnOpenPage`** — `Rec.SetRange("Variant Filter", '');` -2. **`CalcAvailQuantities`** — `Item.SetRange("Variant Filter", '');` -3. **`SOACreateTaskImpl.CalcItemProjAvailableBalance`** — `Item.SetRange("Variant Filter", '');` -4. **`SOAItemSearch.OnAfterCheckItemAvailable`** — `Item.SetRange("Variant Filter", '');` - -The Item table's availability FlowFields (`Inventory`, `Qty. on Sales Order`, etc.) all respect the `Variant Filter` — so setting it to a specific variant code will correctly calculate variant-level availability. The platform handles this. - -### Constraint: Page source table is Item, not Item Variant - -The page source table is `Item`, meaning: -- **One row per item** — we cannot show the same item twice with different variants -- Variant Filter is a FlowFilter on Item, not a field per row - -### Decision: Add Variant Filter field on page header (Option 1) - -**Agreed and implemented.** The page keeps `Item` as its source table and obtains the resolved variant through its existing event pattern. Availability, pricing, translation, and shipment-date calculations use that resolved code. - -``` -OnAfterGetRecord: - VariantCode := GetResolvedVariant(Rec."No."); // from Item Selector result - → CalcAvailQuantities uses VariantCode instead of '' - → CalcPrice uses VariantCode - → SOAShipmentDateMgt.SetParameters uses VariantCode -``` - -**Why this approach:** -- Minimal change to existing page structure -- All Item FlowFields already calculate correctly when Variant Filter is set — no rework needed -- Pricing naturally picks up variant-specific prices -- Confirmed by Volodymyr: "if we know the exact variant code, it's very easy to calculate price" - -**Accepted limitation:** One variant per item row. For multi-variant same-item requests (e.g., "5 Blue + 3 Red Fairy Dolls"), these are handled as **separate searches** via agent instructions (agreed with Qasim). - -**Future consideration:** May change to support multiple lines per item later, but the first iteration intentionally avoids redesigning the availability page. Ship the single-variant behavior first, gather feedback and telemetry, and revisit an item+variant buffer only if users need all alternatives displayed. - -### Programmatic availability pre-check (AGREED) - -Before showing results to the agent, **programmatically check availability** for the resolved item+variant: - -``` -Item Selector returns: best match (item + variant) + alternatives (item + variant) - → Check: is best match available? - Yes → show only best match to agent - No → show alternatives to agent instead -``` - -This avoids relying on the agent to interpret availability and make decisions. The decision is made in code before the agent sees the results. - -### Implemented Components - -| File | Change | -|---|---| -| `SOAMultiItemsAvailability.Page.al` | Reads the resolved variant, applies it to availability and translation, validates it on the temporary Sales Line, and passes it to shipment-date calculation. | -| `SOAItemSearch.Codeunit.al` | Supplies the resolved variant, applies variant-specific availability filtering, and stores the selected mapping. | -| `SOACreateTaskImpl.Codeunit.al` | Preserves the source item's Variant Filter when calculating projected available balance. | - ---- - -## 4. Pricing with Variants (AGREED) - -### Baseline - -`CalcPrice` previously created a temporary Sales Quote line without Variant Code: -```al -TempSalesLine.Validate(Type, TempSalesLine.Type::Item); -TempSalesLine.Validate("No.", Rec."No."); -TempSalesLine.Validate(Quantity, 1); -// Baseline omitted Variant Code, producing a generic item price -``` - -BC's pricing engine uses Variant Code to find variant-specific prices and discounts. - -### Implemented Behavior - -After validating `"No."`, validate the variant code: - -```al -TempSalesLine.Validate("No.", Rec."No."); -if VariantCode <> '' then - TempSalesLine.Validate("Variant Code", VariantCode); -TempSalesLine.Validate(Quantity, 1); -``` - -**Confirmed straightforward** by Volodymyr: "if we know the exact variant code, it's very easy to calculate price." The BC pricing engine handles the rest. - -### Item Translation with Variants - -Variant-specific Item Translation lookup uses: - -```al -if ItemTranslation.Get(Rec."No.", VariantCode, LanguageCode) then ... -``` - -This ensures variant-specific translated descriptions are shown when available. - ---- - -## 5. Alternative Variant Suggestions (AGREED) - -### Scenario - -Customer requests "5 Blue Fairy Dolls". The Blue variant is out of stock. The agent should suggest other available variants (Red, Green). - -### Design — LLM-driven alternatives with programmatic availability pre-check - -**Agreed in design review:** The Item Selector returns both the best match and alternative variants in a single LLM call. We then programmatically check availability before showing results to the agent. - -``` -Item Selector returns: - - Best match: AItem-0004 + variant BLUE (confidence: matching) - - Alternative: AItem-0004 + variant RED (confidence: alternative) - - Alternative: AItem-0004 + variant GREEN (confidence: alternative) - -Programmatic pre-check: - - BLUE available? → Show only BLUE to agent - - BLUE not available? → Pick the first available same-item alternative variant and show that single variant to the agent -``` - -**Key principles (from design review):** -- No extra LLM round-trip for finding alternatives — they come back in the same Item Selector response -- Availability check is done **programmatically** (using existing helper functions), not by the agent -- Agent only sees items/variants that are already confirmed available (or the best match if availability check is disabled) -- Item Selector runs for exact matches when the item has variants. A single exact item with no variants bypasses the selector - -### Implementation compromise — single displayed alternative variant - -The initial implementation keeps `SOA Multi Items Availability` source table as `Item` and therefore keeps one row per item. To avoid redesigning the availability page as a temporary item+variant buffer, alternative variants are handled internally as an ordered list of candidates for the same item. The programmatic availability check selects the first available alternative variant and stores only that variant in the `Item SystemId → Variant Code` mapping shown on the page. - -This means the agent sees one available alternative variant, not all available alternatives. This preserves the existing item-based page behavior and reduces risk to non-variant item flows. - -**Confirmed for the first iteration in the handoff review:** Andrei selected the simplest current behavior: show one available alternative variant, ship it in the major release, and use feedback and telemetry to decide whether displaying all available variants justifies an availability-page redesign. - -The alternative is a suggestion, not an automatic substitution. When the requested variant is unavailable, do not create a quote until the customer confirms the offered alternative variant. - -For requests where an interchangeable variant does not exist, such as "Yellow MagicToyland Fairy Doll" when only BLUE, GREEN, and RED exist, the Item Selector may return concrete same-item variants. When at least one concrete variant alternative is present, search post-processing suppresses generic variantless item alternatives, availability-checks the concrete alternatives, and exposes the first available same-item variant. It does not synthesize variants omitted by the selector. - -### Smart alternative suggestions via prompting - -Andrei raised that variant interchangeability is context-dependent: -- Shoe sizes (44 vs 42) — **not interchangeable** -- Keyboard colors (black vs white) — **interchangeable** - -**Agreed approach:** Use LLM prompting to handle this. Instruct the Item Selector to only return alternative variants that are "closely related" or "reasonable substitutes." The LLM should use common sense — e.g., don't suggest adult bicycle when kids bicycle is requested, but do suggest white keyboard when black is unavailable. - -Variant values that affect fit, compatibility, or another non-interchangeable requirement are not reasonable substitutes. For example, do not suggest shoe size 42 when the customer requested size 44 unless the customer explicitly permits other sizes. - -### Fallback behavior - -If no matching variant or safe concrete alternative qualifies: -- The selector returns a valid empty result -- Search treats the empty result as authoritative and does not restore the original candidates -- The agent treats the result as a customer-facing unavailability outcome, sends a reply explaining that the requested item/variant and any suitable alternative are unavailable, and does not request internal assistance -- The agent must not create a quote or offer an unsafe substitute - -### Implemented Components - -| File | Change | -|---|---| -| `itemselector-task.md` | Restricts alternatives to interchangeable dimensions and requires concrete alternative entries. | -| `SOAItemSelectorFunc.Codeunit.al` | Parses matching and alternative variant entries and validates their state combinations. | -| `SOAItemSearch.Codeunit.al` | Availability-checks matching entries first, then concrete alternatives, and stores the selected mapping. | -| `SalesOrderAgent-AgentInstructions.md` | Routes an empty result for a variant-specific request to a customer reply instead of internal availability assistance. | - ---- - -## 6. Sales Quote/Order Line Creation (AGREED) - -### Implemented State - -The `Variant Code` field is visible on both Sales Quote Subform and Sales Order Subform page customizations: - -```al -modify("Variant Code") { Visible = true; } -``` - -### Implementation - -1. `Variant Code` is visible on the SOA page customizations -2. When the agent creates a sales line, it sets Variant Code after Item No. when a variant was resolved -3. The orchestration flow carries the resolved variant code from search and availability through line creation - -### Agent behavior with visible Variant Code - -Volodymyr raised: if Variant Code is always visible but some items don't have variants, will the agent try to fill it with something? - -**Agreed approach:** Rely on proper prompting/instructions to guide the agent. If items have variants, the agent uses the resolved variant code from the search phase. If items don't have variants, the field stays empty. Andrei noted companies using variants typically want it selected everywhere — this aligns with making it always visible. - -### Implemented Components - -| File | Change | -|---|---| -| `SOASalesQuoteSubform.PageCust.al` | Shows Variant Code on Sales Quote lines. | -| `SOASalesOrderSubform.PageCust.al` | Shows Variant Code on Sales Order lines. | - ---- - -## 7. End-to-End Flow (Target State) - -``` -1. Email arrives: "5 Blue Fairy Dolls, 3 Red Bicycles" - -2. SOA Dispatcher → SOA Impl → Opens SOA Multi Items Availability - -3. OnBeforeFindRecord fires → SOAItemSearch.FindRecordItem(): - a. Platform search finds candidates including AItem-0004 (Fairy Doll) and AItem-0011 (Bicycle) - b. SOAItemSearch enriches candidates with authoritative Item Variant data - c. Item Selector (LLM) receives candidates with column_values including the Variants array - d. Item Selector returns: - - AItem-0004, variant_code: BLUE, confidence: matching, variant_match: matching - - AItem-0004, variant_code: RED, confidence: alternative, variant_match: alternative - - AItem-0004, variant_code: GREEN, confidence: alternative, variant_match: alternative - - AItem-0011, variant_code: RED, confidence: matching, variant_match: matching - - Each entry also contains a concise reason (omitted here for brevity) - e. Programmatic availability check: - - AItem-0004 + BLUE → available ✓ → use BLUE - - AItem-0011 + RED → available ✓ → use RED - f. Store mapping: {AItem-0004 → BLUE, AItem-0011 → RED} - -4. OnAfterGetRecord for AItem-0004: - a. VariantCode = BLUE (from mapping) - b. CalcAvailQuantities with Variant Filter = 'BLUE' - c. CalcPrice with Variant Code = BLUE on temp Sales Line - -5. OnAfterGetRecord for AItem-0011: - a. VariantCode = RED (from mapping) - b. CalcAvailQuantities with Variant Filter = 'RED' - c. CalcPrice with Variant Code = RED on temp Sales Line - -6. Agent reads availability results → Creates Sales Quote: - a. Line 1: Item AItem-0004, Variant Code BLUE, Qty 5 - b. Line 2: Item AItem-0011, Variant Code RED, Qty 3 - -7. Agent sends reply email mentioning variants in descriptions -``` - -### Alternative scenario (variant unavailable): - -``` -3c. Item Selector returns: - - AItem-0004, variant_code: BLUE, confidence: matching, variant_match: matching - - AItem-0004, variant_code: RED, confidence: alternative, variant_match: alternative - - AItem-0004, variant_code: GREEN, confidence: alternative, variant_match: alternative - -3d. Programmatic availability check: - - AItem-0004 + BLUE → NOT available ✗ - - AItem-0004 + RED → available ✓ → use RED as the selected alternative - - AItem-0004 + GREEN → not shown because the item page displays one variant per item row - -3e. Agent sees RED (not BLUE) on availability page - → Does not create a quote yet - → Reply email says BLUE is unavailable, offers RED, and asks the customer to confirm - → Creates the quote with RED only after customer confirmation -``` - ---- - -## 8. Test Scenarios - -The base `SOA-QUOTE_ITEMS_HANDLING_VARIANTS.yaml` dataset contains ten active scenarios: - -| Test | Required behavior | -|---|---| -| `QUOTE_5_ITEMS_VARIANTS_01` | Resolve explicit variant labels to exact Variant Codes on quote lines. | -| `QUOTE_5_ITEMS_VARIANTS_02` | Resolve variant wording embedded in item descriptions. | -| `QUOTE_3_ITEMS_VARIANTS_03` | Resolve semantic and broad variant wording, including an acceptable "any color" request. | -| `QUOTE_3_ITEMS_VARIANTS_04` | Omit Variant Code when no variant is requested. | -| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_05` | For a missing interchangeable color, offer exactly one available same-item color, do not offer another item, and wait for confirmation. | -| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_06` | When the requested size exists but is unavailable, do not offer other sizes. | -| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_07` | When the requested size does not exist, preserve the authoritative empty selection, offer no other size, and create no quote. | -| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_08` | Honor an explicit customer prohibition against color substitution and create no quote. | -| `QUOTE_1_ITEM_VARIANT_ALTERNATIVE_10` | When the requested color exists but is unavailable, offer one safe available same-item color without silently substituting. | -| `QUOTE_2_ITEMS_VARIANT_CONTEXT_ISOLATION_11` | Keep variant context isolated between multiple requested items. | - -The `variantCode` expectations are active. Scenarios `_07`, `_08`, `_10`, and `_11` were specifically verified while simplifying the prompt contract. All ten active scenarios should be rerun after changes to selector output parsing, candidate validation, or availability fallback. - -### Deferred Scenario - -`_09` (variant mandatory but not specified) is intentionally not active. The Item Selector candidate payload does not include `Variant Mandatory if Exists`, so the selector cannot reliably distinguish an optional blank variant from a mandatory missing variant. Add this scenario only after that metadata is supplied to the selector or deterministic downstream enforcement is implemented. - -### Additional Coverage - -- Variant-specific pricing differs from the base item price -- Variant code and semantic variant wording in non-English languages -- Same item requested with multiple variants through separate searches -- PDF or attachment input containing item variants -- Larger item counts and mixed variant requests -- Alternative item whose best candidate also requires an alternative variant - -### Known Runtime Note - -A spaced variant code such as `AGE - 6-8` can trigger an agent-runtime lookup failure and produce a quote line with quantity 0 even when the outgoing message is otherwise correct. Track this separately from application search and availability behavior. - ---- - -## 9. Implemented Architecture - -### Selector Contract - -1. The tool schema requires one `selected_items` array. -2. Every selected entry requires `item_no`, overall selection `confidence`, `variant_match`, `variant_substitution_safety`, and `reason`. -3. `variant_code` is optional and must be omitted when `variant_match` is `not_requested`. -4. `variant_match` is `matching` when the code fulfills the request and `alternative` when the code is a safe substitute. -5. `variant_substitution_safety` is `safe` only for presentation-only changes that preserve suitability or changes explicitly permitted by the customer. It is `unsafe` for suitability-affecting, prohibited, or uncertain changes and `not_applicable` for exact matches or requests without variant intent. -6. The parser discards every alternative that is not explicitly classified `safe`, independently of its code or description. If no safe entry remains, the result is a valid empty selection. -7. A valid empty array is a successful business result. Missing or malformed required output outside the fail-closed alternative-safety handling is a selector failure. -8. There is no separate unresolved-request output. Safe alternatives must be concrete selected entries; unsafe missing variants produce an empty result. - -### Search and Validation - -1. Item Selector runs after candidate collection for standard and broader search paths and for exact item matches that have variants. A single exact item match with no variants bypasses the selector. -2. Candidate payloads preserve indexed item fields and add an authoritative `Variants` array from Item Variant records before entering the untrusted-data envelope with supporting message context. -3. Returned item numbers must belong to the original candidate set. -4. Returned variant codes must exist for the selected item. -5. Matching item+variant pairs are availability-filtered first; concrete alternatives are considered only when no matching pair remains available. -6. The selected mapping is stored as `Item SystemId -> Variant Code` and consumed by the availability page. - -### Availability, Pricing, and Quote Creation - -1. Availability calculations apply the resolved Variant Filter. -2. Shipment date calculations receive the resolved Variant Code. -3. Temporary sales lines validate Variant Code before price calculation. -4. Item Translation lookup uses the resolved variant. -5. Variant Code is visible on SOA sales quote and sales order subforms. -6. Alternative variants are suggestions and require customer confirmation before quote creation. - -### Telemetry - -Telemetry distinguishes valid empty selection, AOAI failure, malformed function output, invalid item number, invalid variant code, rejected `variant_match` combinations, and server-side candidate or variant ownership rejection counts. It records categories, booleans, and aggregate counts only; customer content, item numbers, variant codes, and descriptions are not logged. - -### Validation Workflow - -1. Run focused AL diagnostics after selector, search, availability, or prompt changes. -2. Build the full workspace with CodeCop. -3. Run all ten active variant accuracy scenarios after changes to selector parsing or fallback behavior. -4. Run the regular accuracy suite to detect non-variant regressions. -5. Track agent-runtime issues, including spaced variant codes, separately from application search and availability failures. - ---- - -## 10. Decisions from Design Review - -| Question | Decision | -|---|---| -| No variant specified but item has variants | Omit `variant_code` and set `variant_match` to `not_requested`. Do not choose a variant without a request signal. | -| Variant feature configuration | Do not add a separate SOA setup Boolean. Customers without Item Variant records retain the standard flow. Mandatory-without-request enforcement is deferred because that field is not in the selector payload. | -| Non-existent variant requested | Return concrete alternatives only for interchangeable dimensions. Return a valid empty result when substitution could change suitability or the customer rejects substitutions. | -| Multi-variant same item | Handle as **separate searches** via agent instructions. One variant per item row on availability page. | -| Variant in reply email | Include variant information (code and/or description) in outgoing email. | -| Item Selector contract | Return one `selected_items` array with optional `variant_code`, required `variant_match`, independent `variant_substitution_safety`, overall selection confidence, and reason. Alternatives are retained only when safety is explicitly `safe`. | -| Where to resolve variants | In Item Selector during search phase (not on availability page). | -| Availability page redesign | Not needed for initial implementation. Keep Item source table + Variant Filter field on header. | -| Alternative variant logic | LLM-driven with deterministic post-processing. When the selector returns concrete variant alternatives, code suppresses generic variantless item alternatives without synthesizing variants from unknown terms. Availability is pre-checked programmatically, and the initial implementation shows the first available same-item alternative variant only. | -| Quote creation for an alternative | Offer the available alternative and wait for customer confirmation. Do not silently substitute the variant or create the quote in the initial response. | -| When best match is unavailable | Show the first available concrete safe alternative. If none qualifies, preserve the authoritative empty result, reply to the customer without requesting internal assistance, and create no quote. | -| Valid empty selector result | Treat it as successful and never fall back to original candidates. | -| Release scope | Target the major release. Do not initially backport to version 28 because the cross-cutting behavior change needs broader validation. | - ---- - -## 11. Notes & Future Considerations - -- **Item attributes at variant level:** Andrei noted that item attributes now exist at the variant level (recently added) and may not yet be included in the item index table. Platform could add more columns to the index. Not a blocker for initial implementation but worth exploring. -- **Item Variant Index Table:** Platform created an item variant index table for this purpose, but the implementation does not query it directly. Search still uses the existing item index for discovery, then enriches selected candidates from authoritative Item Variant records before Item Selector runs. -- **Consistency of alternatives:** Item Selector runs whenever variant evaluation may be needed, so concrete alternatives remain available for exact items with variants and for standard and broader search paths. -- **Buffer table approach:** Could be revisited after feedback and telemetry if we need to display multiple variants per item as separate rows on the availability page. -- **Message-context scope:** The latest incoming email body and its non-ignored extracted attachment text are approved as supporting context for same-item intent. Broader use for vague item discovery should be evaluated separately for relevance, token cost, and cross-item interference. -- **Dynamic variant enum:** Potential way to constrain model output to known variant codes, but not approved as a requirement; server-side validation remains necessary either way. -- **Mandatory variant metadata:** Add `Variant Mandatory if Exists` to candidate metadata or enforce it downstream before activating the deferred `_09` scenario. -- **Alternative item + alternative variant:** The handoff review did not confirm this combined fallback scenario as supported. Keep it explicit in tests and scope discussions. diff --git a/src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace b/src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace deleted file mode 100644 index cbd1819e74f..00000000000 --- a/src/Apps/W1/SalesOrderAgent/app/src/Integration/app.code-workspace +++ /dev/null @@ -1,11 +0,0 @@ -{ - "folders": [ - { - "path": "../.." - }, - { - "path": "../../../../../../../../master/App/Internal/Apps/SalesOrderAgent/test" - } - ], - "settings": {} -} \ No newline at end of file From b53f402c7ecbfe484b37fa936e1a7ea32f693115 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 19 Sep 2026 12:00:31 +0200 Subject: [PATCH 22/22] Remove unused using directive for D365Sales in FS Integration Test Library --- .../test library/src/FSIntegrationTestLibrary.Codeunit.al | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al index 0a1fa501cd3..e0c4101c4d2 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -4,7 +4,6 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.TestLibraries.DynamicsFieldService; -using Microsoft.Integration.D365Sales; using Microsoft.Integration.DynamicsFieldService; using Microsoft.Service.Archive; using Microsoft.Service.Document;