diff --git a/src/Apps/W1/ExpenseAgent/app/app.json b/src/Apps/W1/ExpenseAgent/app/app.json index fadf16fcbc8..45e492e888a 100644 --- a/src/Apps/W1/ExpenseAgent/app/app.json +++ b/src/Apps/W1/ExpenseAgent/app/app.json @@ -134,7 +134,7 @@ }, { "from": 7073, - "to": 7103 + "to": 7123 } ], "features": [ diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ActivityLog/ExpenseActivityLogAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ActivityLog/ExpenseActivityLogAPI.Page.al new file mode 100644 index 00000000000..d3589c872a9 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ActivityLog/ExpenseActivityLogAPI.Page.al @@ -0,0 +1,230 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +page 7122 "Expense Activity Log API" +{ + APIGroup = 'expense'; + APIPublisher = 'microsoft'; + APIVersion = 'beta'; + EntityCaption = 'Expense Activity Log Entry'; + EntitySetCaption = 'Expense Activity Log Entries'; + EntityName = 'expenseActivityLogEntry'; + EntitySetName = 'expenseActivityLogEntries'; + PageType = API; + DelayedInsert = true; + SourceTable = "Expense Activity Log Entry"; + ODataKeyFields = SystemId; + DataAccessIntent = ReadOnly; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + Extensible = false; + AboutText = 'Provides activity history when scoped through an expense report, posted expense report, or expense user. Direct unscoped access is not allowed. Expense user history requires the historyActorRole filter.'; + + layout + { + area(Content) + { + repeater(General) + { + field(id; Rec.SystemId) + { + Caption = 'ID'; + } + field(entryNumber; Rec."Entry No.") + { + Caption = 'Entry Number'; + } + field(sourceTableId; Rec."Source Table ID") + { + Caption = 'Source Table ID'; + } + field(sourceId; Rec."Source Record System ID") + { + Caption = 'Source ID'; + } + field(subjectTableId; Rec."Subject Table ID") + { + Caption = 'Subject Table ID'; + } + field(subjectId; Rec."Subject System ID") + { + Caption = 'Subject ID'; + } + field(documentNumber; Rec."Document No.") + { + Caption = 'Document Number'; + } + field(documentDescription; Rec."Document Description") + { + Caption = 'Document Description'; + } + field(eventType; Rec."Event Type") + { + Caption = 'Event Type'; + } + field(occurredAt; Rec."Occurred At") + { + Caption = 'Occurred At'; + } + field(initiatedBy; Rec."Initiated By") + { + Caption = 'Initiated By'; + } + field(actorRole; Rec."Actor Role") + { + Caption = 'Actor Role'; + } + field(actorTableId; Rec."Actor Table ID") + { + Caption = 'Actor Table ID'; + } + field(actorId; Rec."Actor Record System ID") + { + Caption = 'Actor ID'; + } + field(actorDisplayName; Rec."Actor Display Name") + { + Caption = 'Actor Display Name'; + } + field(comment; Rec.Comment) + { + Caption = 'Comment'; + } + field(amountLCY; Rec."Amount (LCY)") + { + Caption = 'Amount (LCY)'; + } + field(currencyLCY; CurrencyLCY) + { + Caption = 'Currency (LCY)'; + } + field(nonRefundableAmountLCY; Rec."Non-Refundable Amount (LCY)") + { + Caption = 'Non-Refundable Amount (LCY)'; + } + field(reimbursableAmount; Rec."Reimbursable Amount") + { + Caption = 'Reimbursable Amount'; + } + field(reimbursableAmountLCY; Rec."Reimbursable Amount (LCY)") + { + Caption = 'Reimbursable Amount (LCY)'; + } + field(refundableAmount; Rec."Refundable Amount") + { + Caption = 'Refundable Amount'; + } + field(refundableAmountLCY; Rec."Refundable Amount (LCY)") + { + Caption = 'Refundable Amount (LCY)'; + } + field(reimbursementCurrencyCode; ReimbursementCurrencyCode) + { + Caption = 'Reimbursement Currency Code'; + } + field(reimbursementCurrencyFactor; Rec."Reimbursement Currency Factor") + { + Caption = 'Reimbursement Currency Factor'; + } + field(categories; Rec.Categories) + { + Caption = 'Categories'; + } + field(attachedReceiptCount; Rec."Attached Receipt Count") + { + Caption = 'Attached Receipt Count'; + } + field(expenseCount; Rec."Expense Count") + { + Caption = 'Expense Count'; + } + field(historyActorRole; Rec."History Actor Role Filter") + { + Caption = 'History Actor Role'; + } + } + } + } + + var + CurrencyHelper: Codeunit "Expense API Currency Helper"; + CurrencyLCY: Code[10]; + HistoryScopeApplied: Boolean; + ReimbursementCurrencyCode: Code[10]; + HistoryActorRoleRequiredErr: Label 'The historyActorRole filter must be specified as Submitter or Approver.'; + ActivityScopeRequiredErr: Label 'Activity log entries must be requested through an expense report, posted expense report, or expense user.'; + + trigger OnInit() + var + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + begin + ExpenseAgentAPIValidation.VerifyAgentAccess(); + end; + + trigger OnOpenPage() + begin + // Avoid JIT load consistency errors by including fields read in OnAfterGetRecord in the initial record buffer. + Rec.AddLoadFields("Reimbursement Currency Code"); + end; + + trigger OnAfterGetRecord() + begin + Clear(CurrencyLCY); + Clear(ReimbursementCurrencyCode); + if Rec."Event Type" in [Rec."Event Type"::Submitted, Rec."Event Type"::Resubmitted, Rec."Event Type"::Posted] then begin + CurrencyLCY := CurrencyHelper.GetCurrencyCodeForAPI(''); + ReimbursementCurrencyCode := CurrencyHelper.GetCurrencyCodeForAPI(Rec."Reimbursement Currency Code"); + end; + end; + + trigger OnFindRecord(Which: Text): Boolean + begin + ApplyHistoryScope(); + exit(Rec.Find(Which)); + end; + + local procedure ApplyHistoryScope() + var + OriginalFilterGroup: Integer; + HasHistoryActorFilters: Boolean; + HasSourceFilters: Boolean; + HistoryActorRoleFilter: Text; + begin + if HistoryScopeApplied then + exit; + + OriginalFilterGroup := Rec.FilterGroup(); + HistoryActorRoleFilter := Rec.GetFilter("History Actor Role Filter"); + Rec.FilterGroup(4); + HasSourceFilters := + (Rec.GetFilter("Source Table ID") <> '') and + (Rec.GetFilter("Source Record System ID") <> ''); + HasHistoryActorFilters := + (Rec.GetFilter("History Actor Table ID Filter") <> '') and + (Rec.GetFilter("History Actor System ID Filter") <> ''); + if HistoryActorRoleFilter = '' then + HistoryActorRoleFilter := Rec.GetFilter("History Actor Role Filter"); + Rec.FilterGroup(0); + if HasHistoryActorFilters then begin + if HistoryActorRoleFilter <> '' then begin + Rec.SetCurrentKey("Occurred At", "Entry No."); + Rec.Ascending(false); + Rec.SetRange("History Subject Match", true) + end else + Error(HistoryActorRoleRequiredErr); + end else + if HasSourceFilters then begin + Rec.SetCurrentKey("Source Table ID", "Source Record System ID", "Occurred At", "Entry No."); + Rec.Ascending(false); + end else + Error(ActivityScopeRequiredErr); + Rec.FilterGroup(OriginalFilterGroup); + HistoryScopeApplied := true; + end; + +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al index f5be5cdeff4..935c0541ff0 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al @@ -221,6 +221,14 @@ page 6928 "Expense Reports API" EntitySetName = 'expenseReportLines'; SubPageLink = "Document No." = field("No."); } + part(activityLogEntries; "Expense Activity Log API") + { + Caption = 'Activity Log Entries'; + EntityName = 'expenseActivityLogEntry'; + EntitySetName = 'expenseActivityLogEntries'; + SubPageLink = "Source Table ID" = const(Database::"Expense Report Header"), + "Source Record System ID" = field(SystemId); + } } } } @@ -459,4 +467,5 @@ page 6928 "Expense Reports API" ActionContext.AddEntityKey(Rec.FieldNo(SystemId), Rec.SystemId); ActionContext.SetResultCode(WebServiceActionResultCode::Updated); end; + } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al index 8ff6c0ba718..2fc6cdac2ab 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al @@ -83,6 +83,14 @@ page 6918 "Expense Users API" SubPageLink = "Expense User No." = field("No."); } + part(activityHistory; "Expense Activity Log API") + { + EntityName = 'expenseActivityLogEntry'; + EntitySetName = 'expenseActivityLogEntries'; + SubPageLink = "History Actor Table ID Filter" = const(Database::"Expense User"), + "History Actor System ID Filter" = field(SystemId); + } + part(approverView; "Approver View API") { EntityName = 'approverView'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al index 2526bd241e1..3dc9557518c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al @@ -162,6 +162,14 @@ page 6957 "Posted Expense Reports API" EntitySetName = 'postedExpenseReportLines'; SubPageLink = "Document No." = field("No."); } + part(activityLogEntries; "Expense Activity Log API") + { + Caption = 'Activity Log Entries'; + EntityName = 'expenseActivityLogEntry'; + EntitySetName = 'expenseActivityLogEntries'; + SubPageLink = "Source Table ID" = const(Database::"Posted Expense Report Header"), + "Source Record System ID" = field(SystemId); + } } } } diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Codeunits/ExpenseActivityLogMgt.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Codeunits/ExpenseActivityLogMgt.Codeunit.al new file mode 100644 index 00000000000..d213d783721 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Codeunits/ExpenseActivityLogMgt.Codeunit.al @@ -0,0 +1,288 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +using System.Security.AccessControl; + +codeunit 6926 "Expense Activity Log Mgt." +{ + Access = Internal; + Permissions = tabledata "Expense Activity Log Entry" = rimd, + tabledata User = r; + + /// + /// Appends an activity entry for an in-flight expense report. + /// + internal procedure LogExpenseReportEvent( + ExpenseReportHeader: Record "Expense Report Header"; + EventType: Enum "Expense Activity Event Type"; + InitiatedBy: Enum "Expense Activity Initiator"; + ActorRole: Enum "Expense Activity Actor Role"; + ActorExpenseUserNo: Code[20]; + EventComment: Text + ): BigInteger + var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + begin + InitializeExpenseReportEntry( + ExpenseActivityLogEntry, ExpenseReportHeader, EventType, InitiatedBy, ActorRole, EventComment, CurrentDateTime()); + SetExpenseUserActor(ExpenseActivityLogEntry, ActorExpenseUserNo); + exit(InsertExpenseReportEntry(ExpenseActivityLogEntry, ExpenseReportHeader)); + end; + + /// + /// Appends an activity entry performed directly by a Business Central user. + /// + internal procedure LogExpenseReportEventByBCUser( + ExpenseReportHeader: Record "Expense Report Header"; + EventType: Enum "Expense Activity Event Type"; + ActorRole: Enum "Expense Activity Actor Role"; + EventComment: Text + ): BigInteger + var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + begin + InitializeExpenseReportEntry( + ExpenseActivityLogEntry, ExpenseReportHeader, EventType, + Enum::"Expense Activity Initiator"::User, ActorRole, EventComment, CurrentDateTime()); + SetBCUserActor(ExpenseActivityLogEntry, UserSecurityId()); + exit(InsertExpenseReportEntry(ExpenseActivityLogEntry, ExpenseReportHeader)); + end; + + /// + /// Appends the retrospective creation entry when activity tracking starts at first submission. + /// + internal procedure LogExpenseReportCreatedEvent(ExpenseReportHeader: Record "Expense Report Header"): BigInteger + var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + OccurredAt: DateTime; + begin + OccurredAt := ExpenseReportHeader.SystemCreatedAt; + if OccurredAt = 0DT then + OccurredAt := CurrentDateTime(); + + InitializeExpenseReportEntry( + ExpenseActivityLogEntry, ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Created, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + '', OccurredAt); + + // Expense Agent API creation stores the report's Expense User; direct BC creation is identified by SystemCreatedBy. + if not IsNullGuid(ExpenseReportHeader."Created By Exp. User Id") then + SetExpenseUserActorBySystemID(ExpenseActivityLogEntry, ExpenseReportHeader."Created By Exp. User Id") + else + SetBCUserActor(ExpenseActivityLogEntry, ExpenseReportHeader.SystemCreatedBy); + + exit(InsertExpenseReportEntry(ExpenseActivityLogEntry, ExpenseReportHeader)); + end; + + /// + /// Reassigns a report's entries to the posted report while preserving event and subject identity. + /// + internal procedure ReassignExpenseReportEntriesToPosted( + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header" + ) + var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + EntryNumbers: List of [BigInteger]; + EntryNumber: BigInteger; + begin + // Capture the primary keys before changing fields used by the source filter. + ExpenseActivityLogEntry.SetLoadFields("Entry No."); + ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Source Record System ID", ExpenseReportHeader.SystemId); + if ExpenseActivityLogEntry.FindSet(true) then + repeat + EntryNumbers.Add(ExpenseActivityLogEntry."Entry No."); + until ExpenseActivityLogEntry.Next() = 0; + + // Update both source fields together so an entry cannot be left with an intermediate source identity. + foreach EntryNumber in EntryNumbers do begin + ExpenseActivityLogEntry.Get(EntryNumber); + ExpenseActivityLogEntry."Source Table ID" := Database::"Posted Expense Report Header"; + ExpenseActivityLogEntry."Source Record System ID" := PostedExpenseReportHeader.SystemId; + ExpenseActivityLogEntry.Modify(false); + end; + end; + + /// + /// Deletes activity entries sourced from the specified record. + /// + internal procedure DeleteEntriesForSource(SourceTableID: Integer; SourceRecordSystemID: Guid) + var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + begin + ExpenseActivityLogEntry.SetRange("Source Table ID", SourceTableID); + ExpenseActivityLogEntry.SetRange("Source Record System ID", SourceRecordSystemID); + ExpenseActivityLogEntry.DeleteAll(); + end; + + internal procedure HasEntriesForSource(SourceTableID: Integer; SourceRecordSystemID: Guid): Boolean + var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + begin + ExpenseActivityLogEntry.SetRange("Source Table ID", SourceTableID); + ExpenseActivityLogEntry.SetRange("Source Record System ID", SourceRecordSystemID); + exit(not ExpenseActivityLogEntry.IsEmpty()); + end; + + local procedure InitializeExpenseReportEntry( + var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportHeader: Record "Expense Report Header"; + EventType: Enum "Expense Activity Event Type"; + InitiatedBy: Enum "Expense Activity Initiator"; + ActorRole: Enum "Expense Activity Actor Role"; + EventComment: Text; + OccurredAt: DateTime + ) + begin + ExpenseActivityLogEntry.Init(); + ExpenseActivityLogEntry."Source Table ID" := Database::"Expense Report Header"; + ExpenseActivityLogEntry."Source Record System ID" := ExpenseReportHeader.SystemId; + ExpenseActivityLogEntry."Subject Table ID" := Database::"Expense Report Header"; + ExpenseActivityLogEntry."Subject System ID" := ExpenseReportHeader.SystemId; + ExpenseActivityLogEntry."Document No." := ExpenseReportHeader."No."; + ExpenseActivityLogEntry."Document Description" := ExpenseReportHeader.Description; + ExpenseActivityLogEntry."Event Type" := EventType; + ExpenseActivityLogEntry."Occurred At" := OccurredAt; + ExpenseActivityLogEntry."Initiated By" := InitiatedBy; + ExpenseActivityLogEntry."Actor Role" := ActorRole; + ExpenseActivityLogEntry.Comment := CopyStr(EventComment, 1, MaxStrLen(ExpenseActivityLogEntry.Comment)); + end; + + local procedure InsertExpenseReportEntry( + var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportHeader: Record "Expense Report Header" + ): BigInteger + begin + if ExpenseActivityLogEntry."Event Type" in [ + ExpenseActivityLogEntry."Event Type"::Submitted, + ExpenseActivityLogEntry."Event Type"::Resubmitted, + ExpenseActivityLogEntry."Event Type"::Posted] + then begin + SetAmountSnapshot(ExpenseActivityLogEntry, ExpenseReportHeader); + SetContentsSnapshot(ExpenseActivityLogEntry, ExpenseReportHeader."No."); + end; + + ExpenseActivityLogEntry.Insert(); + exit(ExpenseActivityLogEntry."Entry No."); + end; + + local procedure SetExpenseUserActor(var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ActorExpenseUserNo: Code[20]) + var + ExpenseUser: Record "Expense User"; + begin + if ActorExpenseUserNo = '' then + exit; + + ExpenseUser.SetLoadFields(SystemId, Name); + if ExpenseUser.Get(ActorExpenseUserNo) then begin + ExpenseActivityLogEntry."Actor Table ID" := Database::"Expense User"; + ExpenseActivityLogEntry."Actor Record System ID" := ExpenseUser.SystemId; + ExpenseActivityLogEntry."Actor Display Name" := ExpenseUser.Name; + end; + end; + + local procedure SetExpenseUserActorBySystemID(var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ActorExpenseUserSystemID: Guid) + var + ExpenseUser: Record "Expense User"; + begin + ExpenseActivityLogEntry."Actor Table ID" := Database::"Expense User"; + ExpenseActivityLogEntry."Actor Record System ID" := ActorExpenseUserSystemID; + + ExpenseUser.SetLoadFields(Name); + if ExpenseUser.GetBySystemId(ActorExpenseUserSystemID) then + ExpenseActivityLogEntry."Actor Display Name" := ExpenseUser.Name; + end; + + local procedure SetBCUserActor(var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ActorUserSecurityID: Guid) + var + User: Record User; + begin + if IsNullGuid(ActorUserSecurityID) then + exit; + + User.SetLoadFields(SystemId, "Full Name", "User Name"); + if not User.Get(ActorUserSecurityID) then + exit; + + ExpenseActivityLogEntry."Actor Table ID" := Database::User; + ExpenseActivityLogEntry."Actor Record System ID" := User.SystemId; + if User."Full Name" <> '' then + ExpenseActivityLogEntry."Actor Display Name" := + CopyStr(User."Full Name", 1, MaxStrLen(ExpenseActivityLogEntry."Actor Display Name")) + else + ExpenseActivityLogEntry."Actor Display Name" := + CopyStr(User."User Name", 1, MaxStrLen(ExpenseActivityLogEntry."Actor Display Name")); + end; + + local procedure SetAmountSnapshot( + var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportHeader: Record "Expense Report Header" + ) + begin + ExpenseReportHeader.CalcFields( + "Amount (LCY)", "Non-Refundable Amount (LCY)", + "Reimbursable Amount", "Reimbursable Amount (LCY)", + "Refundable Amount", "Refundable Amount (LCY)"); + ExpenseActivityLogEntry."Amount (LCY)" := ExpenseReportHeader."Amount (LCY)"; + ExpenseActivityLogEntry."Non-Refundable Amount (LCY)" := ExpenseReportHeader."Non-Refundable Amount (LCY)"; + ExpenseActivityLogEntry."Reimbursable Amount" := ExpenseReportHeader."Reimbursable Amount"; + ExpenseActivityLogEntry."Reimbursable Amount (LCY)" := ExpenseReportHeader."Reimbursable Amount (LCY)"; + ExpenseActivityLogEntry."Refundable Amount" := ExpenseReportHeader."Refundable Amount"; + ExpenseActivityLogEntry."Refundable Amount (LCY)" := ExpenseReportHeader."Refundable Amount (LCY)"; + ExpenseActivityLogEntry."Reimbursement Currency Code" := ExpenseReportHeader."Reimbursement Currency Code"; + ExpenseActivityLogEntry."Reimbursement Currency Factor" := ExpenseReportHeader."Reimbursement Currency Factor"; + end; + + local procedure SetContentsSnapshot( + var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportNo: Code[20] + ) + var + ExpenseReportLine: Record "Expense Report Line"; + Categories: JsonArray; + CategoryCodes: List of [Code[20]]; + CategoriesText: Text; + CandidateCategoriesText: Text; + CategoriesTruncated: Boolean; + begin + ExpenseReportLine.SetLoadFields("Expense Category", "Receipt Attached"); + ExpenseReportLine.SetRange("Document No.", ExpenseReportNo); + if ExpenseReportLine.FindSet() then + repeat + ExpenseActivityLogEntry."Expense Count" += 1; + if ExpenseReportLine."Receipt Attached" then + ExpenseActivityLogEntry."Attached Receipt Count" += 1; + + // Add the category to the list if it is not already present and if it fits within the maximum length of the Categories field. + if (not CategoriesTruncated) and + (ExpenseReportLine."Expense Category" <> '') and + (not CategoryCodes.Contains(ExpenseReportLine."Expense Category")) + then begin + CategoryCodes.Add(ExpenseReportLine."Expense Category"); + Categories.Add(ExpenseReportLine."Expense Category"); + Categories.WriteTo(CandidateCategoriesText); + if StrLen(CandidateCategoriesText) > MaxStrLen(ExpenseActivityLogEntry.Categories) then begin + Categories.RemoveAt(Categories.Count() - 1); + Categories.Add('...'); + Categories.WriteTo(CandidateCategoriesText); + while StrLen(CandidateCategoriesText) > MaxStrLen(ExpenseActivityLogEntry.Categories) do begin + Categories.RemoveAt(Categories.Count() - 2); + Categories.WriteTo(CandidateCategoriesText); + end; + CategoriesText := CandidateCategoriesText; + CategoriesTruncated := true; + end; + CategoriesText := CandidateCategoriesText; + end; + until ExpenseReportLine.Next() = 0; + + ExpenseActivityLogEntry.Categories := + CopyStr(CategoriesText, 1, MaxStrLen(ExpenseActivityLogEntry.Categories)); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityActorRole.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityActorRole.Enum.al new file mode 100644 index 00000000000..70bb9c6435b --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityActorRole.Enum.al @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +enum 6924 "Expense Activity Actor Role" +{ + Access = Internal; + Extensible = false; + Caption = 'Expense Activity Actor Role'; + + value(0; " ") + { + Caption = ' ', Locked = true; + } + value(1; Submitter) + { + Caption = 'Submitter'; + } + value(2; Approver) + { + Caption = 'Approver'; + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityEventType.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityEventType.Enum.al new file mode 100644 index 00000000000..1da514c4cd3 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityEventType.Enum.al @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +enum 6922 "Expense Activity Event Type" +{ + Access = Internal; + Extensible = false; + Caption = 'Expense Activity Event Type'; + + value(0; " ") + { + Caption = ' ', Locked = true; + } + value(1; Created) + { + Caption = 'Created'; + } + value(2; ExpenseAddedToReport) + { + Caption = 'Expense added to report'; + } + value(3; ExpenseRemovedFromReport) + { + Caption = 'Expense removed from report'; + } + value(10; Submitted) + { + Caption = 'Submitted'; + } + value(11; Resubmitted) + { + Caption = 'Resubmitted'; + } + value(12; Recalled) + { + Caption = 'Recalled'; + } + value(20; Approved) + { + Caption = 'Approved'; + } + value(21; Rejected) + { + Caption = 'Rejected'; + } + value(22; ReopenedByApprover) + { + Caption = 'Reopened by approver'; + } + value(30; CommentAdded) + { + Caption = 'Comment added'; + } + value(40; PolicyEvaluated) + { + Caption = 'Policy evaluated'; + } + value(50; Edited) + { + Caption = 'Edited'; + } + value(100; Posted) + { + Caption = 'Posted'; + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityInitiator.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityInitiator.Enum.al new file mode 100644 index 00000000000..128313f36f1 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityInitiator.Enum.al @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +enum 6923 "Expense Activity Initiator" +{ + Access = Internal; + Extensible = false; + Caption = 'Expense Activity Initiator'; + + value(0; " ") + { + Caption = ' ', Locked = true; + } + value(1; User) + { + Caption = 'User'; + } + value(2; Agent) + { + Caption = 'Agent'; + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Pages/ExpenseActivityLogFactBox.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Pages/ExpenseActivityLogFactBox.Page.al new file mode 100644 index 00000000000..eac5c064307 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Pages/ExpenseActivityLogFactBox.Page.al @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +page 7123 "Expense Activity Log FactBox" +{ + PageType = ListPart; + SourceTable = "Expense Activity Log Entry"; + SourceTableView = sorting("Source Table ID", "Source Record System ID", "Occurred At", "Entry No.") order(descending); + Caption = 'Activity Log'; + ApplicationArea = Basic, Suite; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + LinksAllowed = false; + + layout + { + area(Content) + { + repeater(Entries) + { + field("Event Type"; Rec."Event Type") + { + ToolTip = 'Specifies what happened to the expense report.'; + } + field("Occurred At"; Rec."Occurred At") + { + ToolTip = 'Specifies when the activity occurred.'; + } + field("Actor Display Name"; Rec."Actor Display Name") + { + ToolTip = 'Specifies the person who performed the activity, when there was one.'; + } + field(Comment; Rec.Comment) + { + ToolTip = 'Specifies the message recorded with the activity.'; + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Tables/ExpenseActivityLogEntry.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Tables/ExpenseActivityLogEntry.Table.al new file mode 100644 index 00000000000..bac62d76be3 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Tables/ExpenseActivityLogEntry.Table.al @@ -0,0 +1,228 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +using System.Security.AccessControl; + +table 7100 "Expense Activity Log Entry" +{ + Access = Internal; + Caption = 'Expense Activity Log Entry'; + DataClassification = CustomerContent; + ReplicateData = false; + + fields + { + field(1; "Entry No."; BigInteger) + { + Caption = 'Entry No.'; + DataClassification = SystemMetadata; + AutoIncrement = true; + } + field(2; "Source Table ID"; Integer) + { + Caption = 'Source Table ID'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies the table containing the source document that currently owns the activity entry.'; + } + field(3; "Source Record System ID"; Guid) + { + Caption = 'Source Record System ID'; + DataClassification = SystemMetadata; + TableRelation = if ("Source Table ID" = const(Database::"Expense Report Header")) "Expense Report Header".SystemId + else + if ("Source Table ID" = const(Database::"Posted Expense Report Header")) "Posted Expense Report Header".SystemId; + ToolTip = 'Specifies the immutable SystemId of the source document that currently owns the activity entry.'; + } + field(4; "Subject Table ID"; Integer) + { + Caption = 'Subject Table ID'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies the table containing the logical subject described by the activity entry.'; + } + field(5; "Subject System ID"; Guid) + { + Caption = 'Subject System ID'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies the stable SystemId of the logical subject described by the activity entry.'; + } + field(6; "Document No."; Code[20]) + { + Caption = 'Document No.'; + DataClassification = CustomerContent; + } + field(7; "Document Description"; Text[100]) + { + Caption = 'Document Description'; + DataClassification = CustomerContent; + } + field(8; "Event Type"; Enum "Expense Activity Event Type") + { + Caption = 'Event Type'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies what happened to the expense report.'; + } + field(9; "Occurred At"; DateTime) + { + Caption = 'Occurred At'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies when the activity occurred.'; + } + field(10; "Initiated By"; Enum "Expense Activity Initiator") + { + Caption = 'Initiated By'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies whether the activity originated from a person or the autonomous Expense Agent.'; + } + field(11; "Actor Role"; Enum "Expense Activity Actor Role") + { + Caption = 'Actor Role'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies the business capacity in which the actor performed the activity.'; + } + field(12; "Actor Table ID"; Integer) + { + Caption = 'Actor Table ID'; + DataClassification = SystemMetadata; + ToolTip = 'Specifies the table containing the record that represents the actor.'; + } + field(13; "Actor Record System ID"; Guid) + { + Caption = 'Actor Record System ID'; + DataClassification = EndUserPseudonymousIdentifiers; + TableRelation = if ("Actor Table ID" = const(Database::"Expense User")) "Expense User".SystemId + else + if ("Actor Table ID" = const(Database::User)) User.SystemId; + ToolTip = 'Specifies the immutable SystemId of the record that represents the actor.'; + } + field(14; "Actor Display Name"; Text[100]) + { + Caption = 'Actor Display Name'; + DataClassification = EndUserIdentifiableInformation; + ToolTip = 'Specifies the actor name captured when the activity occurred.'; + } + field(15; "Amount (LCY)"; Decimal) + { + Caption = 'Amount (LCY)'; + DataClassification = AccountData; + } + field(16; "Non-Refundable Amount (LCY)"; Decimal) + { + Caption = 'Non-Refundable Amount (LCY)'; + DataClassification = AccountData; + } + field(17; "Reimbursable Amount"; Decimal) + { + Caption = 'Reimbursable Amount'; + DataClassification = AccountData; + } + field(18; "Reimbursable Amount (LCY)"; Decimal) + { + Caption = 'Reimbursable Amount (LCY)'; + DataClassification = AccountData; + } + field(19; "Refundable Amount"; Decimal) + { + Caption = 'Refundable Amount'; + DataClassification = AccountData; + } + field(20; "Refundable Amount (LCY)"; Decimal) + { + Caption = 'Refundable Amount (LCY)'; + DataClassification = AccountData; + } + field(21; "Reimbursement Currency Code"; Code[10]) + { + Caption = 'Reimbursement Currency Code'; + DataClassification = AccountData; + } + field(22; "Reimbursement Currency Factor"; Decimal) + { + Caption = 'Reimbursement Currency Factor'; + DataClassification = AccountData; + DecimalPlaces = 0 : 15; + } + field(50; Comment; Text[2048]) + { + Caption = 'Comment'; + DataClassification = CustomerContent; + ToolTip = 'Specifies the message recorded with the activity.'; + } + field(51; Categories; Text[2048]) + { + Caption = 'Categories'; + DataClassification = CustomerContent; + } + field(52; "Attached Receipt Count"; Integer) + { + Caption = 'Attached Receipt Count'; + DataClassification = SystemMetadata; + } + field(53; "Expense Count"; Integer) + { + Caption = 'Expense Count'; + DataClassification = SystemMetadata; + } + field(100; "History Actor Table ID Filter"; Integer) + { + Caption = 'History Actor Table ID Filter'; + FieldClass = FlowFilter; + } + field(101; "History Actor System ID Filter"; Guid) + { + Caption = 'History Actor System ID Filter'; + FieldClass = FlowFilter; + } + field(102; "History Actor Role Filter"; Enum "Expense Activity Actor Role") + { + Caption = 'History Actor Role Filter'; + FieldClass = FlowFilter; + } + field(103; "History Subject Match"; Boolean) + { + Caption = 'History Subject Match'; + FieldClass = FlowField; + CalcFormula = exist("Expense Activity Log Entry" where( + "Subject Table ID" = field("Subject Table ID"), + "Subject System ID" = field("Subject System ID"), + "Actor Table ID" = field("History Actor Table ID Filter"), + "Actor Record System ID" = field("History Actor System ID Filter"), + "Actor Role" = field("History Actor Role Filter"))); + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + key(Source; "Source Table ID", "Source Record System ID", "Occurred At", "Entry No.") + { + } + key(Subject; "Subject Table ID", "Subject System ID", "Occurred At", "Entry No.") + { + } + key(Actor; "Actor Table ID", "Actor Record System ID", "Actor Role", "Subject Table ID", "Subject System ID", "Occurred At", "Entry No.") + { + } + key(Occurred; "Occurred At", "Entry No.") + { + } + } + + trigger OnModify() + begin + Error(CannotModifyErr); + end; + + trigger OnRename() + begin + Error(CannotModifyErr); + end; + + var + CannotModifyErr: Label 'Activity log entries cannot be modified.'; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al index 3d94ec53559..1614b486e78 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al @@ -69,61 +69,98 @@ codeunit 6901 "Expense Report Approval Mgmt" procedure Submit(var ExpenseReportHeader: Record "Expense Report Header") var ExpenseUser: Record "Expense User"; + IsResubmission: Boolean; begin if ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval" then exit; + IsResubmission := ExpenseReportHeader."Submission DateTime" <> 0DT; ExpenseUser.Get(GetExpenseUserNo()); ExpenseReportHeader.TestApprovalStatus(); ExpenseReportHeader.UpdateApproverID(); SetApprovalStatusToPendingApprovalInExpenseReport(ExpenseReportHeader, ExpenseUser."No.", ExpenseUser."User Id For Approvals"); + LogExpenseReportSubmission(ExpenseReportHeader, ExpenseUser."No.", IsResubmission); end; internal procedure Submit(var ExpenseReportHeader: Record "Expense Report Header"; SubmitterExpenseUserNo: Code[20]) var ExpenseUser: Record "Expense User"; + IsResubmission: Boolean; begin if ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval" then exit; + IsResubmission := ExpenseReportHeader."Submission DateTime" <> 0DT; ExpenseUser.Get(SubmitterExpenseUserNo); ExpenseReportHeader.TestApprovalStatus(); ExpenseReportHeader.UpdateApproverID(); SetApprovalStatusToPendingApprovalInExpenseReport(ExpenseReportHeader, SubmitterExpenseUserNo, ExpenseUser."User Id For Approvals"); + LogExpenseReportSubmission(ExpenseReportHeader, SubmitterExpenseUserNo, IsResubmission); end; procedure ReopenSubmitted(var ExpenseReportHeader: Record "Expense Report Header") + var + SubmitterExpenseUserNo: Code[20]; + IsRecall: Boolean; begin if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Open then exit; + IsRecall := ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval"; + if IsRecall then + SubmitterExpenseUserNo := GetExpenseUserNo(); + ExpenseReportHeader.Status := ExpenseReportHeader.Status::Open; ExpenseReportHeader.Modify(true); + if IsRecall then + LogExpenseReportRecalled(ExpenseReportHeader, SubmitterExpenseUserNo); end; procedure ReopenApproved(var ExpenseReportHeader: Record "Expense Report Header") + var + ApproverExpenseUserNo: Code[20]; begin if ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval" then exit; CheckApproverPermissions(ExpenseReportHeader); + ApproverExpenseUserNo := GetExpenseUserNo(); + ReopenApprovedAfterAuthorization(ExpenseReportHeader, ApproverExpenseUserNo); + end; + + local procedure ReopenApprovedAfterAuthorization( + var ExpenseReportHeader: Record "Expense Report Header"; + ApproverExpenseUserNo: Code[20] + ) + begin ExpenseReportHeader.UpdateApproverID(); ExpenseReportHeader.Status := ExpenseReportHeader.Status::"Pending Approval"; ExpenseReportHeader.Modify(true); + LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::ReopenedByApprover, + Enum::"Expense Activity Actor Role"::Approver, + ApproverExpenseUserNo, + ''); end; procedure Reject(var ExpenseReportHeader: Record "Expense Report Header") + var + ApproverExpenseUserNo: Code[20]; begin if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Rejected then exit; ExpenseReportHeader.TestField(Status, ExpenseReportHeader.Status::"Pending Approval"); CheckApproverPermissions(ExpenseReportHeader); - SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Rejected, GetExpenseUserNo(), CopyStr(UserId(), 1, 50)); + ApproverExpenseUserNo := GetExpenseUserNo(); + + SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Rejected, ApproverExpenseUserNo, CopyStr(UserId(), 1, 50)); + LogExpenseReportRejected(ExpenseReportHeader, ApproverExpenseUserNo, ''); end; internal procedure Reject(var ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20]; RejectReason: Text) @@ -138,17 +175,22 @@ codeunit 6901 "Expense Report Approval Mgmt" UpdateApproverComment(ExpenseReportHeader, RejectReason); SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Rejected, ApproverExpenseUserNo, ExpenseUser."User Id For Approvals"); + LogExpenseReportRejected(ExpenseReportHeader, ApproverExpenseUserNo, RejectReason); end; procedure Approve(var ExpenseReportHeader: Record "Expense Report Header") + var + ApproverExpenseUserNo: Code[20]; begin if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Approved then exit; ExpenseReportHeader.TestField(Status, ExpenseReportHeader.Status::"Pending Approval"); CheckApproverPermissions(ExpenseReportHeader); + ApproverExpenseUserNo := GetExpenseUserNo(); - SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Approved, GetExpenseUserNo(), CopyStr(UserId(), 1, 50)); + SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Approved, ApproverExpenseUserNo, CopyStr(UserId(), 1, 50)); + LogExpenseReportApproved(ExpenseReportHeader, ApproverExpenseUserNo); end; internal procedure Approve(var ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20]) @@ -162,6 +204,7 @@ codeunit 6901 "Expense Report Approval Mgmt" CheckApproverPermissions(ExpenseUser); SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Approved, ApproverExpenseUserNo, ExpenseUser."User Id For Approvals"); + LogExpenseReportApproved(ExpenseReportHeader, ApproverExpenseUserNo); end; local procedure SetApprovalStatusInExpenseReport(var ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportStatus: Enum "Expense Report Status"; ApproverExpenseUserNo: Code[20]; ApproverUserId: Code[50]) @@ -191,6 +234,78 @@ codeunit 6901 "Expense Report Approval Mgmt" ExpenseReportHeader.Modify(true); end; + local procedure LogExpenseReportSubmission(ExpenseReportHeader: Record "Expense Report Header"; SubmitterExpenseUserNo: Code[20]; IsResubmission: Boolean) + var + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EventType: Enum "Expense Activity Event Type"; + begin + // Start tracking with the earlier Created event, including reports first acted on after upgrade. + if not ExpenseActivityLogMgt.HasEntriesForSource(Database::"Expense Report Header", ExpenseReportHeader.SystemId) then + ExpenseActivityLogMgt.LogExpenseReportCreatedEvent(ExpenseReportHeader); + + if IsResubmission then + EventType := EventType::Resubmitted + else + EventType := EventType::Submitted; + + ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + EventType, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + SubmitterExpenseUserNo, + ''); + end; + + local procedure LogExpenseReportEvent( + ExpenseReportHeader: Record "Expense Report Header"; + EventType: Enum "Expense Activity Event Type"; + ActorRole: Enum "Expense Activity Actor Role"; + ActorExpenseUserNo: Code[20]; + EventComment: Text + ) + var + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + begin + ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + EventType, + Enum::"Expense Activity Initiator"::User, + ActorRole, + ActorExpenseUserNo, + EventComment); + end; + + local procedure LogExpenseReportApproved(ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20]) + begin + LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Approved, + Enum::"Expense Activity Actor Role"::Approver, + ApproverExpenseUserNo, + ''); + end; + + local procedure LogExpenseReportRejected(ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20]; RejectReason: Text) + begin + LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Rejected, + Enum::"Expense Activity Actor Role"::Approver, + ApproverExpenseUserNo, + RejectReason); + end; + + local procedure LogExpenseReportRecalled(ExpenseReportHeader: Record "Expense Report Header"; SubmitterExpenseUserNo: Code[20]) + begin + LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Recalled, + Enum::"Expense Activity Actor Role"::Submitter, + SubmitterExpenseUserNo, + ''); + end; + internal procedure NoExpenseLinesToProcess(ExpenseApprovalAction: Enum "Expense Approval Action") begin if not GuiAllowed() then diff --git a/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al index 1ff1b0a8bff..3283d846fcc 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al @@ -205,6 +205,14 @@ page 6980 "Manager Expense Report" SubPageLink = "Expense Report No." = field("Document No."), "Report Line No." = field("Line No."); Visible = Rec."No." <> ''; } + part(Activity; "Expense Activity Log FactBox") + { + ApplicationArea = Basic, Suite; + Caption = 'Activity Log'; + SubPageLink = "Source Table ID" = const(Database::"Expense Report Header"), + "Source Record System ID" = field(SystemId); + Visible = Rec."No." <> ''; + } part("Expense Picture"; "Expense Picture") { ApplicationArea = Basic, Suite; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al index 481d011a1b8..88168cd6b62 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al @@ -32,6 +32,8 @@ codeunit 6906 "Expense Capabilities Provider" exit(IsConsolidatedProjectsEnabled()); Capability::VATSpecifications: exit(IsVATSpecificationsEnabled()); + Capability::ActivityLog: + exit(true); end; exit(false); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al index 7ccca2dc8cd..dfee086137e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al @@ -54,4 +54,12 @@ enum 6984 "Expense Capability" { Caption = 'VAT Specifications', Locked = true; } + + /// + /// Expense activity history API is available. + /// + value(4; ActivityLog) + { + Caption = 'Activity Log', Locked = true; + } } diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al index 815d674c65d..b7b670641e1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al @@ -119,15 +119,26 @@ codeunit 6987 "Expense Report-Post" end; local procedure CheckAndCreatePostedDocument(var ExpenseReportHeader: Record "Expense Report Header") + var + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; begin AmountToEmployee := 0; ValidateExpenseReportForPosting(ExpenseReportHeader); CreatePostedExpenseReport(ExpenseReportHeader); + if not PreviewMode then + if ExpenseActivityLogMgt.HasEntriesForSource(Database::"Expense Report Header", ExpenseReportHeader.SystemId) then + ExpenseActivityLogMgt.LogExpenseReportEventByBCUser( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Posted, + Enum::"Expense Activity Actor Role"::" ", + ''); UpdateLastPostingNos(ExpenseReportHeader); ProcessExpenseReportLines(ExpenseReportHeader); InsertPstdExpReportHeaderVATSpecs(ExpenseReportHeader."No.", PostedExpenseReportHeader."No."); if AmountToEmployee <> 0 then PostEmployeeEntry(ExpenseReportHeader); + if not PreviewMode then + ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader, PostedExpenseReportHeader); end; local procedure ValidateExpenseReportForPosting(var ExpenseReportHeader: Record "Expense Report Header") diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al index 5b47a10a5f4..a12d79258f7 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al @@ -240,6 +240,14 @@ page 6910 "Expense Report" SubPageLink = "Expense Report No." = field("Document No."), "Report Line No." = field("Line No."); Visible = Rec."No." <> ''; } + part(Activity; "Expense Activity Log FactBox") + { + ApplicationArea = Basic, Suite; + Caption = 'Activity Log'; + SubPageLink = "Source Table ID" = const(Database::"Expense Report Header"), + "Source Record System ID" = field(SystemId); + Visible = Rec."No." <> ''; + } part("Expense Picture"; "Expense Picture") { ApplicationArea = Basic, Suite; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al index 389f4a52f38..38a12f7e12a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al @@ -171,6 +171,14 @@ page 6998 "Posted Expense Report" UpdatePropagation = Both; SubPageLink = "Posted Expense Report No." = field("No."); } + part(Activity; "Expense Activity Log FactBox") + { + ApplicationArea = Basic, Suite; + Caption = 'Activity Log'; + SubPageLink = "Source Table ID" = const(Database::"Posted Expense Report Header"), + "Source Record System ID" = field(SystemId); + Visible = Rec."No." <> ''; + } part("Expense Picture"; "Expense Picture") { ApplicationArea = Basic, Suite; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al index 014a686e6f5..023e1e8810c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al @@ -587,7 +587,9 @@ table 6906 "Expense Report Header" var ExpenseReportLine: Record "Expense Report Line"; ExpenseReportCommentLine: Record "Expense Report Comment Line"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; begin + ExpenseActivityLogMgt.DeleteEntriesForSource(Database::"Expense Report Header", Rec.SystemId); ExpenseReportCommentLine.DeleteComments(ExpenseReportCommentLine."Document Type"::"Expense Report", Rec."No."); ExpenseReportLine.SetRange("Document No.", Rec."No."); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al index 8c21b013bed..01cc9cdeeae 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al @@ -304,6 +304,9 @@ table 6915 "Posted Expense Report Header" { Clustered = true; } + key(ExpenseUser; "Expense User No.", "No.") + { + } } trigger OnInsert() @@ -323,7 +326,9 @@ table 6915 "Posted Expense Report Header" trigger OnDelete() var PostedExpenseReportLines: Record "Posted Expense Report Line"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; begin + ExpenseActivityLogMgt.DeleteEntriesForSource(Database::"Posted Expense Report Header", Rec.SystemId); PostedExpenseReportLines.SetRange("Document No.", Rec."No."); PostedExpenseReportLines.DeleteAll(); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al index 933ac08b4c5..cbb047d41ce 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al @@ -58,5 +58,6 @@ permissionset 6953 "Expense Agent - Data" #endif tabledata "Expense Agent Env. Consumption" = Ri, tabledata "Tenant Feedback Setting" = R, - tabledata "EA Outbox Email" = RIM; + tabledata "EA Outbox Email" = RIM, + tabledata "Expense Activity Log Entry" = R; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al index 06eea4d3891..e7a6dd1d947 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al @@ -18,6 +18,7 @@ permissionset 6952 "Expense Agent - Objects" page "Expense User Cons. API" = X, page "Approver View API" = X, page "Expense Capabilities API" = X, + page "Expense Activity Log API" = X, page "Expense Agent Access Ctrl API" = X, page "Expense Agent Setup API" = X, page "Expense Approval Setup API" = X, @@ -74,6 +75,7 @@ permissionset 6952 "Expense Agent - Objects" table Expense = X, table "Expense Approval Setup" = X, table "Expense Capabilities Buffer" = X, + table "Expense Activity Log Entry" = X, table "Expense Project Buf" = X, #if not CLEAN29 #pragma warning disable AL0432 // Object is obsoleted @@ -122,6 +124,7 @@ permissionset 6952 "Expense Agent - Objects" codeunit "Expense Attachment Mgt." = X, codeunit "Expense Auto Population" = X, codeunit "Expense Capabilities Provider" = X, + codeunit "Expense Activity Log Mgt." = X, codeunit "Expense Projects Builder" = X, codeunit "Expense Consumption Handler" = X, codeunit "Expense Currency" = X, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al index faa7d10fd10..275a9c4ee7a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al @@ -61,6 +61,7 @@ permissionset 6904 "Expense Management - Objects" table "Expense Vendor" = X, table "Expense Capabilities Buffer" = X, table Traveler = X, + table "Expense Activity Log Entry" = X, table "Expense Project Buf" = X, page "EA Billing Overview" = X, page "EA Scheduler Tasks" = X, @@ -148,6 +149,7 @@ permissionset 6904 "Expense Management - Objects" #pragma warning restore AL0432 #endif page "Expense Report FactBox" = X, + page "Expense Activity Log FactBox" = X, page "Expense Approval Setups Part" = X, page "Expense Picture" = X, page Travelers = X, @@ -198,6 +200,7 @@ permissionset 6904 "Expense Management - Objects" codeunit "Expense OAuth Client" = X, codeunit "Expense API Currency Helper" = X, codeunit "Expense Capabilities Provider" = X, + codeunit "Expense Activity Log Mgt." = X, codeunit "Expense Projects Builder" = X, codeunit "Import Expense User" = X, report "Expense Report Cover Page" = X, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al index 36b5e3c8691..b52505ecc24 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al @@ -58,5 +58,6 @@ permissionset 6907 "Expense Mgmt. Read" tabledata Traveler = R, tabledata "Expense VAT Specification" = R, tabledata "Expense Report Line VAT Spec." = R, + tabledata "Expense Activity Log Entry" = R, tabledata "Posted Exp. Rep. Line VAT Spec" = R; } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al index cd30483ed8a..ac57004ba1d 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al @@ -21,6 +21,7 @@ codeunit 6950 "Expense Agent Privacy Subs." local procedure ClassifyExpenseAgent() var + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; PostedExpRepLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; begin @@ -58,6 +59,11 @@ codeunit 6950 "Expense Agent Privacy Subs." DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Team"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Approval Setup"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Agent Access Control"); + DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Activity Log Entry"); + DataClassificationMgt.SetFieldToPersonal( + Database::"Expense Activity Log Entry", ExpenseActivityLogEntry.FieldNo("Actor Record System ID")); + DataClassificationMgt.SetFieldToPersonal( + Database::"Expense Activity Log Entry", ExpenseActivityLogEntry.FieldNo("Actor Display Name")); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Vendor"); #if not CLEAN29 #pragma warning disable AL0432 // Object is obsoleted diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al new file mode 100644 index 00000000000..e7672c950a6 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al @@ -0,0 +1,699 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Test.ExpenseAgent; + +using Microsoft.ExpenseAgent; +using Microsoft.HumanResources.Employee; + +codeunit 148343 "Expense Activity Log API Test" +{ + Subtype = Test; + TestType = IntegrationTest; + RequiredTestIsolation = Disabled; + TestPermissions = Disabled; + + var + Assert: Codeunit Assert; + LibraryExpense: Codeunit "Library - Expense"; + LibraryGraphMgt: Codeunit "Library - Graph Mgt"; + LibraryTestInitialize: Codeunit "Library - Test Initialize"; + APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; + IsInitialized: Boolean; + ServiceNameTok: Label 'expenseActivityLogEntries', Locked = true; + ExpenseReportsServiceNameTok: Label 'expenseReports', Locked = true; + ExpenseUsersServiceNameTok: Label 'expenseUsers', Locked = true; + TestDescriptionPrefixLbl: Label 'ACTIVITY API TEST ', Locked = true; + MethodNotAllowedResponseErr: Label 'Response code is 405', Locked = true; + BadRequestResponseErr: Label 'Response code is 400', Locked = true; + SubmitActionTok: Label 'Microsoft.NAV.releaseAndMarkPendingApprovalExpenseReport', Locked = true; + ApproveActionTok: Label 'Microsoft.NAV.approvedExpenseReport', Locked = true; + RejectAndReopenActionTok: Label 'Microsoft.NAV.rejectAndReopenExpenseReport', Locked = true; + + [Test] + procedure ActivityLogEntryIsExposedThroughReadOnlyAPI() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EntryNo: BigInteger; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] The read-only activity-log API exposes contract and diagnostic fields. + // [GIVEN] A Submitted activity entry. + Initialize(); + CreateTestExpenseUser(ExpenseUser); + CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No."); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + 'Submitted for approval'); + ExpenseActivityLogEntry.Get(EntryNo); + ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Source Record System ID", ExpenseReportHeader.SystemId); + Assert.RecordCount(ExpenseActivityLogEntry, 1); + Commit(); + + // [WHEN] The report-scoped activity-log collection is requested. + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ExpenseReportHeader.SystemId), + Page::"Expense Reports API", + ExpenseReportsServiceNameTok, + ServiceNameTok); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + + // [THEN] The response contains stable identity, event, currency, and comment values. + Assert.AreNotEqual( + 0, + StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)))), + 'Response must contain the activity entry ID. Response: ' + ResponseText); + Assert.AreNotEqual(0, StrPos(ResponseText, '"entrynumber"'), 'Response must contain the internal entry number.'); + Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseReportHeader.SystemId)))), 'Response must contain the stable subject ID.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"actortableid"'), 'Response must identify the actor record table.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"actorid"'), 'Response must identify the actor record.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"eventtype":"submitted"'), 'Response must contain the stable submitted enum member name.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"currencylcy"'), 'Response must identify the LCY used by LCY amount fields.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"expensecount"'), 'Response must contain the expense count snapshot.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"attachedreceiptcount"'), 'Response must contain the attached receipt count snapshot.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"comment":"submitted for approval"'), 'Response must contain the event comment.'); + CompleteTest(); + end; + + [Test] + procedure ActivityLogAPIRejectsWrites() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EntryNo: BigInteger; + ResponseText: Text; + CollectionURL: Text; + EntryURL: Text; + begin + // [SCENARIO] The activity-log API cannot be used to mutate audit entries. + // [GIVEN] An existing activity entry and its collection and entity URLs. + Initialize(); + CreateTestExpenseUser(ExpenseUser); + CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No."); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Created, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + ExpenseActivityLogEntry.Get(EntryNo); + Commit(); + + CollectionURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ExpenseReportHeader.SystemId), + Page::"Expense Reports API", + ExpenseReportsServiceNameTok, + ServiceNameTok); + EntryURL := + CollectionURL + '(' + + LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)) + ')'; + + // [WHEN] A POST is attempted. + // [THEN] The API rejects it with Method Not Allowed. + asserterror LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(CollectionURL, '{}', ResponseText, 405); + Assert.ExpectedError(MethodNotAllowedResponseErr); + + // [WHEN] A PATCH is attempted. + // [THEN] The API rejects it with Method Not Allowed. + asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(EntryURL, '{"comment":"changed"}', ResponseText, 405); + Assert.ExpectedError(MethodNotAllowedResponseErr); + + // [WHEN] A DELETE is attempted. + // [THEN] The API rejects it with Method Not Allowed. + asserterror LibraryGraphMgt.DeleteFromWebServiceAndCheckResponseCode(EntryURL, '', ResponseText, 400); + Assert.ExpectedError(BadRequestResponseErr); + CompleteTest(); + end; + + [Test] + procedure NonSnapshotActivityDoesNotExposeCurrencies() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EntryNo: BigInteger; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] Non-snapshot activity does not imply currencies for empty amount fields. + // [GIVEN] A Created activity entry. + Initialize(); + CreateTestExpenseUser(ExpenseUser); + CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No."); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Created, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + ExpenseActivityLogEntry.Get(EntryNo); + Commit(); + + // [WHEN] The activity entry is requested through the report scope. + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ExpenseReportHeader.SystemId), + Page::"Expense Reports API", + ExpenseReportsServiceNameTok, + ServiceNameTok); + TargetURL += '(' + LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)) + ')'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + + // [THEN] Both LCY and reimbursement currency fields are blank. + Assert.AreNotEqual(0, StrPos(ResponseText, '"currencylcy":""'), 'Non-snapshot activity must not expose an LCY currency.'); + Assert.AreNotEqual(0, StrPos(ResponseText, '"reimbursementcurrencycode":""'), 'Non-snapshot activity must not expose a reimbursement currency.'); + CompleteTest(); + end; + + [Test] + procedure UnscopedActivityAPIHidesEntries() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EntryNo: BigInteger; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] The activity API does not expose company-wide history without a document or user scope. + // [GIVEN] An existing activity entry. + Initialize(); + CreateTestExpenseUser(ExpenseUser); + CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No."); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + ExpenseActivityLogEntry.Get(EntryNo); + Commit(); + + // [WHEN] The root activity collection is requested without scope. + TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Activity Log API", ServiceNameTok); + asserterror LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 400); + + // [THEN] The endpoint explains that a source or Expense User scope is required. + Assert.ExpectedError('Activity log entries must be requested through an expense report, posted expense report, or expense user.'); + CompleteTest(); + end; + + [Test] + procedure SubmitterHistoryReturnsCompleteTimelinesForSubmittedReports() + var + ExpenseUser: array[2] of Record "Expense User"; + ExpenseReportHeader: array[2] of Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ExpenseActivityLogEntry: array[3] of Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EntryNo: BigInteger; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] Submitter history contains every activity entry on reports submitted by the user. + // [GIVEN] Two reports submitted by different users, with another user's approval on the first report. + Initialize(); + CreateTestExpenseUser(ExpenseUser[1]); + CreateTestExpenseUser(ExpenseUser[2]); + CreateTestExpenseReport(ExpenseReportHeader[1], ExpenseUser[1]."No."); + CreateTestExpenseReport(ExpenseReportHeader[2], ExpenseUser[2]."No."); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[1], + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser[1]."No.", + ''); + ExpenseActivityLogEntry[1].Get(EntryNo); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[1], + Enum::"Expense Activity Event Type"::Approved, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Approver, + ExpenseUser[2]."No.", + ''); + ExpenseActivityLogEntry[2].Get(EntryNo); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[2], + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser[2]."No.", + ''); + ExpenseActivityLogEntry[3].Get(EntryNo); + PostedExpenseReportHeader.Init(); + PostedExpenseReportHeader."No." := CopyStr(Format(CreateGuid()), 1, MaxStrLen(PostedExpenseReportHeader."No.")); + PostedExpenseReportHeader.Description := ExpenseReportHeader[1].Description; + PostedExpenseReportHeader.Insert(); + ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader[1], PostedExpenseReportHeader); + Commit(); + + // [WHEN] User-scoped activity is requested without a history role. + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ExpenseUser[1].SystemId), + Page::"Expense Users API", + ExpenseUsersServiceNameTok, + ServiceNameTok); + asserterror LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 400); + + // [THEN] The endpoint explains that the role is required. + Assert.ExpectedError('The historyActorRole filter must be specified as Submitter or Approver.'); + + // [WHEN] Submitter history is requested through the first Expense User. + TargetURL += '?$filter=historyActorRole eq ''Submitter'''; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + + // [THEN] Both posted-source entries on the first report are returned, while the other submitter's report is excluded. + Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[1].SystemId)))), 'Submitter history must contain the submitted entry.'); + Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[2].SystemId)))), 'Submitter history must contain the approver entry on the same report.'); + Assert.AreEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[3].SystemId)))), 'Submitter history must exclude another submitter''s report.'); + CompleteTest(); + end; + + [Test] + procedure ApproverHistoryReturnsCompleteTimelinesForActedOnReports() + var + ExpenseUser: array[3] of Record "Expense User"; + ExpenseReportHeader: array[2] of Record "Expense Report Header"; + ExpenseActivityLogEntry: array[4] of Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EntryNo: BigInteger; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] Approver history contains every activity entry on reports acted on by the approver. + // [GIVEN] Two submitted reports approved by different Expense Users. + Initialize(); + CreateTestExpenseUser(ExpenseUser[1]); + CreateTestExpenseUser(ExpenseUser[2]); + CreateTestExpenseUser(ExpenseUser[3]); + CreateTestExpenseReport(ExpenseReportHeader[1], ExpenseUser[1]."No."); + CreateTestExpenseReport(ExpenseReportHeader[2], ExpenseUser[1]."No."); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[1], + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser[1]."No.", + ''); + ExpenseActivityLogEntry[1].Get(EntryNo); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[1], + Enum::"Expense Activity Event Type"::Approved, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Approver, + ExpenseUser[2]."No.", + ''); + ExpenseActivityLogEntry[2].Get(EntryNo); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[2], + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser[1]."No.", + ''); + ExpenseActivityLogEntry[3].Get(EntryNo); + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader[2], + Enum::"Expense Activity Event Type"::Approved, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Approver, + ExpenseUser[3]."No.", + ''); + ExpenseActivityLogEntry[4].Get(EntryNo); + Commit(); + + // [WHEN] Approver history is requested through the second Expense User. + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ExpenseUser[2].SystemId), + Page::"Expense Users API", + ExpenseUsersServiceNameTok, + ServiceNameTok); + TargetURL += '?$filter=historyActorRole eq ''Approver'''; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + + // [THEN] The complete first-report timeline is returned and the other approver's report is excluded. + Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[1].SystemId)))), 'Approver history must contain the submitter entry on the acted-on report.'); + Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[2].SystemId)))), 'Approver history must contain the approver entry.'); + Assert.AreEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[3].SystemId)))), 'Approver history must exclude a report acted on by another approver.'); + Assert.AreEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[4].SystemId)))), 'Approver history must exclude the other approver entry.'); + CompleteTest(); + end; + + [Test] + procedure E2EActivityLogScenario() + var + SubmitterExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpensePaymentMethod: Record "Expense Payment Method"; + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + SubjectSystemID: Guid; + RunToken: Code[8]; + begin + // [SCENARIO] A complete approval round trip is exposed through active, posted, and user-history APIs. + Initialize(); + RunToken := CreateRunToken(); + CreateE2ESetup( + SubmitterExpenseUser, ApproverExpenseUser, + ExpenseCategory, ExpensePaymentMethod, RunToken); + CreateE2EReport( + ExpenseReportHeader, SubmitterExpenseUser, + ExpenseCategory, ExpensePaymentMethod, RunToken); + Commit(); + + // [WHEN] The report is submitted, rejected/reopened, resubmitted, and approved through API actions. + InvokeReportAction( + ExpenseReportHeader.SystemId, SubmitActionTok, + CreateActorRequestBody('submitterExpenseUserNo', SubmitterExpenseUser."No.")); + ExpenseReportHeader.Get(ExpenseReportHeader."No."); + InvokeReportAction( + ExpenseReportHeader.SystemId, RejectAndReopenActionTok, + CreateRejectRequestBody(ApproverExpenseUser."No.", 'E2E send back ' + RunToken)); + ExpenseReportHeader.Get(ExpenseReportHeader."No."); + InvokeReportAction( + ExpenseReportHeader.SystemId, SubmitActionTok, + CreateActorRequestBody('submitterExpenseUserNo', SubmitterExpenseUser."No.")); + ExpenseReportHeader.Get(ExpenseReportHeader."No."); + InvokeReportAction( + ExpenseReportHeader.SystemId, ApproveActionTok, + CreateActorRequestBody('approverExpenseUserNo', ApproverExpenseUser."No.")); + ExpenseReportHeader.Get(ExpenseReportHeader."No."); + + // [THEN] The active report timeline contains the approval. + VerifyReportActivity( + ExpenseReportHeader.SystemId, Page::"Expense Reports API", + ExpenseReportsServiceNameTok, Enum::"Expense Activity Event Type"::Approved); + + // [WHEN] The approved report is moved to a posted source fixture. + SubjectSystemID := ExpenseReportHeader.SystemId; + MoveReportToPostedSource(ExpenseReportHeader, PostedExpenseReportHeader); + Commit(); + + // [THEN] Posted and user-scoped APIs expose the complete timeline. + VerifyReportActivity( + PostedExpenseReportHeader.SystemId, Page::"Posted Expense Reports API", + 'postedExpenseReports', Enum::"Expense Activity Event Type"::Posted); + VerifyUserHistory(SubmitterExpenseUser.SystemId, 'Submitter', SubjectSystemID); + VerifyUserHistory(ApproverExpenseUser.SystemId, 'Approver', SubjectSystemID); + CompleteTest(); + end; + + local procedure CreateE2ESetup( + var SubmitterExpenseUser: Record "Expense User"; + var ApproverExpenseUser: Record "Expense User"; + var ExpenseCategory: Record "Expense Category"; + var ExpensePaymentMethod: Record "Expense Payment Method"; + RunToken: Code[8] + ) + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + begin + CreateTestExpenseUser(SubmitterExpenseUser); + SubmitterExpenseUser."User Id For Approvals" := + CopyStr('SUBMITTER-' + RunToken, 1, MaxStrLen(SubmitterExpenseUser."User Id For Approvals")); + SubmitterExpenseUser.Modify(); + + CreateTestExpenseUser(ApproverExpenseUser); + ApproverExpenseUser."Can Approve" := true; + ApproverExpenseUser."User Id For Approvals" := + CopyStr('APPROVER-' + RunToken, 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals")); + ApproverExpenseUser.Modify(); + LibraryExpense.CreateExpenseApprovalSetup( + ExpenseApprovalSetup, SubmitterExpenseUser."No.", ApproverExpenseUser."No."); + + LibraryExpense.CreateExpenseCategory( + ExpenseCategory, + ExpenseCategory."Reimbursement Type"::"Employee Paid", + ExpenseCategory."Expense Detail Required"::" "); + ExpenseCategory.Description := + CopyStr(TestDescriptionPrefixLbl + RunToken, 1, MaxStrLen(ExpenseCategory.Description)); + ExpenseCategory.Modify(); + LibraryExpense.FindExpensePaymentMethod( + ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); + end; + + local procedure CreateE2EReport( + var ExpenseReportHeader: Record "Expense Report Header"; + SubmitterExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpensePaymentMethod: Record "Expense Payment Method"; + RunToken: Code[8] + ) + var + ExpenseReportLine: Record "Expense Report Line"; + begin + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, SubmitterExpenseUser."No.", '', ''); + ExpenseReportHeader.Description := + CopyStr(TestDescriptionPrefixLbl + 'E2E ' + RunToken, 1, MaxStrLen(ExpenseReportHeader.Description)); + ExpenseReportHeader.Modify(); + LibraryExpense.CreateExpenseReportLine( + ExpenseReportLine, + ExpenseReportHeader, + SubmitterExpenseUser."No.", + ExpenseCategory.Code, + ExpensePaymentMethod.Code, + true, + '', + 100); + end; + + local procedure CreateActorRequestBody(PropertyName: Text; ExpenseUserNo: Code[20]) RequestBody: JsonObject + begin + RequestBody.Add(PropertyName, ExpenseUserNo); + end; + + local procedure CreateRejectRequestBody(ApproverExpenseUserNo: Code[20]; RejectReason: Text) RequestBody: JsonObject + begin + RequestBody.Add('approverExpenseUserNo', ApproverExpenseUserNo); + RequestBody.Add('rejectReason', RejectReason); + end; + + local procedure InvokeReportAction(ReportSystemID: Guid; ActionName: Text; RequestBody: JsonObject) + var + ResponseText: Text; + RequestBodyText: Text; + TargetURL: Text; + begin + RequestBody.WriteTo(RequestBodyText); + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ReportSystemID), Page::"Expense Reports API", ExpenseReportsServiceNameTok, ActionName); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBodyText, ResponseText, 200); + end; + + local procedure MoveReportToPostedSource( + var ExpenseReportHeader: Record "Expense Report Header"; + var PostedExpenseReportHeader: Record "Posted Expense Report Header" + ) + var + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + begin + PostedExpenseReportHeader.Init(); + PostedExpenseReportHeader.TransferFields(ExpenseReportHeader); + PostedExpenseReportHeader.Insert(); + ExpenseActivityLogMgt.LogExpenseReportEventByBCUser( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Posted, + Enum::"Expense Activity Actor Role"::" ", + ''); + ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted( + ExpenseReportHeader, PostedExpenseReportHeader); + ExpenseReportHeader.Delete(true); + end; + + local procedure VerifyReportActivity( + ReportSystemID: Guid; + ParentPageID: Integer; + ParentServiceName: Text; + ExpectedEventType: Enum "Expense Activity Event Type" + ) + var + ResponseText: Text; + TargetURL: Text; + begin + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ReportSystemID), ParentPageID, ParentServiceName, ServiceNameTok); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + Assert.AreNotEqual( + 0, + StrPos(LowerCase(ResponseText), LowerCase(Format(ExpectedEventType))), + 'The report activity response does not contain the expected event type.'); + end; + + local procedure VerifyUserHistory(ExpenseUserSystemID: Guid; HistoryRole: Text; SubjectSystemID: Guid) + var + ResponseText: Text; + TargetURL: Text; + begin + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(ExpenseUserSystemID), + Page::"Expense Users API", + ExpenseUsersServiceNameTok, + ServiceNameTok); + TargetURL += '?$filter=historyActorRole eq ''' + HistoryRole + ''''; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + Assert.AreNotEqual( + 0, + StrPos( + LowerCase(ResponseText), + LowerCase(LibraryGraphMgt.StripBrackets(Format(SubjectSystemID)))), + 'The user history response does not contain the expected subject.'); + end; + + local procedure CreateRunToken(): Code[8] + begin + exit(CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, 8)); + end; + + local procedure Initialize() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Activity Log API Test"); + CleanupTestData(); + if IsInitialized then + exit; + + BindSubscription(APITestAuthHelper); + LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Activity Log API Test"); + if not ExpenseAgentSetup.Get() then begin + ExpenseAgentSetup.Init(); + ExpenseAgentSetup.Insert(); + end; + ExpenseAgentSetup."Enable Agent" := true; + ExpenseAgentSetup."Enable Approval Workflow" := false; + ExpenseAgentSetup."Use Rules" := false; + ExpenseAgentSetup.Modify(); + LibraryExpense.SetupNumberSeriesInExpenseMgmt(); + LibraryExpense.InitializeExpenseSourceCode(); + IsInitialized := true; + Commit(); + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Activity Log API Test"); + end; + + local procedure CreateTestExpenseReport(var ExpenseReportHeader: Record "Expense Report Header"; ExpenseUserNo: Code[20]) + begin + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUserNo, '', ''); + ExpenseReportHeader.Description := + CopyStr(TestDescriptionPrefixLbl + Format(CreateGuid()), 1, MaxStrLen(ExpenseReportHeader.Description)); + ExpenseReportHeader.Modify(); + end; + + local procedure CreateTestExpenseUser(var ExpenseUser: Record "Expense User") + begin + LibraryExpense.CreateExpenseUser(ExpenseUser); + ExpenseUser.Name := + CopyStr(TestDescriptionPrefixLbl + Format(CreateGuid()), 1, MaxStrLen(ExpenseUser.Name)); + ExpenseUser.Modify(); + end; + + local procedure CompleteTest() + begin + CleanupTestData(); + Commit(); + end; + + local procedure CleanupTestData() + var + Expense: Record Expense; + ExpenseApprovalSetup: Record "Expense Approval Setup"; + ExpenseCategory: Record "Expense Category"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseSubcategory: Record "Expense Subcategory"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseUser: Record "Expense User"; + Employee: Record Employee; + CategoryCodes: List of [Code[20]]; + EmployeeNumbers: List of [Code[20]]; + ExpenseUserNumbers: List of [Code[20]]; + CategoryCode: Code[20]; + EmployeeNo: Code[20]; + ExpenseUserNo: Code[20]; + begin + ExpenseUser.SetLoadFields("No.", "Employee No."); + ExpenseUser.SetFilter(Name, TestDescriptionPrefixLbl + '*'); + if ExpenseUser.FindSet() then + repeat + ExpenseUserNumbers.Add(ExpenseUser."No."); + if (ExpenseUser."Employee No." <> '') and + (not EmployeeNumbers.Contains(ExpenseUser."Employee No.")) + then + EmployeeNumbers.Add(ExpenseUser."Employee No."); + until ExpenseUser.Next() = 0; + + ExpenseCategory.SetLoadFields(Code); + ExpenseCategory.SetFilter(Description, TestDescriptionPrefixLbl + '*'); + if ExpenseCategory.FindSet() then + repeat + if not CategoryCodes.Contains(ExpenseCategory.Code) then + CategoryCodes.Add(ExpenseCategory.Code); + until ExpenseCategory.Next() = 0; + + ExpenseActivityLogEntry.SetFilter("Document Description", TestDescriptionPrefixLbl + '*'); + ExpenseActivityLogEntry.DeleteAll(); + + ExpenseReportHeader.SetFilter(Description, TestDescriptionPrefixLbl + '*'); + ExpenseReportHeader.DeleteAll(true); + + PostedExpenseReportHeader.SetFilter(Description, TestDescriptionPrefixLbl + '*'); + PostedExpenseReportHeader.DeleteAll(true); + + foreach ExpenseUserNo in ExpenseUserNumbers do begin + Expense.SetLoadFields("Expense Category", "Expense Report No."); + Expense.SetRange("Expense User No.", ExpenseUserNo); + if Expense.FindSet() then + repeat + if (Expense."Expense Category" <> '') and + (not CategoryCodes.Contains(Expense."Expense Category")) + then + CategoryCodes.Add(Expense."Expense Category"); + until Expense.Next() = 0; + Expense.ModifyAll("Expense Report No.", ''); + Expense.DeleteAll(true); + + ExpenseApprovalSetup.SetRange("Expense User No.", ExpenseUserNo); + ExpenseApprovalSetup.DeleteAll(); + ExpenseApprovalSetup.Reset(); + ExpenseApprovalSetup.SetRange("Approver No.", ExpenseUserNo); + ExpenseApprovalSetup.DeleteAll(); + ExpenseApprovalSetup.Reset(); + + if ExpenseUser.Get(ExpenseUserNo) then + ExpenseUser.Delete(true); + end; + + foreach EmployeeNo in EmployeeNumbers do + if Employee.Get(EmployeeNo) then + Employee.Delete(true); + + foreach CategoryCode in CategoryCodes do begin + ExpenseSubcategory.SetRange("Expense Category Code", CategoryCode); + ExpenseSubcategory.DeleteAll(true); + if ExpenseCategory.Get(CategoryCode) then + ExpenseCategory.Delete(true); + end; + end; + +} diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al index b1b5e4d839b..2e7467a3e9e 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al @@ -10,19 +10,18 @@ codeunit 148318 "Expense Capabilities API Test" { Subtype = Test; TestType = IntegrationTest; + RequiredTestIsolation = Disabled; TestPermissions = Disabled; var Assert: Codeunit Assert; + LibraryExpenseAgent: Codeunit "Library - Expense Agent"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expenseCapabilities', Locked = true; - ProjectsCapabilityNameTok: Label '"capabilityname":"projects"', Locked = true; - ConsolidatedCapabilityNameTok: Label '"capabilityname":"consolidatedprojects"', Locked = true; - IsEnabledTrueTok: Label '"isenabled":true', Locked = true; - IsEnabledFalseTok: Label '"isenabled":false', Locked = true; + ActivityLogCapabilityNameTok: Label 'activityLog', Locked = true; [Test] procedure CapabilitiesProjectsEnabledViaAPI() @@ -34,6 +33,7 @@ codeunit 148318 "Expense Capabilities API Test" // [SCENARIO] When Expense Agent Setup has "Enable Project Fields" = true, // the capabilities API exposes a 'projects' row with isEnabled = true. Initialize(); + LibraryExpenseAgent.BackupExpenseAgentSetup(); // [GIVEN] Expense Agent Setup exists with Enable Project Fields = true. if not ExpenseAgentSetup.Get() then begin @@ -47,15 +47,32 @@ codeunit 148318 "Expense Capabilities API Test" // [WHEN] The expenseCapabilities collection is fetched through the API. TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); - ResponseText := StripWhitespace(LowerCase(ResponseText)); - - // [THEN] A 'projects' row is present with isEnabled = true. - Assert.AreNotEqual(0, StrPos(ResponseText, ProjectsCapabilityNameTok), - 'Response must contain a projects capability row.'); - Assert.AreNotEqual(0, StrPos(ResponseText, IsEnabledTrueTok), - 'Response must contain at least one isEnabled=true value.'); - Assert.AreEqual(0, StrPos(ResponseText, IsEnabledFalseTok), - 'Response must NOT contain any isEnabled=false value when Projects is the only capability and it is enabled.'); + + // [THEN] The Projects capability is enabled, regardless of other capability states. + Assert.IsTrue( + ResponseContainsCapabilityState(ResponseText, 'projects', true), + 'Response must contain an enabled projects capability row.'); + LibraryExpenseAgent.RestoreExpenseAgentSetup(); + Commit(); + end; + + [Test] + procedure ActivityLogCapabilityEnabledViaAPI() + var + TargetURL: Text; + ResponseText: Text; + begin + // [SCENARIO] The Activity Log capability is always advertised when the API is installed. + Initialize(); + + // [WHEN] The expenseCapabilities collection is fetched through the API. + TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + + // [THEN] ActivityLog is present and enabled. + Assert.IsTrue( + ResponseContainsCapabilityState(ResponseText, ActivityLogCapabilityNameTok, true), + 'Response must contain an enabled activityLog capability row.'); end; [Test] @@ -68,6 +85,7 @@ codeunit 148318 "Expense Capabilities API Test" // [SCENARIO] When Expense Agent Setup has "Enable Project Fields" = false, // the capabilities API exposes a 'projects' row with isEnabled = false. Initialize(); + LibraryExpenseAgent.BackupExpenseAgentSetup(); // [GIVEN] Expense Agent Setup exists with Enable Project Fields = false. if not ExpenseAgentSetup.Get() then begin @@ -81,13 +99,13 @@ codeunit 148318 "Expense Capabilities API Test" // [WHEN] The expenseCapabilities collection is fetched through the API. TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); - ResponseText := StripWhitespace(LowerCase(ResponseText)); - // [THEN] The 'projects' row is present and isEnabled = false. - Assert.AreNotEqual(0, StrPos(ResponseText, ProjectsCapabilityNameTok), - 'Response must contain a projects capability row.'); - Assert.AreNotEqual(0, StrPos(ResponseText, IsEnabledFalseTok), - 'Projects row must be reported as isEnabled = false when Enable Project Fields is false.'); + // [THEN] The Projects capability is disabled. + Assert.IsTrue( + ResponseContainsCapabilityState(ResponseText, 'projects', false), + 'Response must contain a disabled projects capability row.'); + LibraryExpenseAgent.RestoreExpenseAgentSetup(); + Commit(); end; [Test] @@ -100,6 +118,7 @@ codeunit 148318 "Expense Capabilities API Test" // [SCENARIO] The 'consolidatedAssignedProjects' capability is reported enabled when // project fields are enabled (the web app uses it to detect the new endpoint). Initialize(); + LibraryExpenseAgent.BackupExpenseAgentSetup(); if not ExpenseAgentSetup.Get() then begin ExpenseAgentSetup.Init(); @@ -111,17 +130,19 @@ codeunit 148318 "Expense Capabilities API Test" TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok); LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); - ResponseText := StripWhitespace(LowerCase(ResponseText)); - // [THEN] A 'consolidatedAssignedProjects' row is present and no isEnabled=false values exist. - Assert.AreNotEqual(0, StrPos(ResponseText, ConsolidatedCapabilityNameTok), - 'Response must contain a consolidatedAssignedProjects capability row.'); - Assert.AreEqual(0, StrPos(ResponseText, IsEnabledFalseTok), - 'No capability must be reported disabled when project fields are enabled.'); + // [THEN] Consolidated Projects is enabled, regardless of other capability states. + Assert.IsTrue( + ResponseContainsCapabilityState(ResponseText, 'consolidatedProjects', true), + 'Response must contain an enabled consolidatedProjects capability row.'); + LibraryExpenseAgent.RestoreExpenseAgentSetup(); + Commit(); end; local procedure Initialize() begin + LibraryExpenseAgent.RestoreExpenseAgentSetup(); + Commit(); LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Capabilities API Test"); if IsInitialized then exit; @@ -133,15 +154,42 @@ codeunit 148318 "Expense Capabilities API Test" LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Capabilities API Test"); end; - local procedure StripWhitespace(Source: Text): Text + local procedure ResponseContainsCapabilityState( + ResponseText: Text; + CapabilityName: Text; + ExpectedEnabled: Boolean + ): Boolean var - Result: Text; + RootObject: JsonObject; + CapabilityObject: JsonObject; + ValueArray: JsonArray; + CapabilityToken: JsonToken; + PropertyToken: JsonToken; + CapabilityIndex: Integer; begin - Result := Source; - Result := DelChr(Result, '=', ' '); - Result := DelChr(Result, '=', Format(10)); // LF - Result := DelChr(Result, '=', Format(13)); // CR - Result := DelChr(Result, '=', Format(9)); // TAB - exit(Result); + RootObject.ReadFrom(ResponseText); + if not RootObject.Get('value', PropertyToken) then + exit(false); + + ValueArray := PropertyToken.AsArray(); + if ValueArray.Count() = 0 then + exit(false); + for CapabilityIndex := 0 to ValueArray.Count() - 1 do begin + Clear(CapabilityToken); + Clear(CapabilityObject); + ValueArray.Get(CapabilityIndex, CapabilityToken); + CapabilityObject := CapabilityToken.AsObject(); + Clear(PropertyToken); + if CapabilityObject.Get('capabilityName', PropertyToken) then + if LowerCase(PropertyToken.AsValue().AsText()) = LowerCase(CapabilityName) then begin + Clear(PropertyToken); + if not CapabilityObject.Get('isEnabled', PropertyToken) then + exit(false); + exit(PropertyToken.AsValue().AsBoolean() = ExpectedEnabled); + end; + end; + + exit(false); end; + } diff --git a/src/Apps/W1/ExpenseAgent/test/src/ActivityLog/ExpenseActivityLogTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ActivityLog/ExpenseActivityLogTest.Codeunit.al new file mode 100644 index 00000000000..cd57be0b0ce --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/ActivityLog/ExpenseActivityLogTest.Codeunit.al @@ -0,0 +1,484 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Test.ExpenseAgent; + +using Microsoft.ExpenseAgent; +using System.Security.AccessControl; + +codeunit 148342 "Expense Activity Log Test" +{ + Subtype = Test; + TestType = UnitTest; + TestPermissions = Disabled; + + var + Assert: Codeunit Assert; + LibraryExpense: Codeunit "Library - Expense"; + LibraryTestInitialize: Codeunit "Library - Test Initialize"; + LibraryUtility: Codeunit "Library - Utility"; + IsInitialized: Boolean; + + [Test] + procedure OnlySnapshotEventsCaptureFinancialValues() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EventType: Enum "Expense Activity Event Type"; + EntryNo: BigInteger; + EventTypeIndex: Integer; + SnapshotEventCount: Integer; + EventTypeOrdinals: List of [Integer]; + SnapshotExpected: Boolean; + begin + // [SCENARIO] Financial and content snapshots are stored only for snapshot event types. + // [GIVEN] A report with financial values, two categories, and one receipt-bearing line. + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + ExpenseReportHeader."Reimbursement Currency Code" := 'USD'; + ExpenseReportHeader."Reimbursement Currency Factor" := 1.25; + ExpenseReportLine."Document No." := ExpenseReportHeader."No."; + ExpenseReportLine."Line No." := 10000; + ExpenseReportLine."Amount (LCY)" := 120; + ExpenseReportLine."Non-Refundable Amount (LCY)" := 20; + ExpenseReportLine."Reimbursable Amount" := 80; + ExpenseReportLine."Reimbursable Amount (LCY)" := 75; + ExpenseReportLine."Refundable Amount" := 25; + ExpenseReportLine."Refundable Amount (LCY)" := 25; + ExpenseReportLine."Expense Category" := 'MEALS'; + ExpenseReportLine."Receipt Attached" := true; + ExpenseReportLine.Insert(); + Clear(ExpenseReportLine); + ExpenseReportLine."Document No." := ExpenseReportHeader."No."; + ExpenseReportLine."Line No." := 20000; + ExpenseReportLine."Expense Category" := 'TRAVEL'; + ExpenseReportLine.Insert(); + + // [WHEN] Every concrete activity event type is logged. + EventTypeOrdinals := EventType.Ordinals(); + for EventTypeIndex := 1 to EventTypeOrdinals.Count() do begin + EventType := Enum::"Expense Activity Event Type".FromInteger(EventTypeOrdinals.Get(EventTypeIndex)); + if EventType.AsInteger() <> 0 then begin + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + EventType, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + + ExpenseActivityLogEntry.Get(EntryNo); + SnapshotExpected := EventType in [EventType::Submitted, EventType::Resubmitted, EventType::Posted]; + + // [THEN] Common identity, actor, and event metadata are always persisted. + Assert.AreEqual(Database::"Expense Report Header", ExpenseActivityLogEntry."Source Table ID", 'Source table ID must identify the expense report.'); + Assert.AreEqual(ExpenseReportHeader.SystemId, ExpenseActivityLogEntry."Source Record System ID", 'Source SystemId must identify the expense report.'); + Assert.AreEqual(ExpenseReportHeader.SystemId, ExpenseActivityLogEntry."Subject System ID", 'Subject SystemId must remain the stable report identity.'); + Assert.AreEqual(Database::"Expense User", ExpenseActivityLogEntry."Actor Table ID", 'Actor table ID must identify the Expense User table.'); + Assert.AreEqual(ExpenseUser.SystemId, ExpenseActivityLogEntry."Actor Record System ID", 'Actor SystemId must identify the expense user.'); + Assert.AreEqual(ExpenseUser.Name, ExpenseActivityLogEntry."Actor Display Name", 'Actor display name must be captured as a snapshot.'); + Assert.AreEqual(EventType, ExpenseActivityLogEntry."Event Type", 'Event type must be persisted.'); + Assert.AreEqual(Enum::"Expense Activity Initiator"::User, ExpenseActivityLogEntry."Initiated By", 'Initiator must be persisted.'); + Assert.AreEqual(Enum::"Expense Activity Actor Role"::Submitter, ExpenseActivityLogEntry."Actor Role", 'Actor role must be persisted.'); + + // [THEN] Only Submitted, Resubmitted, and Posted contain financial and content snapshots. + if SnapshotExpected then begin + SnapshotEventCount += 1; + Assert.AreEqual(120, ExpenseActivityLogEntry."Amount (LCY)", 'Total amount in LCY must be persisted.'); + Assert.AreEqual(20, ExpenseActivityLogEntry."Non-Refundable Amount (LCY)", 'Non-refundable amount in LCY must be persisted.'); + Assert.AreEqual(80, ExpenseActivityLogEntry."Reimbursable Amount", 'Reimbursable amount must be persisted.'); + Assert.AreEqual(75, ExpenseActivityLogEntry."Reimbursable Amount (LCY)", 'Reimbursable amount in LCY must be persisted.'); + Assert.AreEqual(25, ExpenseActivityLogEntry."Refundable Amount", 'Refundable amount must be persisted.'); + Assert.AreEqual(25, ExpenseActivityLogEntry."Refundable Amount (LCY)", 'Refundable amount in LCY must be persisted.'); + Assert.AreEqual(2, ExpenseActivityLogEntry."Expense Count", 'Expense count must include every report line.'); + Assert.AreEqual(1, ExpenseActivityLogEntry."Attached Receipt Count", 'Attached receipt count must include only report lines with an attached receipt.'); + Assert.AreEqual('USD', ExpenseActivityLogEntry."Reimbursement Currency Code", 'Reimbursement currency code must be persisted.'); + Assert.AreEqual(1.25, ExpenseActivityLogEntry."Reimbursement Currency Factor", 'Reimbursement currency factor must be persisted.'); + Assert.AreNotEqual('', ExpenseActivityLogEntry.Categories, 'Snapshot events must persist categories.'); + end else begin + Assert.AreEqual(0, ExpenseActivityLogEntry."Amount (LCY)", 'Non-snapshot events must not duplicate financial values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Non-Refundable Amount (LCY)", 'Non-snapshot events must not duplicate financial values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Reimbursable Amount", 'Non-snapshot events must not duplicate financial values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Reimbursable Amount (LCY)", 'Non-snapshot events must not duplicate financial values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Refundable Amount", 'Non-snapshot events must not duplicate financial values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Refundable Amount (LCY)", 'Non-snapshot events must not duplicate financial values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Expense Count", 'Non-snapshot events must not duplicate expense counts.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Attached Receipt Count", 'Non-snapshot events must not duplicate attached receipt counts.'); + Assert.AreEqual('', ExpenseActivityLogEntry."Reimbursement Currency Code", 'Non-snapshot events must not duplicate currency values.'); + Assert.AreEqual(0, ExpenseActivityLogEntry."Reimbursement Currency Factor", 'Non-snapshot events must not duplicate currency values.'); + Assert.AreEqual('', ExpenseActivityLogEntry.Categories, 'Non-snapshot events must not duplicate categories.'); + end; + end; + end; + + Assert.AreEqual(3, SnapshotEventCount, 'Exactly three activity event types must capture snapshots.'); + end; + + [Test] + procedure CreatedEventFallsBackToBCUserActor() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + User: Record User; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + EmptyGuid: Guid; + EntryNo: BigInteger; + begin + // [SCENARIO] Retrospective creation identifies the direct BC user when no Expense User creator was stored. + // [GIVEN] A report with a platform creator but no Created By Expense User Id. + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + ExpenseReportHeader."Created By Exp. User Id" := EmptyGuid; + ExpenseReportHeader.Modify(false); + User.Get(ExpenseReportHeader.SystemCreatedBy); + + // [WHEN] The retrospective Created entry is logged. + EntryNo := ExpenseActivityLogMgt.LogExpenseReportCreatedEvent(ExpenseReportHeader); + + // [THEN] The actor points to the BC User record identified by SystemCreatedBy. + ExpenseActivityLogEntry.Get(EntryNo); + Assert.AreEqual(Database::User, ExpenseActivityLogEntry."Actor Table ID", 'Created activity must identify the BC User table.'); + Assert.AreEqual(User.SystemId, ExpenseActivityLogEntry."Actor Record System ID", 'Created activity must identify the BC User record.'); + Assert.AreEqual(ExpenseReportHeader.SystemCreatedAt, ExpenseActivityLogEntry."Occurred At", 'Created activity must use the source record creation timestamp.'); + end; + + [Test] + procedure ApprovalLifecycleLogsImportantEvents() + var + SubmitterExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt"; + begin + // [SCENARIO] The approval lifecycle records creation, submission, rejection, resubmission, and approval. + // [GIVEN] A released expense report with a submitter and approver. + Initialize(); + CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader); + + // [WHEN] The report is submitted, rejected, resubmitted, and approved. + ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No."); + ExpenseReportApprovalMgt.Reject(ExpenseReportHeader, ApproverExpenseUser."No.", 'Please explain the change.'); + ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No."); + ExpenseReportApprovalMgt.Approve(ExpenseReportHeader, ApproverExpenseUser."No."); + + // [THEN] The report has the expected ordered activity entries. + ExpenseActivityLogEntry.SetRange("Subject Table ID", Database::"Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId); + ExpenseActivityLogEntry.SetCurrentKey("Entry No."); + ExpenseActivityLogEntry.FindSet(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Created, ExpenseActivityLogEntry."Event Type", 'The first entry must record report creation.'); + Assert.AreEqual(ExpenseReportHeader.SystemCreatedAt, ExpenseActivityLogEntry."Occurred At", 'The creation entry must use the report creation timestamp.'); + ExpenseActivityLogEntry.Next(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Submitted, ExpenseActivityLogEntry."Event Type", 'The second entry must record first submission.'); + ExpenseActivityLogEntry.Next(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Rejected, ExpenseActivityLogEntry."Event Type", 'The third entry must record rejection.'); + Assert.AreEqual('Please explain the change.', ExpenseActivityLogEntry.Comment, 'The rejection entry must preserve the approver comment.'); + ExpenseActivityLogEntry.Next(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Resubmitted, ExpenseActivityLogEntry."Event Type", 'The fourth entry must record resubmission.'); + ExpenseActivityLogEntry.Next(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Approved, ExpenseActivityLogEntry."Event Type", 'The fifth entry must record approval.'); + Assert.AreEqual(0, ExpenseActivityLogEntry.Next(), 'No additional approval lifecycle entries are expected.'); + end; + + [Test] + procedure ReopeningPendingApprovalLogsRecall() + var + SubmitterExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt"; + EntryCountBeforeRejectedReopen: Integer; + begin + // [SCENARIO] Returning a pending report to Open is a recall, while reopening a rejected report is not logged. + // [GIVEN] A submitted expense report whose submitter is the current BC user. + Initialize(); + CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader); + ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No."); + + // [WHEN] The pending report is reopened. + ExpenseReportApprovalMgt.ReopenSubmitted(ExpenseReportHeader); + + // [THEN] A Recalled entry is appended. + ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId); + ExpenseActivityLogEntry.FindLast(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Recalled, ExpenseActivityLogEntry."Event Type", 'Pending approval to Open must be recorded as recalled.'); + + // [WHEN] The same operation reopens a rejected report. + ExpenseReportHeader.Status := ExpenseReportHeader.Status::Rejected; + ExpenseReportHeader.Modify(true); + EntryCountBeforeRejectedReopen := ExpenseActivityLogEntry.Count(); + ExpenseReportApprovalMgt.ReopenSubmitted(ExpenseReportHeader); + + // [THEN] No additional activity entry is appended. + ExpenseActivityLogEntry.Reset(); + ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId); + Assert.AreEqual(EntryCountBeforeRejectedReopen, ExpenseActivityLogEntry.Count(), 'Reopening a rejected report must not create an activity entry.'); + end; + + [Test] + procedure CategoriesSnapshotRemovesTrailingCategoryToFitEllipsis() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + Categories: JsonArray; + LastCategory: JsonToken; + EntryNo: BigInteger; + CategoryIndex: Integer; + begin + // [SCENARIO] An overflowing category snapshot remains valid and signals omitted categories. + // [GIVEN] A report with more maximum-length unique categories than the snapshot field can store. + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + for CategoryIndex := 1 to 90 do begin + Clear(ExpenseReportLine); + ExpenseReportLine."Document No." := ExpenseReportHeader."No."; + ExpenseReportLine."Line No." := CategoryIndex * 10000; + ExpenseReportLine."Expense Category" := CopyStr(PadStr(Format(CategoryIndex), 20, 'X'), 1, MaxStrLen(ExpenseReportLine."Expense Category")); + ExpenseReportLine."Receipt Attached" := CategoryIndex = 90; + ExpenseReportLine.Insert(); + end; + + // [WHEN] A Submitted entry captures the report contents. + EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + + // [THEN] The category JSON fits, remains valid, and ends with an ellipsis. + ExpenseActivityLogEntry.Get(EntryNo); + Assert.IsTrue(StrLen(ExpenseActivityLogEntry.Categories) <= MaxStrLen(ExpenseActivityLogEntry.Categories), 'Categories must not exceed the field length.'); + Assert.IsTrue(Categories.ReadFrom(ExpenseActivityLogEntry.Categories), 'Categories must remain valid JSON.'); + Categories.Get(Categories.Count() - 1, LastCategory); + Assert.AreEqual('...', LastCategory.AsValue().AsText(), 'An overflowing category snapshot must end with an ellipsis.'); + Assert.AreEqual(1, ExpenseActivityLogEntry."Attached Receipt Count", 'Attached receipt counting must continue after the category snapshot overflows.'); + end; + + [Test] + procedure ExistingReportStartsTimelineWhenResubmitted() + var + SubmitterExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt"; + begin + // [SCENARIO] A report submitted before activity tracking starts gets a complete timeline when resubmitted. + // [GIVEN] A released report with an earlier submission timestamp but no activity entries. + Initialize(); + CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader); + ExpenseReportHeader."Submission DateTime" := CurrentDateTime() - 1000; + ExpenseReportHeader.Modify(true); + + // [WHEN] The existing report is resubmitted. + ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No."); + + // [THEN] The timeline starts with Created and records the action as Resubmitted. + ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId); + ExpenseActivityLogEntry.SetCurrentKey("Entry No."); + ExpenseActivityLogEntry.FindSet(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Created, ExpenseActivityLogEntry."Event Type", 'The timeline must start with report creation.'); + ExpenseActivityLogEntry.Next(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Resubmitted, ExpenseActivityLogEntry."Event Type", 'An earlier submission timestamp must produce Resubmitted.'); + Assert.AreEqual(0, ExpenseActivityLogEntry.Next(), 'Only creation and resubmission entries are expected.'); + end; + + [Test] + procedure ReopeningApprovedReportIsLogged() + var + SubmitterExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + CurrentUserExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt"; + begin + // [SCENARIO] Reopening an approved report records the approver lifecycle action. + // [GIVEN] An approved report whose approver is the only Expense User mapped to the current BC user. + Initialize(); + CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader); + CurrentUserExpenseUser.SetRange("User Id For Approvals", UserId()); + CurrentUserExpenseUser.ModifyAll("User Id For Approvals", ''); + ApproverExpenseUser."User Id For Approvals" := + CopyStr(UserId(), 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals")); + ApproverExpenseUser.Modify(); + ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No."); + ExpenseReportApprovalMgt.Approve(ExpenseReportHeader, ApproverExpenseUser."No."); + + // [WHEN] The approver reopens the approved report. + ExpenseReportApprovalMgt.ReopenApproved(ExpenseReportHeader); + + // [THEN] ReopenedByApprover is the latest activity. + ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId); + ExpenseActivityLogEntry.FindLast(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::ReopenedByApprover, ExpenseActivityLogEntry."Event Type", 'Reopening an approved report must be logged.'); + Assert.AreEqual(ApproverExpenseUser.SystemId, ExpenseActivityLogEntry."Actor Record System ID", 'The reopen entry must identify the approver.'); + end; + + [Test] + procedure ReassigningToPostedChangesOwnerButKeepsSubject() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + DecoyEntryNo: BigInteger; + OriginalEntryNo: BigInteger; + OriginalEntrySystemID: Guid; + OriginalSubjectSystemID: Guid; + begin + // [SCENARIO] Posting reassigns only the exact entries owned by the source report. + // [GIVEN] Two source-report entries and an unowned decoy sharing the posted header SystemId. + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + OriginalEntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Created, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + ExpenseActivityLogEntry.Get(OriginalEntryNo); + OriginalEntrySystemID := ExpenseActivityLogEntry.SystemId; + OriginalSubjectSystemID := ExpenseReportHeader.SystemId; + + PostedExpenseReportHeader.Init(); + PostedExpenseReportHeader."No." := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(PostedExpenseReportHeader."No.")); + PostedExpenseReportHeader.Insert(); + + Clear(ExpenseActivityLogEntry); + ExpenseActivityLogEntry.Init(); + ExpenseActivityLogEntry."Source Table ID" := Database::"Expense Report Header"; + ExpenseActivityLogEntry."Source Record System ID" := PostedExpenseReportHeader.SystemId; + ExpenseActivityLogEntry."Subject Table ID" := Database::"Expense Report Header"; + ExpenseActivityLogEntry."Subject System ID" := CreateGuid(); + ExpenseActivityLogEntry."Event Type" := Enum::"Expense Activity Event Type"::Created; + ExpenseActivityLogEntry."Occurred At" := CurrentDateTime(); + ExpenseActivityLogEntry.Insert(); + DecoyEntryNo := ExpenseActivityLogEntry."Entry No."; + + // [WHEN] The source report entries are reassigned to the posted report. + ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader, PostedExpenseReportHeader); + + // [THEN] The source entries have the posted owner while their event and subject identities remain stable. + ExpenseActivityLogEntry.Reset(); + ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Source Record System ID", ExpenseReportHeader.SystemId); + Assert.RecordIsEmpty(ExpenseActivityLogEntry); + ExpenseActivityLogEntry.Reset(); + ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Posted Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Source Record System ID", PostedExpenseReportHeader.SystemId); + ExpenseActivityLogEntry.FindFirst(); + Assert.RecordCount(ExpenseActivityLogEntry, 2); + Assert.AreEqual(Database::"Posted Expense Report Header", ExpenseActivityLogEntry."Source Table ID", 'Posted entries must be sourced from the posted report.'); + Assert.AreEqual(OriginalSubjectSystemID, ExpenseActivityLogEntry."Subject System ID", 'Posting must not change the contract subject identity.'); + ExpenseActivityLogEntry.SetRange(SystemId, OriginalEntrySystemID); + Assert.RecordIsNotEmpty(ExpenseActivityLogEntry); + + // [THEN] The unmarked decoy remains owned by an active report. + ExpenseActivityLogEntry.Get(DecoyEntryNo); + Assert.AreEqual(Database::"Expense Report Header", ExpenseActivityLogEntry."Source Table ID", 'An unmarked active entry with the posted source GUID must not be reassigned.'); + end; + + [Test] + procedure SourceDeletionRemovesActivityEntries() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + begin + // [SCENARIO] Activity entries are deleted with the active or posted source document. + // [GIVEN] An activity entry reassigned from an active report to a posted report. + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + PostedExpenseReportHeader.Init(); + PostedExpenseReportHeader."No." := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(PostedExpenseReportHeader."No.")); + PostedExpenseReportHeader.Insert(); + ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader, PostedExpenseReportHeader); + + // [WHEN] The active report is deleted after reassignment. + ExpenseReportHeader.Delete(true); + + // [THEN] The entry remains with the posted source. + ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Posted Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Source Record System ID", PostedExpenseReportHeader.SystemId); + Assert.RecordIsNotEmpty(ExpenseActivityLogEntry); + + // [WHEN] The posted source document is deleted. + PostedExpenseReportHeader.Delete(true); + + // [THEN] Its activity entries are deleted. + Assert.RecordIsEmpty(ExpenseActivityLogEntry); + end; + + local procedure CreateApprovalScenario( + var + SubmitterExpenseUser: Record "Expense User"; + var + ApproverExpenseUser: Record "Expense User"; + var + ExpenseReportHeader: Record "Expense Report Header" + ) + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + begin + LibraryExpense.CreateExpenseUser(SubmitterExpenseUser); + SubmitterExpenseUser."User Id For Approvals" := CopyStr(UserId(), 1, MaxStrLen(SubmitterExpenseUser."User Id For Approvals")); + SubmitterExpenseUser.Modify(); + + LibraryExpense.CreateExpenseUser(ApproverExpenseUser); + ApproverExpenseUser."Can Approve" := true; + ApproverExpenseUser."User Id For Approvals" := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals")); + ApproverExpenseUser.Modify(); + LibraryExpense.CreateExpenseApprovalSetup(ExpenseApprovalSetup, SubmitterExpenseUser."No.", ApproverExpenseUser."No."); + + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, SubmitterExpenseUser."No.", '', ''); + ExpenseReportHeader.Status := ExpenseReportHeader.Status::Released; + ExpenseReportHeader.Modify(true); + end; + + local procedure Initialize() + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Activity Log Test"); + if IsInitialized then + exit; + + LibraryExpense.SetupNumberSeriesInExpenseMgmt(); + IsInitialized := true; + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Activity Log Test"); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al index 3793db08e1f..4e6f30224b4 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al @@ -232,6 +232,7 @@ codeunit 148338 "Expense Event Subs. Perm. Test" local procedure Initialize() begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Event Subs. Perm. Test"); + RestoreFullPermissions(); LibraryExpense.CleanTransactionalData(); LibraryExpense.CleanUpBeforeTesting(); if IsInitialized then diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al index 96c5c08243e..2b83eab1447 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al @@ -131,6 +131,70 @@ codeunit 148302 "Expense Report Posting Test" Assert.RecordCount(PostedExpenseReportLine, 1); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure PostingExpenseReportLogsPostedActivity() + var + Expense: Record Expense; + Employee: Record Employee; + PostCode: Record "Post Code"; + ExpensePaymentMethod: Record "Expense Payment Method"; + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; + ReleaseExpenseDocument: Codeunit "Release Expense Document"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt."; + OriginalSubjectSystemID: Guid; + Amount: Decimal; + begin + // [SCENARIO] Posting records a Posted activity entry and reassigns the report timeline to the posted source. + Initialize(); + + // [GIVEN] A released expense report with activity tracking already started. + LibraryERM.FindPostCode(PostCode); + Amount := LibraryRandom.RandInt(100); + CreateExpense(Expense, true, '', Amount); + LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); + Expense.Validate("Payment Method Code", ExpensePaymentMethod.Code); + Expense.Modify(); + ExpenseUser.Get(Expense."Expense User No."); + Employee.Get(ExpenseUser."Employee No."); + LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + ReleaseExpenseDocument.PerformManualCheckAndRelease(Expense); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); + CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + ExpenseReportHeader.PerformManualRelease(); + OriginalSubjectSystemID := ExpenseReportHeader.SystemId; + ExpenseActivityLogMgt.LogExpenseReportEvent( + ExpenseReportHeader, + Enum::"Expense Activity Event Type"::Submitted, + Enum::"Expense Activity Initiator"::User, + Enum::"Expense Activity Actor Role"::Submitter, + ExpenseUser."No.", + ''); + + // [WHEN] The report is posted through Expense Report-Post. + ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + + // [THEN] Submitted and Posted entries are sourced from the posted report and keep the original subject. + PostedExpenseReportHeader.SetRange("Expense User No.", ExpenseUser."No."); + PostedExpenseReportHeader.FindFirst(); + ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Posted Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Source Record System ID", PostedExpenseReportHeader.SystemId); + Assert.RecordCount(ExpenseActivityLogEntry, 2); + ExpenseActivityLogEntry.SetRange("Subject Table ID", Database::"Expense Report Header"); + ExpenseActivityLogEntry.SetRange("Subject System ID", OriginalSubjectSystemID); + ExpenseActivityLogEntry.SetCurrentKey("Subject Table ID", "Subject System ID", "Occurred At", "Entry No."); + ExpenseActivityLogEntry.FindLast(); + Assert.AreEqual(Enum::"Expense Activity Event Type"::Posted, ExpenseActivityLogEntry."Event Type", 'The latest activity entry must be Posted.'); + Assert.AreEqual(Database::User, ExpenseActivityLogEntry."Actor Table ID", 'Posting must identify the current BC User actor.'); + Assert.IsFalse(IsNullGuid(ExpenseActivityLogEntry."Actor Record System ID"), 'Posting must persist the BC User SystemId.'); + Assert.AreEqual(Amount, ExpenseActivityLogEntry."Amount (LCY)", 'Posted activity must preserve the report amount snapshot.'); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure ExpenseReportMustBePostedForMultipleReleasedExpense() diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al index 51be9afd94e..f686afbb501 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al @@ -614,6 +614,7 @@ codeunit 148300 "Library - Expense" ExpenseReportLinePerDiem: Record "Expense Report Line Per Diem"; ExpenseRuleViolation: Record "Expense Rule Violation"; ExpenseReportRuleViolation: Record "Expense Report Rule Violation"; + ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; begin // First, clear expense report references to avoid "cannot delete attachment" validation. Expense.ModifyAll("Expense Report No.", ''); @@ -624,6 +625,7 @@ codeunit 148300 "Library - Expense" ExpenseReportLineParticip.DeleteAll(); ExpenseReportLinePerDiem.DeleteAll(); ExpenseReportLine.DeleteAll(); + ExpenseActivityLogEntry.DeleteAll(); ExpenseReportHeader.DeleteAll(); ExpenseReportRuleViolation.DeleteAll(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpenseAgent.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpenseAgent.Codeunit.al new file mode 100644 index 00000000000..1b2cf7ee89c --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpenseAgent.Codeunit.al @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Test.ExpenseAgent; + +using Microsoft.ExpenseAgent; + +codeunit 148341 "Library - Expense Agent" +{ + SingleInstance = true; + + var + TempExpenseAgentSetup: Record "Expense Agent Setup" temporary; + ExpenseAgentSetupExisted: Boolean; + HasExpenseAgentSetupBackup: Boolean; + + internal procedure BackupExpenseAgentSetup() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + if HasExpenseAgentSetupBackup then + RestoreExpenseAgentSetup(); + + TempExpenseAgentSetup.DeleteAll(); + ExpenseAgentSetupExisted := ExpenseAgentSetup.Get(); + if ExpenseAgentSetupExisted then begin + TempExpenseAgentSetup.Init(); + TempExpenseAgentSetup.TransferFields(ExpenseAgentSetup, true); + TempExpenseAgentSetup.Insert(); + end; + HasExpenseAgentSetupBackup := true; + end; + + internal procedure RestoreExpenseAgentSetup() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + if not HasExpenseAgentSetupBackup then + exit; + + if ExpenseAgentSetupExisted then begin + TempExpenseAgentSetup.Get(); + ExpenseAgentSetup.Get(); + ExpenseAgentSetup.TransferFields(TempExpenseAgentSetup, false); + ExpenseAgentSetup.Modify(false); + end else + if ExpenseAgentSetup.Get() then + ExpenseAgentSetup.Delete(false); + + TempExpenseAgentSetup.DeleteAll(); + Clear(ExpenseAgentSetupExisted); + Clear(HasExpenseAgentSetupBackup); + end; +} diff --git a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json index 380b2231c07..f2fbf699587 100644 --- a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json +++ b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json @@ -19,6 +19,46 @@ "codeunitName": "Expense Capabilities API Test", "method": "CapabilitiesConsolidatedProjectsFollowsProjectFieldsViaAPI" }, + { + "codeunitId": 148318, + "codeunitName": "Expense Capabilities API Test", + "method": "ActivityLogCapabilityEnabledViaAPI" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "ActivityLogEntryIsExposedThroughReadOnlyAPI" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "ActivityLogAPIRejectsWrites" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "NonSnapshotActivityDoesNotExposeCurrencies" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "UnscopedActivityAPIHidesEntries" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "SubmitterHistoryReturnsCompleteTimelinesForSubmittedReports" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "ApproverHistoryReturnsCompleteTimelinesForActedOnReports" + }, + { + "codeunitId": 148343, + "codeunitName": "Expense Activity Log API Test", + "method": "E2EActivityLogScenario" + }, { "codeunitId": 148333, "codeunitName": "Expense Agent Setup API Test",