From b0d410b73b1f6853beaead28f897ee4da1464b6c Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Fri, 18 Sep 2026 16:16:43 +0200 Subject: [PATCH 1/5] [Master Data Management] Fixing the inline media synch, source watermarking and error message when admin didn't consent privacy notice --- .../MDMCrossEnvRead.PermissionSet.al | 6 +- .../MDMCrossEnvDataSource.Codeunit.al | 14 ++- .../MDMHttpSourceTransport.Codeunit.al | 12 +- .../src/MDMCrossEnvConsumerTests.Codeunit.al | 118 ++++++++++++++++++ 4 files changed, 142 insertions(+), 8 deletions(-) diff --git a/src/Apps/W1/MasterDataManagement/app/permissions/MDMCrossEnvRead.PermissionSet.al b/src/Apps/W1/MasterDataManagement/app/permissions/MDMCrossEnvRead.PermissionSet.al index 42921205af4..934cb4be7e2 100644 --- a/src/Apps/W1/MasterDataManagement/app/permissions/MDMCrossEnvRead.PermissionSet.al +++ b/src/Apps/W1/MasterDataManagement/app/permissions/MDMCrossEnvRead.PermissionSet.al @@ -19,6 +19,7 @@ using Microsoft.Purchases.Vendor; using Microsoft.Sales.Customer; using Microsoft.Sales.Setup; using System.Environment; +using System.Privacy; /// /// Assigned to the customer-registered Entra app on the SOURCE (Microsoft Entra Application Card) to grant @@ -66,5 +67,8 @@ permissionset 7242 "MDM Cross-Env Read" tabledata "Tax Area" = R, tabledata "Tax Group" = R, tabledata "Tax Jurisdiction" = R, - tabledata "Tenant Media" = R; + tabledata "Tenant Media" = R, + // Read the consent tables so the source can answer "not consented" as a structured signal instead of throwing a permission error (surfaced as a bare HTTP 403). + tabledata "Privacy Notice" = R, + tabledata "Privacy Notice Approval" = R; } diff --git a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMCrossEnvDataSource.Codeunit.al b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMCrossEnvDataSource.Codeunit.al index 30cbc1a28ec..e828115d8ae 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMCrossEnvDataSource.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMCrossEnvDataSource.Codeunit.al @@ -258,8 +258,10 @@ codeunit 7249 "MDM Cross-Env Data Source" implements "IMDM Data Source" begin SourceRecordRef.Close(); SourceRecordRef.Open(IntegrationTableId, true); - InlineMedia.Reset(); // fresh fetch: drop any prior inline media bytes - SourceWatermark.Reset(); + // Keep the batch's per-batch caches (inline media + source watermarks): GetBySystemId/GetById/GetByUidFilter + // run during the write loop (coupling, conflict checks), so resetting here would drop the pictures the apply + // step still needs and the real source timestamps GetRowLastModifiedOn reads back. Only the batch fetch + // (GetModifiedBatch) resets them. if SystemIds.Count() = 0 then exit; Transport := GetTransport(); @@ -281,8 +283,8 @@ codeunit 7249 "MDM Cross-Env Data Source" implements "IMDM Data Source" begin SourceRecordRef.Close(); SourceRecordRef.Open(TableId, true); - InlineMedia.Reset(); - SourceWatermark.Reset(); + // Keep the current batch's per-batch caches (see FetchBySystemIds): a related-table read during the write + // loop must not drop the batch's inline media or source watermarks. Transport := GetTransport(); SourceCapabilities.EnsureSupported(Transport, RecordsFeatureTok); // Full read from the start (selector '{}' = no watermark), narrowed to the related rows by the row filter. @@ -303,8 +305,8 @@ codeunit 7249 "MDM Cross-Env Data Source" implements "IMDM Data Source" NotIndexed := false; SourceRecordRef.Close(); SourceRecordRef.Open(TableId, true); - InlineMedia.Reset(); - SourceWatermark.Reset(); + // Keep the current batch's per-batch caches (see FetchBySystemIds): a related-table read during the write + // loop must not drop the batch's inline media or source watermarks. Transport := GetTransport(); SourceCapabilities.EnsureSupported(Transport, RecordsFeatureTok); if not TryParseCleanResponse(Transport.GetRecords(TableId, BuildFieldIdsForTable(TableId), '{}', PageSize(), RowFilter), Response, NotIndexed) then diff --git a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMHttpSourceTransport.Codeunit.al b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMHttpSourceTransport.Codeunit.al index 1bba9a4b3af..db88437c337 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMHttpSourceTransport.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MDMHttpSourceTransport.Codeunit.al @@ -30,6 +30,7 @@ codeunit 7247 "MDM Http Source Transport" implements "IMDM Source Transport" InvalidSourceUrlErr: Label 'The source environment URL is not a valid Business Central endpoint.'; InvalidSourceUrlAuditTxt: Label 'Blocked a cross-environment request: the configured source environment URL host ''%1'' is not a valid Business Central endpoint.', Comment = '%1 = the rejected host'; HttpErr: Label 'The source environment returned HTTP %1.', Comment = '%1 = HTTP status code'; + AccessDeniedErr: Label 'The source environment %1 denied access (HTTP %2). On the source environment, assign the registered app the ''Master Data Mgt. - Cross Environment'' permission set and approve the cross-environment privacy notice, then try again.', Comment = '%1 = source environment name, %2 = HTTP status code'; ServiceNameTok: Label 'MDMCrossEnvSource', Locked = true; ScopeTok: Label 'https://api.businesscentral.dynamics.com/.default', Locked = true; ScopePPETok: Label 'https://api.businesscentral.dynamics-tie.com/.default', Locked = true; @@ -97,7 +98,7 @@ codeunit 7247 "MDM Http Source Transport" implements "IMDM Source Transport" exit(UnwrapODataValue(ResponseBodyText)); if not ShouldRetry(ResponseMessage, Attempt, RetryAfter) then begin LogRequestFailure(MasterDataManagementSetup, ActionName, ResponseMessage); - Error(SetupNavigationError(StrSubstNo(HttpErr, ResponseMessage.HttpStatusCode()))); + Error(SetupNavigationError(HttpStatusErrorMessage(MasterDataManagementSetup, ResponseMessage.HttpStatusCode()))); end; Sleep(RetryAfter); end else begin @@ -111,6 +112,15 @@ codeunit 7247 "MDM Http Source Transport" implements "IMDM Source Transport" end; end; + // 401/403 is an authorization failure on the SOURCE (missing permission set or unapproved privacy notice), so + // return a remedy the source admin can act on; other status codes keep the generic message. + local procedure HttpStatusErrorMessage(var MasterDataManagementSetup: Record "Master Data Management Setup"; StatusCode: Integer): Text + begin + if StatusCode in [401, 403] then + exit(StrSubstNo(AccessDeniedErr, MasterDataManagementSetup."Source Environment Name", StatusCode)); + exit(StrSubstNo(HttpErr, StatusCode)); + end; + local procedure LogRequestFailure(var MasterDataManagementSetup: Record "Master Data Management Setup"; ActionName: Text; var ResponseMessage: HttpResponseMessage) var AuditLog: Codeunit "Audit Log"; diff --git a/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al b/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al index 291d47e5f51..d5488a6d3f0 100644 --- a/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al @@ -810,6 +810,124 @@ codeunit 139932 "MDM Cross-Env Consumer Tests" CleanUp(); end; + [Test] + procedure CrossEnvInlineMediaSurvivesAncillaryReFetch() + var + FirstRecord, SecondRecord : Record "MDM Test Table A"; + IntegrationTableMapping: Record "Integration Table Mapping"; + LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; + InProcessTransport: Codeunit "MDM In-Process Transport"; + SourceRecordRef: RecordRef; + Watermark: DateTime; + begin + // [FEATURE] [Master Data Management] [Cross-Environment] + // [SCENARIO] A batch caches every record's picture; a per-record re-fetch (GetBySystemId, as the write loop + // does for coupling/conflict checks) must NOT drop the OTHER records' cached pictures. Regression guard for + // the bug where only the first record kept its image. + Initialize(); + + // [GIVEN] two source records with pictures, materialized as one batch (both pictures cached) + Watermark := CurrentDateTime(); + Sleep(100); + CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); + CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); + CreateTestTableAMapping(IntegrationTableMapping); + IntegrationTableMapping."Synch. Modified On Filter" := Watermark; + IntegrationTableMapping.Modify(); + + LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); + InProcessTransport.Activate(); + + LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'Precondition: first record picture should be cached'); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'Precondition: second record picture should be cached'); + + // [WHEN] the write loop re-fetches one record by SystemId (coupling/conflict lookup) + LibraryMasterDataMgt.DataSourceGetBySystemId(Database::"MDM Test Table A", FirstRecord.SystemId, SourceRecordRef); + + // [THEN] the other record's cached picture is still there for the transfer-time apply + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'A per-record re-fetch must not drop the batch''s other cached pictures'); + + CleanUp(); + end; + + [Test] + procedure CrossEnvSourceWatermarkSurvivesAncillaryReFetch() + var + FirstRecord, SecondRecord : Record "MDM Test Table A"; + IntegrationTableMapping: Record "Integration Table Mapping"; + LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; + InProcessTransport: Codeunit "MDM In-Process Transport"; + SourceRecordRef: RecordRef; + Watermark, CachedModifiedAt : DateTime; + begin + // [FEATURE] [Master Data Management] [Cross-Environment] + // [SCENARIO] The per-batch source watermark cache (read by GetRowLastModifiedOn during the write loop) must + // survive a per-record re-fetch, so conflict detection keeps each record's real source timestamp. + Initialize(); + + Watermark := CurrentDateTime(); + Sleep(100); + CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); + CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); + CreateTestTableAMapping(IntegrationTableMapping); + IntegrationTableMapping."Synch. Modified On Filter" := Watermark; + IntegrationTableMapping.Modify(); + + LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); + InProcessTransport.Activate(); + + LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); + Assert.IsTrue(LibraryMasterDataMgt.TryGetSourceWatermark(SecondRecord.SystemId, CachedModifiedAt), 'Precondition: second record source watermark should be cached'); + + // [WHEN] the write loop re-fetches the first record by SystemId + LibraryMasterDataMgt.DataSourceGetBySystemId(Database::"MDM Test Table A", FirstRecord.SystemId, SourceRecordRef); + + // [THEN] the other record's source watermark is still cached for conflict detection + Assert.IsTrue(LibraryMasterDataMgt.TryGetSourceWatermark(SecondRecord.SystemId, CachedModifiedAt), 'A per-record re-fetch must not drop the batch''s other source watermarks'); + + CleanUp(); + end; + + [Test] + procedure CrossEnvInlineMediaSurvivesRelatedTableRead() + var + FirstRecord, SecondRecord : Record "MDM Test Table A"; + IntegrationTableMapping: Record "Integration Table Mapping"; + LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; + InProcessTransport: Codeunit "MDM In-Process Transport"; + SourceRecordRef: RecordRef; + Watermark: DateTime; + begin + // [FEATURE] [Master Data Management] [Cross-Environment] + // [SCENARIO] Resolving a related table (contact business relations) during the write loop must NOT drop the + // batch's cached pictures. Regression guard for the contact-side variant of the "only one picture" bug. + Initialize(); + + Watermark := CurrentDateTime(); + Sleep(100); + CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); + CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); + CreateTestTableAMapping(IntegrationTableMapping); + IntegrationTableMapping."Synch. Modified On Filter" := Watermark; + IntegrationTableMapping.Modify(); + + LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); + InProcessTransport.Activate(); + + LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'Precondition: first record picture should be cached'); + + // [WHEN] a related-table read runs during the write loop (contact business relation resolution) + LibraryMasterDataMgt.DataSourceGetRecordsByFilter(Database::"Contact Business Relation", '', SourceRecordRef); + + // [THEN] the batch's cached pictures are still there for the transfer-time apply + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'A related-table read must not drop the batch''s cached pictures'); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'A related-table read must not drop the batch''s cached pictures'); + + CleanUp(); + end; + local procedure CreateTestTableAMapping(var IntegrationTableMapping: Record "Integration Table Mapping") var IntegrationFieldMapping: Record "Integration Field Mapping"; From 971b1e4f5310bcf52c5bece6ed92f17d31a77f9f Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Mon, 21 Sep 2026 20:33:10 +0200 Subject: [PATCH 2/5] [Master Data Management] Fixing contact synch issue and minor setup page issue --- .../pages/MasterDataManagementSetup.Page.al | 2 +- .../src/tables/MasterDataMgtCoupling.Table.al | 6 + .../src/LibraryMasterDataMgt.Codeunit.al | 11 ++ .../src/MDMCrossEnvConsumerTests.Codeunit.al | 124 +++--------------- 4 files changed, 37 insertions(+), 106 deletions(-) diff --git a/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al b/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al index 613d9ffdd47..0b87ab19a45 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al +++ b/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al @@ -29,7 +29,7 @@ page 7230 "Master Data Management Setup" { ApplicationArea = Suite; Editable = IsEditable; - Visible = not CrossEnvConfigured; // same-environment source company; hidden once a source environment is configured + Visible = not (CrossEnvConfigured and Rec."Is Enabled"); // same-env source company: blank under cross-env, so hide it once cross-env is set up and enabled (view cross-env details via the wizard) ToolTip = 'Specifies the name of the source company that you synchronize data from.'; } field("Source Environment Name"; Rec."Source Environment Name") diff --git a/src/Apps/W1/MasterDataManagement/app/src/tables/MasterDataMgtCoupling.Table.al b/src/Apps/W1/MasterDataManagement/app/src/tables/MasterDataMgtCoupling.Table.al index 4e70c023e61..ae66d2bc57d 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/tables/MasterDataMgtCoupling.Table.al +++ b/src/Apps/W1/MasterDataManagement/app/src/tables/MasterDataMgtCoupling.Table.al @@ -221,6 +221,7 @@ table 7231 "Master Data Mgt. Coupling" internal procedure InsertRecord(IntegrationSysID: Guid; SysId: Guid; TableId: Integer) var + ExistingCoupling: Record "Master Data Mgt. Coupling"; LocalRecordRef: RecordRef; EmptyGuid: Guid; begin @@ -234,6 +235,11 @@ table 7231 "Master Data Mgt. Coupling" exit; end; + // Another sync job (e.g. the customer/vendor run that auto-created and coupled the company contact) can create + // this exact coupling first; treat that as done instead of failing the whole record on the duplicate key. + if ExistingCoupling.Get(IntegrationSysID, SysId) then + exit; + Reset(); Init(); "Integration System ID" := IntegrationSysID; diff --git a/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al b/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al index 141e8e06dd0..ca6ffb9742e 100644 --- a/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al @@ -102,6 +102,17 @@ codeunit 139757 "Library - Master Data Mgt." exit(MasterDataManagement.GetIntegrationRecordRef(IntegrationTableID, MasterDataMgtCoupling, RecRef)); end; + /// Creates a Master Data Mgt. coupling via the table's InsertRecord (used to exercise duplicate-coupling resilience). + /// The source record SystemId. + /// The local record SystemId. + /// The coupled local table ID. + procedure InsertCoupling(IntegrationSystemId: Guid; LocalSystemId: Guid; TableId: Integer) + var + MasterDataMgtCoupling: Record "Master Data Mgt. Coupling"; + begin + MasterDataMgtCoupling.InsertRecord(IntegrationSystemId, LocalSystemId, TableId); + end; + /// Gets the integration record reference identified by a coupling ID. /// The integration table mapping to resolve against. /// The coupling ID to resolve. diff --git a/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al b/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al index d5488a6d3f0..c881e6c40c3 100644 --- a/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al @@ -811,120 +811,34 @@ codeunit 139932 "MDM Cross-Env Consumer Tests" end; [Test] - procedure CrossEnvInlineMediaSurvivesAncillaryReFetch() + procedure DuplicateCouplingInsertIsIdempotent() var - FirstRecord, SecondRecord : Record "MDM Test Table A"; - IntegrationTableMapping: Record "Integration Table Mapping"; - LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; - InProcessTransport: Codeunit "MDM In-Process Transport"; - SourceRecordRef: RecordRef; - Watermark: DateTime; - begin - // [FEATURE] [Master Data Management] [Cross-Environment] - // [SCENARIO] A batch caches every record's picture; a per-record re-fetch (GetBySystemId, as the write loop - // does for coupling/conflict checks) must NOT drop the OTHER records' cached pictures. Regression guard for - // the bug where only the first record kept its image. - Initialize(); - - // [GIVEN] two source records with pictures, materialized as one batch (both pictures cached) - Watermark := CurrentDateTime(); - Sleep(100); - CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); - CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); - CreateTestTableAMapping(IntegrationTableMapping); - IntegrationTableMapping."Synch. Modified On Filter" := Watermark; - IntegrationTableMapping.Modify(); - - LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); - InProcessTransport.Activate(); - - LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); - Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'Precondition: first record picture should be cached'); - Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'Precondition: second record picture should be cached'); - - // [WHEN] the write loop re-fetches one record by SystemId (coupling/conflict lookup) - LibraryMasterDataMgt.DataSourceGetBySystemId(Database::"MDM Test Table A", FirstRecord.SystemId, SourceRecordRef); - - // [THEN] the other record's cached picture is still there for the transfer-time apply - Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'A per-record re-fetch must not drop the batch''s other cached pictures'); - - CleanUp(); - end; - - [Test] - procedure CrossEnvSourceWatermarkSurvivesAncillaryReFetch() - var - FirstRecord, SecondRecord : Record "MDM Test Table A"; - IntegrationTableMapping: Record "Integration Table Mapping"; - LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; - InProcessTransport: Codeunit "MDM In-Process Transport"; - SourceRecordRef: RecordRef; - Watermark, CachedModifiedAt : DateTime; - begin - // [FEATURE] [Master Data Management] [Cross-Environment] - // [SCENARIO] The per-batch source watermark cache (read by GetRowLastModifiedOn during the write loop) must - // survive a per-record re-fetch, so conflict detection keeps each record's real source timestamp. - Initialize(); - - Watermark := CurrentDateTime(); - Sleep(100); - CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); - CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); - CreateTestTableAMapping(IntegrationTableMapping); - IntegrationTableMapping."Synch. Modified On Filter" := Watermark; - IntegrationTableMapping.Modify(); - - LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); - InProcessTransport.Activate(); - - LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); - Assert.IsTrue(LibraryMasterDataMgt.TryGetSourceWatermark(SecondRecord.SystemId, CachedModifiedAt), 'Precondition: second record source watermark should be cached'); - - // [WHEN] the write loop re-fetches the first record by SystemId - LibraryMasterDataMgt.DataSourceGetBySystemId(Database::"MDM Test Table A", FirstRecord.SystemId, SourceRecordRef); - - // [THEN] the other record's source watermark is still cached for conflict detection - Assert.IsTrue(LibraryMasterDataMgt.TryGetSourceWatermark(SecondRecord.SystemId, CachedModifiedAt), 'A per-record re-fetch must not drop the batch''s other source watermarks'); - - CleanUp(); - end; - - [Test] - procedure CrossEnvInlineMediaSurvivesRelatedTableRead() - var - FirstRecord, SecondRecord : Record "MDM Test Table A"; - IntegrationTableMapping: Record "Integration Table Mapping"; + LocalRecord: Record "MDM Test Table A"; + MasterDataMgtCoupling: Record "Master Data Mgt. Coupling"; LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; - InProcessTransport: Codeunit "MDM In-Process Transport"; - SourceRecordRef: RecordRef; - Watermark: DateTime; + IntegrationSystemId: Guid; begin // [FEATURE] [Master Data Management] [Cross-Environment] - // [SCENARIO] Resolving a related table (contact business relations) during the write loop must NOT drop the - // batch's cached pictures. Regression guard for the contact-side variant of the "only one picture" bug. + // [SCENARIO] Re-creating an existing coupling (as when a concurrent customer/vendor run already coupled the + // auto-created company contact) is a no-op instead of failing the record on a duplicate key. Initialize(); - Watermark := CurrentDateTime(); - Sleep(100); - CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); - CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); - CreateTestTableAMapping(IntegrationTableMapping); - IntegrationTableMapping."Synch. Modified On Filter" := Watermark; - IntegrationTableMapping.Modify(); - - LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); - InProcessTransport.Activate(); - - LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); - Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'Precondition: first record picture should be cached'); + // [GIVEN] a local record already coupled to a source record + Clear(LocalRecord); + LocalRecord."Primary Key" := CopyStr('K' + Format(LibraryRandomInt()), 1, MaxStrLen(LocalRecord."Primary Key")); + LocalRecord.Insert(); + IntegrationSystemId := CreateGuid(); + LibraryMasterDataMgt.InsertCoupling(IntegrationSystemId, LocalRecord.SystemId, Database::"MDM Test Table A"); - // [WHEN] a related-table read runs during the write loop (contact business relation resolution) - LibraryMasterDataMgt.DataSourceGetRecordsByFilter(Database::"Contact Business Relation", '', SourceRecordRef); + // [WHEN] the same coupling is created again (concurrent/re-entrant coupling) + LibraryMasterDataMgt.InsertCoupling(IntegrationSystemId, LocalRecord.SystemId, Database::"MDM Test Table A"); - // [THEN] the batch's cached pictures are still there for the transfer-time apply - Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'A related-table read must not drop the batch''s cached pictures'); - Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'A related-table read must not drop the batch''s cached pictures'); + // [THEN] no error, and exactly one coupling row exists + MasterDataMgtCoupling.SetRange("Integration System ID", IntegrationSystemId); + MasterDataMgtCoupling.SetRange("Local System ID", LocalRecord.SystemId); + Assert.AreEqual(1, MasterDataMgtCoupling.Count(), 'A duplicate coupling insert must be a no-op, not a second row or a duplicate-key error'); + MasterDataMgtCoupling.DeleteAll(); // couplings are outside DeleteTestArtifacts' cleanup CleanUp(); end; From 79a7d004ca342e7e7ae5aba54180e47270297498 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Mon, 21 Sep 2026 21:07:01 +0200 Subject: [PATCH 3/5] [Master Data Management] Fixing the synchronization of blanked date/datetime and re-adding tests that were mistakenly dropped during PR creation --- .../MasterDataMgtSubscribers.Codeunit.al | 30 +++++ .../test library/src/MDMTestTableA.Table.al | 8 ++ .../src/MDMCrossEnvConsumerTests.Codeunit.al | 120 +++++++++++++++++- .../src/MasterDataMgtSynchTests.Codeunit.al | 63 +++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataMgtSubscribers.Codeunit.al b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataMgtSubscribers.Codeunit.al index 30fddaed90b..1d25ed4df28 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataMgtSubscribers.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataMgtSubscribers.Codeunit.al @@ -269,6 +269,15 @@ codeunit 7237 "Master Data Mgt. Subscribers" end; end; + // The BaseApp sync engine does not transfer a blanked (0D/0DT) Date/DateTime, so clearing it on the source + // leaves the subsidiary value stale; force the blank through so the clear is mirrored (bug 648540). + if SourceFieldRef.Type in [SourceFieldRef.Type::Date, SourceFieldRef.Type::DateTime] then + if IsBlankDateTime(SourceFieldRef) and not IsBlankDateTime(DestinationFieldRef) then begin + NewValue := SourceFieldRef.Value(); + IsValueFound := true; + NeedsConversion := false; + end; + if SourceFieldRef.Value() <> DestinationFieldRef.Value() then begin DestinationRecordRef := DestinationFieldRef.Record(); DestinationRecCreatedAt := DestinationRecordRef.Field(DestinationRecordRef.SystemCreatedAtNo()).Value(); @@ -311,6 +320,27 @@ codeunit 7237 "Master Data Mgt. Subscribers" end; end; + // A blanked Date/DateTime reads as 0D/0DT; the BaseApp transfer treats that as "no value" and skips it. + local procedure IsBlankDateTime(FieldReference: FieldRef): Boolean + var + DateValue: Date; + DateTimeValue: DateTime; + begin + case FieldReference.Type of + FieldReference.Type::Date: + begin + DateValue := FieldReference.Value(); + exit(DateValue = 0D); + end; + FieldReference.Type::DateTime: + begin + DateTimeValue := FieldReference.Value(); + exit(DateTimeValue = 0DT); + end; + end; + exit(false); + end; + local procedure UpdateMedia(var SourceFieldRef: FieldRef; var DestinationFieldRef: FieldRef; var NewValue: Variant): Boolean var SourceTenantMedia, DestinationTenantMedia : Record "Tenant Media"; diff --git a/src/Apps/W1/MasterDataManagement/test library/src/MDMTestTableA.Table.al b/src/Apps/W1/MasterDataManagement/test library/src/MDMTestTableA.Table.al index e24c5b35773..86cddcc4ebe 100644 --- a/src/Apps/W1/MasterDataManagement/test library/src/MDMTestTableA.Table.al +++ b/src/Apps/W1/MasterDataManagement/test library/src/MDMTestTableA.Table.al @@ -29,6 +29,14 @@ table 139757 "MDM Test Table A" { Caption = 'Test Image'; } + field(6; "Test Date"; Date) + { + Caption = 'Test Date'; + } + field(7; "Test DateTime"; DateTime) + { + Caption = 'Test DateTime'; + } } keys diff --git a/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al b/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al index c881e6c40c3..10cf3da255e 100644 --- a/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test/src/MDMCrossEnvConsumerTests.Codeunit.al @@ -818,7 +818,7 @@ codeunit 139932 "MDM Cross-Env Consumer Tests" LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; IntegrationSystemId: Guid; begin - // [FEATURE] [Master Data Management] [Cross-Environment] + // [FEATURE] [AI test 0.4] // [SCENARIO] Re-creating an existing coupling (as when a concurrent customer/vendor run already coupled the // auto-created company contact) is a no-op instead of failing the record on a duplicate key. Initialize(); @@ -842,6 +842,124 @@ codeunit 139932 "MDM Cross-Env Consumer Tests" CleanUp(); end; + [Test] + procedure CrossEnvInlineMediaSurvivesAncillaryReFetch() + var + FirstRecord, SecondRecord : Record "MDM Test Table A"; + IntegrationTableMapping: Record "Integration Table Mapping"; + LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; + InProcessTransport: Codeunit "MDM In-Process Transport"; + SourceRecordRef: RecordRef; + Watermark: DateTime; + begin + // [FEATURE] [AI test 0.4] + // [SCENARIO] A batch caches every record's picture; a per-record re-fetch (GetBySystemId, as the write loop + // does for coupling/conflict checks) must NOT drop the OTHER records' cached pictures. Regression guard for + // the bug where only the first record kept its image. + Initialize(); + + // [GIVEN] two source records with pictures, materialized as one batch (both pictures cached) + Watermark := CurrentDateTime(); + Sleep(100); + CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); + CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); + CreateTestTableAMapping(IntegrationTableMapping); + IntegrationTableMapping."Synch. Modified On Filter" := Watermark; + IntegrationTableMapping.Modify(); + + LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); + InProcessTransport.Activate(); + + LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'Precondition: first record picture should be cached'); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'Precondition: second record picture should be cached'); + + // [WHEN] the write loop re-fetches one record by SystemId (coupling/conflict lookup) + LibraryMasterDataMgt.DataSourceGetBySystemId(Database::"MDM Test Table A", FirstRecord.SystemId, SourceRecordRef); + + // [THEN] the other record's cached picture is still there for the transfer-time apply + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'A per-record re-fetch must not drop the batch''s other cached pictures'); + + CleanUp(); + end; + + [Test] + procedure CrossEnvSourceWatermarkSurvivesAncillaryReFetch() + var + FirstRecord, SecondRecord : Record "MDM Test Table A"; + IntegrationTableMapping: Record "Integration Table Mapping"; + LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; + InProcessTransport: Codeunit "MDM In-Process Transport"; + SourceRecordRef: RecordRef; + Watermark, CachedModifiedAt : DateTime; + begin + // [FEATURE] [AI test 0.4] + // [SCENARIO] The per-batch source watermark cache (read by GetRowLastModifiedOn during the write loop) must + // survive a per-record re-fetch, so conflict detection keeps each record's real source timestamp. + Initialize(); + + Watermark := CurrentDateTime(); + Sleep(100); + CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); + CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); + CreateTestTableAMapping(IntegrationTableMapping); + IntegrationTableMapping."Synch. Modified On Filter" := Watermark; + IntegrationTableMapping.Modify(); + + LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); + InProcessTransport.Activate(); + + LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); + Assert.IsTrue(LibraryMasterDataMgt.TryGetSourceWatermark(SecondRecord.SystemId, CachedModifiedAt), 'Precondition: second record source watermark should be cached'); + + // [WHEN] the write loop re-fetches the first record by SystemId + LibraryMasterDataMgt.DataSourceGetBySystemId(Database::"MDM Test Table A", FirstRecord.SystemId, SourceRecordRef); + + // [THEN] the other record's source watermark is still cached for conflict detection + Assert.IsTrue(LibraryMasterDataMgt.TryGetSourceWatermark(SecondRecord.SystemId, CachedModifiedAt), 'A per-record re-fetch must not drop the batch''s other source watermarks'); + + CleanUp(); + end; + + [Test] + procedure CrossEnvInlineMediaSurvivesRelatedTableRead() + var + FirstRecord, SecondRecord : Record "MDM Test Table A"; + IntegrationTableMapping: Record "Integration Table Mapping"; + LibraryMasterDataMgt: Codeunit "Library - Master Data Mgt."; + InProcessTransport: Codeunit "MDM In-Process Transport"; + SourceRecordRef: RecordRef; + Watermark: DateTime; + begin + // [FEATURE] [AI test 0.4] + // [SCENARIO] Resolving a related table (contact business relations) during the write loop must NOT drop the + // batch's cached pictures. Regression guard for the contact-side variant of the "only one picture" bug. + Initialize(); + + Watermark := CurrentDateTime(); + Sleep(100); + CreateTestTableAWithImage(FirstRecord, 'first picture bytes'); + CreateTestTableAWithImage(SecondRecord, 'second picture bytes'); + CreateTestTableAMapping(IntegrationTableMapping); + IntegrationTableMapping."Synch. Modified On Filter" := Watermark; + IntegrationTableMapping.Modify(); + + LibraryMasterDataMgt.SetSourceEnvironmentName('PROD'); + InProcessTransport.Activate(); + + LibraryMasterDataMgt.DataSourceGetModifiedSet(IntegrationTableMapping, '', SourceRecordRef); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'Precondition: first record picture should be cached'); + + // [WHEN] a related-table read runs during the write loop (contact business relation resolution) + LibraryMasterDataMgt.DataSourceGetRecordsByFilter(Database::"Contact Business Relation", '', SourceRecordRef); + + // [THEN] the batch's cached pictures are still there for the transfer-time apply + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(FirstRecord.SystemId, FirstRecord.FieldNo("Test Image")), 'A related-table read must not drop the batch''s cached pictures'); + Assert.IsTrue(LibraryMasterDataMgt.InlineMediaCacheContains(SecondRecord.SystemId, SecondRecord.FieldNo("Test Image")), 'A related-table read must not drop the batch''s cached pictures'); + + CleanUp(); + end; + local procedure CreateTestTableAMapping(var IntegrationTableMapping: Record "Integration Table Mapping") var IntegrationFieldMapping: Record "Integration Field Mapping"; diff --git a/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al b/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al index 330c50664f9..309e8643640 100644 --- a/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al @@ -172,6 +172,69 @@ codeunit 139758 "Master Data Mgt. Synch. Tests" Assert.AreEqual(false, NeedsConversion, ''); end; + [Test] + [HandlerFunctions('SynchronizationEnabledMessageHandler')] + procedure SynchronizingBlankedDateAndDateTimeClearsDestination() + var + SourceRecord: Record "MDM Test Table A"; + DestinationRecord: Record "MDM Test Table A"; + MasterDataMgtCoupling: Record "Master Data Mgt. Coupling"; + SourceRecordRef: RecordRef; + DestinationRecordRef: RecordRef; + SourceFieldRef: FieldRef; + DestinationFieldRef: FieldRef; + NeedsConversion: Boolean; + IsValueFound: Boolean; + NewValue: Variant; + TransferredDate: Date; + TransferredDateTime: DateTime; + begin + // [FEATURE] [AI test 0.4] + Initialize(); + + // [GIVEN] a coupled pair whose source Date/DateTime are blank and destination Date/DateTime are set + SourceRecord."Primary Key" := CopyStr(LibraryRandom.RandText(20), 1, MaxStrLen(SourceRecord."Primary Key")); + SourceRecord.Insert(); // Test Date / Test DateTime left blank (0D / 0DT) + DestinationRecord."Primary Key" := CopyStr(LibraryRandom.RandText(20), 1, MaxStrLen(DestinationRecord."Primary Key")); + DestinationRecord."Test Date" := Today(); + DestinationRecord."Test DateTime" := CurrentDateTime(); + DestinationRecord.Insert(); + MasterDataMgtCoupling."Integration System ID" := SourceRecord.SystemId; + MasterDataMgtCoupling."Local System ID" := DestinationRecord.SystemId; + MasterDataMgtCoupling."Table ID" := Database::"MDM Test Table A"; + MasterDataMgtCoupling."Last Synch. Modified On" := DestinationRecord.SystemModifiedAt; + MasterDataMgtCoupling.Insert(); + + SourceRecordRef.Open(Database::"MDM Test Table A"); + SourceRecordRef.GetTable(SourceRecord); + DestinationRecordRef.Open(Database::"MDM Test Table A"); + DestinationRecordRef.GetTable(DestinationRecord); + + // [WHEN] synch transfers the blanked Date [THEN] the blank is transferred (bug 648540: BaseApp skips it) + SourceFieldRef := SourceRecordRef.Field(SourceRecord.FieldNo("Test Date")); + DestinationFieldRef := DestinationRecordRef.Field(DestinationRecord.FieldNo("Test Date")); + LibraryMasterDataMgt.HandleOnTransferFieldData(SourceFieldRef, DestinationFieldRef, NewValue, IsValueFound, NeedsConversion); + Assert.AreEqual(true, IsValueFound, 'The blanked Date must be transferred, not skipped'); + Assert.AreEqual(false, NeedsConversion, ''); + TransferredDate := NewValue; + Assert.AreEqual(0D, TransferredDate, 'The destination Date must be cleared to match the source'); + + // [WHEN] synch transfers the blanked DateTime [THEN] the blank is transferred + Clear(NewValue); + IsValueFound := false; + SourceFieldRef := SourceRecordRef.Field(SourceRecord.FieldNo("Test DateTime")); + DestinationFieldRef := DestinationRecordRef.Field(DestinationRecord.FieldNo("Test DateTime")); + LibraryMasterDataMgt.HandleOnTransferFieldData(SourceFieldRef, DestinationFieldRef, NewValue, IsValueFound, NeedsConversion); + Assert.AreEqual(true, IsValueFound, 'The blanked DateTime must be transferred, not skipped'); + Assert.AreEqual(false, NeedsConversion, ''); + TransferredDateTime := NewValue; + Assert.AreEqual(0DT, TransferredDateTime, 'The destination DateTime must be cleared to match the source'); + + MasterDataMgtCoupling.Delete(); + SourceRecord.Delete(); + DestinationRecord.Delete(); + end; + [Test] [HandlerFunctions('SynchronizationEnabledMessageHandler')] procedure SynchronizingPrimaryKeyChange() From 23e85bedbb0c3bea07d3203b807ca43aa6971f3f Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Mon, 21 Sep 2026 21:32:46 +0200 Subject: [PATCH 4/5] [Master Data Management] Fixing the user-friendliness issue of Integration synchronization errors page --- .../MasterDataManagement.Codeunit.al | 13 ++++++++- .../src/LibraryMasterDataMgt.Codeunit.al | 9 +++++++ .../src/MasterDataMgtSynchTests.Codeunit.al | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataManagement.Codeunit.al b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataManagement.Codeunit.al index 895c0671732..4cd5558e726 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataManagement.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/app/src/codeunits/MasterDataManagement.Codeunit.al @@ -74,6 +74,7 @@ codeunit 7233 "Master Data Management" EmptyIntegrationRecordSystemIdErr: Label 'Empty integration record system id.', Locked = true; DeletionConflictHandledRemoveCouplingTxt: Label 'Deletion conflict handled by removing the coupling to the deleted record.', Locked = true; DeletionConflictHandledRestoreRecordTxt: Label 'Deletion conflict handled by restoring the deleted record.', Locked = true; + DeletionConflictSourceRecordErr: Label 'The source record %1 cannot be synchronized because the record it is coupled to has been deleted. Restore the deleted record, or choose a Deletion-Conflict Resolution for this table on the Synchronization Tables page.', Comment = '%1 = the source record identifier'; ResetAllCustomIntegrationTableMappingsLbl: Label 'One or more of the selected integration table mappings is custom. \\To restore a custom table mapping, you must subscribe to the event OnBeforeResetTableMapping in codeunit "Master Data Mgt. Setup Default" and implement the defaults for each custom table mapping. \\Do you want to continue?'; DeletedRecordWithZeroTableIdTxt: Label 'CRM Integration Record with zero Table ID has been deleted. Integration ID: %1, CRM ID: %2', Locked = true; AllRecordsMarkedAsSkippedTxt: Label 'All of selected %1 records are marked as skipped.', Comment = '%1 = table caption'; @@ -1181,7 +1182,12 @@ codeunit 7233 "Master Data Management" end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Integration Rec. Synch. Invoke", 'OnDeletionConflictDetected', '', false, false)] - local procedure HandleOnDeletionConflictDetected(var IntegrationTableMapping: Record "Integration Table Mapping"; var SourceRecordRef: RecordRef; var DeletionConflictHandled: Boolean) + local procedure OnDeletionConflictDetected(var IntegrationTableMapping: Record "Integration Table Mapping"; var SourceRecordRef: RecordRef; var DeletionConflictHandled: Boolean) + begin + HandleOnDeletionConflictDetected(IntegrationTableMapping, SourceRecordRef, DeletionConflictHandled); + end; + + internal procedure HandleOnDeletionConflictDetected(var IntegrationTableMapping: Record "Integration Table Mapping"; var SourceRecordRef: RecordRef; var DeletionConflictHandled: Boolean) var IntegrationSystemId: Guid; begin @@ -1215,6 +1221,11 @@ codeunit 7233 "Master Data Management" Session.LogMessage('0000J89', DeletionConflictHandledRestoreRecordTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryTok); end; end; + + // The platform's default deletion-conflict error names only the table; when MDM hasn't resolved the conflict + // (e.g. Deletion-Conflict Resolution = None), fail with a message that identifies the specific source record. + if (not DeletionConflictHandled) and (IntegrationTableMapping.Type = IntegrationTableMapping.Type::"Master Data Management") then + Error(DeletionConflictSourceRecordErr, Format(SourceRecordRef.RecordId(), 0, 1)); end; [EventSubscriber(ObjectType::Page, Page::"My Notifications", 'OnInitializingNotificationWithDefaultState', '', false, false)] diff --git a/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al b/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al index ca6ffb9742e..d296fd9663b 100644 --- a/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test library/src/LibraryMasterDataMgt.Codeunit.al @@ -16,6 +16,15 @@ codeunit 139757 "Library - Master Data Mgt." MasterDataMgtSubscribers.HandleOnTransferFieldData(SourceFieldRef, DestinationFieldRef, NewValue, IsValueFound, NeedsConversion); end; + /// Invokes the deletion-conflict subscriber logic that resolves or fails a coupled-to-deleted-record conflict. + /// The integration table mapping being synchronized. + /// The source record whose coupled record was deleted. + /// Returns whether the conflict was resolved. + procedure HandleOnDeletionConflictDetected(var IntegrationTableMapping: Record "Integration Table Mapping"; var SourceRecordRef: RecordRef; var DeletionConflictHandled: Boolean) + begin + MasterDataManagement.HandleOnDeletionConflictDetected(IntegrationTableMapping, SourceRecordRef, DeletionConflictHandled); + end; + /// Renames the destination record before modification when the source primary key has changed. /// The integration table mapping being synchronized. /// The source record providing the new primary key. diff --git a/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al b/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al index 309e8643640..73c5c72b6d5 100644 --- a/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al +++ b/src/Apps/W1/MasterDataManagement/test/src/MasterDataMgtSynchTests.Codeunit.al @@ -235,6 +235,33 @@ codeunit 139758 "Master Data Mgt. Synch. Tests" DestinationRecord.Delete(); end; + [Test] + [HandlerFunctions('SynchronizationEnabledMessageHandler')] + procedure DeletionConflictErrorIdentifiesSourceRecord() + var + IntegrationTableMapping: Record "Integration Table Mapping"; + SourceCustomer: Record Customer; + SourceRecordRef: RecordRef; + DeletionConflictHandled: Boolean; + begin + // [FEATURE] [AI test 0.4] + // [SCENARIO] An unresolved deletion conflict fails with an error that names the specific source record, not just its table (bug 647736). + Initialize(); + + // [GIVEN] a mapping whose Deletion-Conflict Resolution is None (the default); Initialize() has enabled MDM + IntegrationTableMapping.Init(); + IntegrationTableMapping.Type := IntegrationTableMapping.Type::"Master Data Management"; + IntegrationTableMapping."Table ID" := Database::Customer; + IntegrationTableMapping."Integration Table ID" := Database::Customer; + LibrarySales.CreateCustomer(SourceCustomer); + SourceRecordRef.GetTable(SourceCustomer); + + // [WHEN] the deletion-conflict subscriber runs and cannot resolve the conflict + // [THEN] the error identifies the specific source record (its number), not just the table + asserterror LibraryMasterDataMgt.HandleOnDeletionConflictDetected(IntegrationTableMapping, SourceRecordRef, DeletionConflictHandled); + Assert.ExpectedError(SourceCustomer."No."); + end; + [Test] [HandlerFunctions('SynchronizationEnabledMessageHandler')] procedure SynchronizingPrimaryKeyChange() From bb1d8c24af817e668eb18036279e2faa4db548d9 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Mon, 21 Sep 2026 21:54:39 +0200 Subject: [PATCH 5/5] [Master Data Management] Fixing indirect permissions issue --- .../app/src/pages/MDMConnectionDetails.Page.al | 2 +- .../app/src/pages/MasterDataFullSynchReview.Page.al | 2 +- .../app/src/pages/MasterDataManagementSetup.Page.al | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Apps/W1/MasterDataManagement/app/src/pages/MDMConnectionDetails.Page.al b/src/Apps/W1/MasterDataManagement/app/src/pages/MDMConnectionDetails.Page.al index af669575d96..0f61f66f384 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/pages/MDMConnectionDetails.Page.al +++ b/src/Apps/W1/MasterDataManagement/app/src/pages/MDMConnectionDetails.Page.al @@ -12,7 +12,7 @@ page 7232 "MDM Connection Details" PageType = NavigatePage; ApplicationArea = Suite; UsageCategory = None; - Permissions = tabledata "Master Data Management Setup" = imd; + Permissions = tabledata "Master Data Management Setup" = rimd; layout { diff --git a/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataFullSynchReview.Page.al b/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataFullSynchReview.Page.al index 9044b56af9e..e844a63b070 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataFullSynchReview.Page.al +++ b/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataFullSynchReview.Page.al @@ -11,7 +11,7 @@ page 7234 "Master Data Full Synch. Review" InsertAllowed = false; ModifyAllowed = false; DeleteAllowed = true; - Permissions = tabledata "Master Data Full Synch. R. Ln." = imd; + Permissions = tabledata "Master Data Full Synch. R. Ln." = rimd; layout { diff --git a/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al b/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al index 0b87ab19a45..4c8875a97ec 100644 --- a/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al +++ b/src/Apps/W1/MasterDataManagement/app/src/pages/MasterDataManagementSetup.Page.al @@ -17,7 +17,7 @@ page 7230 "Master Data Management Setup" SourceTable = "Master Data Management Setup"; UsageCategory = Administration; AdditionalSearchTerms = 'mdm,master data'; - Permissions = tabledata "Master Data Management Setup" = imd; + Permissions = tabledata "Master Data Management Setup" = rimd; layout {