diff --git a/src/Apps/W1/EDocument/App/docs/patterns.md b/src/Apps/W1/EDocument/App/docs/patterns.md index 18306a11478..f43634e71cd 100644 --- a/src/Apps/W1/EDocument/App/docs/patterns.md +++ b/src/Apps/W1/EDocument/App/docs/patterns.md @@ -214,3 +214,29 @@ The codebase uses compiler directives to manage deprecation: - `#if not CLEANSCHEMA26` / `#if not CLEANSCHEMA29` -- table schema changes (field removals) that need separate cleanup due to schema migration constraints When reading the code, content inside these blocks is legacy. The code outside (or in the `#else` branch) is the current implementation. + +## Notification state vs. display + +Purchase Document Draft notifications are split across two codeunits so that a +call site tells you whether UI is raised. + +`"E-Doc. Draft Notif. State"` (6436) owns the `E-Document Notification` rows, the +Sub Total mismatch calculation, and its telemetry. It never constructs or sends a +`Notification` and knows nothing about `My Notifications`. + +`"E-Document Notification"` (6123) is the only codeunit consumers call. It gates on +`My Notifications`, delegates state changes downward, builds `Notification` objects, +and hosts the `Dismiss…`/`Disable…` action handlers. The dependency is one-way. + +The procedure verb is the contract: + +| Prefix | Guarantee | +|---|---| +| `Add…` / `Refresh…` / `Remove…` / `ReArm…` | State only, never displays | +| `Show…` / `RefreshAndShow…` | Displays a notification | +| `Is…` | Query, no side effects | + +Handler names passed to `Notification.AddAction` are resolved by string at runtime, +so `DismissSubTotalMismatchNotification` and friends cannot be renamed safely. + +*Updated: 2026-08-05 -- documented the notification state vs. display split.* diff --git a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocDraftNotifState.Codeunit.al b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocDraftNotifState.Codeunit.al new file mode 100644 index 00000000000..27196eec4e8 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocDraftNotifState.Codeunit.al @@ -0,0 +1,383 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument; + +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using System.Telemetry; + +/// +/// Owns the persisted state of Purchase Document Draft notifications: the rows in the +/// "E-Document Notification" table, the Sub Total mismatch evaluation, and its telemetry. +/// This codeunit never displays anything. Everything user-facing lives in +/// codeunit "E-Document Notification", which is the only caller of this one. +/// +codeunit 6436 "E-Doc. Draft Notif. State" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + var + VendorMatchedByNameNotAddressMsg: Label 'Vendor matched by name but not by address.'; + SubTotalMismatchMsg: Label 'The document total does not match the sum of the lines. Review the amounts before finalizing the draft.'; + SubTotalMismatchNoToleranceTxt: Label 'E-Document purchase draft header Sub Total differs from the sum of the lines.'; + SubTotalMismatchNotificationCreatedTxt: Label 'E-Document purchase draft Sub Total mismatch notification state created.'; + + /// + /// Re-evaluates the Sub Total mismatch from the persisted lines and updates the persisted + /// notification. A previously dismissed notification stays dismissed. + /// + /// The draft header as currently loaded by the caller + /// True if the state was evaluated; false if the header carries no e-document. + procedure RefreshSubTotalMismatch(EDocumentPurchaseHeader: Record "E-Document Purchase Header"): Boolean + var + RoundingPrecision: Decimal; + LinesSubTotal: Decimal; + LineCount: Integer; + begin + if EDocumentPurchaseHeader."E-Document Entry No." = 0 then + exit(false); + RoundingPrecision := GetRoundingPrecision(EDocumentPurchaseHeader); + LinesSubTotal := CalculateLinesSubTotal(EDocumentPurchaseHeader."E-Document Entry No.", RoundingPrecision, LineCount); + ApplyMismatchState(EDocumentPurchaseHeader, LinesSubTotal, LineCount, RoundingPrecision); + exit(true); + end; + + /// + /// Re-evaluates the Sub Total mismatch after the user changed an amount on the header, + /// re-arming a previously dismissed notification. + /// + /// The draft header as currently edited by the user + /// True if the state was evaluated; false if the header carries no e-document. + procedure RefreshSubTotalMismatchAfterHeaderEdit(EDocumentPurchaseHeader: Record "E-Document Purchase Header"): Boolean + begin + if EDocumentPurchaseHeader."E-Document Entry No." = 0 then + exit(false); + ReArmSubTotalMismatch(EDocumentPurchaseHeader."E-Document Entry No."); + exit(RefreshSubTotalMismatch(EDocumentPurchaseHeader)); + end; + + /// + /// Re-evaluates the Sub Total mismatch after the user changed an amount on a draft line, + /// re-arming a previously dismissed notification. The supplied line is used instead of its + /// persisted version, because page field validation runs before the record is written. + /// + /// The line as currently edited by the user + /// True if the state was evaluated; false if the owning header could not be read. + procedure RefreshSubTotalMismatchAfterLineEdit(EDocumentPurchaseLine: Record "E-Document Purchase Line"): Boolean + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + RoundingPrecision: Decimal; + LinesSubTotal: Decimal; + LineCount: Integer; + begin + if not GetHeaderForLine(EDocumentPurchaseLine, EDocumentPurchaseHeader) then + exit(false); + ReArmSubTotalMismatch(EDocumentPurchaseHeader."E-Document Entry No."); + RoundingPrecision := GetRoundingPrecision(EDocumentPurchaseHeader); + LinesSubTotal := CalculateLinesSubTotalWithPendingLine(EDocumentPurchaseHeader."E-Document Entry No.", EDocumentPurchaseLine, RoundingPrecision, LineCount); + ApplyMismatchState(EDocumentPurchaseHeader, LinesSubTotal, LineCount, RoundingPrecision); + exit(true); + end; + + /// + /// Re-evaluates the Sub Total mismatch while a draft line is being deleted, re-arming a + /// previously dismissed notification. The line is still persisted when this runs, so it is + /// excluded explicitly. + /// + /// The line being deleted + /// True if the state was evaluated; false if the owning header could not be read. + procedure RefreshSubTotalMismatchAfterLineDeletion(EDocumentPurchaseLine: Record "E-Document Purchase Line"): Boolean + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + RoundingPrecision: Decimal; + LinesSubTotal: Decimal; + LineCount: Integer; + begin + if not GetHeaderForLine(EDocumentPurchaseLine, EDocumentPurchaseHeader) then + exit(false); + ReArmSubTotalMismatch(EDocumentPurchaseHeader."E-Document Entry No."); + RoundingPrecision := GetRoundingPrecision(EDocumentPurchaseHeader); + LinesSubTotal := CalculateLinesSubTotalExcludingLine(EDocumentPurchaseHeader."E-Document Entry No.", EDocumentPurchaseLine, RoundingPrecision, LineCount); + ApplyMismatchState(EDocumentPurchaseHeader, LinesSubTotal, LineCount, RoundingPrecision); + exit(true); + end; + + /// + /// Persists the Vendor Matched By Name Not Address notification for the current user. + /// Gating on My Notifications is the caller's responsibility. + /// + /// Id of e-document + procedure AddVendorMatchedByNameNotAddress(EDocumentEntryNo: Integer) + begin + AddNotification(EDocumentEntryNo, VendorMatchedByNameNotAddressNotificationId(), "E-Document Notification Type"::"Vendor Matched By Name Not Address", VendorMatchedByNameNotAddressMsg); + end; + + /// + /// Persists the Sub Total Mismatch notification for the current user. + /// Gating on My Notifications is the caller's responsibility. + /// + /// Id of e-document + procedure AddSubTotalMismatch(EDocumentEntryNo: Integer) + begin + AddNotification(EDocumentEntryNo, SubTotalMismatchNotificationId(), "E-Document Notification Type"::"Sub Total Mismatch", SubTotalMismatchMsg); + end; + + local procedure AddNotification(EDocumentEntryNo: Integer; NotificationId: Guid; NotificationType: Enum "E-Document Notification Type"; NotificationMessage: Text) + var + EDocumentNotification: Record "E-Document Notification"; + begin + if EDocumentNotification.Get(EDocumentEntryNo, NotificationId, UserId()) then + exit; + EDocumentNotification.Validate("E-Document Entry No.", EDocumentEntryNo); + EDocumentNotification.Validate(ID, NotificationId); + EDocumentNotification.Validate("User Id", CopyStr(UserId(), 1, MaxStrLen(EDocumentNotification."User Id"))); + EDocumentNotification.Validate(Type, NotificationType); + EDocumentNotification.Validate(Message, CopyStr(NotificationMessage, 1, MaxStrLen(EDocumentNotification.Message))); + EDocumentNotification.Insert(true); + end; + + /// + /// Removes the persisted Sub Total Mismatch notification for the current user, e.g. when the totals re-converge. + /// + /// Id of e-document + procedure RemoveSubTotalMismatch(EDocumentEntryNo: Integer) + var + EDocumentNotification: Record "E-Document Notification"; + begin + if EDocumentNotification.Get(EDocumentEntryNo, SubTotalMismatchNotificationId(), UserId()) then + EDocumentNotification.Delete(true); + end; + + /// + /// Removes a previously dismissed Sub Total Mismatch row so the mismatch can be shown again after an amount edit. + /// + /// Id of e-document + procedure ReArmSubTotalMismatch(EDocumentEntryNo: Integer) + var + EDocumentNotification: Record "E-Document Notification"; + begin + if not EDocumentNotification.Get(EDocumentEntryNo, SubTotalMismatchNotificationId(), UserId()) then + exit; + if EDocumentNotification.Dismissed then + EDocumentNotification.Delete(true); + end; + + /// + /// Marks a persisted notification as dismissed, keeping the row so it is not re-shown to this user. + /// + /// Id of e-document + /// Id of the notification + procedure MarkDismissed(EDocumentEntryNo: Integer; NotificationId: Guid) + var + EDocumentNotification: Record "E-Document Notification"; + begin + if not EDocumentNotification.Get(EDocumentEntryNo, NotificationId, UserId()) then + exit; + EDocumentNotification.Dismissed := true; + EDocumentNotification.Modify(true); + end; + + /// + /// Deletes every persisted notification of a type for the current user. + /// + /// The notification type to clear + procedure DeleteAllOfType(NotificationType: Enum "E-Document Notification Type") + var + EDocumentNotification: Record "E-Document Notification"; + begin + EDocumentNotification.SetRange(Type, NotificationType); + EDocumentNotification.SetRange("User Id", UserId()); + EDocumentNotification.DeleteAll(true); + end; + + /// + /// Reads one persisted notification for the current user. + /// + /// Id of e-document + /// Id of the notification + /// The row, when found + procedure GetNotification(EDocumentEntryNo: Integer; NotificationId: Guid; var EDocumentNotification: Record "E-Document Notification"): Boolean + begin + exit(EDocumentNotification.Get(EDocumentEntryNo, NotificationId, UserId())); + end; + + /// + /// Finds the Purchase Document Draft notifications that are pending display for the current user. + /// + /// Id of e-document + /// Filtered and positioned on the first pending row + procedure FindPendingDraftNotifications(EDocumentEntryNo: Integer; var EDocumentNotification: Record "E-Document Notification"): Boolean + begin + EDocumentNotification.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentNotification.SetFilter(Type, '%1|%2', + "E-Document Notification Type"::"Vendor Matched By Name Not Address", + "E-Document Notification Type"::"Sub Total Mismatch"); + EDocumentNotification.SetRange("User Id", UserId()); + EDocumentNotification.SetRange(Dismissed, false); + exit(EDocumentNotification.FindSet()); + end; + + /// + /// Returns whether a Sub Total Mismatch row exists for the current user and e-document. + /// + /// Id of e-document + procedure SubTotalMismatchExists(EDocumentEntryNo: Integer): Boolean + var + EDocumentNotification: Record "E-Document Notification"; + begin + exit(EDocumentNotification.Get(EDocumentEntryNo, SubTotalMismatchNotificationId(), UserId())); + end; + + /// + /// Returns whether the current user has dismissed the Sub Total Mismatch notification for the e-document. + /// + /// Id of e-document + procedure IsSubTotalMismatchDismissed(EDocumentEntryNo: Integer): Boolean + var + EDocumentNotification: Record "E-Document Notification"; + begin + if EDocumentNotification.Get(EDocumentEntryNo, SubTotalMismatchNotificationId(), UserId()) then + exit(EDocumentNotification.Dismissed); + exit(false); + end; + + procedure VendorMatchedByNameNotAddressNotificationId(): Guid + begin + exit('bc0d8537-8e8d-4d94-a07a-a5a54c729d2a'); + end; + + procedure SubTotalMismatchNotificationId(): Guid + begin + exit('a1e6c0d2-3b4f-4c8a-9d1e-2f7b6a5c4d3e'); + end; + + local procedure ApplyMismatchState(EDocumentPurchaseHeader: Record "E-Document Purchase Header"; LinesSubTotal: Decimal; LineCount: Integer; RoundingPrecision: Decimal) + var + Telemetry: Codeunit Telemetry; + CustomDimensions: Dictionary of [Text, Text]; + Difference: Decimal; + Tolerance: Decimal; + EDocumentEntryNo: Integer; + NotificationExisted: Boolean; + begin + EDocumentEntryNo := EDocumentPurchaseHeader."E-Document Entry No."; + Difference := Abs(EDocumentPurchaseHeader."Sub Total" - LinesSubTotal); + Tolerance := LineCount * RoundingPrecision; + + CustomDimensions.Add('EntryNo', Format(EDocumentEntryNo)); + CustomDimensions.Add('LineCount', Format(LineCount)); + CustomDimensions.Add('WithinTolerance', Format(Difference <= Tolerance, 0, 9)); + CustomDimensions.Add('DifferenceMagnitude', DifferenceMagnitudeBucket(Difference, EDocumentPurchaseHeader."Sub Total")); + + if Difference <= Tolerance then begin + RemoveSubTotalMismatch(EDocumentEntryNo); + exit; + end; + + // Only log on the transition into the mismatch state, not on every subsequent amount edit. + NotificationExisted := SubTotalMismatchExists(EDocumentEntryNo); + if not NotificationExisted then + Telemetry.LogMessage('0000UVL', SubTotalMismatchNoToleranceTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, CustomDimensions); + + if IsSubTotalMismatchDismissed(EDocumentEntryNo) then + exit; + AddSubTotalMismatch(EDocumentEntryNo); + if not NotificationExisted then + Telemetry.LogMessage('0000UVM', SubTotalMismatchNotificationCreatedTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, CustomDimensions); + end; + + local procedure GetHeaderForLine(EDocumentPurchaseLine: Record "E-Document Purchase Line"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"): Boolean + begin + if EDocumentPurchaseLine."E-Document Entry No." = 0 then + exit(false); + exit(EDocumentPurchaseHeader.Get(EDocumentPurchaseLine."E-Document Entry No.")); + end; + + local procedure GetRoundingPrecision(EDocumentPurchaseHeader: Record "E-Document Purchase Header"): Decimal + var + EDocumentImportHelper: Codeunit "E-Document Import Helper"; + begin + exit(Abs(EDocumentImportHelper.GetCurrencyRoundingPrecision(EDocumentPurchaseHeader."Currency Code"))); + end; + + local procedure CalculateLinesSubTotal(EDocumentEntryNo: Integer; RoundingPrecision: Decimal; var LineCount: Integer) LinesSubTotal: Decimal + var + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + LineCount := 0; + if not FindLines(EDocumentEntryNo, EDocumentPurchaseLine) then + exit; + repeat + LinesSubTotal += LineSubTotal(EDocumentPurchaseLine, RoundingPrecision); + LineCount += 1; + until EDocumentPurchaseLine.Next() = 0; + end; + + local procedure CalculateLinesSubTotalWithPendingLine(EDocumentEntryNo: Integer; PendingEDocumentPurchaseLine: Record "E-Document Purchase Line"; RoundingPrecision: Decimal; var LineCount: Integer) LinesSubTotal: Decimal + var + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + PendingLineFound: Boolean; + begin + LineCount := 0; + if FindLines(EDocumentEntryNo, EDocumentPurchaseLine) then + repeat + if EDocumentPurchaseLine."Line No." = PendingEDocumentPurchaseLine."Line No." then begin + PendingLineFound := true; + LinesSubTotal += LineSubTotal(PendingEDocumentPurchaseLine, RoundingPrecision); + end else + LinesSubTotal += LineSubTotal(EDocumentPurchaseLine, RoundingPrecision); + LineCount += 1; + until EDocumentPurchaseLine.Next() = 0; + + if not PendingLineFound then begin + LinesSubTotal += LineSubTotal(PendingEDocumentPurchaseLine, RoundingPrecision); + LineCount += 1; + end; + end; + + local procedure CalculateLinesSubTotalExcludingLine(EDocumentEntryNo: Integer; ExcludedEDocumentPurchaseLine: Record "E-Document Purchase Line"; RoundingPrecision: Decimal; var LineCount: Integer) LinesSubTotal: Decimal + var + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + begin + LineCount := 0; + if not FindLines(EDocumentEntryNo, EDocumentPurchaseLine) then + exit; + repeat + if EDocumentPurchaseLine."Line No." <> ExcludedEDocumentPurchaseLine."Line No." then begin + LinesSubTotal += LineSubTotal(EDocumentPurchaseLine, RoundingPrecision); + LineCount += 1; + end; + until EDocumentPurchaseLine.Next() = 0; + end; + + local procedure FindLines(EDocumentEntryNo: Integer; var EDocumentPurchaseLine: Record "E-Document Purchase Line"): Boolean + begin + EDocumentPurchaseLine.SetLoadFields("E-Document Entry No.", "Line No.", Quantity, "Unit Price", "Total Discount"); + EDocumentPurchaseLine.SetRange("E-Document Entry No.", EDocumentEntryNo); + exit(EDocumentPurchaseLine.FindSet()); + end; + + local procedure LineSubTotal(EDocumentPurchaseLine: Record "E-Document Purchase Line"; RoundingPrecision: Decimal): Decimal + begin + exit(Round(EDocumentPurchaseLine.Quantity * EDocumentPurchaseLine."Unit Price", RoundingPrecision) - EDocumentPurchaseLine."Total Discount"); + end; + + local procedure DifferenceMagnitudeBucket(Difference: Decimal; HeaderSubTotal: Decimal): Text + var + RelativeDifference: Decimal; + begin + if Difference = 0 then + exit('None'); + if HeaderSubTotal = 0 then + exit('Unknown'); + RelativeDifference := Abs(Difference / HeaderSubTotal); + if RelativeDifference < 0.01 then + exit('Below1Pct'); + if RelativeDifference < 0.1 then + exit('Below10Pct'); + exit('AtLeast10Pct'); + end; +} diff --git a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Codeunit.al b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Codeunit.al index acefe71c5f8..a5321f99694 100644 --- a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Codeunit.al @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; using System.Environment.Configuration; codeunit 6123 "E-Document Notification" @@ -12,45 +13,81 @@ codeunit 6123 "E-Document Notification" InherentEntitlements = X; InherentPermissions = X; + var + EDocDraftNotifState: Codeunit "E-Doc. Draft Notif. State"; + /// - /// Adds a notification that informs a user of Purchase Document Draft that a vendor is matched by name but not by address. - /// Id of e-document + /// Persists a notification that informs a user of Purchase Document Draft that a vendor is matched by name but not by address. + /// Does not display anything. /// + /// Id of e-document procedure AddVendorMatchedByNameNotAddressNotification(EDocumentEntryNo: Integer) var - EDocumentNotification: Record "E-Document Notification"; MyNotifications: Record "My Notifications"; - VendorMatchedByNameNotAddressMsg: Label 'Vendor matched by name but not by address.'; begin if not GuiAllowed() then exit; - if not MyNotifications.IsEnabled(GetVendorMatchedByNameNotAddressNotificationId()) then + if not MyNotifications.IsEnabled(EDocDraftNotifState.VendorMatchedByNameNotAddressNotificationId()) then exit; - if EDocumentNotification.Get(EDocumentEntryNo, GetVendorMatchedByNameNotAddressNotificationId(), UserId()) then + EDocDraftNotifState.AddVendorMatchedByNameNotAddress(EDocumentEntryNo); + end; + + /// + /// Persists a notification that informs a user of Purchase Document Draft that the header Sub Total no longer matches the sum of the lines. + /// Does not display anything. + /// + /// Id of e-document + procedure AddSubTotalMismatchNotification(EDocumentEntryNo: Integer) + begin + if not IsSubTotalMismatchNotificationEnabled() then exit; - EDocumentNotification.Validate("E-Document Entry No.", EDocumentEntryNo); - EDocumentNotification.Validate(ID, GetVendorMatchedByNameNotAddressNotificationId()); - EDocumentNotification.Validate("User Id", UserId()); - EDocumentNotification.Validate(Type, "E-Document Notification Type"::"Vendor Matched By Name Not Address"); - EDocumentNotification.Validate(Message, VendorMatchedByNameNotAddressMsg); - EDocumentNotification.Insert(true); + EDocDraftNotifState.AddSubTotalMismatch(EDocumentEntryNo); end; /// - /// Send notifications for Purchase Document Draft page + /// Removes the persisted Sub Total Mismatch notification for the current user and e-document, e.g. when the totals re-converge. + /// /// Id of e-document + procedure RemoveSubTotalMismatchNotification(EDocumentEntryNo: Integer) + begin + EDocDraftNotifState.RemoveSubTotalMismatch(EDocumentEntryNo); + end; + + /// + /// Re-evaluates the Sub Total mismatch and updates the persisted notification. Does not display anything. + /// + /// The draft header as currently loaded by the caller + procedure RefreshSubTotalMismatch(EDocumentPurchaseHeader: Record "E-Document Purchase Header") + begin + if not IsSubTotalMismatchNotificationEnabled() then + exit; + EDocDraftNotifState.RefreshSubTotalMismatch(EDocumentPurchaseHeader); + end; + + /// + /// Refreshes the Sub Total mismatch state and then shows every pending Purchase Document Draft notification once. + /// + /// Id of e-document + procedure RefreshAndShowPendingDraftNotifications(EDocumentEntryNo: Integer) + var + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + begin + if EDocumentPurchaseHeader.Get(EDocumentEntryNo) then + RefreshSubTotalMismatch(EDocumentPurchaseHeader); + SendPurchaseDocumentDraftNotifications(EDocumentEntryNo); + end; + + /// + /// Shows every pending Purchase Document Draft notification for the current user. /// + /// Id of e-document procedure SendPurchaseDocumentDraftNotifications(EDocumentEntryNo: Integer) var EDocumentNotification: Record "E-Document Notification"; begin if not GuiAllowed() then exit; - - EDocumentNotification.SetRange("E-Document Entry No.", EDocumentEntryNo); - EDocumentNotification.SetRange(Type, "E-Document Notification Type"::"Vendor Matched By Name Not Address"); - EDocumentNotification.SetRange("User Id", UserId()); - if not EDocumentNotification.FindSet() then + if not EDocDraftNotifState.FindPendingDraftNotifications(EDocumentEntryNo, EDocumentNotification) then exit; repeat @@ -58,21 +95,94 @@ codeunit 6123 "E-Document Notification" until EDocumentNotification.Next() = 0; end; + /// + /// Re-evaluates the Sub Total mismatch after a header amount edit, re-arming a dismissal, and shows the notification if it applies. + /// + /// The draft header as currently edited by the user + procedure RefreshAndShowSubTotalMismatchAfterHeaderEdit(EDocumentPurchaseHeader: Record "E-Document Purchase Header") + begin + if not IsSubTotalMismatchNotificationEnabled() then + exit; + if not EDocDraftNotifState.RefreshSubTotalMismatchAfterHeaderEdit(EDocumentPurchaseHeader) then + exit; + ShowSubTotalMismatchNotification(EDocumentPurchaseHeader."E-Document Entry No."); + end; + + /// + /// Re-evaluates the Sub Total mismatch after a line amount edit, re-arming a dismissal, and shows the notification if it applies. + /// + /// The line as currently edited by the user + procedure RefreshAndShowSubTotalMismatchAfterLineEdit(EDocumentPurchaseLine: Record "E-Document Purchase Line") + begin + if not IsSubTotalMismatchNotificationEnabled() then + exit; + if not EDocDraftNotifState.RefreshSubTotalMismatchAfterLineEdit(EDocumentPurchaseLine) then + exit; + ShowSubTotalMismatchNotification(EDocumentPurchaseLine."E-Document Entry No."); + end; + + /// + /// Re-evaluates the Sub Total mismatch while a line is being deleted, re-arming a dismissal, and shows the notification if it applies. + /// + /// The line being deleted + procedure RefreshAndShowSubTotalMismatchAfterLineDeletion(EDocumentPurchaseLine: Record "E-Document Purchase Line") + begin + if not IsSubTotalMismatchNotificationEnabled() then + exit; + if not EDocDraftNotifState.RefreshSubTotalMismatchAfterLineDeletion(EDocumentPurchaseLine) then + exit; + ShowSubTotalMismatchNotification(EDocumentPurchaseLine."E-Document Entry No."); + end; + + /// + /// Shows the Sub Total Mismatch notification for the e-document, if it is persisted and not dismissed. + /// + /// Id of e-document + procedure ShowSubTotalMismatchNotification(EDocumentEntryNo: Integer) + var + EDocumentNotification: Record "E-Document Notification"; + begin + if not GuiAllowed() then + exit; + if not EDocDraftNotifState.GetNotification(EDocumentEntryNo, EDocDraftNotifState.SubTotalMismatchNotificationId(), EDocumentNotification) then + exit; + if EDocumentNotification.Dismissed then + exit; + SendNotification(EDocumentNotification); + end; + + /// + /// Returns whether the current user has the Sub Total Mismatch notification enabled. + /// + procedure IsSubTotalMismatchNotificationEnabled(): Boolean + var + MyNotifications: Record "My Notifications"; + begin + exit(GuiAllowed() and MyNotifications.IsEnabled(EDocDraftNotifState.SubTotalMismatchNotificationId())); + end; + + /// + /// Returns whether the current user has dismissed the Sub Total Mismatch notification for the e-document. + /// + /// Id of e-document + procedure IsSubTotalMismatchDismissed(EDocumentEntryNo: Integer): Boolean + begin + exit(EDocDraftNotifState.IsSubTotalMismatchDismissed(EDocumentEntryNo)); + end; + /// /// Dismisses the notification of the certain Purchase Document Draft that informs a user about a vendor that is matched by name but not by address. + /// The persisted notification row is kept and marked as dismissed so it is not re-shown to this user. /// - /// + /// Current notification procedure DismissVendorMatchedByNameNotAddressNotification(Notification: Notification) var - EDocumentNotification: Record "E-Document Notification"; EDocumentEntryNo: Integer; Id: Guid; begin - Evaluate(EDocumentEntryNo, Notification.GetData(EDocumentNotification.FieldName("E-Document Entry No."))); - Evaluate(Id, Notification.GetData(EDocumentNotification.FieldName(ID))); - if not EDocumentNotification.Get(EDocumentEntryNo, Id, UserId()) then + if not TryGetNotificationKeys(Notification, EDocumentEntryNo, Id) then exit; - EDocumentNotification.Delete(true); + EDocDraftNotifState.MarkDismissed(EDocumentEntryNo, Id); end; /// @@ -82,31 +192,69 @@ codeunit 6123 "E-Document Notification" procedure DisableVendorMatchedByNameNotAddressNotification(Notification: Notification) var MyNotifications: Record "My Notifications"; - EDocumentNotification: Record "E-Document Notification"; VendorMatchedByNameNotAddressNotificationNameTok: Label 'Notify user of Purchase Document Draft that vendor is matched by name but not by address.'; VendorMatchedByNameNotAddressNotificationDescTok: Label 'Show a notification informing a user of Purchase Document Draft that a vendor is matched by name but not by address.'; begin if MyNotifications.WritePermission() then - if not MyNotifications.Disable(GetVendorMatchedByNameNotAddressNotificationId()) then - MyNotifications.InsertDefault(GetVendorMatchedByNameNotAddressNotificationId(), VendorMatchedByNameNotAddressNotificationNameTok, VendorMatchedByNameNotAddressNotificationDescTok, false); - EDocumentNotification.SetRange(Type, "E-Document Notification Type"::"Vendor Matched By Name Not Address"); - EDocumentNotification.SetRange("User Id", UserId()); - EDocumentNotification.DeleteAll(true); + if not MyNotifications.Disable(EDocDraftNotifState.VendorMatchedByNameNotAddressNotificationId()) then + MyNotifications.InsertDefault(EDocDraftNotifState.VendorMatchedByNameNotAddressNotificationId(), VendorMatchedByNameNotAddressNotificationNameTok, VendorMatchedByNameNotAddressNotificationDescTok, false); + EDocDraftNotifState.DeleteAllOfType("E-Document Notification Type"::"Vendor Matched By Name Not Address"); + end; + + /// + /// Dismisses the Sub Total Mismatch notification for the current Purchase Document Draft. + /// The persisted notification row is kept and marked as dismissed so the mismatch is not + /// re-shown to this user until an amount edit re-arms it. + /// + /// Current notification + procedure DismissSubTotalMismatchNotification(Notification: Notification) + var + EDocumentEntryNo: Integer; + Id: Guid; + begin + if not TryGetNotificationKeys(Notification, EDocumentEntryNo, Id) then + exit; + EDocDraftNotifState.MarkDismissed(EDocumentEntryNo, Id); + end; + + /// + /// Disables the Sub Total Mismatch notification for the current user. + /// + /// Current notification + procedure DisableSubTotalMismatchNotification(Notification: Notification) + var + MyNotifications: Record "My Notifications"; + SubTotalMismatchNotificationNameTok: Label 'Notify user of Purchase Document Draft that the document total does not match the sum of the lines.'; + SubTotalMismatchNotificationDescTok: Label 'Show a notification informing a user of Purchase Document Draft that the header total no longer matches the sum of the lines.'; + begin + if MyNotifications.WritePermission() then + if not MyNotifications.Disable(EDocDraftNotifState.SubTotalMismatchNotificationId()) then + MyNotifications.InsertDefault(EDocDraftNotifState.SubTotalMismatchNotificationId(), SubTotalMismatchNotificationNameTok, SubTotalMismatchNotificationDescTok, false); + EDocDraftNotifState.DeleteAllOfType("E-Document Notification Type"::"Sub Total Mismatch"); + end; + + local procedure TryGetNotificationKeys(Notification: Notification; var EDocumentEntryNo: Integer; var Id: Guid): Boolean + var + EDocumentNotification: Record "E-Document Notification"; + begin + if not Evaluate(EDocumentEntryNo, Notification.GetData(EDocumentNotification.FieldName("E-Document Entry No."))) then + exit(false); + exit(Evaluate(Id, Notification.GetData(EDocumentNotification.FieldName(ID)))); end; local procedure SendNotification(EDocumentNotification: Record "E-Document Notification") var MyNotifications: Record "My Notifications"; - VendorMatchedByNameNotAddressNotification: Notification; + DraftNotification: Notification; begin if not MyNotifications.IsEnabled(EDocumentNotification.ID) then exit; - VendorMatchedByNameNotAddressNotification.Id := EDocumentNotification.ID; - VendorMatchedByNameNotAddressNotification.Message := EDocumentNotification.Message; - VendorMatchedByNameNotAddressNotification.Scope := NotificationScope::LocalScope; - AddActionsToNotification(VendorMatchedByNameNotAddressNotification, EDocumentNotification); - VendorMatchedByNameNotAddressNotification.Send(); + DraftNotification.Id := EDocumentNotification.ID; + DraftNotification.Message := EDocumentNotification.Message; + DraftNotification.Scope := NotificationScope::LocalScope; + AddActionsToNotification(DraftNotification, EDocumentNotification); + DraftNotification.Send(); end; local procedure AddActionsToNotification(var Notification: Notification; EDocumentNotification: Record "E-Document Notification") @@ -114,16 +262,19 @@ codeunit 6123 "E-Document Notification" DismissMsg: Label 'Dismiss'; DontShowThisAgainMsg: Label 'Don''t show this again.'; begin - if EDocumentNotification.Type <> "E-Document Notification Type"::"Vendor Matched By Name Not Address" then - exit; Notification.SetData(EDocumentNotification.FieldName("E-Document Entry No."), Format(EDocumentNotification."E-Document Entry No.")); Notification.SetData(EDocumentNotification.FieldName(ID), EDocumentNotification.ID); - Notification.AddAction(DismissMsg, Codeunit::"E-Document Notification", 'DismissVendorMatchedByNameNotAddressNotification'); - Notification.AddAction(DontShowThisAgainMsg, Codeunit::"E-Document Notification", 'DisableVendorMatchedByNameNotAddressNotification'); - end; - - local procedure GetVendorMatchedByNameNotAddressNotificationId(): Guid - begin - exit('bc0d8537-8e8d-4d94-a07a-a5a54c729d2a'); + case EDocumentNotification.Type of + "E-Document Notification Type"::"Vendor Matched By Name Not Address": + begin + Notification.AddAction(DismissMsg, Codeunit::"E-Document Notification", 'DismissVendorMatchedByNameNotAddressNotification'); + Notification.AddAction(DontShowThisAgainMsg, Codeunit::"E-Document Notification", 'DisableVendorMatchedByNameNotAddressNotification'); + end; + "E-Document Notification Type"::"Sub Total Mismatch": + begin + Notification.AddAction(DismissMsg, Codeunit::"E-Document Notification", 'DismissSubTotalMismatchNotification'); + Notification.AddAction(DontShowThisAgainMsg, Codeunit::"E-Document Notification", 'DisableSubTotalMismatchNotification'); + end; + end; end; -} \ No newline at end of file +} diff --git a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Table.al b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Table.al index 901e6946f9d..8b696bc59e5 100644 --- a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Table.al +++ b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotification.Table.al @@ -39,6 +39,12 @@ table 6126 "E-Document Notification" Caption = 'Message'; ToolTip = 'Specifies the message of the E-Document notification.'; } + field(6; Dismissed; Boolean) + { + Caption = 'Dismissed'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies whether the user has dismissed the notification.'; + } } keys { diff --git a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotificationType.Enum.al b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotificationType.Enum.al index 7324cbae959..f7b4c00f77b 100644 --- a/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotificationType.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Document/Notification/EDocumentNotificationType.Enum.al @@ -16,4 +16,8 @@ enum 6126 "E-Document Notification Type" { Caption = 'Vendor Matched By Name Not Address'; } + value(2; "Sub Total Mismatch") + { + Caption = 'Sub Total Mismatch'; + } } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al index 6526749b9cb..7b208ae2ae1 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocPurchaseDraftSubform.Page.al @@ -346,6 +346,7 @@ page 6183 "E-Doc. Purchase Draft Subform" TempEDocumentPOMatchWarnings: Record "E-Doc PO Match Warning"; EDocPurchaseHistMapping: Codeunit "E-Doc. Purchase Hist. Mapping"; EDocPOMatching: Codeunit "E-Doc. PO Matching"; + EDocumentNotification: Codeunit "E-Document Notification"; AdditionalColumns, OrderMatchedCaption, MatchWarningsCaption, MatchWarningsStyleExpr, MatchedEntityName : Text; LineAmount: Decimal; DimVisible1, DimVisible2, HasAdditionalColumns, IsEDocumentMatchedToAnyPOLine, IsLineMatchedToOrderLine, IsLineMatchedToReceiptLine, HasEDocumentOrderMatchWarnings, VATProdPostGroupIsVisible : Boolean; @@ -382,6 +383,12 @@ page 6183 "E-Doc. Purchase Draft Subform" SetVATProductPostingGroupVisibility(); end; + trigger OnDeleteRecord(): Boolean + begin + EDocumentNotification.RefreshAndShowSubTotalMismatchAfterLineDeletion(Rec); + exit(true); + end; + internal procedure SetEDocumentPurchaseHeader(EDocPurchHeader: Record "E-Document Purchase Header") begin EDocumentPurchaseHeader := EDocPurchHeader; @@ -407,10 +414,8 @@ page 6183 "E-Doc. Purchase Draft Subform" VATProdPostGroupIsVisible := PurchSetup."Resolve VAT Group Purch EDoc"; end; - local procedure UpdateCalculatedAmounts(UpdateParentRecord: Boolean) + local procedure UpdateCalculatedAmounts(UserModifiedAmount: Boolean) var - TotalEDocPurchaseLine: Record "E-Document Purchase Line"; - EDocumentImportHelper: Codeunit "E-Document Import Helper"; LineSubtotal: Decimal; DiscountExceedsSubtotalErr: Label 'Discount should not exceed the subtotal of the line'; begin @@ -423,19 +428,9 @@ page 6183 "E-Doc. Purchase Draft Subform" else if Rec."Total Discount" / LineSubtotal > 1 then Error(DiscountExceedsSubtotalErr); - if not UpdateParentRecord then + if not UserModifiedAmount then exit; - if not EDocumentPurchaseHeader.Get(Rec."E-Document Entry No.") then - exit; - EDocumentPurchaseHeader."Sub Total" := 0; - TotalEDocPurchaseLine.SetRange("E-Document Entry No.", Rec."E-Document Entry No."); - if TotalEDocPurchaseLine.FindSet() then - repeat - EDocumentPurchaseHeader."Sub Total" += Round(TotalEDocPurchaseLine.Quantity * TotalEDocPurchaseLine."Unit Price", EDocumentImportHelper.GetCurrencyRoundingPrecision(EDocumentPurchaseHeader."Currency Code")) - TotalEDocPurchaseLine."Total Discount"; - until TotalEDocPurchaseLine.Next() = 0; - EDocumentPurchaseHeader.Total := EDocumentPurchaseHeader."Sub Total" + EDocumentPurchaseHeader."Total VAT" - EDocumentPurchaseHeader."Total Discount"; - EDocumentPurchaseHeader.Modify(); - CurrPage.Update(); + EDocumentNotification.RefreshAndShowSubTotalMismatchAfterLineEdit(Rec); end; local procedure SetHasAdditionalColumns() diff --git a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al index 0dd57bc3350..01b95af20c6 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Import/Purchase/EDocumentPurchaseDraft.Page.al @@ -214,6 +214,7 @@ page 6181 "E-Document Purchase Draft" begin UpdateTotal(); EDocumentPurchaseHeader.Modify(); + GlobalEDocumentNotification.RefreshAndShowSubTotalMismatchAfterHeaderEdit(EDocumentPurchaseHeader); CurrPage.Update(); end; } @@ -266,6 +267,7 @@ page 6181 "E-Document Purchase Draft" trigger OnValidate() begin EDocumentPurchaseHeader.Modify(); + GlobalEDocumentNotification.RefreshAndShowSubTotalMismatchAfterHeaderEdit(EDocumentPurchaseHeader); CurrPage.Update(); end; } @@ -509,7 +511,6 @@ page 6181 "E-Document Purchase Draft" var EDocumentDataStorage: Record "E-Doc. Data Storage"; PurchasesPayablesSetup: Record "Purchases & Payables Setup"; - EDocumentNotification: Codeunit "E-Document Notification"; EDocPOMatching: Codeunit "E-Doc. PO Matching"; MatchesRemovedMsg: Label 'This e-document was matched to purchase order lines, but the matches are no longer consistent with the current data. The matches have been removed'; begin @@ -528,7 +529,7 @@ page 6181 "E-Document Purchase Draft" HasErrors := false; PageEditable := IsEditable(); IsCreditMemo := Rec."Document Type" = Enum::"E-Document Type"::"Purchase Credit Memo"; - EDocumentNotification.SendPurchaseDocumentDraftNotifications(Rec."Entry No"); + GlobalEDocumentNotification.RefreshAndShowPendingDraftNotifications(Rec."Entry No"); if PurchasesPayablesSetup.Get() then ApplyVATDiffEnabled := PurchasesPayablesSetup."Apply VAT Diff. For Purch EDoc"; @@ -779,6 +780,7 @@ page 6181 "E-Document Purchase Draft" EDocumentPurchaseHeader: Record "E-Document Purchase Header"; EDocumentServiceStatus: Record "E-Document Service Status"; EDocumentErrorHelper: Codeunit "E-Document Error Helper"; + GlobalEDocumentNotification: Codeunit "E-Document Notification"; EDocumentProcessing: Codeunit "E-Document Processing"; FeatureTelemetry: Codeunit "Feature Telemetry"; GlobalEDocumentHelper: Codeunit "E-Document Helper"; diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocPurchDraftTotalsTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocPurchDraftTotalsTests.Codeunit.al new file mode 100644 index 00000000000..b0f5e8b5c3c --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocPurchDraftTotalsTests.Codeunit.al @@ -0,0 +1,957 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Test; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration; +using Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using Microsoft.Finance.Currency; +using Microsoft.Purchases.Vendor; + +codeunit 135648 "E-Doc Purch Draft Totals Tests" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + var + EDocumentService: Record "E-Document Service"; + Assert: Codeunit Assert; + LibraryEDoc: Codeunit "Library - E-Document"; + LibraryUtility: Codeunit "Library - Utility"; + ExpectedNotificationId: Guid; + ExpectedNotificationEntryNo: Integer; + SentNotificationCount: Integer; + + [Test] + procedure AddSubTotalMismatchNotificationPersistsRecord() + var + EDocumentNotification: Codeunit "E-Document Notification"; + EntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] Adding a Sub Total Mismatch notification persists exactly one record for the user + e-document + Initialize(); + + // [GIVEN] A clean notification table for a given entry no + EntryNo := 909091; + + // [WHEN] Adding the notification twice (idempotent) + EDocumentNotification.AddSubTotalMismatchNotification(EntryNo); + EDocumentNotification.AddSubTotalMismatchNotification(EntryNo); + + // [THEN] Exactly one record of type Sub Total Mismatch exists + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EntryNo), 'Exactly one Sub Total Mismatch notification must exist.'); + end; + + [Test] + procedure RemoveSubTotalMismatchNotificationDeletesRecord() + var + EDocumentNotification: Codeunit "E-Document Notification"; + EntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] Removing the notification deletes the persisted record (totals re-converged) + Initialize(); + + // [GIVEN] A persisted Sub Total Mismatch notification + EntryNo := 909092; + EDocumentNotification.AddSubTotalMismatchNotification(EntryNo); + + // [GIVEN] The notification was actually persisted + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EntryNo), 'The Sub Total Mismatch notification must exist before removal.'); + + // [WHEN] Removing it + EDocumentNotification.RemoveSubTotalMismatchNotification(EntryNo); + + // [THEN] No record remains for that entry no + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EntryNo), 'The Sub Total Mismatch notification must be removed.'); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure EditingLineDoesNotOverwriteHeaderSubTotal() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Editing a draft line no longer overwrites the extracted header Sub Total / Total + Initialize(); + + // [GIVEN] An inbound e-document with header Sub Total intentionally different from the sum of the lines + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + + // [WHEN] Editing the line quantity on the draft page + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + EDocumentPurchaseDraft.Lines.Quantity.SetValue(3); + EDocumentPurchaseDraft.Close(); + + // [THEN] The header Sub Total / Total are unchanged (no overwrite from the sum of the lines) + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + VerifySubTotalMismatchNotificationShown(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure AddingLineTriggersSubTotalMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Adding a new draft line that makes the sum of the lines diverge from the header Sub Total shows the Sub Total Mismatch notification + Initialize(); + + // [GIVEN] An inbound e-document "E" whose header Sub Total (1000) matches its single line (1000) + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + + // [GIVEN] The purchase draft page is open on "E" while the header Sub Total still matches the sum of the lines + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'No Sub Total Mismatch notification should exist before adding the line.'); + + // [WHEN] Adding a new line of 500 so the sum of the lines (1500) no longer matches the header Sub Total (1000) + EDocumentPurchaseDraft.Lines.New(); + EDocumentPurchaseDraft.Lines.Description.SetValue('Added line'); + EDocumentPurchaseDraft.Lines.Quantity.SetValue(1); + EDocumentPurchaseDraft.Lines."Direct Unit Cost".SetValue(500); + // Leave the new row so it is committed and the totals are re-evaluated against the persisted lines + EDocumentPurchaseDraft.Lines.First(); + EDocumentPurchaseDraft.Close(); + + // [THEN] The Sub Total Mismatch notification is shown (SendNotificationHandler) and persisted for "E" + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A Sub Total Mismatch notification should exist after adding the line.'); + VerifySubTotalMismatchNotificationShown(); + end; + + [Test] + procedure DifferenceEqualToToleranceDoesNotCreateNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] A Sub Total difference equal to the rounding tolerance does not create a notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with one line whose subtotal differs from the header by exactly 0.01 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000.01); + + // [WHEN] Opening the purchase draft for "E" + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] No Sub Total Mismatch notification is persisted because the difference equals the one-line tolerance + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A difference equal to the tolerance must not create a notification.'); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure HeaderSubtotalAboveLinesBeyondToleranceCreatesNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] A header Sub Total above the lines by more than the tolerance creates a notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000.02 and line subtotal 1000 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000.02); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + + // [WHEN] Opening the purchase draft for "E" + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] One Sub Total Mismatch notification is persisted for "E" + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A header subtotal above the lines beyond tolerance must create a notification.'); + VerifySubTotalMismatchNotificationShown(); + + // [THEN] The extracted header totals remain unchanged + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000.02, 1000.02); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure LinesSubtotalAboveHeaderBeyondToleranceCreatesNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] A lines Sub Total above the header by more than the tolerance creates a notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000 and line subtotal 1000.02 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000.02); + + // [WHEN] Opening the purchase draft for "E" + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] One Sub Total Mismatch notification is persisted for "E" + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A lines subtotal above the header beyond tolerance must create a notification.'); + VerifySubTotalMismatchNotificationShown(); + + // [THEN] The extracted header totals remain unchanged + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + procedure MultipleLinesUsePerLineRoundingAndAccumulatedTolerance() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Multiple lines use per-line currency rounding and accumulate the allowed tolerance + Initialize(); + + // [GIVEN] An inbound e-document "E" with two lines that each round from 500.004 to 500 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000.02); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500.004); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500.004); + + // [WHEN] Opening the purchase draft for "E" + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] No notification is persisted because the 0.02 difference equals the two-line tolerance + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A difference equal to the accumulated tolerance must not create a notification.'); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure EditingLineToReconcileSubtotalRemovesMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Editing a line so its subtotal matches the header removes the mismatch notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000, line subtotal 1000.02, and a mismatch notification + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000.02); + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A Sub Total Mismatch notification must exist before reconciling the line.'); + VerifySubTotalMismatchNotificationShown(); + + // [WHEN] Changing the line direct unit cost to 1000 + EDocumentPurchaseDraft.Lines."Direct Unit Cost".SetValue(1000); + EDocumentPurchaseDraft.Close(); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] The Sub Total Mismatch notification is removed + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'The Sub Total Mismatch notification must be removed after reconciling the line.'); + + // [THEN] The extracted header totals remain unchanged + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure DeletingLineReevaluatesMismatchWithoutChangingHeaderTotals() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Deleting a line re-evaluates the mismatch without changing the extracted header totals + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000 matching two lines of 500 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'No Sub Total Mismatch notification should exist before deleting the line.'); + + // [WHEN] Deleting the second line + EDocumentPurchaseLine.Delete(); + EDocumentPurchaseDraft.Close(); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] One Sub Total Mismatch notification is persisted for "E" + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Deleting a line must create a Sub Total Mismatch notification.'); + VerifySubTotalMismatchNotificationShown(); + + // [THEN] The extracted header totals remain unchanged + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + procedure DismissedNotificationSuppressesSubTotalMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [SCENARIO] When the notification is marked dismissed, opening the draft (OnAfterGetRecord) does not re-show the mismatch notification + Initialize(); + + // [GIVEN] A draft with header Sub Total 1000 and a single line subtotal 500 (mismatch beyond tolerance) + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + + // [GIVEN] The notification row is already marked as dismissed for the user + SetSubTotalMismatchDismissed(EDocument."Entry No", true); + + // [WHEN] Opening the purchase draft (fires OnAfterGetRecord repeatedly) + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + + // [THEN] No Sub Total Mismatch notification is persisted while dismissed + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A dismissed header must suppress the Sub Total Mismatch notification.'); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure EditingAmountReArmsDismissedSubTotalMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [SCENARIO] Editing a line amount while dismissed re-arms and re-shows the mismatch notification + Initialize(); + + // [GIVEN] A draft with header Sub Total 1000, one line subtotal 500 (mismatch), marked as dismissed + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + SetSubTotalMismatchDismissed(EDocument."Entry No", true); + + // [GIVEN] The draft is open and the notification is suppressed + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'The notification must be suppressed before the amount edit.'); + + // [WHEN] Editing the line quantity to 3 (lines subtotal 1500, still a mismatch) + EDocumentPurchaseDraft.Lines.Quantity.SetValue(3); + + // [THEN] The notification is shown again and the dismissed state is cleared + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Editing an amount must re-arm and re-show the mismatch notification.'); + Assert.IsFalse(GetSubTotalMismatchDismissed(EDocument."Entry No"), 'Editing an amount must clear the dismissed state.'); + VerifySubTotalMismatchNotificationShown(); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + procedure DismissActionMarksNotificationDismissed() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentNotification: Codeunit "E-Document Notification"; + DismissNotification: Notification; + begin + // [SCENARIO] Invoking the Dismiss action marks the persisted notification as dismissed (row is kept, not deleted) + Initialize(); + + // [GIVEN] A draft with a persisted Sub Total Mismatch notification + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + EDocumentNotification.AddSubTotalMismatchNotification(EDocument."Entry No"); + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'The notification must be shown before dismissing.'); + + // [WHEN] The Dismiss action runs + DismissNotification := BuildSubTotalMismatchNotification(EDocument."Entry No"); + EDocumentNotification.DismissSubTotalMismatchNotification(DismissNotification); + + // [THEN] The notification is no longer shown but the row persists marked as dismissed + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Dismissing must stop showing the notification.'); + Assert.IsTrue(GetSubTotalMismatchDismissed(EDocument."Entry No"), 'Dismissing must mark the notification as dismissed.'); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure HeaderSubTotalEditBeyondToleranceCreatesMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Editing the header Amount Excl. VAT so it diverges from the lines creates and shows the mismatch notification + Initialize(); + + // [GIVEN] An inbound e-document "E" whose header Sub Total (1000) matches its single line (1000) + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + + // [GIVEN] The purchase draft page is open on "E" without a mismatch + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'No Sub Total Mismatch notification should exist before the header edit.'); + + // [WHEN] Setting the header Amount Excl. VAT to 1500 + EDocumentPurchaseDraft."Amount Excl. VAT".SetValue(1500); + + // [THEN] The Sub Total Mismatch notification is persisted and shown for "E" + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Editing the header Sub Total beyond the tolerance must create a notification.'); + VerifySubTotalMismatchNotificationShown(); + + // [THEN] The edited header totals are kept + VerifyHeaderTotals(EDocumentPurchaseHeader, 1500, 1500); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure HeaderSubTotalEditToMatchLinesRemovesMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Editing the header Amount Excl. VAT so it matches the lines removes the mismatch notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1500 and a single line of 1000 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1500); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + + // [GIVEN] The purchase draft page is open on "E" and the mismatch notification was shown + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A Sub Total Mismatch notification must exist before the header edit.'); + VerifySubTotalMismatchNotificationShown(); + + // [WHEN] Setting the header Amount Excl. VAT to 1000 + EDocumentPurchaseDraft."Amount Excl. VAT".SetValue(1000); + + // [THEN] The Sub Total Mismatch notification is removed for "E" + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Reconciling the header Sub Total must remove the notification.'); + + // [THEN] The edited header totals are kept + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure HeaderSubTotalEditReArmsDismissedMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Editing the header Amount Excl. VAT re-arms a previously dismissed mismatch notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000, a single line of 500, and a dismissed notification + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + SetSubTotalMismatchDismissed(EDocument."Entry No", true); + + // [GIVEN] The purchase draft page is open on "E" and the notification is suppressed + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'The notification must be suppressed before the header edit.'); + + // [WHEN] Setting the header Amount Excl. VAT to 1500 (lines subtotal 500, still a mismatch) + EDocumentPurchaseDraft."Amount Excl. VAT".SetValue(1500); + + // [THEN] The notification is shown again and the dismissed state is cleared + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Editing the header Sub Total must re-arm and re-show the mismatch notification.'); + Assert.IsFalse(GetSubTotalMismatchDismissed(EDocument."Entry No"), 'Editing the header Sub Total must clear the dismissed state.'); + VerifySubTotalMismatchNotificationShown(); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure CurrencyCodeEditToCoarserRoundingRemovesMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + CurrencyCode: Code[10]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Changing the header Currency Code to a currency with a coarser rounding precision widens the tolerance and removes the mismatch notification + Initialize(); + + // [GIVEN] A currency "C" with Amount Rounding Precision 1 + CurrencyCode := CreateCurrencyWithRoundingPrecision(1); + + // [GIVEN] An inbound e-document "E" in local currency with header Sub Total 1000.5 and a single line of 1000 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000.5); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + + // [GIVEN] The purchase draft page is open on "E" and the mismatch notification was shown for the 0.01 tolerance + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A Sub Total Mismatch notification must exist in local currency.'); + VerifySubTotalMismatchNotificationShown(); + + // [WHEN] Setting the header Currency Code to "C" + EDocumentPurchaseDraft."Currency Code".SetValue(CurrencyCode); + + // [THEN] The 0.5 difference is within the one-line tolerance of 1 and the notification is removed + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A coarser rounding precision must widen the tolerance and remove the notification.'); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure CurrencyCodeEditToFinerRoundingCreatesMismatchNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + CurrencyCode: Code[10]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Changing the header Currency Code to local currency narrows the tolerance and creates the mismatch notification + Initialize(); + + // [GIVEN] A currency "C" with Amount Rounding Precision 1 + CurrencyCode := CreateCurrencyWithRoundingPrecision(1); + + // [GIVEN] An inbound e-document "E" in "C" with header Sub Total 1000.5 and a single line of 1000 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000.5); + SetHeaderCurrencyCode(EDocumentPurchaseHeader, CurrencyCode); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + + // [GIVEN] The purchase draft page is open on "E" without a mismatch because the tolerance is 1 + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'No Sub Total Mismatch notification should exist for the coarse rounding precision.'); + + // [WHEN] Clearing the header Currency Code so local currency rounding applies + EDocumentPurchaseDraft."Currency Code".SetValue(''); + + // [THEN] The 0.5 difference exceeds the one-line tolerance of 0.01 and the notification is shown + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A finer rounding precision must narrow the tolerance and create the notification.'); + VerifySubTotalMismatchNotificationShown(); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure LineDeletionEvaluationExcludesDeletedLineAndCreatesNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentNotification: Codeunit "E-Document Notification"; + begin + // [FEATURE] [AI test] + // [SCENARIO] The line deletion evaluation, as run by the subform OnDeleteRecord trigger, excludes the line being deleted while it is still persisted + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000 matching two lines "L1" and "L2" of 500 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'No Sub Total Mismatch notification should exist while the lines match the header.'); + + // [WHEN] Evaluating the mismatch for "L2" as deleted, before the row is removed from the database + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + EDocumentNotification.RefreshAndShowSubTotalMismatchAfterLineDeletion(EDocumentPurchaseLine); + + // [THEN] The remaining line subtotal of 500 no longer matches the header and the notification is shown + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Deleting a line must exclude it from the lines subtotal and create a notification.'); + VerifySubTotalMismatchNotificationShown(); + + // [THEN] The extracted header totals remain unchanged + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure LineDeletionEvaluationReconcilingSubTotalRemovesNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentNotification: Codeunit "E-Document Notification"; + EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Deleting the line that caused the divergence removes the mismatch notification + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000 and lines "L1" of 1000 and "L2" of 500 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + + // [GIVEN] The purchase draft page is open on "E" and the mismatch notification was shown + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + OpenPurchaseDraft(EDocumentPurchaseDraft, EDocument); + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'A Sub Total Mismatch notification must exist before deleting the line.'); + VerifySubTotalMismatchNotificationShown(); + + // [WHEN] Evaluating the mismatch for "L2" as deleted + EDocumentNotification.RefreshAndShowSubTotalMismatchAfterLineDeletion(EDocumentPurchaseLine); + + // [THEN] The remaining line subtotal of 1000 matches the header and the notification is removed + Assert.AreEqual(0, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Deleting the diverging line must remove the notification.'); + + // [THEN] The extracted header totals remain unchanged + VerifyHeaderTotals(EDocumentPurchaseHeader, 1000, 1000); + EDocumentPurchaseDraft.Close(); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure DismissVendorMatchNotificationKeepsRowMarkedDismissed() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentNotification: Codeunit "E-Document Notification"; + DismissNotification: Notification; + begin + // [FEATURE] [AI test] + // [SCENARIO] Dismissing the Vendor Matched By Name Not Address notification keeps the row and marks it as dismissed + Initialize(); + + // [GIVEN] An inbound e-document "E" with a Vendor Matched By Name Not Address notification + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + EDocumentNotification.AddVendorMatchedByNameNotAddressNotification(EDocument."Entry No"); + + // [GIVEN] The notification is sent for "E" + ExpectVendorMatchNotification(EDocument."Entry No"); + EDocumentNotification.SendPurchaseDocumentDraftNotifications(EDocument."Entry No"); + VerifyNotificationSentCount(1); + + // [WHEN] The Dismiss action runs + DismissNotification := BuildVendorMatchNotification(EDocument."Entry No"); + EDocumentNotification.DismissVendorMatchedByNameNotAddressNotification(DismissNotification); + + // [THEN] The row is kept and marked as dismissed + Assert.AreEqual(1, CountVendorMatchNotificationRows(EDocument."Entry No"), 'Dismissing must keep the persisted notification row.'); + Assert.IsTrue(GetVendorMatchDismissed(EDocument."Entry No"), 'Dismissing must mark the notification as dismissed.'); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure DismissedVendorMatchNotificationIsNotSentAgain() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentNotification: Codeunit "E-Document Notification"; + DismissNotification: Notification; + begin + // [FEATURE] [AI test] + // [SCENARIO] A dismissed Vendor Matched By Name Not Address notification is not sent again when the draft notifications are sent + Initialize(); + + // [GIVEN] An inbound e-document "E" with a Vendor Matched By Name Not Address notification that was sent once + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + EDocumentNotification.AddVendorMatchedByNameNotAddressNotification(EDocument."Entry No"); + ExpectVendorMatchNotification(EDocument."Entry No"); + EDocumentNotification.SendPurchaseDocumentDraftNotifications(EDocument."Entry No"); + VerifyNotificationSentCount(1); + + // [GIVEN] The user dismissed the notification + DismissNotification := BuildVendorMatchNotification(EDocument."Entry No"); + EDocumentNotification.DismissVendorMatchedByNameNotAddressNotification(DismissNotification); + + // [WHEN] Sending the purchase draft notifications for "E" again + EDocumentNotification.SendPurchaseDocumentDraftNotifications(EDocument."Entry No"); + + // [THEN] The notification is not sent a second time + VerifyNotificationSentCount(1); + end; + + [Test] + procedure RefreshSubTotalMismatchPersistsWithoutShowingNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentNotification: Codeunit "E-Document Notification"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Refreshing the Sub Total mismatch state persists the notification without showing it + Initialize(); + + // [GIVEN] An inbound e-document "E" with header Sub Total 1000 and a single line of 500 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + + // [WHEN] Refreshing the Sub Total mismatch state, with no notification handler registered + EDocumentNotification.RefreshSubTotalMismatch(EDocumentPurchaseHeader); + + // [THEN] The notification is persisted, and nothing was shown - a shown notification would fail this test as unhandled + Assert.AreEqual(1, CountSubTotalMismatchNotifications(EDocument."Entry No"), 'Refreshing must persist the Sub Total Mismatch notification.'); + end; + + [Test] + [HandlerFunctions('SendNotificationHandler')] + procedure OnlyHeaderEditRefreshReArmsDismissedNotification() + var + EDocument: Record "E-Document"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentNotification: Codeunit "E-Document Notification"; + begin + // [FEATURE] [AI test] + // [SCENARIO] A plain refresh keeps a dismissal, while a refresh after a header edit re-arms it + Initialize(); + + // [GIVEN] An inbound e-document "E" whose header Sub Total 1000 does not match its single line of 500 + CreatePurchaseDraft(EDocument, EDocumentPurchaseHeader, 1000); + CreatePurchaseLine(EDocumentPurchaseLine, EDocument, 1, 500); + + // [GIVEN] The user dismissed the Sub Total Mismatch notification for "E" + SetSubTotalMismatchDismissed(EDocument."Entry No", true); + + // [WHEN] Refreshing the state without an amount edit + EDocumentNotification.RefreshSubTotalMismatch(EDocumentPurchaseHeader); + + // [THEN] The notification stays dismissed + Assert.IsTrue(GetSubTotalMismatchDismissed(EDocument."Entry No"), 'A plain refresh must not re-arm a dismissed notification.'); + + // [WHEN] Refreshing after a header amount edit + ExpectSubTotalMismatchNotification(EDocument."Entry No"); + EDocumentNotification.RefreshAndShowSubTotalMismatchAfterHeaderEdit(EDocumentPurchaseHeader); + + // [THEN] The notification is re-armed and shown again + Assert.IsFalse(GetSubTotalMismatchDismissed(EDocument."Entry No"), 'A refresh after a header edit must re-arm a dismissed notification.'); + VerifySubTotalMismatchNotificationShown(); + end; + + local procedure Initialize() + var + EDocument: Record "E-Document"; + EDocumentServiceStatus: Record "E-Document Service Status"; + EDocumentPurchaseHeader: Record "E-Document Purchase Header"; + EDocumentPurchaseLine: Record "E-Document Purchase Line"; + EDocumentNotificationRec: Record "E-Document Notification"; + begin + Clear(ExpectedNotificationId); + ExpectedNotificationEntryNo := 0; + SentNotificationCount := 0; + + EDocumentNotificationRec.SetRange("User Id", UserId()); + EDocumentNotificationRec.DeleteAll(); + EDocumentPurchaseLine.DeleteAll(); + EDocumentPurchaseHeader.DeleteAll(); + EDocumentServiceStatus.DeleteAll(); + EDocument.DeleteAll(); + end; + + local procedure CreatePurchaseDraft(var EDocument: Record "E-Document"; var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; HeaderSubTotal: Decimal) + var + Vendor: Record Vendor; + begin + LibraryEDoc.SetupStandardVAT(); + LibraryEDoc.SetupStandardPurchaseScenario(Vendor, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::Mock, Enum::"E-Document Import Process"::"Version 2.0"); + LibraryEDoc.CreateInboundEDocument(EDocument, EDocumentService); + + EDocumentPurchaseHeader := LibraryEDoc.MockPurchaseDraftPrepared(EDocument); + EDocumentPurchaseHeader."Sub Total" := HeaderSubTotal; + EDocumentPurchaseHeader."Total VAT" := 0; + EDocumentPurchaseHeader.Total := HeaderSubTotal; + EDocumentPurchaseHeader.Modify(); + end; + + local procedure CreatePurchaseLine(var EDocumentPurchaseLine: Record "E-Document Purchase Line"; EDocument: Record "E-Document"; Quantity: Decimal; UnitPrice: Decimal) + begin + EDocumentPurchaseLine := LibraryEDoc.InsertPurchaseDraftLine(EDocument); + EDocumentPurchaseLine.Description := 'Totals test line'; + EDocumentPurchaseLine.Quantity := Quantity; + EDocumentPurchaseLine."Unit Price" := UnitPrice; + EDocumentPurchaseLine.Modify(); + end; + + local procedure OpenPurchaseDraft(var EDocumentPurchaseDraft: TestPage "E-Document Purchase Draft"; EDocument: Record "E-Document") + begin + // The page must be opened on the e-document so that OnOpenPage, which re-evaluates the + // Sub Total mismatch, runs with the record instead of an initialized one. + EDocumentPurchaseDraft.Trap(); + Page.Run(Page::"E-Document Purchase Draft", EDocument); + EDocumentPurchaseDraft.Lines.First(); + end; + + local procedure CreateCurrencyWithRoundingPrecision(RoundingPrecision: Decimal): Code[10] + var + Currency: Record Currency; + begin + // The Code validation raises the base application missing exchange rates notification, which would reach the shared notification handler. + Currency.Init(); + Currency.Code := CopyStr(LibraryUtility.GenerateRandomCode(Currency.FieldNo(Code), Database::Currency), 1, MaxStrLen(Currency.Code)); + Currency."Amount Rounding Precision" := RoundingPrecision; + Currency."Unit-Amount Rounding Precision" := RoundingPrecision; + Currency.Insert(); + exit(Currency.Code); + end; + + local procedure SetHeaderCurrencyCode(var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; CurrencyCode: Code[10]) + begin + EDocumentPurchaseHeader."Currency Code" := CurrencyCode; + EDocumentPurchaseHeader.Modify(); + end; + + local procedure CountSubTotalMismatchNotifications(EDocumentEntryNo: Integer): Integer + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + EDocumentNotificationRec.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentNotificationRec.SetRange(Type, "E-Document Notification Type"::"Sub Total Mismatch"); + EDocumentNotificationRec.SetRange("User Id", UserId()); + EDocumentNotificationRec.SetRange(Dismissed, false); + exit(EDocumentNotificationRec.Count()); + end; + + local procedure VerifyHeaderTotals(var EDocumentPurchaseHeader: Record "E-Document Purchase Header"; ExpectedSubTotal: Decimal; ExpectedTotal: Decimal) + begin + EDocumentPurchaseHeader.Get(EDocumentPurchaseHeader."E-Document Entry No."); + Assert.AreEqual(ExpectedSubTotal, EDocumentPurchaseHeader."Sub Total", 'Header Sub Total must not be changed by line edits.'); + Assert.AreEqual(ExpectedTotal, EDocumentPurchaseHeader.Total, 'Header Total must not be changed by line edits.'); + end; + + local procedure GetSubTotalMismatchDismissed(EDocumentEntryNo: Integer): Boolean + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + if EDocumentNotificationRec.Get(EDocumentEntryNo, SubTotalMismatchNotificationId(), UserId()) then + exit(EDocumentNotificationRec.Dismissed); + exit(false); + end; + + local procedure SetSubTotalMismatchDismissed(EDocumentEntryNo: Integer; Dismissed: Boolean) + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + if not EDocumentNotificationRec.Get(EDocumentEntryNo, SubTotalMismatchNotificationId(), UserId()) then begin + EDocumentNotificationRec.Init(); + EDocumentNotificationRec."E-Document Entry No." := EDocumentEntryNo; + EDocumentNotificationRec.ID := SubTotalMismatchNotificationId(); + EDocumentNotificationRec."User Id" := UserId(); + EDocumentNotificationRec.Type := "E-Document Notification Type"::"Sub Total Mismatch"; + EDocumentNotificationRec.Insert(); + end; + EDocumentNotificationRec.Dismissed := Dismissed; + EDocumentNotificationRec.Modify(); + end; + + local procedure SubTotalMismatchNotificationId(): Guid + begin + exit('a1e6c0d2-3b4f-4c8a-9d1e-2f7b6a5c4d3e'); + end; + + local procedure BuildSubTotalMismatchNotification(EDocumentEntryNo: Integer) Notification: Notification + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + Notification.SetData(EDocumentNotificationRec.FieldName("E-Document Entry No."), Format(EDocumentEntryNo)); + Notification.SetData(EDocumentNotificationRec.FieldName(ID), SubTotalMismatchNotificationId()); + end; + + local procedure ExpectSubTotalMismatchNotification(EDocumentEntryNo: Integer) + begin + ExpectedNotificationId := SubTotalMismatchNotificationId(); + ExpectedNotificationEntryNo := EDocumentEntryNo; + SentNotificationCount := 0; + end; + + local procedure VerifySubTotalMismatchNotificationShown() + begin + Assert.IsTrue(SentNotificationCount > 0, 'The Sub Total Mismatch notification must be shown.'); + end; + + local procedure VerifyNotificationSentCount(ExpectedCount: Integer) + begin + Assert.AreEqual(ExpectedCount, SentNotificationCount, 'The notification was sent an unexpected number of times.'); + end; + + local procedure VendorMatchNotificationId(): Guid + begin + exit('bc0d8537-8e8d-4d94-a07a-a5a54c729d2a'); + end; + + local procedure CountVendorMatchNotificationRows(EDocumentEntryNo: Integer): Integer + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + EDocumentNotificationRec.SetRange("E-Document Entry No.", EDocumentEntryNo); + EDocumentNotificationRec.SetRange(Type, "E-Document Notification Type"::"Vendor Matched By Name Not Address"); + EDocumentNotificationRec.SetRange("User Id", UserId()); + exit(EDocumentNotificationRec.Count()); + end; + + local procedure GetVendorMatchDismissed(EDocumentEntryNo: Integer): Boolean + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + if EDocumentNotificationRec.Get(EDocumentEntryNo, VendorMatchNotificationId(), UserId()) then + exit(EDocumentNotificationRec.Dismissed); + exit(false); + end; + + local procedure BuildVendorMatchNotification(EDocumentEntryNo: Integer) Notification: Notification + var + EDocumentNotificationRec: Record "E-Document Notification"; + begin + Notification.SetData(EDocumentNotificationRec.FieldName("E-Document Entry No."), Format(EDocumentEntryNo)); + Notification.SetData(EDocumentNotificationRec.FieldName(ID), VendorMatchNotificationId()); + end; + + local procedure ExpectVendorMatchNotification(EDocumentEntryNo: Integer) + begin + ExpectedNotificationId := VendorMatchNotificationId(); + ExpectedNotificationEntryNo := EDocumentEntryNo; + SentNotificationCount := 0; + end; + + [SendNotificationHandler] + procedure SendNotificationHandler(var Notification: Notification): Boolean + var + EDocumentNotificationRec: Record "E-Document Notification"; + ActualEntryNo: Integer; + begin + SentNotificationCount += 1; + Assert.AreEqual(ExpectedNotificationId, Notification.Id, 'An unexpected notification was shown.'); + Assert.AreEqual(Format(ExpectedNotificationId), Notification.GetData(EDocumentNotificationRec.FieldName(ID)), 'The notification carries an unexpected ID.'); + Evaluate(ActualEntryNo, Notification.GetData(EDocumentNotificationRec.FieldName("E-Document Entry No."))); + Assert.AreEqual(ExpectedNotificationEntryNo, ActualEntryNo, 'The notification was shown for an unexpected e-document.'); + exit(true); + end; +}