diff --git a/src/Apps/IT/SubcontractingMigrationIT/App/src/ITSubcMigration.Codeunit.al b/src/Apps/IT/SubcontractingMigrationIT/App/src/ITSubcMigration.Codeunit.al index 4f695c8ecfd..99521e3cfe3 100644 --- a/src/Apps/IT/SubcontractingMigrationIT/App/src/ITSubcMigration.Codeunit.al +++ b/src/Apps/IT/SubcontractingMigrationIT/App/src/ITSubcMigration.Codeunit.al @@ -1,6 +1,7 @@ #if not CLEAN28 namespace Microsoft.Manufacturing.Subcontracting.Migration; +using Microsoft.Inventory.Location; using Microsoft.Inventory.Transfer; using Microsoft.Manufacturing.Document; using Microsoft.Manufacturing.Routing; @@ -41,6 +42,8 @@ codeunit 149951 "IT Subc. Migration" #if not CLEAN28 LegacySubcFeatureHandler.CheckCanDisableLegacySubcontracting(); #endif + // Validate before confirmation for prompt feedback, then repeat under locks to prevent concurrent changes. + CheckSubcontractingLocations(); UIAllowed := ShowDialog and GuiAllowed(); if UIAllowed then begin ConfirmDisableLegacySubcontracting(); @@ -49,6 +52,8 @@ codeunit 149951 "IT Subc. Migration" LockTables(); Clear(PreMigrationCounts); + // This authoritative validation covers changes made by other sessions while confirmation was pending. + CheckSubcontractingLocations(); RunMigration(); if UIAllowed then @@ -441,6 +446,7 @@ codeunit 149951 "IT Subc. Migration" RoutingLine: Record "Routing Line"; Vendor: Record Vendor; PurchaseHeader: Record "Purchase Header"; + Location: Record Location; #pragma warning disable AL0432 LegacySubcontractorPrice: Record "Subcontractor Prices"; #pragma warning restore AL0432 @@ -455,6 +461,7 @@ codeunit 149951 "IT Subc. Migration" RoutingLine.LockTable(); Vendor.LockTable(); PurchaseHeader.LockTable(); + Location.LockTable(); LegacySubcontractorPrice.LockTable(); SubcontractorPrice.LockTable(); ManufacturingSetup.LockTable(); @@ -573,6 +580,91 @@ codeunit 149951 "IT Subc. Migration" PurchaseHeader.SetFilter("Subcontracting Location Code", '<>%1', ''); end; + [ErrorBehavior(ErrorBehavior::Collect)] + internal procedure CheckSubcontractingLocations() + var + Vendor: Record Vendor; + PurchaseHeader: Record "Purchase Header"; + Location: Record Location; + LegacySubcontractingLocations: Dictionary of [Code[10], Boolean]; + LocationCode: Code[10]; + UnsupportedWarehouseSettings: Text; + CollectedErrors: List of [ErrorInfo]; + CollectedError: ErrorInfo; + BlockingError: ErrorInfo; + BlockingErrorTextBuilder: TextBuilder; + begin + SetVendorMigrationFilters(Vendor); + Vendor.SetLoadFields("Subcontracting Location Code"); + if Vendor.FindSet() then + repeat + AddLegacySubcontractingLocation(LegacySubcontractingLocations, Vendor."Subcontracting Location Code"); + until Vendor.Next() = 0; + + SetPurchaseHeaderMigrationFilters(PurchaseHeader); + PurchaseHeader.SetLoadFields("Subcontracting Location Code"); + if PurchaseHeader.FindSet() then + repeat + AddLegacySubcontractingLocation(LegacySubcontractingLocations, PurchaseHeader."Subcontracting Location Code"); + until PurchaseHeader.Next() = 0; + + Location.SetLoadFields( + "Bin Mandatory", + "Require Pick", + "Require Put-away", + "Require Receive", + "Require Shipment", + "Use As In-Transit"); + foreach LocationCode in LegacySubcontractingLocations.Keys() do + if not Location.Get(LocationCode) then + Error(ErrorInfo.Create(StrSubstNo(MissingSubcontractingLocationErr, LocationCode), true)) + else begin + UnsupportedWarehouseSettings := GetUnsupportedWarehouseSettings(Location); + if UnsupportedWarehouseSettings <> '' then + Error(ErrorInfo.Create(StrSubstNo(UnsupportedSubcontractingLocationErr, Location.Code, UnsupportedWarehouseSettings), true)); + end; + + if HasCollectedErrors() then begin + CollectedErrors := GetCollectedErrors(true); + foreach CollectedError in CollectedErrors do + BlockingErrorTextBuilder.AppendLine(CollectedError.Message()); + BlockingError.Message := StrSubstNo(SubcontractingLocationsBlockedErr, BlockingErrorTextBuilder.ToText()); + BlockingError.DataClassification := DataClassification::CustomerContent; + BlockingError.ErrorType := ErrorType::Client; + BlockingError.Collectible := false; + Error(BlockingError); + end; + end; + + local procedure AddLegacySubcontractingLocation(var LegacySubcontractingLocations: Dictionary of [Code[10], Boolean]; LocationCode: Code[10]) + begin + if not LegacySubcontractingLocations.ContainsKey(LocationCode) then + LegacySubcontractingLocations.Add(LocationCode, true); + end; + + local procedure GetUnsupportedWarehouseSettings(Location: Record Location): Text + var + UnsupportedWarehouseSettings: Text; + begin + AddUnsupportedWarehouseSetting(UnsupportedWarehouseSettings, Location."Bin Mandatory", Location.FieldCaption("Bin Mandatory")); + AddUnsupportedWarehouseSetting(UnsupportedWarehouseSettings, Location."Require Pick", Location.FieldCaption("Require Pick")); + AddUnsupportedWarehouseSetting(UnsupportedWarehouseSettings, Location."Require Put-away", Location.FieldCaption("Require Put-away")); + AddUnsupportedWarehouseSetting(UnsupportedWarehouseSettings, Location."Require Receive", Location.FieldCaption("Require Receive")); + AddUnsupportedWarehouseSetting(UnsupportedWarehouseSettings, Location."Require Shipment", Location.FieldCaption("Require Shipment")); + AddUnsupportedWarehouseSetting(UnsupportedWarehouseSettings, Location."Use As In-Transit", Location.FieldCaption("Use As In-Transit")); + exit(UnsupportedWarehouseSettings); + end; + + local procedure AddUnsupportedWarehouseSetting(var UnsupportedWarehouseSettings: Text; IsEnabled: Boolean; WarehouseSettingCaption: Text) + begin + if not IsEnabled then + exit; + + if UnsupportedWarehouseSettings <> '' then + UnsupportedWarehouseSettings += ', '; + UnsupportedWarehouseSettings += WarehouseSettingCaption; + end; + local procedure VerifyMigration() var TransferLine: Record "Transfer Line"; @@ -658,6 +750,9 @@ codeunit 149951 "IT Subc. Migration" VerifyingPhaseLbl: Label 'Verifying migration...'; VerifyingProgressEntityLbl: Label 'Verification step'; MigrationVerificationFailedErr: Label 'Migration verification failed for %1: expected %2 record(s) but found %3 after migration.', Comment = '%1 = entity name, %2 = pre-migration count, %3 = post-migration count'; + SubcontractingLocationsBlockedErr: Label 'Migration can''t start because one or more subcontracting locations are invalid. Resolve the following issues and run the precheck again:\%1', Comment = '%1 = detailed location validation errors'; + UnsupportedSubcontractingLocationErr: Label 'Migration can''t start because subcontracting location %1 uses unsupported warehouse settings: %2. Update the location or subcontracting setup, and then run the precheck again.', Comment = '%1 = location code, %2 = unsupported warehouse settings'; + MissingSubcontractingLocationErr: Label 'Migration can''t start because legacy subcontracting data references location %1, but that location doesn''t exist. Update the legacy vendor or purchase document, and then run the precheck again.', Comment = '%1 = location code'; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Legacy Subc. Feature Handler", 'OnMigrationSubcontractingData', '', false, false)] local procedure MigrateSubconOnMigrationSubcontractingData() diff --git a/src/Apps/IT/SubcontractingMigrationIT/Test/src/ITSubcMigrationTests.Codeunit.al b/src/Apps/IT/SubcontractingMigrationIT/Test/src/ITSubcMigrationTests.Codeunit.al index 0eb2157044b..b92e041ee68 100644 --- a/src/Apps/IT/SubcontractingMigrationIT/Test/src/ITSubcMigrationTests.Codeunit.al +++ b/src/Apps/IT/SubcontractingMigrationIT/Test/src/ITSubcMigrationTests.Codeunit.al @@ -33,6 +33,9 @@ codeunit 149956 "IT Subc. Migration Tests" LibraryWarehouse: Codeunit "Library - Warehouse"; LibraryUtility: Codeunit "Library - Utility"; Initialized: Boolean; + SubcontractingLocationsBlockedErr: Label 'Migration can''t start because one or more subcontracting locations are invalid.'; + UnsupportedSubcontractingLocationErr: Label 'Migration can''t start because subcontracting location %1 uses unsupported warehouse settings: %2. Update the location or subcontracting setup, and then run the precheck again.', Comment = '%1 = location code, %2 = unsupported warehouse settings'; + MissingSubcontractingLocationErr: Label 'Migration can''t start because legacy subcontracting data references location %1, but that location doesn''t exist. Update the legacy vendor or purchase document, and then run the precheck again.', Comment = '%1 = location code'; [Test] [Scope('OnPrem')] @@ -828,6 +831,160 @@ codeunit 149956 "IT Subc. Migration Tests" 'Routing line "Transfer WIP Item" should be migrated.'); end; + [Test] + [Scope('OnPrem')] + procedure CheckSubcontractingLocations_ReportsEveryLocationAndSetting() + var + Vendor: Record Vendor; + PurchaseHeader: Record "Purchase Header"; + VendorLocation: Record Location; + PurchaseLocation: Record Location; + ITSubcMigration: Codeunit "IT Subc. Migration"; + BlockingError: Text; + begin + // [SCENARIO] The migration precheck reports every incompatible location and its unsupported warehouse settings + Initialize(); + + // [GIVEN] A vendor whose legacy subcontracting location requires bins and picks + LibraryWarehouse.CreateLocation(VendorLocation); + VendorLocation."Bin Mandatory" := true; + VendorLocation."Require Pick" := true; + VendorLocation.Modify(false); + LibraryPurchase.CreateVendor(Vendor); + Vendor."Subcontracting Location Code" := VendorLocation.Code; + Vendor.Modify(false); + + // [GIVEN] A purchase header with a different legacy location that requires warehouse handling + LibraryWarehouse.CreateLocation(PurchaseLocation); + PurchaseLocation."Require Put-away" := true; + PurchaseLocation."Require Receive" := true; + PurchaseLocation."Require Shipment" := true; + PurchaseLocation.Modify(false); + LibraryPurchase.CreatePurchHeader(PurchaseHeader, PurchaseHeader."Document Type"::Invoice, Vendor."No."); + PurchaseHeader."Subcontracting Location Code" := PurchaseLocation.Code; + PurchaseHeader.Modify(false); + + // [WHEN] The subcontracting location precheck runs + asserterror ITSubcMigration.CheckSubcontractingLocations(); + Assert.ExpectedError(SubcontractingLocationsBlockedErr); + + // [THEN] The blocking error reports every incompatible location and its unsupported settings + BlockingError := GetLastErrorText(); + Assert.IsTrue( + BlockingError.Contains( + StrSubstNo( + UnsupportedSubcontractingLocationErr, + VendorLocation.Code, + VendorLocation.FieldCaption("Bin Mandatory") + ', ' + VendorLocation.FieldCaption("Require Pick"))), + 'The precheck should report the vendor location and its unsupported settings.'); + Assert.IsTrue( + BlockingError.Contains( + StrSubstNo( + UnsupportedSubcontractingLocationErr, + PurchaseLocation.Code, + PurchaseLocation.FieldCaption("Require Put-away") + ', ' + + PurchaseLocation.FieldCaption("Require Receive") + ', ' + + PurchaseLocation.FieldCaption("Require Shipment"))), + 'The precheck should report the purchase location and its unsupported settings.'); + end; + + [Test] + [Scope('OnPrem')] + procedure CheckSubcontractingLocations_ReportsMissingAndUnsupportedLegacyLocations() + var + Vendor: Record Vendor; + PurchaseHeader: Record "Purchase Header"; + MissingLocation: Record Location; + UnsupportedLocation: Record Location; + ITSubcMigration: Codeunit "IT Subc. Migration"; + BlockingError: Text; + begin + // [SCENARIO] The migration precheck aggregates missing and unsupported legacy subcontracting locations + Initialize(); + + // [GIVEN] A vendor whose legacy subcontracting location references a deleted location + LibraryWarehouse.CreateLocation(MissingLocation); + LibraryPurchase.CreateVendor(Vendor); + Vendor."Subcontracting Location Code" := MissingLocation.Code; + Vendor.Modify(false); + MissingLocation.Delete(false); + + // [GIVEN] A purchase header whose legacy subcontracting location requires bins + LibraryWarehouse.CreateLocation(UnsupportedLocation); + UnsupportedLocation."Bin Mandatory" := true; + UnsupportedLocation.Modify(false); + LibraryPurchase.CreatePurchHeader(PurchaseHeader, PurchaseHeader."Document Type"::Invoice, Vendor."No."); + PurchaseHeader."Subcontracting Location Code" := UnsupportedLocation.Code; + PurchaseHeader.Modify(false); + Commit(); + + // [WHEN] The subcontracting location precheck runs + asserterror ITSubcMigration.CheckSubcontractingLocations(); + Assert.ExpectedError(SubcontractingLocationsBlockedErr); + + // [THEN] The blocking error reports both legacy location problems + BlockingError := GetLastErrorText(); + Assert.IsTrue( + BlockingError.Contains(StrSubstNo(MissingSubcontractingLocationErr, MissingLocation.Code)), + 'The precheck should report the missing legacy location.'); + Assert.IsTrue( + BlockingError.Contains( + StrSubstNo( + UnsupportedSubcontractingLocationErr, + UnsupportedLocation.Code, + UnsupportedLocation.FieldCaption("Bin Mandatory"))), + 'The precheck should continue and report the unsupported legacy location.'); + end; + + [Test] + [Scope('OnPrem')] + procedure StartDisableLegacySubcontracting_BlocksInTransitLocationBeforeMigration() + var + Vendor: Record Vendor; + Location: Record Location; + TransferLine: Record "Transfer Line"; + PurchaseLine: Record "Purchase Line"; + ITSubcMigration: Codeunit "IT Subc. Migration"; + begin + // [SCENARIO] Disabling legacy subcontracting stops before migration when a location is used as in-transit + Initialize(); + + // [GIVEN] No open WIP transfers or purchase orders + TransferLine.SetRange("WIP Item", true); + if not TransferLine.IsEmpty() then + TransferLine.DeleteAll(); + PurchaseLine.SetRange("Document Type", PurchaseLine."Document Type"::Order); +#pragma warning disable AL0432 + PurchaseLine.SetRange("WIP Item", true); +#pragma warning restore AL0432 + if not PurchaseLine.IsEmpty() then + PurchaseLine.DeleteAll(); + + // [GIVEN] A vendor with an unmigrated legacy subcontracting location used as in-transit + LibraryWarehouse.CreateLocation(Location); + Location."Use As In-Transit" := true; + Location.Modify(false); + LibraryPurchase.CreateVendor(Vendor); + Vendor."Subcontracting Location Code" := Location.Code; + Vendor."Subc. Location Code" := ''; + Vendor.Modify(false); + Commit(); + + // [WHEN] Legacy subcontracting is disabled + asserterror ITSubcMigration.StartDisableLegacySubcontracting(false); + + // [THEN] The precheck reports the incompatible location + Assert.ExpectedError( + StrSubstNo( + UnsupportedSubcontractingLocationErr, + Location.Code, + Location.FieldCaption("Use As In-Transit"))); + + // [THEN] Migration has not changed the vendor + Vendor.Get(Vendor."No."); + Assert.AreEqual('', Vendor."Subc. Location Code", 'The vendor must not be migrated when the precheck fails.'); + end; + [Test] [HandlerFunctions('ConfirmHandlerReturnFalse')] [Scope('OnPrem')]