From 8552212fbe71dda07a314dee78d4e746a623460a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 5 Sep 2026 11:41:33 +0200 Subject: [PATCH 01/36] [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 60d8d99d3d..f757872eff 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 fbb03b0416..7b3718b9e8 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 94c69e79ff..e34187db4e 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/36] [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 f757872eff..6eb0240c68 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 7b3718b9e8..efb737ecfc 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 e34187db4e..7d1a9960cc 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/36] [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 6eb0240c68..51e90640fe 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 efb737ecfc..bb6799d13d 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 7d1a9960cc..9194a8fc34 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/36] [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 51e90640fe..f757872eff 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/36] [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 f757872eff..51e90640fe 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 bb6799d13d..1edcd404a8 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 9194a8fc34..a1cda6b17c 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/36] [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 1edcd404a8..9730996f44 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/36] [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 51e90640fe..0a89e4f15a 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/36] [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 0a89e4f15a..4e11d523e2 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 9730996f44..8045404d0f 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 a1cda6b17c..587cf28e63 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/36] [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 4e11d523e2..e125ac1429 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 8045404d0f..d7a2db47d7 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/36] [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 e125ac1429..de7fe74ad8 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/36] [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 de7fe74ad8..567442cd61 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/36] [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 567442cd61..60f39d4eb5 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/36] [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 60f39d4eb5..dcdfea896e 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/36] [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 dcdfea896e..16ba1badb0 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/36] [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 16ba1badb0..521196d911 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/36] [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 521196d911..9cb582dbb6 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/36] 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 9cb582dbb6..521196d911 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 587cf28e63..a8f8052166 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 0000000000..502171a1df --- /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 0000000000..cbd1819e74 --- /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/36] 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 521196d911..16ba1badb0 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/36] 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 16ba1badb0..0159b187f6 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 d7a2db47d7..0a1fa501cd 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 a8f8052166..d58586da84 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/36] 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 0159b187f6..bfd34e09e0 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/36] 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 502171a1df..0000000000 --- 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 cbd1819e74..0000000000 --- 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/36] 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 0a1fa501cd..e0c4101c4d 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; From e8302a63d9db4358400aaac00b19e1801fc4ef13 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Mon, 21 Sep 2026 23:23:47 +0200 Subject: [PATCH 23/36] Implement handling for field modification checks in FS Int. Table Subscriber and add corresponding test case --- .../FSIntTableSubscriber.Codeunit.al | 9 ++++++ .../src/FSIntegrationTestLibrary.Codeunit.al | 7 +++++ .../test/src/FSIntegrationTest.Codeunit.al | 30 +++++++++++++++++++ 3 files changed, 46 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 bfd34e09e0..9068c0b537 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -360,12 +360,21 @@ codeunit 6610 "FS Int. Table Subscriber" [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) + begin + HandleOnBeforeIsFieldModified(SourceFieldRef, DestinationFieldRef, Result, IsHandled); + end; + + internal procedure HandleOnBeforeIsFieldModified(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var Result: Boolean; var IsHandled: Boolean) var + FSConnectionSetup: Record "FS Connection Setup"; Item: Record Item; CRMProduct: Record "CRM Product"; SourceRecordRef: RecordRef; DestinationRecordRef: RecordRef; begin + if not FSConnectionSetup.IsEnabled() then + exit; + if not IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then exit; 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 e0c4101c4d..1a7e247c0a 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -65,6 +65,13 @@ codeunit 139205 "FS Integration Test Library" exit(FSIntTableSubscriber.GetCustomerAssetConversion(ItemIsManaged)); end; + procedure HandleOnBeforeIsFieldModified(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var Result: Boolean; var IsHandled: Boolean) + var + FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; + begin + FSIntTableSubscriber.HandleOnBeforeIsFieldModified(SourceFieldRef, DestinationFieldRef, Result, IsHandled); + end; + procedure IgnorePostedJobJournalLinesOnQueryPostFilterIgnoreRecord(SourceRecordRef: RecordRef; var IgnoreRecord: 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 d58586da84..a835dfec0c 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1626,6 +1626,36 @@ codeunit 139204 "FS Integration Test" Assert.IsFalse(FSIntegrationTestLibrary.GetCustomerAssetConversion(true), 'A managed item should disable customer asset conversion.'); end; + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure ItemProductComparisonIgnoredWhenFieldServiceIsDisabled() + var + Item: Record Item; + CRMProduct: Record "CRM Product"; + SourceRecordRef: RecordRef; + DestinationRecordRef: RecordRef; + SourceFieldRef: FieldRef; + DestinationFieldRef: FieldRef; + Result: Boolean; + IsHandled: Boolean; + begin + // [FEATURE] [Item-Product Mapping] + // [SCENARIO] Field Service does not handle synchronization comparisons while its integration is disabled. + Initialize(); + + // [GIVEN] The fields used to derive customer asset conversion and no Field Service connection setup. + SourceRecordRef.GetTable(Item); + SourceFieldRef := SourceRecordRef.Field(Item.FieldNo("Coupled to Dataverse")); + DestinationRecordRef.GetTable(CRMProduct); + DestinationFieldRef := DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)); + + // [WHEN] The synchronization engine compares those fields. + FSIntegrationTestLibrary.HandleOnBeforeIsFieldModified(SourceFieldRef, DestinationFieldRef, Result, IsHandled); + + // [THEN] Field Service leaves the comparison to the synchronization engine. + Assert.IsFalse(IsHandled, 'Field Service should not handle synchronization comparisons while its integration is disabled.'); + end; + [Test] [TransactionModel(TransactionModel::AutoCommit)] procedure ItemProductMappingDisablesCustomerAssetConversion() From 182701ab80312ed8d00efc3b14301303840baa71 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 22 Sep 2026 14:00:28 +0200 Subject: [PATCH 24/36] Refactor FS Int. Table Subscriber to use ItemIsManaged for customer asset conversion checks and update corresponding test case --- .../Codeunits/FSIntTableSubscriber.Codeunit.al | 15 ++++++--------- .../test/src/FSIntegrationTest.Codeunit.al | 2 -- 2 files changed, 6 insertions(+), 11 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 9068c0b537..16b6973bac 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -367,10 +367,9 @@ codeunit 6610 "FS Int. Table Subscriber" internal procedure HandleOnBeforeIsFieldModified(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var Result: Boolean; var IsHandled: Boolean) var FSConnectionSetup: Record "FS Connection Setup"; - Item: Record Item; CRMProduct: Record "CRM Product"; - SourceRecordRef: RecordRef; DestinationRecordRef: RecordRef; + ItemIsManaged: Boolean; begin if not FSConnectionSetup.IsEnabled() then exit; @@ -378,11 +377,10 @@ codeunit 6610 "FS Int. Table Subscriber" if not IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then exit; - SourceRecordRef := SourceFieldRef.Record(); - SourceRecordRef.SetTable(Item); + ItemIsManaged := SourceFieldRef.Value(); DestinationRecordRef := DestinationFieldRef.Record(); DestinationRecordRef.SetTable(CRMProduct); - Result := CRMProduct.ConvertToCustomerAsset <> GetCustomerAssetConversion(Item."Coupled to Dataverse"); + Result := CRMProduct.ConvertToCustomerAsset <> GetCustomerAssetConversion(ItemIsManaged); IsHandled := true; end; @@ -399,7 +397,6 @@ codeunit 6610 "FS Int. Table Subscriber" ServiceHeader: Record "Service Header"; ServiceLine: Record "Service Line"; ItemUnitOfMeasure: Record "Item Unit of Measure"; - Item: Record Item; SourceRecordRef: RecordRef; DestinationRecordRef: RecordRef; NAVItemUomRecordId: RecordId; @@ -409,6 +406,7 @@ codeunit 6610 "FS Int. Table Subscriber" QuantityToTransferToInvoice: Decimal; QuantityCurrentlyConsumed: Decimal; QuantityCurrentlyInvoiced: Decimal; + ItemIsManaged: Boolean; NotCoupledCRMUomErr: Label 'The unit is not coupled to a unit of measure.'; begin if not FSConnectionSetup.IsEnabled() then @@ -422,9 +420,8 @@ codeunit 6610 "FS Int. Table Subscriber" exit; if IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then begin - SourceRecordRef := SourceFieldRef.Record(); - SourceRecordRef.SetTable(Item); - NewValue := GetCustomerAssetConversion(Item."Coupled to Dataverse"); + ItemIsManaged := SourceFieldRef.Value(); + NewValue := GetCustomerAssetConversion(ItemIsManaged); IsValueFound := true; NeedsConversion := false; exit; diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index a835dfec0c..7e974f7e40 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1599,8 +1599,6 @@ 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); From 7ecda20a187fbc5662ffdd5bb70096d2b8ff255d Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Tue, 22 Sep 2026 23:21:08 +0200 Subject: [PATCH 25/36] Add ExistingCRMProduct record handling for customer asset conversion checks --- .../app/src/Codeunits/FSIntTableSubscriber.Codeunit.al | 5 ++++- .../test/src/FSIntegrationTest.Codeunit.al | 2 +- 2 files changed, 5 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 16b6973bac..15f68a31e9 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -368,6 +368,7 @@ codeunit 6610 "FS Int. Table Subscriber" var FSConnectionSetup: Record "FS Connection Setup"; CRMProduct: Record "CRM Product"; + ExistingCRMProduct: Record "CRM Product"; DestinationRecordRef: RecordRef; ItemIsManaged: Boolean; begin @@ -380,7 +381,9 @@ codeunit 6610 "FS Int. Table Subscriber" ItemIsManaged := SourceFieldRef.Value(); DestinationRecordRef := DestinationFieldRef.Record(); DestinationRecordRef.SetTable(CRMProduct); - Result := CRMProduct.ConvertToCustomerAsset <> GetCustomerAssetConversion(ItemIsManaged); + ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); + ExistingCRMProduct.Get(CRMProduct.ProductId); + Result := ExistingCRMProduct.ConvertToCustomerAsset <> GetCustomerAssetConversion(ItemIsManaged); IsHandled := true; end; diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 7e974f7e40..3c70255f72 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1639,7 +1639,7 @@ codeunit 139204 "FS Integration Test" begin // [FEATURE] [Item-Product Mapping] // [SCENARIO] Field Service does not handle synchronization comparisons while its integration is disabled. - Initialize(); + ResetFSEnvironment(); // [GIVEN] The fields used to derive customer asset conversion and no Field Service connection setup. SourceRecordRef.GetTable(Item); From e3ec809109bccb43a81395ce272c3f1673d14d04 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 25 Sep 2026 14:28:08 +0200 Subject: [PATCH 26/36] Refactor FS Int. Table Subscriber and related tests to streamline customer asset conversion logic --- .../FSIntTableSubscriber.Codeunit.al | 73 ++++--------------- .../src/FSIntegrationTestLibrary.Codeunit.al | 14 ---- .../test/src/FSIntegrationTest.Codeunit.al | 62 +++------------- 3 files changed, 24 insertions(+), 125 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 15f68a31e9..e5a8cccb3f 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,17 @@ codeunit 6610 "FS Int. Table Subscriber" SourceDestCode := GetSourceDestCode(SourceRecordRef, DestinationRecordRef); case SourceDestCode of + 'Item-CRM Product': + begin + if DestinationIsInserted then + DestinationRecordRef.LoadFields(CRMProduct.FieldNo(ConvertToCustomerAsset)); + DestinationRecordRef.SetTable(CRMProduct); + if CRMProduct.ConvertToCustomerAsset then begin + CRMProduct.ConvertToCustomerAsset := false; + AdditionalFieldsWereModified := true; + DestinationRecordRef.GetTable(CRMProduct); + end; + end; 'FS Work Order Product-Service Line': begin SourceRecordRef.SetTable(FSWorkOrderProduct); @@ -358,35 +370,6 @@ codeunit 6610 "FS Int. Table Subscriber" end; end; - [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) - begin - HandleOnBeforeIsFieldModified(SourceFieldRef, DestinationFieldRef, Result, IsHandled); - end; - - internal procedure HandleOnBeforeIsFieldModified(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var Result: Boolean; var IsHandled: Boolean) - var - FSConnectionSetup: Record "FS Connection Setup"; - CRMProduct: Record "CRM Product"; - ExistingCRMProduct: Record "CRM Product"; - DestinationRecordRef: RecordRef; - ItemIsManaged: Boolean; - begin - if not FSConnectionSetup.IsEnabled() then - exit; - - if not IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then - exit; - - ItemIsManaged := SourceFieldRef.Value(); - DestinationRecordRef := DestinationFieldRef.Record(); - DestinationRecordRef.SetTable(CRMProduct); - ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); - ExistingCRMProduct.Get(CRMProduct.ProductId); - Result := ExistingCRMProduct.ConvertToCustomerAsset <> GetCustomerAssetConversion(ItemIsManaged); - IsHandled := 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 @@ -409,7 +392,6 @@ codeunit 6610 "FS Int. Table Subscriber" QuantityToTransferToInvoice: Decimal; QuantityCurrentlyConsumed: Decimal; QuantityCurrentlyInvoiced: Decimal; - ItemIsManaged: Boolean; NotCoupledCRMUomErr: Label 'The unit is not coupled to a unit of measure.'; begin if not FSConnectionSetup.IsEnabled() then @@ -422,14 +404,6 @@ codeunit 6610 "FS Int. Table Subscriber" if SourceFieldRef.Record().Number() = DestinationFieldRef.Record().Number() then exit; - if IsItemCouplingToCustomerAssetConversion(SourceFieldRef, DestinationFieldRef) then begin - ItemIsManaged := SourceFieldRef.Value(); - NewValue := GetCustomerAssetConversion(ItemIsManaged); - 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 @@ -710,23 +684,6 @@ 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 @@ -1517,13 +1474,13 @@ codeunit 6610 "FS Int. Table Subscriber" IntegrationFieldMapping.Direction::ToIntegrationTable, '', false, false); - // Coupled Business Central items are managed by Field Service customer assets. + // Business Central service items are the source for Field Service customer assets. Sender.InsertIntegrationFieldMapping( IntegrationTableMappingName, - Item.FieldNo("Coupled to Dataverse"), + 0, 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 1a7e247c0a..4e1517aeb9 100644 --- a/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test library/src/FSIntegrationTestLibrary.Codeunit.al @@ -58,20 +58,6 @@ codeunit 139205 "FS Integration Test Library" FSIntTableSubscriber.UpdateQuantities(FSBookableResourceBooking, ServiceLine); end; - procedure GetCustomerAssetConversion(ItemIsManaged: Boolean): Boolean - var - FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; - begin - exit(FSIntTableSubscriber.GetCustomerAssetConversion(ItemIsManaged)); - end; - - procedure HandleOnBeforeIsFieldModified(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var Result: Boolean; var IsHandled: Boolean) - var - FSIntTableSubscriber: Codeunit "FS Int. Table Subscriber"; - begin - FSIntTableSubscriber.HandleOnBeforeIsFieldModified(SourceFieldRef, DestinationFieldRef, Result, IsHandled); - end; - procedure IgnorePostedJobJournalLinesOnQueryPostFilterIgnoreRecord(SourceRecordRef: RecordRef; var IgnoreRecord: 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 3c70255f72..c1ba8774a3 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1582,6 +1582,7 @@ 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"; @@ -1592,12 +1593,15 @@ codeunit 139204 "FS Integration Test" LibraryCRMIntegration.CreateCRMConnectionSetup('', '@@test@@', true); InitSetup(true, ''); - // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes. + // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes and the existing mapping has no conversion rule. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); LibraryCRMIntegration.CreateCoupledItemAndProduct(Item, CRMProduct); CRMProduct.ConvertToCustomerAsset := true; CRMProduct.Modify(); + 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. @@ -1608,76 +1612,29 @@ codeunit 139204 "FS Integration Test" Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); end; - [Test] - [TransactionModel(TransactionModel::AutoRollback)] - procedure CustomerAssetConversionReflectsItemManagement() - begin - // [FEATURE] [Item-Product Mapping] - // [SCENARIO] Customer asset conversion is disabled only for items managed through synchronization. - - // [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.'); - - // [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::AutoRollback)] - procedure ItemProductComparisonIgnoredWhenFieldServiceIsDisabled() - var - Item: Record Item; - CRMProduct: Record "CRM Product"; - SourceRecordRef: RecordRef; - DestinationRecordRef: RecordRef; - SourceFieldRef: FieldRef; - DestinationFieldRef: FieldRef; - Result: Boolean; - IsHandled: Boolean; - begin - // [FEATURE] [Item-Product Mapping] - // [SCENARIO] Field Service does not handle synchronization comparisons while its integration is disabled. - ResetFSEnvironment(); - - // [GIVEN] The fields used to derive customer asset conversion and no Field Service connection setup. - SourceRecordRef.GetTable(Item); - SourceFieldRef := SourceRecordRef.Field(Item.FieldNo("Coupled to Dataverse")); - DestinationRecordRef.GetTable(CRMProduct); - DestinationFieldRef := DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)); - - // [WHEN] The synchronization engine compares those fields. - FSIntegrationTestLibrary.HandleOnBeforeIsFieldModified(SourceFieldRef, DestinationFieldRef, Result, IsHandled); - - // [THEN] Field Service leaves the comparison to the synchronization engine. - Assert.IsFalse(IsHandled, 'Field Service should not handle synchronization comparisons while its integration is disabled.'); - 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 derives native Field Service customer asset creation from item management. + // [SCENARIO] The item-product mapping always disables native Field Service customer asset creation. Initialize(); InitSetup(true, ''); // [WHEN] The default item-product mapping is reset. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); - // [THEN] Convert to Customer Asset is mapped from the item's Dataverse coupling state in the outbound direction. + // [THEN] Convert to Customer Asset is mapped to 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(Item.FieldNo("Coupled to Dataverse"), IntegrationFieldMapping."Field No.", 'The mapping should use the item coupling state.'); + 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('', IntegrationFieldMapping."Constant Value", 'The mapping should not use a constant value.'); + Assert.AreEqual('false', IntegrationFieldMapping."Constant Value", 'The mapping should disable Convert to Customer Asset.'); end; local procedure Initialize() @@ -2015,4 +1972,3 @@ codeunit 139204 "FS Integration Test" InventorySetup.Modify(true); end; } - From b06ffe49e23973057d514ceabf327c68529c8ace Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 25 Sep 2026 19:10:50 +0200 Subject: [PATCH 27/36] Implement logic to disable customer asset conversion during initial item synchronization and add corresponding test case --- .../FSIntTableSubscriber.Codeunit.al | 19 +++++---- .../test/src/FSIntegrationTest.Codeunit.al | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 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 e5a8cccb3f..b42a9d0bbe 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,14 +303,18 @@ 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 CRMProduct.ConvertToCustomerAsset then begin - CRMProduct.ConvertToCustomerAsset := false; - AdditionalFieldsWereModified := true; - DestinationRecordRef.GetTable(CRMProduct); - end; + if DestinationIsInserted then begin + ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); + ExistingCRMProduct.Get(CRMProduct.ProductId); + if not ExistingCRMProduct.ConvertToCustomerAsset then + exit; + end else + if not CRMProduct.ConvertToCustomerAsset then + exit; + + DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); + AdditionalFieldsWereModified := true; end; 'FS Work Order Product-Service Line': begin diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index c1ba8774a3..53eee18ae2 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1576,6 +1576,46 @@ codeunit 139204 "FS Integration Test" Assert.AreEqual(0, WorkOrderProduct.QuantityConsumed, 'Quantity Consumed should not be written into Work Order record for Posting Preview action.'); end; + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure InitialItemSynchronizationDisablesCustomerAssetConversion() + var + CRMIntegrationRecord: Record "CRM Integration Record"; + CRMProduct: Record "CRM Product"; + CRMTransactioncurrency: Record "CRM Transactioncurrency"; + CRMUom: Record "CRM Uom"; + CRMUomschedule: Record "CRM Uomschedule"; + Currency: Record Currency; + IntegrationTableMapping: Record "Integration Table Mapping"; + Item: Record Item; + UnitOfMeasure: Record "Unit of Measure"; + CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; + CRMSetupDefaults: Codeunit "CRM Setup Defaults"; + begin + // [FEATURE] [Item-Product Mapping] + // [SCENARIO] The first synchronization of an item disables native Field Service customer asset creation. + Initialize(); + LibraryCRMIntegration.CreateCRMConnectionSetup('', '@@test@@', true); + InitSetup(true, ''); + + // [GIVEN] An uncoupled item whose unit of measure and currency are coupled to Dataverse. + CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); + LibraryCRMIntegration.CreateCoupledUnitOfMeasureAndUomSchedule(UnitOfMeasure, CRMUom, CRMUomschedule); + LibraryCRMIntegration.CreateCoupledCurrencyAndTransactionCurrency(Currency, CRMTransactioncurrency); + LibraryInventory.CreateItem(Item); + Item.Validate("Base Unit of Measure", UnitOfMeasure.Code); + Item.Modify(); + IntegrationTableMapping.Get('ITEM-PRODUCT'); + + // [WHEN] The item is synchronized to a new Field Service product. + CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); + + // [THEN] Native Field Service customer asset creation is disabled on the new product. + Assert.IsTrue(CRMIntegrationRecord.FindByRecordID(Item.RecordId()), 'The item should be coupled to a product.'); + CRMProduct.Get(CRMIntegrationRecord."CRM ID"); + Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); + end; + [Test] [TransactionModel(TransactionModel::AutoCommit)] procedure ItemSynchronizationDisablesCustomerAssetConversion() From 2ec90f95dd1bb3cddf14a3141c56fe18464d3d7e Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Fri, 25 Sep 2026 22:29:04 +0200 Subject: [PATCH 28/36] Disable customer asset conversion during item synchronization and update related test case --- .../FSIntTableSubscriber.Codeunit.al | 31 ++++++++++++------- .../test/src/FSIntegrationTest.Codeunit.al | 2 +- 2 files changed, 20 insertions(+), 13 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 b42a9d0bbe..a299dee391 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -303,18 +303,14 @@ codeunit 6610 "FS Int. Table Subscriber" case SourceDestCode of 'Item-CRM Product': begin - DestinationRecordRef.SetTable(CRMProduct); - if DestinationIsInserted then begin - ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); - ExistingCRMProduct.Get(CRMProduct.ProductId); - if not ExistingCRMProduct.ConvertToCustomerAsset then - exit; - end else - if not CRMProduct.ConvertToCustomerAsset then - exit; + if not DestinationIsInserted then + exit; - DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); - AdditionalFieldsWereModified := true; + DestinationRecordRef.SetTable(CRMProduct); + ExistingCRMProduct.SetLoadFields(ConvertToCustomerAsset); + ExistingCRMProduct.Get(CRMProduct.ProductId); + if ExistingCRMProduct.ConvertToCustomerAsset then + AdditionalFieldsWereModified := true; end; 'FS Work Order Product-Service Line': begin @@ -1400,6 +1396,8 @@ codeunit 6610 "FS Int. Table Subscriber" SourceDestCode := GetSourceDestCode(SourceRecordRef, DestinationRecordRef); case SourceDestCode of + 'Item-CRM Product': + DisableCustomerAssetConversion(DestinationRecordRef); 'FS Work Order Service-Job Journal Line': UpdateCorrelatedJobJournalLine(SourceRecordRef, DestinationRecordRef); 'Service Header-FS Work Order': @@ -1652,7 +1650,7 @@ codeunit 6610 "FS Int. Table Subscriber" end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnBeforeInsertRecord', '', true, false)] - local procedure HandleOnBeforeInsertRecord(SourceRecordRef: RecordRef; DestinationRecordRef: RecordRef) + local procedure HandleOnBeforeInsertRecord(SourceRecordRef: RecordRef; var DestinationRecordRef: RecordRef) var FSProjectTask: Record "FS Project Task"; Customer: Record Customer; @@ -1687,6 +1685,8 @@ codeunit 6610 "FS Int. Table Subscriber" SourceDestCode := GetSourceDestCode(SourceRecordRef, DestinationRecordRef); case SourceDestCode of + 'Item-CRM Product': + DisableCustomerAssetConversion(DestinationRecordRef); 'Location-FS Warehouse': SetCompanyId(DestinationRecordRef); 'Service Item-FS Customer Asset': @@ -2806,6 +2806,13 @@ codeunit 6610 "FS Int. Table Subscriber" exit(''); end; + local procedure DisableCustomerAssetConversion(var DestinationRecordRef: RecordRef) + var + CRMProduct: Record "CRM Product"; + begin + DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); + end; + [EventSubscriber(ObjectType::Table, Database::"Service Mgt. Setup", 'OnBeforeValidateEvent', 'One Service Item Line/Order', true, false)] local procedure ServiceMgtSetupOnBeforeValidateOneServiceItemLinePerOrder(var Rec: Record "Service Mgt. Setup") var diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 53eee18ae2..a20f2071ce 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1608,7 +1608,7 @@ codeunit 139204 "FS Integration Test" IntegrationTableMapping.Get('ITEM-PRODUCT'); // [WHEN] The item is synchronized to a new Field Service product. - CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); + CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, true); // [THEN] Native Field Service customer asset creation is disabled on the new product. Assert.IsTrue(CRMIntegrationRecord.FindByRecordID(Item.RecordId()), 'The item should be coupled to a product.'); From 05155ed85bc89389c621a4674a5268a5a9620c3a Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 26 Sep 2026 13:11:49 +0200 Subject: [PATCH 29/36] Fix Field Service item synchronization test setup Use the standard mock CRM connection for item-product synchronization tests and assert the synchronization job succeeds before validating coupling and customer asset conversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/src/FSIntegrationTest.Codeunit.al | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index a20f2071ce..4ffc7ce407 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1586,17 +1586,19 @@ codeunit 139204 "FS Integration Test" CRMUom: Record "CRM Uom"; CRMUomschedule: Record "CRM Uomschedule"; Currency: Record Currency; + IntegrationSynchJob: Record "Integration Synch. Job"; IntegrationTableMapping: Record "Integration Table Mapping"; Item: Record Item; UnitOfMeasure: Record "Unit of Measure"; CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; CRMSetupDefaults: Codeunit "CRM Setup Defaults"; + JobID: Guid; begin // [FEATURE] [Item-Product Mapping] // [SCENARIO] The first synchronization of an item disables native Field Service customer asset creation. Initialize(); - LibraryCRMIntegration.CreateCRMConnectionSetup('', '@@test@@', true); InitSetup(true, ''); + LibraryCRMIntegration.ConfigureCRM(); // [GIVEN] An uncoupled item whose unit of measure and currency are coupled to Dataverse. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); @@ -1608,9 +1610,12 @@ codeunit 139204 "FS Integration Test" IntegrationTableMapping.Get('ITEM-PRODUCT'); // [WHEN] The item is synchronized to a new Field Service product. - CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, true); + JobID := CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, true); // [THEN] Native Field Service customer asset creation is disabled on the new product. + IntegrationSynchJob.Get(JobID); + IntegrationSynchJob.TestField(Failed, 0); + IntegrationSynchJob.TestField(Inserted, 1); Assert.IsTrue(CRMIntegrationRecord.FindByRecordID(Item.RecordId()), 'The item should be coupled to a product.'); CRMProduct.Get(CRMIntegrationRecord."CRM ID"); Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); @@ -1623,15 +1628,17 @@ codeunit 139204 "FS Integration Test" Item: Record Item; CRMProduct: Record "CRM Product"; IntegrationFieldMapping: Record "Integration Field Mapping"; + IntegrationSynchJob: Record "Integration Synch. Job"; IntegrationTableMapping: Record "Integration Table Mapping"; CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; CRMSetupDefaults: Codeunit "CRM Setup Defaults"; + JobID: Guid; begin // [FEATURE] [Item-Product Mapping] // [SCENARIO] Synchronizing an item disables native Field Service customer asset creation. Initialize(); - LibraryCRMIntegration.CreateCRMConnectionSetup('', '@@test@@', true); InitSetup(true, ''); + LibraryCRMIntegration.ConfigureCRM(); // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes and the existing mapping has no conversion rule. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); @@ -1645,9 +1652,12 @@ codeunit 139204 "FS Integration Test" IntegrationTableMapping.Get('ITEM-PRODUCT'); // [WHEN] The item is synchronized to the Field Service product. - CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); + JobID := CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); // [THEN] Native Field Service customer asset creation is disabled. + IntegrationSynchJob.Get(JobID); + IntegrationSynchJob.TestField(Failed, 0); + IntegrationSynchJob.TestField(Modified, 1); CRMProduct.Get(CRMProduct.ProductId); Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); end; From 901cfcf621ae45d957cd0bead657f3c8f6e0c760 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 26 Sep 2026 19:08:53 +0200 Subject: [PATCH 30/36] Refactor customer asset conversion logic in FS Int. Table Subscriber and update integration tests --- .../FSIntTableSubscriber.Codeunit.al | 4 +++- .../test/src/FSIntegrationTest.Codeunit.al | 24 ++++++++++++------- 2 files changed, 19 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 a299dee391..9144d360dc 100644 --- a/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/app/src/Codeunits/FSIntTableSubscriber.Codeunit.al @@ -2810,7 +2810,9 @@ codeunit 6610 "FS Int. Table Subscriber" var CRMProduct: Record "CRM Product"; begin - DestinationRecordRef.Field(CRMProduct.FieldNo(ConvertToCustomerAsset)).Value(false); + DestinationRecordRef.SetTable(CRMProduct); + CRMProduct.ConvertToCustomerAsset := false; + DestinationRecordRef.GetTable(CRMProduct); end; [EventSubscriber(ObjectType::Table, Database::"Service Mgt. Setup", 'OnBeforeValidateEvent', 'One Service Item Line/Order', true, false)] diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 4ffc7ce407..b2b9efbcc9 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1586,7 +1586,6 @@ codeunit 139204 "FS Integration Test" CRMUom: Record "CRM Uom"; CRMUomschedule: Record "CRM Uomschedule"; Currency: Record Currency; - IntegrationSynchJob: Record "Integration Synch. Job"; IntegrationTableMapping: Record "Integration Table Mapping"; Item: Record Item; UnitOfMeasure: Record "Unit of Measure"; @@ -1613,9 +1612,7 @@ codeunit 139204 "FS Integration Test" JobID := CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, true); // [THEN] Native Field Service customer asset creation is disabled on the new product. - IntegrationSynchJob.Get(JobID); - IntegrationSynchJob.TestField(Failed, 0); - IntegrationSynchJob.TestField(Inserted, 1); + VerifySuccessfulIntegrationSynchJob(JobID, 1, 0); Assert.IsTrue(CRMIntegrationRecord.FindByRecordID(Item.RecordId()), 'The item should be coupled to a product.'); CRMProduct.Get(CRMIntegrationRecord."CRM ID"); Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); @@ -1628,7 +1625,6 @@ codeunit 139204 "FS Integration Test" Item: Record Item; CRMProduct: Record "CRM Product"; IntegrationFieldMapping: Record "Integration Field Mapping"; - IntegrationSynchJob: Record "Integration Synch. Job"; IntegrationTableMapping: Record "Integration Table Mapping"; CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; CRMSetupDefaults: Codeunit "CRM Setup Defaults"; @@ -1655,9 +1651,7 @@ codeunit 139204 "FS Integration Test" JobID := CRMIntegrationTableSynch.SynchRecord(IntegrationTableMapping, Item.RecordId(), true, false); // [THEN] Native Field Service customer asset creation is disabled. - IntegrationSynchJob.Get(JobID); - IntegrationSynchJob.TestField(Failed, 0); - IntegrationSynchJob.TestField(Modified, 1); + VerifySuccessfulIntegrationSynchJob(JobID, 0, 1); CRMProduct.Get(CRMProduct.ProductId); Assert.IsFalse(CRMProduct.ConvertToCustomerAsset, 'Convert to Customer Asset should be disabled.'); end; @@ -1716,6 +1710,20 @@ codeunit 139204 "FS Integration Test" SetTenantLicenseStateToTrial(); end; + local procedure VerifySuccessfulIntegrationSynchJob(JobID: Guid; ExpectedInserted: Integer; ExpectedModified: Integer) + var + IntegrationSynchJob: Record "Integration Synch. Job"; + IntegrationSynchJobErrors: Record "Integration Synch. Job Errors"; + begin + IntegrationSynchJob.Get(JobID); + IntegrationSynchJobErrors.SetRange("Integration Synch. Job ID", JobID); + if IntegrationSynchJobErrors.FindFirst() then + Assert.Fail('Unexpected synchronization error: ' + IntegrationSynchJobErrors.Message); + IntegrationSynchJob.TestField(Failed, 0); + IntegrationSynchJob.TestField(Inserted, ExpectedInserted); + IntegrationSynchJob.TestField(Modified, ExpectedModified); + end; + procedure ResetFSEnvironment() var FSConnectionSetup: Record "FS Connection Setup"; From f888bf41fbedb858b06b22044ef05ca7c6d3bef5 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sat, 26 Sep 2026 22:34:05 +0200 Subject: [PATCH 31/36] Couple item UOMs in Field Service sync tests Create Item Unit of Measure couplings required by the Item-Product synchronization dependency before exercising initial and existing product synchronization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/src/FSIntegrationTest.Codeunit.al | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index b2b9efbcc9..6aba79b59d 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1584,11 +1584,9 @@ codeunit 139204 "FS Integration Test" CRMProduct: Record "CRM Product"; CRMTransactioncurrency: Record "CRM Transactioncurrency"; CRMUom: Record "CRM Uom"; - CRMUomschedule: Record "CRM Uomschedule"; Currency: Record Currency; IntegrationTableMapping: Record "Integration Table Mapping"; Item: Record Item; - UnitOfMeasure: Record "Unit of Measure"; CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; CRMSetupDefaults: Codeunit "CRM Setup Defaults"; JobID: Guid; @@ -1601,11 +1599,8 @@ codeunit 139204 "FS Integration Test" // [GIVEN] An uncoupled item whose unit of measure and currency are coupled to Dataverse. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); - LibraryCRMIntegration.CreateCoupledUnitOfMeasureAndUomSchedule(UnitOfMeasure, CRMUom, CRMUomschedule); + CreateItemWithCoupledUnitOfMeasure(Item, CRMUom); LibraryCRMIntegration.CreateCoupledCurrencyAndTransactionCurrency(Currency, CRMTransactioncurrency); - LibraryInventory.CreateItem(Item); - Item.Validate("Base Unit of Measure", UnitOfMeasure.Code); - Item.Modify(); IntegrationTableMapping.Get('ITEM-PRODUCT'); // [WHEN] The item is synchronized to a new Field Service product. @@ -1624,6 +1619,7 @@ codeunit 139204 "FS Integration Test" var Item: Record Item; CRMProduct: Record "CRM Product"; + CRMUom: Record "CRM Uom"; IntegrationFieldMapping: Record "Integration Field Mapping"; IntegrationTableMapping: Record "Integration Table Mapping"; CRMIntegrationTableSynch: Codeunit "CRM Integration Table Synch."; @@ -1638,7 +1634,8 @@ codeunit 139204 "FS Integration Test" // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes and the existing mapping has no conversion rule. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); - LibraryCRMIntegration.CreateCoupledItemAndProduct(Item, CRMProduct); + CreateItemWithCoupledUnitOfMeasure(Item, CRMUom); + LibraryCRMIntegration.CoupleItem(Item, CRMUom, CRMProduct); CRMProduct.ConvertToCustomerAsset := true; CRMProduct.Modify(); @@ -1724,6 +1721,21 @@ codeunit 139204 "FS Integration Test" IntegrationSynchJob.TestField(Modified, ExpectedModified); end; + local procedure CreateItemWithCoupledUnitOfMeasure(var Item: Record Item; var CRMUom: Record "CRM Uom") + var + ItemUnitOfMeasure: Record "Item Unit of Measure"; + UnitOfMeasure: Record "Unit of Measure"; + DefaultCRMUom: Record "CRM Uom"; + CRMUomschedule: Record "CRM Uomschedule"; + begin + LibraryCRMIntegration.CreateCoupledUnitOfMeasureAndUomSchedule(UnitOfMeasure, DefaultCRMUom, CRMUomschedule); + LibraryInventory.CreateItem(Item); + Item.Validate("Base Unit of Measure", UnitOfMeasure.Code); + Item.Modify(); + ItemUnitOfMeasure.Get(Item."No.", Item."Base Unit of Measure"); + LibraryCRMIntegration.CoupleItemUnitOfMeasure(ItemUnitOfMeasure, CRMUomschedule, CRMUom); + end; + procedure ResetFSEnvironment() var FSConnectionSetup: Record "FS Connection Setup"; From 763ed099b47809622d755a9feaa8590111ee7f14 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sun, 27 Sep 2026 11:20:21 +0200 Subject: [PATCH 32/36] Initialize CRM base currency in item sync tests Create the mock CRM organization and copy its base currency to the CRM connection before running Item-Product synchronization scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/src/FSIntegrationTest.Codeunit.al | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 6aba79b59d..8a411928a4 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1594,8 +1594,7 @@ codeunit 139204 "FS Integration Test" // [FEATURE] [Item-Product Mapping] // [SCENARIO] The first synchronization of an item disables native Field Service customer asset creation. Initialize(); - InitSetup(true, ''); - LibraryCRMIntegration.ConfigureCRM(); + InitializeItemProductSynchronization(); // [GIVEN] An uncoupled item whose unit of measure and currency are coupled to Dataverse. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); @@ -1629,8 +1628,7 @@ codeunit 139204 "FS Integration Test" // [FEATURE] [Item-Product Mapping] // [SCENARIO] Synchronizing an item disables native Field Service customer asset creation. Initialize(); - InitSetup(true, ''); - LibraryCRMIntegration.ConfigureCRM(); + InitializeItemProductSynchronization(); // [GIVEN] A coupled item and product where Convert to Customer Asset is Yes and the existing mapping has no conversion rule. CRMSetupDefaults.ResetItemProductMapping('ITEM-PRODUCT', false); @@ -1736,6 +1734,20 @@ codeunit 139204 "FS Integration Test" LibraryCRMIntegration.CoupleItemUnitOfMeasure(ItemUnitOfMeasure, CRMUomschedule, CRMUom); end; + local procedure InitializeItemProductSynchronization() + var + CRMConnectionSetup: Record "CRM Connection Setup"; + CRMOrganization: Record "CRM Organization"; + begin + InitSetup(true, ''); + LibraryCRMIntegration.ConfigureCRM(); + LibraryCRMIntegration.CreateCRMOrganization(); + CRMOrganization.FindFirst(); + CRMConnectionSetup.Get(); + CRMConnectionSetup.BaseCurrencyId := CRMOrganization.BaseCurrencyId; + CRMConnectionSetup.Modify(); + end; + procedure ResetFSEnvironment() var FSConnectionSetup: Record "FS Connection Setup"; From cae07cc5ec38296cdfa3c426e8272c7a4954e62b Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sun, 27 Sep 2026 17:28:48 +0200 Subject: [PATCH 33/36] Update CRM connection setup logic in item synchronization test --- .../test/src/FSIntegrationTest.Codeunit.al | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 8a411928a4..ee2942781e 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1737,15 +1737,15 @@ codeunit 139204 "FS Integration Test" local procedure InitializeItemProductSynchronization() var CRMConnectionSetup: Record "CRM Connection Setup"; - CRMOrganization: Record "CRM Organization"; begin InitSetup(true, ''); LibraryCRMIntegration.ConfigureCRM(); LibraryCRMIntegration.CreateCRMOrganization(); - CRMOrganization.FindFirst(); CRMConnectionSetup.Get(); - CRMConnectionSetup.BaseCurrencyId := CRMOrganization.BaseCurrencyId; - CRMConnectionSetup.Modify(); + CRMConnectionSetup.Validate("Is Enabled", false); + CRMConnectionSetup.Modify(true); + CRMConnectionSetup.Validate("Is Enabled", true); + CRMConnectionSetup.Modify(true); end; procedure ResetFSEnvironment() From a0fb3dfd71f4cb6caddce312b6bd5b8d72576130 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Sun, 27 Sep 2026 21:03:59 +0200 Subject: [PATCH 34/36] Initialize CDS base currency in item sync tests Populate the enabled Dataverse connection setup from the mock CRM organization so CRM synchronization reads the configured base currency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/src/FSIntegrationTest.Codeunit.al | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index ee2942781e..b78f861d32 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1736,6 +1736,7 @@ codeunit 139204 "FS Integration Test" local procedure InitializeItemProductSynchronization() var + CDSConnectionSetup: Record "CDS Connection Setup"; CRMConnectionSetup: Record "CRM Connection Setup"; begin InitSetup(true, ''); @@ -1746,6 +1747,8 @@ codeunit 139204 "FS Integration Test" CRMConnectionSetup.Modify(true); CRMConnectionSetup.Validate("Is Enabled", true); CRMConnectionSetup.Modify(true); + CDSConnectionSetup.Get(); + CDSConnectionSetup.SetBaseCurrencyData(); end; procedure ResetFSEnvironment() From f0f98786c0703c4721cf134efcb604915cd2ce83 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Mon, 28 Sep 2026 09:03:26 +0200 Subject: [PATCH 35/36] Create CRM unit groups in item sync tests Use the Unit Group mapping test helpers so Item-Product synchronization has both the generated CRM unit group and its coupled base CRM Uom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/src/FSIntegrationTest.Codeunit.al | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index b78f861d32..3f11e5fe49 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1722,14 +1722,11 @@ codeunit 139204 "FS Integration Test" local procedure CreateItemWithCoupledUnitOfMeasure(var Item: Record Item; var CRMUom: Record "CRM Uom") var ItemUnitOfMeasure: Record "Item Unit of Measure"; - UnitOfMeasure: Record "Unit of Measure"; - DefaultCRMUom: Record "CRM Uom"; + UnitGroup: Record "Unit Group"; CRMUomschedule: Record "CRM Uomschedule"; begin - LibraryCRMIntegration.CreateCoupledUnitOfMeasureAndUomSchedule(UnitOfMeasure, DefaultCRMUom, CRMUomschedule); - LibraryInventory.CreateItem(Item); - Item.Validate("Base Unit of Measure", UnitOfMeasure.Code); - Item.Modify(); + LibraryCRMIntegration.CreateCoupledItemUnitGroupAndUomSchedule(UnitGroup, CRMUomschedule); + Item.GetBySystemId(UnitGroup."Source Id"); ItemUnitOfMeasure.Get(Item."No.", Item."Base Unit of Measure"); LibraryCRMIntegration.CoupleItemUnitOfMeasure(ItemUnitOfMeasure, CRMUomschedule, CRMUom); end; From 5b923452ef1f05200f827f52b018f7f808578a43 Mon Sep 17 00:00:00 2001 From: Stefan Tomasevic Date: Mon, 28 Sep 2026 12:25:54 +0200 Subject: [PATCH 36/36] Seed Dataverse company in item sync tests Create the current BC company in the mocked Dataverse organization before Item-Product synchronization so company-scoped CRM records can be written. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/src/FSIntegrationTest.Codeunit.al | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al index 3f11e5fe49..da32168176 100644 --- a/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al +++ b/src/Apps/W1/FieldServiceIntegration/test/src/FSIntegrationTest.Codeunit.al @@ -1733,6 +1733,7 @@ codeunit 139204 "FS Integration Test" local procedure InitializeItemProductSynchronization() var + CDSCompany: Record "CDS Company"; CDSConnectionSetup: Record "CDS Connection Setup"; CRMConnectionSetup: Record "CRM Connection Setup"; begin @@ -1746,6 +1747,7 @@ codeunit 139204 "FS Integration Test" CRMConnectionSetup.Modify(true); CDSConnectionSetup.Get(); CDSConnectionSetup.SetBaseCurrencyData(); + LibraryCRMIntegration.EnsureCDSCompany(CDSCompany); end; procedure ResetFSEnvironment()