From 0192e996b79d7ad69fb6258f4e0b7af45f4f2867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miljan=20Milosavljevi=C4=87?= Date: Wed, 12 Aug 2026 16:55:27 +0200 Subject: [PATCH] [Subscription Billing] Delete Sales Subscription Lines in the invoiced-order deletion reports Reports 299 "Delete Invoiced Sales Orders" and 291 "Delete Invd Blnkt Sales Orders" remove sales lines with Delete() and DeleteAll(), which default to RunTrigger = false, so the OnDelete() trigger of tableextension 8054 "Sales Line" never fires. The Sales Subscription Lines of the deleted document survive as orphans pointing at a document that no longer exists, and nothing in the UI can reach them. Add an event subscriber for each report to codeunit 8069, following the per-path pattern the app already uses for Sales-Post and Sales-Quote to Order. Report 299 hooks OnAfterDeleteSalesLinesLoop rather than OnBeforeDeleteSalesHeader, so the subscription lines are cleaned up on the branch where an unassigned Charge (Item) line keeps the order alive after its item lines have been deleted. Consolidate the four trigger-less deletion paths on a new SalesHeader.DeleteSalesServiceCommitments() in tableextension 8053, which batch-deletes the subscription lines of a whole document. It deliberately does not filter on document type: Copy Document carries Sales Subscription Lines over to invoices and credit memos, and those still have to be removed when the document is posted. Both deletion procedures are public rather than internal. This is intentional - customers already need to call the deletion logic from outside the app, so they are exposed as a supported entry point and documented with XML comments. Tests cover both reports and posting an invoice that carries subscription lines copied from an order. The two report tests use TransactionModel::AutoCommit because both reports commit unconditionally. Fixes #10175 Co-Authored-By: Claude Opus 5 (1M context) --- .../SalesSubscriptionLineMgmt.Codeunit.al | 27 +-- .../Table Extensions/SalesHeader.TableExt.al | 25 +++ .../Table Extensions/SalesLine.TableExt.al | 10 +- .../SalesServiceCommitmentTest.Codeunit.al | 172 ++++++++++++++++++ 4 files changed, 221 insertions(+), 13 deletions(-) diff --git a/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Codeunits/SalesSubscriptionLineMgmt.Codeunit.al b/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Codeunits/SalesSubscriptionLineMgmt.Codeunit.al index d1bb6d67b9b..2b2be944b26 100644 --- a/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Codeunits/SalesSubscriptionLineMgmt.Codeunit.al +++ b/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Codeunits/SalesSubscriptionLineMgmt.Codeunit.al @@ -303,14 +303,21 @@ codeunit 8069 "Sales Subscription Line Mgmt." end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", OnBeforeSalesLineDeleteAll, '', false, false)] - local procedure DeleteSalesServiceCommitmentOnBeforeSalesLineDeleteAll(var SalesLine: Record "Sales Line") - var - SalesServiceCommitment: Record "Sales Subscription Line"; + local procedure DeleteSalesServiceCommitmentOnBeforeSalesLineDeleteAll(var SalesLine: Record "Sales Line"; CommitIsSuppressed: Boolean; var SalesHeader: Record "Sales Header") begin - if not SalesLine.FindFirst() then - exit; - SalesServiceCommitment.FilterOnDocument(SalesLine."Document Type", SalesLine."Document No."); - SalesServiceCommitment.DeleteAll(false); + SalesHeader.DeleteSalesServiceCommitments(); + end; + + [EventSubscriber(ObjectType::Report, Report::"Delete Invoiced Sales Orders", OnAfterDeleteSalesLinesLoop, '', false, false)] + local procedure DeleteSalesServiceCommitmentOnAfterDeleteSalesLinesLoop(var SalesHeader: Record "Sales Header") + begin + SalesHeader.DeleteSalesServiceCommitments(); + end; + + [EventSubscriber(ObjectType::Report, Report::"Delete Invd Blnkt Sales Orders", OnBeforeDeleteSalesHeader, '', false, false)] + local procedure DeleteSalesServiceCommitmentOnBeforeDeleteBlanketSalesHeader(var SalesHeader: Record "Sales Header") + begin + SalesHeader.DeleteSalesServiceCommitments(); end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Quote to Order", OnAfterInsertSalesOrderLine, '', false, false)] @@ -321,12 +328,8 @@ codeunit 8069 "Sales Subscription Line Mgmt." [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Quote to Order", OnRunOnAfterSalesQuoteLineDeleteAll, '', false, false)] local procedure DeleteSalesServiceCommitmentOnAfterSalesQuoteLineDeleteAll(var SalesHeaderRec: Record "Sales Header") - var - SalesServiceCommitment: Record "Sales Subscription Line"; begin - SalesServiceCommitment.SetRange("Document Type", SalesHeaderRec."Document Type"); - SalesServiceCommitment.SetRange("Document No.", SalesHeaderRec."No."); - SalesServiceCommitment.DeleteAll(false); + SalesHeaderRec.DeleteSalesServiceCommitments(); end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Blanket Sales Order to Order", OnAfterInsertSalesOrderLine, '', false, false)] diff --git a/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesHeader.TableExt.al b/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesHeader.TableExt.al index 566a4f9b773..4ff4f7032c6 100644 --- a/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesHeader.TableExt.al +++ b/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesHeader.TableExt.al @@ -28,6 +28,31 @@ tableextension 8053 "Sales Header" extends "Sales Header" } } + /// + /// Deletes the Sales Subscription Lines of every line of this sales document in one batch. + /// Use this instead of the Sales Line procedure DeleteSalesServiceCommitment() when all lines of the + /// document are removed without running their triggers, for example when the document is posted, when + /// a quote is converted into an order, or by the "Delete Invoiced Sales Orders" and + /// "Delete Invoiced Blanket Sales Orders" batch jobs. + /// Unlike the Sales Line procedure this does not skip document types outside Quote, Order and Blanket + /// Order, because Copy Document carries Sales Subscription Lines over to an Invoice or Credit Memo as + /// well, and those have to be cleaned up when the document is posted. + /// Temporary records are skipped, so calling this on a temporary Sales Header never deletes the real + /// Sales Subscription Lines of a document that happens to share its number. + /// + procedure DeleteSalesServiceCommitments() + var + SalesServiceCommitment: Record "Sales Subscription Line"; + begin + if Rec.IsTemporary() then + exit; + SalesServiceCommitment.FilterOnDocument(Rec."Document Type", Rec."No."); + if SalesServiceCommitment.IsEmpty() then + exit; + + SalesServiceCommitment.DeleteAll(false); + end; + local procedure GetLastLineNo(): Integer var SalesLine: Record "Sales Line"; diff --git a/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesLine.TableExt.al b/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesLine.TableExt.al index c1047609e4d..6ad8c55a4ef 100644 --- a/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesLine.TableExt.al +++ b/src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Table Extensions/SalesLine.TableExt.al @@ -189,7 +189,15 @@ tableextension 8054 "Sales Line" extends "Sales Line" Rec."Sell-to Customer No." := SourceSalesHeader."Sell-to Customer No."; end; - internal procedure DeleteSalesServiceCommitment() + /// + /// Deletes the Sales Subscription Lines that belong to this Sales Line. + /// Call this whenever a Sales Line is removed without running its triggers (Delete(false), DeleteAll(false)), + /// because the OnDelete() trigger of this table extension does not fire in that case and the + /// Sales Subscription Lines would be left behind as orphaned records. + /// Temporary records and document types that cannot carry Sales Subscription Lines are skipped, + /// so the call is safe for any Sales Line. + /// + procedure DeleteSalesServiceCommitment() var SalesServiceCommitment: Record "Sales Subscription Line"; begin diff --git a/src/Apps/W1/Subscription Billing/Test/Service Commitments/SalesServiceCommitmentTest.Codeunit.al b/src/Apps/W1/Subscription Billing/Test/Service Commitments/SalesServiceCommitmentTest.Codeunit.al index d1dddf514d6..100932e50c0 100644 --- a/src/Apps/W1/Subscription Billing/Test/Service Commitments/SalesServiceCommitmentTest.Codeunit.al +++ b/src/Apps/W1/Subscription Billing/Test/Service Commitments/SalesServiceCommitmentTest.Codeunit.al @@ -67,6 +67,8 @@ codeunit 139915 "Sales Service Commitment Test" NotCreatedProperlyErr: Label 'Subscription Lines are not created properly.', Locked = true; SalesServiceCommitmentCannotBeDeletedErr: Label 'The Sales Subscription Line cannot be deleted, because it is the last line with Process Contract Renewal. Please delete the Sales line in order to delete the Sales Subscription Line.', Locked = true; NaturalNumberRatioErr: Label 'The ratio of ''%1'' and ''%2'' or vice versa must give a natural number.', Comment = '%1=Field Caption, %2=Field Caption', Locked = true; + SalesOrderNotDeletedErr: Label 'The Sales Order was not deleted by the Delete Invoiced Sales Orders batch job.', Locked = true; + BlanketSalesOrderNotDeletedErr: Label 'The Blanket Sales Order was not deleted by the Delete Invoiced Blanket Sales Orders batch job.', Locked = true; #region Tests @@ -2109,10 +2111,180 @@ codeunit 139915 "Sales Service Commitment Test" ServiceCommitment.TestField("Subscription Line End Date", 0D); end; + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure CheckDeleteSalesServiceCommitmentOnDeleteInvoicedSalesOrders() + var + FetchSalesHeader: Record "Sales Header"; + OtherSalesHeader: Record "Sales Header"; + OtherSalesLine: Record "Sales Line"; + SalesOrderNo: Code[20]; + begin + // [SCENARIO] Report "Delete Invoiced Sales Orders" deletes the Sales Subscription Lines of the removed Sales Order + Initialize(); + + // [GIVEN] A Sales Order with a Subscription Item that has Sales Subscription Lines + ContractTestLibrary.SetupSalesServiceCommitmentItemAndAssignToServiceCommitmentPackage(Item, Enum::"Item Service Commitment Type"::"Service Commitment Item", ServiceCommitmentPackage.Code); + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, ''); + LibrarySales.CreateSalesLineWithShipmentDate(SalesLine, SalesHeader, Enum::"Sales Line Type"::Item, Item."No.", WorkDate(), LibraryRandom.RandIntInRange(2, 10)); + SalesOrderNo := SalesHeader."No."; + SalesServiceCommitment.FilterOnSalesLine(SalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [GIVEN] A second, untouched Sales Order with Sales Subscription Lines + LibrarySales.CreateSalesHeader(OtherSalesHeader, OtherSalesHeader."Document Type"::Order, ''); + LibrarySales.CreateSalesLineWithShipmentDate(OtherSalesLine, OtherSalesHeader, Enum::"Sales Line Type"::Item, Item."No.", WorkDate(), LibraryRandom.RandIntInRange(2, 10)); + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnSalesLine(OtherSalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [GIVEN] The Sales Order is fully shipped and invoiced, but not removed by posting + LibrarySales.PostSalesDocument(SalesHeader, true, true); + FetchSalesHeader.Get(SalesHeader."Document Type"::Order, SalesOrderNo); + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnDocument(SalesHeader."Document Type"::Order, SalesOrderNo); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [WHEN] Running report "Delete Invoiced Sales Orders" for the Sales Order + RunDeleteInvoicedSalesOrders(SalesOrderNo); + + // [THEN] The Sales Order is deleted + Assert.IsFalse(FetchSalesHeader.Get(SalesHeader."Document Type"::Order, SalesOrderNo), SalesOrderNotDeletedErr); + + // [THEN] No Sales Subscription Line of the deleted Sales Order is left behind + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnDocument(SalesHeader."Document Type"::Order, SalesOrderNo); + Assert.RecordIsEmpty(SalesServiceCommitment); + + // [THEN] The Sales Subscription Lines of the second Sales Order are untouched + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnSalesLine(OtherSalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + end; + + [Test] + [TransactionModel(TransactionModel::AutoCommit)] + procedure CheckDeleteSalesServiceCommitmentOnDeleteInvdBlnktSalesOrders() + var + FetchSalesHeader: Record "Sales Header"; + OtherSalesHeader: Record "Sales Header"; + OtherSalesLine: Record "Sales Line"; + SalesOrder: Record "Sales Header"; + SecondSalesLine: Record "Sales Line"; + BlanketSalesOrderToOrder: Codeunit "Blanket Sales Order to Order"; + BlanketOrderNo: Code[20]; + begin + // [SCENARIO] Report "Delete Invd Blnkt Sales Orders" deletes the Sales Subscription Lines of the removed Blanket Sales Order + Initialize(); + + // [GIVEN] A Blanket Sales Order with two lines that both have Sales Subscription Lines + ContractTestLibrary.SetupSalesServiceCommitmentItemAndAssignToServiceCommitmentPackage(Item, Enum::"Item Service Commitment Type"::"Sales with Service Commitment", ServiceCommitmentPackage.Code); + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::"Blanket Order", ''); + LibrarySales.CreateSalesLineWithShipmentDate(SalesLine, SalesHeader, Enum::"Sales Line Type"::Item, Item."No.", WorkDate(), LibraryRandom.RandIntInRange(2, 10)); + LibrarySales.CreateSalesLineWithShipmentDate(SecondSalesLine, SalesHeader, Enum::"Sales Line Type"::Item, Item."No.", WorkDate(), LibraryRandom.RandIntInRange(2, 10)); + BlanketOrderNo := SalesHeader."No."; + SalesServiceCommitment.FilterOnSalesLine(SalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnSalesLine(SecondSalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [GIVEN] A second, untouched Blanket Sales Order with Sales Subscription Lines + LibrarySales.CreateSalesHeader(OtherSalesHeader, OtherSalesHeader."Document Type"::"Blanket Order", ''); + LibrarySales.CreateSalesLineWithShipmentDate(OtherSalesLine, OtherSalesHeader, Enum::"Sales Line Type"::Item, Item."No.", WorkDate(), LibraryRandom.RandIntInRange(2, 10)); + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnSalesLine(OtherSalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [GIVEN] The Blanket Sales Order is fully converted into a Sales Order, which is then fully shipped and invoiced + Clear(BlanketSalesOrderToOrder); + BlanketSalesOrderToOrder.SetHideValidationDialog(true); + BlanketSalesOrderToOrder.Run(SalesHeader); + BlanketSalesOrderToOrder.GetSalesOrderHeader(SalesOrder); + LibrarySales.PostSalesDocument(SalesOrder, true, true); + + FetchSalesHeader.Get(SalesHeader."Document Type"::"Blanket Order", BlanketOrderNo); + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnDocument(SalesHeader."Document Type"::"Blanket Order", BlanketOrderNo); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [WHEN] Running report "Delete Invd Blnkt Sales Orders" for the Blanket Sales Order + RunDeleteInvdBlnktSalesOrders(BlanketOrderNo); + + // [THEN] The Blanket Sales Order is deleted + Assert.IsFalse(FetchSalesHeader.Get(SalesHeader."Document Type"::"Blanket Order", BlanketOrderNo), BlanketSalesOrderNotDeletedErr); + + // [THEN] No Sales Subscription Line of the deleted Blanket Sales Order is left behind + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnDocument(SalesHeader."Document Type"::"Blanket Order", BlanketOrderNo); + Assert.RecordIsEmpty(SalesServiceCommitment); + + // [THEN] The Sales Subscription Lines of the second Blanket Sales Order are untouched + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnSalesLine(OtherSalesLine); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + end; + + [Test] + procedure CheckDeleteSalesServiceCommitmentOnPostSalesInvoiceCopiedFromSalesOrder() + var + SalesInvoiceHeader2: Record "Sales Header"; + CopyDocMgt: Codeunit "Copy Document Mgt."; + SalesInvoiceNo: Code[20]; + begin + // [SCENARIO] Posting a Sales Invoice that carries Sales Subscription Lines copied from a Sales Order deletes them + Initialize(); + + // [GIVEN] A Sales Order with an Item with Subscription Lines + ContractTestLibrary.SetupSalesServiceCommitmentItemAndAssignToServiceCommitmentPackage(Item, Enum::"Item Service Commitment Type"::"Sales with Service Commitment", ServiceCommitmentPackage.Code); + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, ''); + LibrarySales.CreateSalesLineWithShipmentDate(SalesLine, SalesHeader, Enum::"Sales Line Type"::Item, Item."No.", WorkDate(), LibraryRandom.RandIntInRange(2, 10)); + + // [GIVEN] A Sales Invoice copied from that Sales Order, which carries over the Sales Subscription Lines + LibrarySales.CreateSalesHeader(SalesInvoiceHeader2, SalesInvoiceHeader2."Document Type"::Invoice, SalesHeader."Sell-to Customer No."); + SalesInvoiceNo := SalesInvoiceHeader2."No."; + CopyDocMgt.CopySalesDoc(Enum::"Sales Document Type From"::Order, SalesHeader."No.", SalesInvoiceHeader2); + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnDocument(SalesInvoiceHeader2."Document Type"::Invoice, SalesInvoiceNo); + Assert.RecordIsNotEmpty(SalesServiceCommitment); + + // [WHEN] Posting the Sales Invoice + LibrarySales.PostSalesDocument(SalesInvoiceHeader2, true, true); + + // [THEN] No Sales Subscription Line of the posted Sales Invoice is left behind + SalesServiceCommitment.Reset(); + SalesServiceCommitment.FilterOnDocument(SalesInvoiceHeader2."Document Type"::Invoice, SalesInvoiceNo); + Assert.RecordIsEmpty(SalesServiceCommitment); + end; + #endregion Tests #region Procedures + local procedure RunDeleteInvdBlnktSalesOrders(BlanketOrderNo: Code[20]) + var + FilterSalesHeader: Record "Sales Header"; + DeleteInvdBlnktSalesOrders: Report "Delete Invd Blnkt Sales Orders"; + begin + FilterSalesHeader.SetRange("Document Type", FilterSalesHeader."Document Type"::"Blanket Order"); + FilterSalesHeader.SetRange("No.", BlanketOrderNo); + DeleteInvdBlnktSalesOrders.SetTableView(FilterSalesHeader); + DeleteInvdBlnktSalesOrders.UseRequestPage(false); + DeleteInvdBlnktSalesOrders.Run(); + end; + + local procedure RunDeleteInvoicedSalesOrders(SalesOrderNo: Code[20]) + var + FilterSalesHeader: Record "Sales Header"; + DeleteInvoicedSalesOrders: Report "Delete Invoiced Sales Orders"; + begin + FilterSalesHeader.SetRange("Document Type", FilterSalesHeader."Document Type"::Order); + FilterSalesHeader.SetRange("No.", SalesOrderNo); + DeleteInvoicedSalesOrders.SetTableView(FilterSalesHeader); + DeleteInvoicedSalesOrders.UseRequestPage(false); + DeleteInvoicedSalesOrders.Run(); + end; + local procedure Initialize() begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Sales Service Commitment Test");