diff --git a/src/Apps/W1/ExpenseAgent/app/app.json b/src/Apps/W1/ExpenseAgent/app/app.json index 1461fd09fa1..3183fd179b3 100644 --- a/src/Apps/W1/ExpenseAgent/app/app.json +++ b/src/Apps/W1/ExpenseAgent/app/app.json @@ -135,6 +135,10 @@ { "from": 7073, "to": 7099 + }, + { + "from": 7101, + "to": 7110 } ], "features": [ diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensePoliciesAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensePoliciesAPI.Page.al new file mode 100644 index 00000000000..354dcc3eabd --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensePoliciesAPI.Page.al @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7104 "Expense Policies API" +{ + APIGroup = 'expense'; + APIPublisher = 'microsoft'; + APIVersion = 'beta'; + EntityCaption = 'Expense Policy'; + EntitySetCaption = 'Expense Policies'; + DelayedInsert = true; + EntityName = 'expensePolicy'; + EntitySetName = 'expensePolicies'; + PageType = API; + ODataKeyFields = SystemId; + SourceTable = "Expense Policy"; + AboutText = 'Provides access to data from the Expense Policy table'; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + + layout + { + area(Content) + { + repeater(General) + { + field(id; Rec.SystemId) + { + Caption = 'Id'; + Editable = false; + } + field(expenseCategoryCode; Rec."Expense Category Code") + { + Caption = 'Expense Category Code'; + } + field(lineNo; Rec."Line No.") + { + Caption = 'Line No.'; + } + field(description; Rec.Description) + { + Caption = 'Description'; + } + field(policyText; Rec."Policy Text") + { + Caption = 'Policy Text'; + } + field(enabled; Rec.Enabled) + { + Caption = 'Enabled'; + } + } + } + } + + trigger OnInit() + var + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + begin + ExpenseAgentAPIValidation.VerifyAgentAccess(); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensePolicyFlagsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensePolicyFlagsAPI.Page.al new file mode 100644 index 00000000000..e151f5d9dd9 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpensePolicyFlagsAPI.Page.al @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7102 "Expense Policy Flags API" +{ + APIGroup = 'expense'; + APIPublisher = 'microsoft'; + APIVersion = 'beta'; + EntityCaption = 'Expense Policy Flag'; + EntitySetCaption = 'Expense Policy Flags'; + DelayedInsert = true; + EntityName = 'expensePolicyFlag'; + EntitySetName = 'expensePolicyFlags'; + PageType = API; + ODataKeyFields = SystemId; + SourceTable = "Expense Policy Flag"; + AboutText = 'Provides access to data from the Expense Policy Flag table'; + InsertAllowed = true; + ModifyAllowed = false; + DeleteAllowed = false; + + layout + { + area(Content) + { + repeater(General) + { + field(id; Rec.SystemId) + { + Caption = 'Id'; + Editable = false; + } + field(subjectSystemId; Rec."Subject System Id") + { + Caption = 'Subject System Id'; + } + field(subjectType; Rec."Subject Type") + { + Caption = 'Subject Type'; + } + field(subjectVersion; Rec."Subject Version") + { + Caption = 'Subject Version'; + Editable = false; + } + field(expenseCategoryCode; Rec."Expense Category Code") + { + Caption = 'Expense Category Code'; + Editable = false; + } + field(policySystemId; Rec."Policy System Id") + { + Caption = 'Policy System Id'; + } + field(policyVersion; Rec."Policy Version") + { + Caption = 'Policy Version'; + Editable = false; + } + field(isCurrent; Rec."Is Current") + { + Caption = 'Is Current'; + Editable = false; + } + field(reason; Rec."Reason") + { + Caption = 'Reason'; + } + field(flaggedAt; Rec."Flagged At") + { + Caption = 'Flagged At'; + } + field(compliant; Rec."Compliant") + { + Caption = 'Compliant'; + } + } + } + } + + trigger OnInit() + var + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + begin + ExpenseAgentAPIValidation.VerifyAgentAccess(); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportLinesAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportLinesAPI.Page.al index e9886c58555..d09c320dcfc 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportLinesAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportLinesAPI.Page.al @@ -325,6 +325,21 @@ page 6929 "Expense Report Lines API" Caption = 'Project Task Description'; Editable = false; } + field(policiesEvaluatedAt; Rec."Policies Evaluated At") + { + Caption = 'Policies Evaluated At'; + Editable = false; + } + field(policyStatus; PolicyStatusDisplay) + { + Caption = 'Policy Status'; + Editable = false; + } + field(hasPolicyViolation; HasPolicyViolationDisplay) + { + Caption = 'Has Policy Violation'; + Editable = false; + } part(expense; "Expenses API") { @@ -368,6 +383,20 @@ page 6929 "Expense Report Lines API" SubPageLink = "Expense Report No." = field("Document No."), "Report Line No." = field("Line No."); } + part(expensePolicyFlags; "Expense Policy Flags API") + { + Caption = 'Expense Policy Flags'; + EntityName = 'expensePolicyFlag'; + EntitySetName = 'expensePolicyFlags'; + SubPageLink = "Subject System Id" = field(SystemId), "Subject Type" = const("Expense Report Line"), "Subject Version" = field("Policy Eval Version"); + } + part(policiesToEvaluate; "Exp. Policies To Eval API") + { + Caption = 'Policies To Evaluate'; + EntityName = 'policyToEvaluate'; + EntitySetName = 'policiesToEvaluate'; + SubPageLink = "Subject System Id" = field(SystemId); + } } } } @@ -382,7 +411,10 @@ page 6929 "Expense Report Lines API" TotalMileage: Decimal; JobDescription: Text[100]; JobTaskDescription: Text[100]; + PolicyStatusDisplay: Enum "Expense Policy Status"; + HasPolicyViolationDisplay: Boolean; TargetExpenseReportNotFoundErr: Label 'Expense report with Id %1 not found.', Comment = '%1 = Expense Report Header SystemId'; + OutstandingPoliciesErr: Label 'Cannot mark policies evaluated: one or more applicable policies have not yet been evaluated for the current version of this expense report line. Retrieve the outstanding policies, submit a verdict for each, and try again.'; trigger OnInit() var @@ -395,7 +427,7 @@ page 6929 "Expense Report Lines API" trigger OnOpenPage() begin // Avoid JIT load consistency errors by ensuring fields read in OnAfterGetRecord are included in the initial record buffer. - Rec.AddLoadFields("Expense Currency Code", "Expense User No.", Mileage, "Round Trip"); + Rec.AddLoadFields("Expense Currency Code", "Expense User No.", Mileage, "Round Trip", "Policy Eval Version", "Evaluated Policy Version"); end; trigger OnAfterGetRecord() @@ -408,6 +440,8 @@ page 6929 "Expense Report Lines API" XCurrencyCodeDisplay := CurrencyCodeDisplay; ExpenseUserSystemId := ExpenseUser.GetSystemIdByExpenseUserNo(Rec."Expense User No."); TotalMileage := ExpenseAutoPopulation.GetEffectiveDistance(Rec.Mileage, Rec."Round Trip"); + PolicyStatusDisplay := Rec.GetPolicyStatus(); + HasPolicyViolationDisplay := Rec.HasCurrentPolicyViolation(); JobDescription := ''; JobTaskDescription := ''; @@ -493,4 +527,24 @@ page 6929 "Expense Report Lines API" ActionContext.AddEntityKey(Rec.FieldNo(SystemId), Rec.SystemId); ActionContext.SetResultCode(WebServiceActionResultCode::Updated); end; + + [ServiceEnabled] + procedure MarkPoliciesEvaluated(var ActionContext: WebServiceActionContext) + var + PoliciesToEvalBuilder: Codeunit "Exp. Policies To Eval Builder"; + begin + // Guard the agent contract: refuse to mark the line evaluated while an applicable policy still + // lacks a verdict for the current version. A partial agent run would otherwise expose a + // Cleared/Flagged status that silently ignores the unevaluated policies. The caller should + // fetch the outstanding policies (policiesToEvaluate), submit a verdict for each, and retry. + if PoliciesToEvalBuilder.HasOutstandingPolicies(Rec) then + Error(OutstandingPoliciesErr); + + Rec.MarkPoliciesEvaluated(); + + ActionContext.SetObjectType(ObjectType::Page); + ActionContext.SetObjectId(Page::"Expense Report Lines 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/PoliciesToEvaluate/ExpPoliciesToEvalAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPoliciesToEvalAPI.Page.al new file mode 100644 index 00000000000..74de18aab5b --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPoliciesToEvalAPI.Page.al @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7108 "Exp. Policies To Eval API" +{ + PageType = API; + APIPublisher = 'microsoft'; + APIGroup = 'expense'; + APIVersion = 'beta'; + EntityName = 'policyToEvaluate'; + EntitySetName = 'policiesToEvaluate'; + EntityCaption = 'Policy To Evaluate'; + EntitySetCaption = 'Policies To Evaluate'; + SourceTable = "Exp. Policy To Eval Buffer"; + SourceTableTemporary = true; + ODataKeyFields = "Subject System Id", "Policy System Id"; + DataAccessIntent = ReadOnly; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + Extensible = false; + AboutTitle = 'Policies To Evaluate'; + AboutText = 'Returns the enabled policies that still need to be evaluated for an expense report line - policies newly introduced or bumped to a new version, or any policy not yet evaluated at the line''s current version. Read this to know which policies to (re)run when a line is stale.'; + + layout + { + area(Content) + { + repeater(General) + { + field(subjectSystemId; Rec."Subject System Id") + { + Caption = 'Subject System Id'; + } + field(subjectVersion; Rec."Subject Version") + { + Caption = 'Subject Version'; + } + field(policySystemId; Rec."Policy System Id") + { + Caption = 'Policy System Id'; + } + field(policyLineNo; Rec."Policy Line No.") + { + Caption = 'Policy Line No.'; + } + field(policyVersion; Rec."Policy Version") + { + Caption = 'Policy Version'; + } + field(expenseCategoryCode; Rec."Expense Category Code") + { + Caption = 'Expense Category Code'; + } + field(description; Rec."Description") + { + Caption = 'Description'; + } + field(policyText; Rec."Policy Text") + { + Caption = 'Policy Text'; + } + } + } + } + + var + Builder: Codeunit "Exp. Policies To Eval Builder"; + BuiltSubjectFilter: Text; + HasBuilt: Boolean; + + trigger OnInit() + var + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + begin + ExpenseAgentAPIValidation.VerifyAgentAccess(); + end; + + trigger OnFindRecord(Which: Text): Boolean + var + SubjectFilter: Text; + FilterView: Text; + begin + SubjectFilter := Rec.GetFilter("Subject System Id"); + // Rebuild whenever the parent subject changes so $expand across many lines stays correct. + if (not HasBuilt) or (SubjectFilter <> BuiltSubjectFilter) then begin + FilterView := Rec.GetView(); + Builder.Build(Rec, SubjectFilter); + Rec.SetView(FilterView); + BuiltSubjectFilter := SubjectFilter; + HasBuilt := true; + end; + exit(Rec.Find(Which)); + end; + + trigger OnNextRecord(Steps: Integer): Integer + begin + exit(Rec.Next(Steps)); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPoliciesToEvalBuilder.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPoliciesToEvalBuilder.Codeunit.al new file mode 100644 index 00000000000..aea3eb66667 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPoliciesToEvalBuilder.Codeunit.al @@ -0,0 +1,109 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +codeunit 7107 "Exp. Policies To Eval Builder" +{ + Access = Internal; + + procedure Build(var TempPolicyToEvalBuffer: Record "Exp. Policy To Eval Buffer" temporary; SubjectSystemIdFilter: Text) + var + ExpenseReportLine: Record "Expense Report Line"; + SubjectSystemId: Guid; + begin + TempPolicyToEvalBuffer.Reset(); + TempPolicyToEvalBuffer.DeleteAll(); + + if not TryEvaluateGuid(SubjectSystemIdFilter, SubjectSystemId) then + exit; + + if not ExpenseReportLine.GetBySystemId(SubjectSystemId) then + exit; + + BuildForLine(TempPolicyToEvalBuffer, ExpenseReportLine); + end; + + local procedure BuildForLine(var TempPolicyToEvalBuffer: Record "Exp. Policy To Eval Buffer" temporary; ExpenseReportLine: Record "Expense Report Line") + var + ExpensePolicy: Record "Expense Policy"; + SubjectSystemId: Guid; + SubjectVersion: Integer; + begin + SubjectSystemId := ExpenseReportLine.SystemId; + SubjectVersion := ExpenseReportLine."Policy Eval Version"; + + SetApplicablePolicyFilter(ExpensePolicy, ExpenseReportLine); + if ExpensePolicy.FindSet() then + repeat + if not FlagExists(SubjectSystemId, ExpensePolicy.SystemId, SubjectVersion, ExpensePolicy."Version") then + InsertRow(TempPolicyToEvalBuffer, SubjectSystemId, SubjectVersion, ExpensePolicy); + until ExpensePolicy.Next() = 0; + end; + + procedure HasOutstandingPolicies(ExpenseReportLine: Record "Expense Report Line"): Boolean + var + ExpensePolicy: Record "Expense Policy"; + SubjectSystemId: Guid; + SubjectVersion: Integer; + begin + // An applicable policy is outstanding when no flag exists for the line's current subject + // version and the policy's current version - i.e. it has not been evaluated yet. Marking a + // line evaluated while a policy is still outstanding would expose a status that ignores it. + SubjectSystemId := ExpenseReportLine.SystemId; + SubjectVersion := ExpenseReportLine."Policy Eval Version"; + + SetApplicablePolicyFilter(ExpensePolicy, ExpenseReportLine); + if ExpensePolicy.FindSet() then + repeat + if not FlagExists(SubjectSystemId, ExpensePolicy.SystemId, SubjectVersion, ExpensePolicy."Version") then + exit(true); + until ExpensePolicy.Next() = 0; + exit(false); + end; + + local procedure SetApplicablePolicyFilter(var ExpensePolicy: Record "Expense Policy"; ExpenseReportLine: Record "Expense Report Line") + begin + // Applicable policies are the enabled report-line policies whose category matches the line + // or is blank (a blank category applies to every category). The category-or-blank rule is + // pushed into the query filter so unrelated categories are never loaded. + ExpensePolicy.SetRange("Subject Type", ExpensePolicy."Subject Type"::"Expense Report Line"); + ExpensePolicy.SetRange(Enabled, true); + ExpensePolicy.SetFilter("Expense Category Code", '%1|%2', ExpenseReportLine."Expense Category", ''); + end; + + local procedure FlagExists(SubjectSystemId: Guid; PolicySystemId: Guid; SubjectVersion: Integer; PolicyVersion: Integer): Boolean + var + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // A policy no longer needs evaluating when a flag already exists for the line's current + // subject version and the policy's current version. This keeps the endpoint idempotent and + // avoids returning policies that were just re-evaluated. + exit(ExpensePolicyFlag.Get(ExpensePolicyFlag."Subject Type"::"Expense Report Line", SubjectSystemId, PolicySystemId, SubjectVersion, PolicyVersion)); + end; + + local procedure InsertRow(var TempPolicyToEvalBuffer: Record "Exp. Policy To Eval Buffer" temporary; SubjectSystemId: Guid; SubjectVersion: Integer; var ExpensePolicy: Record "Expense Policy") + begin + TempPolicyToEvalBuffer.Init(); + TempPolicyToEvalBuffer."Subject System Id" := SubjectSystemId; + TempPolicyToEvalBuffer."Policy System Id" := ExpensePolicy.SystemId; + TempPolicyToEvalBuffer."Subject Version" := SubjectVersion; + TempPolicyToEvalBuffer."Policy Line No." := ExpensePolicy."Line No."; + TempPolicyToEvalBuffer."Policy Version" := ExpensePolicy."Version"; + TempPolicyToEvalBuffer."Expense Category Code" := ExpensePolicy."Expense Category Code"; + TempPolicyToEvalBuffer."Description" := ExpensePolicy."Description"; + TempPolicyToEvalBuffer."Policy Text" := ExpensePolicy."Policy Text"; + if TempPolicyToEvalBuffer.Insert() then; + end; + + local procedure TryEvaluateGuid(SystemIdFilter: Text; var SystemId: Guid): Boolean + begin + Clear(SystemId); + if SystemIdFilter = '' then + exit(false); + if not Evaluate(SystemId, SystemIdFilter) then + exit(false); + exit(not IsNullGuid(SystemId)); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPolicyToEvalBuffer.Table.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPolicyToEvalBuffer.Table.al new file mode 100644 index 00000000000..7fa23562cda --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PoliciesToEvaluate/ExpPolicyToEvalBuffer.Table.al @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +table 7106 "Exp. Policy To Eval Buffer" +{ + Access = Internal; + TableType = Temporary; + Caption = 'Expense Policy To Evaluate Buffer'; + DataClassification = CustomerContent; + + fields + { + field(1; "Subject System Id"; Guid) + { + Caption = 'Subject System Id'; + } + field(2; "Policy System Id"; Guid) + { + Caption = 'Policy System Id'; + } + field(10; "Subject Version"; Integer) + { + Caption = 'Subject Version'; + } + field(11; "Policy Line No."; Integer) + { + Caption = 'Policy Line No.'; + } + field(12; "Policy Version"; Integer) + { + Caption = 'Policy Version'; + } + field(13; "Expense Category Code"; Code[20]) + { + Caption = 'Expense Category Code'; + } + field(14; "Description"; Text[50]) + { + Caption = 'Description'; + } + field(15; "Policy Text"; Text[2048]) + { + Caption = 'Policy Text'; + } + } + + keys + { + key(PK; "Subject System Id", "Policy System Id") + { + Clustered = true; + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpPolicyFlagsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpPolicyFlagsAPI.Page.al new file mode 100644 index 00000000000..844a8ee6214 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpPolicyFlagsAPI.Page.al @@ -0,0 +1,87 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7105 "Posted Exp. Policy Flags API" +{ + APIGroup = 'expense'; + APIPublisher = 'microsoft'; + APIVersion = 'beta'; + EntityCaption = 'Posted Expense Policy Flag'; + EntitySetCaption = 'Posted Expense Policy Flags'; + EntityName = 'postedExpensePolicyFlag'; + EntitySetName = 'postedExpensePolicyFlags'; + PageType = API; + ODataKeyFields = SystemId; + Editable = false; + InsertAllowed = false; + DeleteAllowed = false; + ModifyAllowed = false; + DataAccessIntent = ReadOnly; + SourceTable = "Posted Exp. Policy Flag"; + AboutText = 'Provides access to data from the Posted Expense Policy Flag table'; + + layout + { + area(Content) + { + repeater(General) + { + field(id; Rec.SystemId) + { + Caption = 'Id'; + Editable = false; + } + field(subjectSystemId; Rec."Subject System Id") + { + Caption = 'Subject System Id'; + } + field(subjectType; Rec."Subject Type") + { + Caption = 'Subject Type'; + } + field(subjectVersion; Rec."Subject Version") + { + Caption = 'Subject Version'; + } + field(expenseCategoryCode; Rec."Expense Category Code") + { + Caption = 'Expense Category Code'; + } + field(policySystemId; Rec."Policy System Id") + { + Caption = 'Policy System Id'; + } + field(policyVersion; Rec."Policy Version") + { + Caption = 'Policy Version'; + } + field(isCurrent; Rec."Is Current") + { + Caption = 'Is Current'; + } + field(reason; Rec."Reason") + { + Caption = 'Reason'; + } + field(flaggedAt; Rec."Flagged At") + { + Caption = 'Flagged At'; + } + field(compliant; Rec."Compliant") + { + Caption = 'Compliant'; + } + } + } + } + + trigger OnInit() + var + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + begin + ExpenseAgentAPIValidation.VerifyAgentAccess(); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpReportLinesAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpReportLinesAPI.Page.al index 6130a460006..678de41bd81 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpReportLinesAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpReportLinesAPI.Page.al @@ -295,6 +295,12 @@ page 6913 "Posted Exp. Report Lines API" EntitySetName = 'postedExpenseReportLineAttachments'; SubPageLink = "Document Id" = field(SystemId); } + part(expensePolicyFlags; "Posted Exp. Policy Flags API") + { + EntityName = 'postedExpensePolicyFlag'; + EntitySetName = 'postedExpensePolicyFlags'; + SubPageLink = "Subject System Id" = field(SystemId), "Subject Type" = const("Expense Report Line"); + } } } } 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..d23e57dd9aa 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 @@ -186,6 +186,7 @@ codeunit 6901 "Expense Report Approval Mgmt" var OutStream: OutStream; begin + Clear(ExpenseReportHeader."Approver Comment"); ExpenseReportHeader."Approver Comment".CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(Comment); ExpenseReportHeader.Modify(true); 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 8aacdd5286f..c650f988235 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al @@ -30,6 +30,8 @@ codeunit 6906 "Expense Capabilities Provider" exit(IsPerDiemLocationsEnabled()); Capability::ConsolidatedProjects: exit(IsConsolidatedProjectsEnabled()); + Capability::AiAssistedPolicyEvaluation: + exit(IsAiAssistedPolicyEvaluationEnabled()); end; exit(false); end; @@ -53,6 +55,15 @@ codeunit 6906 "Expense Capabilities Provider" exit(IsProjectsEnabled()); end; + local procedure IsAiAssistedPolicyEvaluationEnabled(): Boolean + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + if not ExpenseAgentSetup.Get() then + exit(false); + exit(ExpenseAgentSetup."Evaluate Policies"); + end; + /// /// Emits one buffer row per enum value with its derived boolean state. /// The Capability Name column holds the enum value identifier. 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 c5c20f639dc..767e93c254b 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al @@ -46,4 +46,15 @@ enum 6984 "Expense Capability" { Caption = 'Consolidated Projects', Locked = true; } + + /// + /// The backend supports AI-assisted policy evaluation (soft policy flags). Reported + /// enabled only when the admin has turned on "Evaluate Policies" in the Expense Agent + /// Setup. A backend that predates this feature omits the value entirely, so the frontend + /// can treat an absent capability as "backend not ready". + /// + value(3; AiAssistedPolicyEvaluation) + { + Caption = 'AI-Assisted Policy Evaluation', Locked = true; + } } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpensePolicyStatus.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpensePolicyStatus.Enum.al new file mode 100644 index 00000000000..4c2959aa876 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpensePolicyStatus.Enum.al @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------------------------ +// 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 6989 "Expense Policy Status" +{ + Extensible = false; + Access = Internal; + + value(0; "Not Evaluated") + { + Caption = 'Not Evaluated'; + } + value(1; Flagged) + { + Caption = 'Flagged'; + } + value(2; Cleared) + { + Caption = 'Cleared'; + } + value(3; Stale) + { + Caption = 'Needs Recheck'; + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpensePolicySubject.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpensePolicySubject.Enum.al new file mode 100644 index 00000000000..bd34bd58a98 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpensePolicySubject.Enum.al @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------------------------------ +// 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 6990 "Expense Policy Subject" +{ + Extensible = true; + Access = Internal; + + value(0; "Expense Report Line") + { + Caption = 'Expense Report Line'; + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpensePolicyFlagCard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpensePolicyFlagCard.Page.al new file mode 100644 index 00000000000..5d7248db87d --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpensePolicyFlagCard.Page.al @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7101 "Expense Policy Flag Card" +{ + PageType = Card; + SourceTable = "Expense Policy Flag"; + Caption = 'Policy Flag Details'; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + LinksAllowed = false; + + layout + { + area(Content) + { + group(General) + { + Caption = 'General'; + + field("Subject Type"; Rec."Subject Type") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the type of record this policy flag belongs to.'; + } + field("Expense Category Code"; Rec."Expense Category Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the expense category the flagged policy applies to.'; + } + field("Flagged At"; Rec."Flagged At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when this policy flag was created.'; + } + } + group(Policy) + { + Caption = 'Policy'; + + field("Policy Text"; Rec."Policy Text") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the policy text that the AI evaluated, captured when the flag was created. It is preserved even if the policy changes later.'; + MultiLine = true; + } + field(Reason; Rec.Reason) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the reason this policy was flagged for the expense.'; + MultiLine = true; + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpensePolicyFlags.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpensePolicyFlags.Page.al new file mode 100644 index 00000000000..7c8c189bd18 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpensePolicyFlags.Page.al @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7099 "Expense Policy Flags" +{ + PageType = List; + SourceTable = "Expense Policy Flag"; + Caption = 'Evaluated Policies'; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + CardPageId = "Expense Policy Flag Card"; + + layout + { + area(Content) + { + repeater(GroupName) + { + field(Compliant; Rec.Compliant) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies whether the expense complied with this policy when it was evaluated. When cleared, the policy was flagged.'; + } + field("Expense Category Code"; Rec."Expense Category Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the expense category the evaluated policy applies to.'; + } + field(Reason; Rec.Reason) + { + ApplicationArea = Basic, Suite; + Caption = 'Reason Flagged'; + ToolTip = 'Specifies the reason this policy was flagged for the expense. Choose the value to see the full flag details.'; + StyleExpr = 'Ambiguous'; + + trigger OnDrillDown() + var + ExpensePolicyFlagCard: Page "Expense Policy Flag Card"; + begin + ExpensePolicyFlagCard.SetRecord(Rec); + ExpensePolicyFlagCard.RunModal(); + end; + } + field("Policy Text"; Rec."Policy Text") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the policy text that the AI evaluated, captured when the flag was created.'; + } + field("Flagged At"; Rec."Flagged At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when this policy flag was created.'; + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/PostedExpPolicyFlagCard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/PostedExpPolicyFlagCard.Page.al new file mode 100644 index 00000000000..fb5124e1224 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/PostedExpPolicyFlagCard.Page.al @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7106 "Posted Exp. Policy Flag Card" +{ + PageType = Card; + SourceTable = "Posted Exp. Policy Flag"; + Caption = 'Policy Flag Details'; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + LinksAllowed = false; + + layout + { + area(Content) + { + group(General) + { + Caption = 'General'; + + field("Subject Type"; Rec."Subject Type") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the type of record this policy flag belongs to.'; + } + field(Compliant; Rec.Compliant) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies whether the expense complied with this policy when it was evaluated. When cleared, the policy was flagged.'; + } + field("Expense Category Code"; Rec."Expense Category Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the expense category the flagged policy applies to.'; + } + field("Flagged At"; Rec."Flagged At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when this policy flag was created.'; + } + } + group(Policy) + { + Caption = 'Policy'; + + field("Policy Text"; Rec."Policy Text") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the policy text that the AI evaluated, captured when the flag was created. It is preserved even if the policy changes later.'; + MultiLine = true; + } + field(Reason; Rec.Reason) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the reason this policy was flagged for the expense.'; + MultiLine = true; + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/PostedExpPolicyFlags.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/PostedExpPolicyFlags.Page.al new file mode 100644 index 00000000000..a3fb53248fc --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/PostedExpPolicyFlags.Page.al @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------------------------------ +// 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 7109 "Posted Exp. Policy Flags" +{ + PageType = List; + SourceTable = "Posted Exp. Policy Flag"; + Caption = 'Evaluated Policies'; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + DeleteAllowed = false; + CardPageId = "Posted Exp. Policy Flag Card"; + + layout + { + area(Content) + { + repeater(GroupName) + { + field(Compliant; Rec.Compliant) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies whether the expense complied with this policy when it was evaluated. When cleared, the policy was flagged.'; + } + field("Expense Category Code"; Rec."Expense Category Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the expense category the evaluated policy applies to.'; + } + field(Reason; Rec.Reason) + { + ApplicationArea = Basic, Suite; + Caption = 'Reason Flagged'; + ToolTip = 'Specifies the reason this policy was flagged for the expense. Choose the value to see the full flag details.'; + StyleExpr = 'Ambiguous'; + + trigger OnDrillDown() + var + PostedExpPolicyFlagCard: Page "Posted Exp. Policy Flag Card"; + begin + PostedExpPolicyFlagCard.SetRecord(Rec); + PostedExpPolicyFlagCard.RunModal(); + end; + } + field("Policy Text"; Rec."Policy Text") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the policy text that the AI evaluated, captured when the flag was created.'; + } + field("Flagged At"; Rec."Flagged At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when this policy flag was created.'; + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpensePolicyFlag.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpensePolicyFlag.Table.al new file mode 100644 index 00000000000..58dc36f965a --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpensePolicyFlag.Table.al @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +table 7096 "Expense Policy Flag" +{ + Access = Internal; + Caption = 'Expense Policy Flag'; + DataClassification = CustomerContent; + ReplicateData = false; + + fields + { + field(1; "Subject System Id"; Guid) + { + Caption = 'Subject System Id'; + } + field(2; "Policy System Id"; Guid) + { + Caption = 'Policy System Id'; + TableRelation = "Expense Policy".SystemId; + } + field(3; "Subject Version"; Integer) + { + Caption = 'Subject Version'; + Editable = false; + } + field(4; "Policy Version"; Integer) + { + Caption = 'Policy Version'; + Editable = false; + } + field(5; "Subject Type"; Enum "Expense Policy Subject") + { + Caption = 'Subject Type'; + } + field(6; "Expense Category Code"; Code[20]) + { + Caption = 'Expense Category Code'; + } + field(7; "Reason"; Text[2048]) + { + Caption = 'Reason'; + } + field(8; "Policy Text"; Text[2048]) + { + Caption = 'Policy Text'; + } + field(9; "Flagged At"; DateTime) + { + Caption = 'Flagged At'; + } + field(10; "Compliant"; Boolean) + { + Caption = 'Compliant'; + } + field(12; "Is Current"; Boolean) + { + Caption = 'Is Current'; + FieldClass = FlowField; + CalcFormula = exist("Expense Policy" where(SystemId = field("Policy System Id"), "Version" = field("Policy Version"))); + Editable = false; + } + } + + keys + { + key(PK; "Subject Type", "Subject System Id", "Policy System Id", "Subject Version", "Policy Version") + { + Clustered = true; + } + key(Category; "Expense Category Code") + { + } + } + + trigger OnInsert() + var + ExpensePolicy: Record "Expense Policy"; + ExpenseReportLine: Record "Expense Report Line"; + ExistingFlag: Record "Expense Policy Flag"; + begin + if "Flagged At" = 0DT then + "Flagged At" := CurrentDateTime(); + + // A flag is an immutable evaluation record. It must reference a real report line and a real, + // enabled policy that actually applies to that line; otherwise a caller could record a verdict + // against a subject or policy that the evaluation model would never pair. + if "Subject Type" <> "Subject Type"::"Expense Report Line" then + Error(UnsupportedSubjectTypeErr); + if not ExpenseReportLine.GetBySystemId("Subject System Id") then + Error(UnknownSubjectErr); + if not ExpensePolicy.GetBySystemId("Policy System Id") then + Error(UnknownPolicyErr); + if not ExpensePolicy.Enabled then + Error(DisabledPolicyErr); + if not PolicyAppliesToLine(ExpensePolicy, ExpenseReportLine) then + Error(InapplicablePolicyErr); + + // Snapshot the subject and policy state as evaluated. Subject Version comes from the parent + // line's current Policy Eval Version; Policy Version records the policy's version at flag time + // so Is Current can tell whether the policy has changed since. + "Subject Version" := ExpenseReportLine."Policy Eval Version"; + "Policy Text" := ExpensePolicy."Policy Text"; + "Expense Category Code" := ExpensePolicy."Expense Category Code"; + "Policy Version" := ExpensePolicy."Version"; + + // Block duplicate evaluations for the same subject+policy version combination. + if ExistingFlag.Get("Subject Type", "Subject System Id", "Policy System Id", "Subject Version", "Policy Version") then + Error(DuplicateEvaluationErr); + end; + + local procedure PolicyAppliesToLine(ExpensePolicy: Record "Expense Policy"; ExpenseReportLine: Record "Expense Report Line"): Boolean + begin + // Mirrors the applicability rule used by the policies-to-evaluate endpoint: an enabled + // report-line policy whose category matches the line or is blank (blank applies to every + // category). + if ExpensePolicy."Subject Type" <> ExpensePolicy."Subject Type"::"Expense Report Line" then + exit(false); + exit((ExpensePolicy."Expense Category Code" = ExpenseReportLine."Expense Category") or (ExpensePolicy."Expense Category Code" = '')); + end; + + var + DuplicateEvaluationErr: Label 'Policy evaluation already ran for this version of the record and policy.'; + UnsupportedSubjectTypeErr: Label 'Only expense report line policy flags are supported.'; + UnknownSubjectErr: Label 'The expense report line referenced by the policy flag does not exist.'; + UnknownPolicyErr: Label 'The expense policy referenced by the policy flag does not exist.'; + DisabledPolicyErr: Label 'A policy flag cannot be recorded for a disabled policy.'; + InapplicablePolicyErr: Label 'The referenced policy does not apply to the expense report line''s category.'; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/PostedExpPolicyFlag.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/PostedExpPolicyFlag.Table.al new file mode 100644 index 00000000000..33244bb7e14 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/PostedExpPolicyFlag.Table.al @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +table 7105 "Posted Exp. Policy Flag" +{ + Access = Internal; + Caption = 'Posted Expense Policy Flag'; + DataClassification = CustomerContent; + ReplicateData = false; + + fields + { + field(1; "Subject System Id"; Guid) + { + Caption = 'Subject System Id'; + } + field(2; "Policy System Id"; Guid) + { + Caption = 'Policy System Id'; + TableRelation = "Expense Policy".SystemId; + } + field(3; "Subject Version"; Integer) + { + Caption = 'Subject Version'; + Editable = false; + } + field(4; "Policy Version"; Integer) + { + Caption = 'Policy Version'; + Editable = false; + } + field(5; "Subject Type"; Enum "Expense Policy Subject") + { + Caption = 'Subject Type'; + } + field(6; "Expense Category Code"; Code[20]) + { + Caption = 'Expense Category Code'; + } + field(7; "Reason"; Text[2048]) + { + Caption = 'Reason'; + } + field(8; "Policy Text"; Text[2048]) + { + Caption = 'Policy Text'; + } + field(9; "Flagged At"; DateTime) + { + Caption = 'Flagged At'; + } + field(10; "Compliant"; Boolean) + { + Caption = 'Compliant'; + } + field(12; "Is Current"; Boolean) + { + Caption = 'Is Current'; + FieldClass = FlowField; + CalcFormula = exist("Expense Policy" where(SystemId = field("Policy System Id"), "Version" = field("Policy Version"))); + Editable = false; + } + } + + keys + { + key(PK; "Subject Type", "Subject System Id", "Policy System Id", "Subject Version", "Policy Version") + { + Clustered = true; + } + key(Category; "Expense Category Code") + { + } + } +} 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 bd5f3bee7a0..11f64f31b91 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 @@ -38,6 +38,8 @@ codeunit 6987 "Expense Report-Post" TableData "Posted Exp. Rep. Line Per Diem" = rimd, TableData "Posted Exp. Rep. Line Particip" = rimd, TableData "Posted Exp. Rep. Line VAT Spec" = rimd, + TableData "Expense Policy Flag" = rd, + TableData "Posted Exp. Policy Flag" = rimd, TableData "Expense Category" = r, TableData "Expense Posting Group" = r, TableData "Expense User" = r; @@ -191,6 +193,7 @@ codeunit 6987 "Expense Report-Post" InsertPstdExpReportLinePerDiem(PostedExpenseReportLine, ExpenseReportLine); InsertPstdExpReportLineItemization(PostedExpenseReportLine, ExpenseReportLine); InsertPstdExpReportLineVATSpecs(PostedExpenseReportLine, ExpenseReportLine); + InsertPstdExpPolicyFlags(PostedExpenseReportLine, ExpenseReportLine); CreateSalesDocument(PostedExpenseReportHeader, PostedExpenseReportLine); if PostedExpenseReportLine."Expense No." <> '' then @@ -385,6 +388,26 @@ codeunit 6987 "Expense Report-Post" until ExpenseReportLineItem.Next() = 0; end; + local procedure InsertPstdExpPolicyFlags(PstdExpenseReportLine: Record "Posted Expense Report Line"; ExpenseReportLine: Record "Expense Report Line") + var + ExpensePolicyFlag: Record "Expense Policy Flag"; + PostedExpPolicyFlag: Record "Posted Exp. Policy Flag"; + begin + // Preserve the policy verdicts that were in effect at posting as an immutable audit + // record, re-pointed to the posted line. Only the currently evaluated version is copied; + // superseded (older-version) flags are historical noise on the open line. + ExpensePolicyFlag.SetRange("Subject System Id", ExpenseReportLine.SystemId); + ExpensePolicyFlag.SetRange("Subject Type", ExpensePolicyFlag."Subject Type"::"Expense Report Line"); + ExpensePolicyFlag.SetRange("Subject Version", ExpenseReportLine."Evaluated Policy Version"); + if ExpensePolicyFlag.FindSet() then + repeat + PostedExpPolicyFlag.Init(); + PostedExpPolicyFlag.TransferFields(ExpensePolicyFlag); + PostedExpPolicyFlag."Subject System Id" := PstdExpenseReportLine.SystemId; + PostedExpPolicyFlag.Insert(); + until ExpensePolicyFlag.Next() = 0; + end; + local procedure DeleteRelatedExpenseReportLines(ExpenseReportHeader: Record "Expense Report Header") var ExpenseReportLineParticipants: Record "Expense Report Line Particip."; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportSubPage.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportSubPage.Page.al index 30240132b0d..4d94f1cf039 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportSubPage.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportSubPage.Page.al @@ -44,6 +44,23 @@ page 6999 "Expense Report SubPage" ApplicationArea = Basic, Suite; ToolTip = 'Specifies if there are any rule violations for the expense line.'; } + field(PolicyStatus; PolicyStatus) + { + ApplicationArea = Basic, Suite; + Caption = 'Policy Status'; + Editable = false; + Visible = PolicyEvaluationEnabled; + ToolTip = 'Specifies the policy evaluation status for the expense: Not Evaluated when the agent has not assessed it yet, Flagged when one or more policy flags exist, or Cleared when it was evaluated and no flags were raised. Choose the value to see the policies that were evaluated for this expense.'; + + trigger OnDrillDown() + var + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + ExpensePolicyFlag.SetRange("Subject Type", "Expense Policy Subject"::"Expense Report Line"); + ExpensePolicyFlag.SetRange("Subject System Id", Rec.SystemId); + Page.RunModal(Page::"Expense Policy Flags", ExpensePolicyFlag); + end; + } field("Expense Location"; Rec."Expense Location") { ApplicationArea = Basic, Suite; @@ -599,6 +616,7 @@ page 6999 "Expense Report SubPage" ExpenseAgentSetup: Record "Expense Agent Setup"; ExpenseReportHeader: Record "Expense Report Header"; ExpenseAutoPopulation: Codeunit "Expense Auto Population"; + ExpenseCapabilitiesProvider: Codeunit "Expense Capabilities Provider"; TotalAmountLCY: Decimal; TotalVATAmountLCY: Decimal; TotalAmountWithoutVATLCY: Decimal; @@ -609,8 +627,10 @@ page 6999 "Expense Report SubPage" IsRuleApplied: Boolean; TotalMileage: Decimal; AllowVATReclaim: Boolean; + PolicyEvaluationEnabled: Boolean; ReimbursementAmountLbl: Label '%1 (%2)', Comment = '%1 = Field Caption, %2 = Field Value'; LCYLbl: Label 'LCY'; + PolicyStatus: Enum "Expense Policy Status"; local procedure UpdateControls() begin @@ -627,6 +647,9 @@ page 6999 "Expense Report SubPage" ExpenseAgentSetup.GetRecordOnce(); AllowVATReclaim := ExpenseAgentSetup."Allow VAT Reclaim"; + PolicyEvaluationEnabled := ExpenseCapabilitiesProvider.IsEnabled(Enum::"Expense Capability"::AiAssistedPolicyEvaluation); + if PolicyEvaluationEnabled then + PolicyStatus := Rec.GetPolicyStatus(); end; local procedure ValidateHeaderAmountField() diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportSubP.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportSubP.Page.al index d8eef0481f1..09346746e39 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportSubP.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportSubP.Page.al @@ -31,6 +31,23 @@ page 6993 "Posted Expense Report SubP." ApplicationArea = Basic, Suite; ToolTip = 'Specifies the category that classifies this expense line.'; } + field(PolicyStatus; PolicyStatus) + { + ApplicationArea = Basic, Suite; + Caption = 'Policy Status'; + Editable = false; + Visible = PolicyEvaluationEnabled; + ToolTip = 'Specifies the policy evaluation status captured when the expense was posted: Not Evaluated when the agent had not assessed it, Flagged when one or more policy flags were raised, or Cleared when it was evaluated and no flags were raised. Choose the value to see the policies that were evaluated for this expense.'; + + trigger OnDrillDown() + var + PostedExpPolicyFlag: Record "Posted Exp. Policy Flag"; + begin + PostedExpPolicyFlag.SetRange("Subject Type", "Expense Policy Subject"::"Expense Report Line"); + PostedExpPolicyFlag.SetRange("Subject System Id", Rec.SystemId); + Page.RunModal(Page::"Posted Exp. Policy Flags", PostedExpPolicyFlag); + end; + } field(Description; Rec.Description) { ApplicationArea = Basic, Suite; @@ -410,6 +427,7 @@ page 6993 "Posted Expense Report SubP." ExpenseAgentSetup: Record "Expense Agent Setup"; PostedExpenseReportHeader: Record "Posted Expense Report Header"; ExpenseAutoPopulation: Codeunit "Expense Auto Population"; + ExpenseCapabilitiesProvider: Codeunit "Expense Capabilities Provider"; TotalAmountLCY: Decimal; TotalVATAmountLCY: Decimal; TotalAmountWithoutVATLCY: Decimal; @@ -420,6 +438,8 @@ page 6993 "Posted Expense Report SubP." IsRuleApplied: Boolean; TotalMileage: Decimal; AllowVATReclaim: Boolean; + PolicyEvaluationEnabled: Boolean; + PolicyStatus: Enum "Expense Policy Status"; local procedure UpdateControls() begin @@ -436,6 +456,9 @@ page 6993 "Posted Expense Report SubP." ExpenseAgentSetup.GetRecordOnce(); AllowVATReclaim := ExpenseAgentSetup."Allow VAT Reclaim"; + PolicyEvaluationEnabled := ExpenseCapabilitiesProvider.IsEnabled(Enum::"Expense Capability"::AiAssistedPolicyEvaluation); + if PolicyEvaluationEnabled then + PolicyStatus := Rec.GetPolicyStatus(); end; local procedure ValidateHeaderAmountField() diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al index 24134fa26cc..dca4da2d57f 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al @@ -1026,6 +1026,31 @@ table 6907 "Expense Report Line" TestStatusOpen(); end; } + field(101; "Has Policy Violation"; Boolean) + { + Caption = 'Has Policy Violation'; + FieldClass = FlowField; + CalcFormula = exist("Expense Policy Flag" where("Subject System Id" = field(SystemId), "Subject Type" = const("Expense Report Line"), "Subject Version" = field("Evaluated Policy Version"), "Compliant" = const(false))); + Editable = false; + } + field(102; "Policies Evaluated At"; DateTime) + { + Caption = 'Policies Evaluated At'; + DataClassification = CustomerContent; + Editable = false; + } + field(103; "Policy Eval Version"; Integer) + { + Caption = 'Policy Eval Version'; + DataClassification = SystemMetadata; + Editable = false; + } + field(104; "Evaluated Policy Version"; Integer) + { + Caption = 'Evaluated Policy Version'; + DataClassification = SystemMetadata; + Editable = false; + } } keys { @@ -1033,6 +1058,9 @@ table 6907 "Expense Report Line" { Clustered = true; } + key(PolicyInvalidation; "Expense Category", "Policies Evaluated At") + { + } } trigger OnInsert() @@ -1047,17 +1075,25 @@ table 6907 "Expense Report Line" trigger OnModify() begin UpdateExpenseUserOnModify(); + if PolicyRelevantFieldChanged() then + "Policy Eval Version" += 1; end; trigger OnDelete() var ExpenseReportCommentLine: Record "Expense Report Comment Line"; ExpenseReportRuleViolation: Record "Expense Report Rule Violation"; + ExpensePolicyFlag: Record "Expense Policy Flag"; begin DeleteAssociatedRecords(); RemoveExpenseReportNoInExpense(); + ExpensePolicyFlag.SetRange("Subject System Id", Rec.SystemId); + ExpensePolicyFlag.SetRange("Subject Type", ExpensePolicyFlag."Subject Type"::"Expense Report Line"); + if not ExpensePolicyFlag.IsEmpty() then + ExpensePolicyFlag.DeleteAll(); + ExpenseReportRuleViolation.SetRange("Expense Report No.", Rec."Document No."); ExpenseReportRuleViolation.SetRange("Report Line No.", Rec."Line No."); if not ExpenseReportRuleViolation.IsEmpty() then @@ -1143,6 +1179,137 @@ table 6907 "Expense Report Line" exit(HideValidationDialog); end; + procedure GetPolicyStatus(): Enum "Expense Policy Status" + begin + if Rec."Policies Evaluated At" = 0DT then begin + // A line that has never been evaluated but has no policy targeting its category is + // already compliant - there is nothing to run against it, so report it Cleared rather + // than "Not Evaluated". This avoids a Pending badge on lines that will never produce a + // flag. Only the never-evaluated branch is short-circuited; a stale line keeps its + // existing status until it is re-evaluated. + if not HasApplicablePolicies() then + exit("Expense Policy Status"::Cleared); + exit("Expense Policy Status"::"Not Evaluated"); + end; + + if Rec."Evaluated Policy Version" < Rec."Policy Eval Version" then + exit("Expense Policy Status"::Stale); + + if Rec.HasCurrentPolicyViolation() then + exit("Expense Policy Status"::Flagged); + + exit("Expense Policy Status"::Cleared); + end; + + internal procedure HasCurrentPolicyViolation(): Boolean + var + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // A line is only Flagged by a violation that still reflects the current policy set. Superseded + // non-compliant flags (policy since changed, disabled, or deleted) are kept as history but must + // not keep a line Flagged - any policy change bumps the policy Version, so the flag's captured + // Policy Version no longer matches and Is Current reads false. The raw "Has Policy Violation" + // FlowField is the cheap gate; this refines it to current flags only. + Rec.CalcFields("Has Policy Violation"); + if not Rec."Has Policy Violation" then + exit(false); + + ExpensePolicyFlag.SetRange("Subject System Id", Rec.SystemId); + ExpensePolicyFlag.SetRange("Subject Type", ExpensePolicyFlag."Subject Type"::"Expense Report Line"); + ExpensePolicyFlag.SetRange("Subject Version", Rec."Evaluated Policy Version"); + ExpensePolicyFlag.SetRange(Compliant, false); + if ExpensePolicyFlag.FindSet() then + repeat + ExpensePolicyFlag.CalcFields("Is Current"); + if ExpensePolicyFlag."Is Current" then + exit(true); + until ExpensePolicyFlag.Next() = 0; + exit(false); + end; + + local procedure HasApplicablePolicies(): Boolean + var + ExpensePolicy: Record "Expense Policy"; + begin + // Mirrors the applicability rule used by the policies-to-evaluate endpoint: an enabled + // report-line policy whose category matches the line or is blank (blank applies to every + // category). + ExpensePolicy.SetRange("Subject Type", ExpensePolicy."Subject Type"::"Expense Report Line"); + ExpensePolicy.SetRange(Enabled, true); + ExpensePolicy.SetFilter("Expense Category Code", '%1|%2', Rec."Expense Category", ''); + exit(not ExpensePolicy.IsEmpty()); + end; + + procedure MarkPoliciesEvaluated() + begin + Rec."Evaluated Policy Version" := Rec."Policy Eval Version"; + Rec."Policies Evaluated At" := CurrentDateTime(); + Rec.Modify(false); + end; + + internal procedure InvalidatePolicyEvaluation() + begin + // Nothing to invalidate unless a current evaluation exists and is still up to date. + // Skipping the write when there is nothing to invalidate also avoids staling a parent + // Expense Report Line handle held by a caller when a child record (participant/itemization/per diem) + // is inserted, modified, or deleted before the caller next modifies the line. + if (Rec."Policies Evaluated At" = 0DT) or (Rec."Policy Eval Version" <> Rec."Evaluated Policy Version") then + exit; + + Rec."Policy Eval Version" += 1; + Rec.Modify(false); + end; + + local procedure PolicyRelevantFieldChanged(): Boolean + var + StoredExpenseReportLine: Record "Expense Report Line"; + RecRef: RecordRef; + xRecRef: RecordRef; + FieldRef: FieldRef; + xFieldRef: FieldRef; + Index: Integer; + begin + // Compare against the committed pre-modify image read straight from the database. + // xRec is unreliable here - even passed explicitly from the trigger it has been + // observed to compare equal to Rec at runtime - so the before-image is fetched by + // primary key instead (OnModify runs before the row is written, so this Get returns + // the old values). If no stored row is found, conservatively treat it as changed. + if not StoredExpenseReportLine.Get(Rec."Document No.", Rec."Line No.") then + exit(true); + RecRef.GetTable(Rec); + xRecRef.GetTable(StoredExpenseReportLine); + for Index := 1 to RecRef.FieldCount() do begin + FieldRef := RecRef.FieldIndex(Index); + if (FieldRef.Class = FieldClass::Normal) and (FieldRef.Number < 2000000000) then + if not IsPolicyNeutralField(FieldRef.Number) then begin + xFieldRef := xRecRef.Field(FieldRef.Number); + if FieldRef.Value() <> xFieldRef.Value() then + exit(true); + end; + end; + exit(false); + end; + + local procedure IsPolicyNeutralField(FieldNo: Integer): Boolean + begin + // Fields whose change must NOT invalidate a policy evaluation: workflow/linkage + // state, the audit stamp written on every modify, and the policy machinery itself. + case FieldNo of + Rec.FieldNo("Document No."), + Rec.FieldNo("Line No."), + Rec.FieldNo("Expense No."), + Rec.FieldNo("Applied Rule Id"), + Rec.FieldNo("Created By Exp. User Id"), + Rec.FieldNo("Modified By Exp. User Id"), + Rec.FieldNo("User Confirmed"), + Rec.FieldNo("Policies Evaluated At"), + Rec.FieldNo("Policy Eval Version"), + Rec.FieldNo("Evaluated Policy Version"): + exit(true); + end; + exit(false); + end; + local procedure ConfirmAndDeleteAssociatedRecords(FieldCaption: Text) var ConfirmManagement: Codeunit "Confirm Management"; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineItem.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineItem.Table.al index aaf4c268f8f..f8263401bc1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineItem.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineItem.Table.al @@ -154,6 +154,13 @@ table 6911 "Expense Report Line Item" Rec.TestField("Expense Subcategory Code"); UpdateExpenseReportLineInformation(Rec."Expense Report No.", Rec."Expense Report Line No."); + + InvalidateParentPolicy(); + end; + + trigger OnModify() + begin + InvalidateParentPolicy(); end; trigger OnDelete() @@ -161,12 +168,22 @@ table 6911 "Expense Report Line Item" TestStatusOpenOfExpenseReport(); UpdateItemizationInformationOnExpenseReportLine(true); + + InvalidateParentPolicy(); end; var ExpenseReportLine: Record "Expense Report Line"; ExpenseReportHelper: Codeunit "Expense Report"; + local procedure InvalidateParentPolicy() + var + ParentExpenseReportLine: Record "Expense Report Line"; + begin + if ParentExpenseReportLine.Get(Rec."Expense Report No.", Rec."Expense Report Line No.") then + ParentExpenseReportLine.InvalidatePolicyEvaluation(); + end; + local procedure UpdateExpenseReportLineInformation(ExpenseReportNo: Code[20]; ExpenseReportLineNo: Integer) begin ExpenseReportLine := ExpenseReportHelper.GetExpenseReportLine(ExpenseReportNo, ExpenseReportLineNo); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineParticip.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineParticip.Table.al index f6b2dff8ceb..63e44476396 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineParticip.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineParticip.Table.al @@ -161,17 +161,34 @@ table 6908 "Expense Report Line Particip." TestStatusOpenOfExpenseReport(); UpdateExpenseReportLineInformation("Expense Report No.", "Expense Report Line No."); + + InvalidateParentPolicy(); + end; + + trigger OnModify() + begin + InvalidateParentPolicy(); end; trigger OnDelete() begin TestStatusOpenOfExpenseReport(); + + InvalidateParentPolicy(); end; var ExpenseReportLine: Record "Expense Report Line"; ExpenseReportHelper: Codeunit "Expense Report"; + local procedure InvalidateParentPolicy() + var + ParentExpenseReportLine: Record "Expense Report Line"; + begin + if ParentExpenseReportLine.Get(Rec."Expense Report No.", Rec."Expense Report Line No.") then + ParentExpenseReportLine.InvalidatePolicyEvaluation(); + end; + local procedure UpdateExpenseReportLineInformation(ExpenseReportNo: Code[20]; ExpenseReportLineNo: Integer) begin ExpenseReportLine := ExpenseReportHelper.GetExpenseReportLine(ExpenseReportNo, ExpenseReportLineNo); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLinePerDiem.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLinePerDiem.Table.al index 5bb5e2e077b..c31262bc533 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLinePerDiem.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLinePerDiem.Table.al @@ -154,6 +154,13 @@ table 6909 "Expense Report Line Per Diem" TestField("Line No."); UpdateExpenseReportLineInformation("Expense Report No.", "Expense Report Line No."); + + InvalidateParentPolicy(); + end; + + trigger OnModify() + begin + InvalidateParentPolicy(); end; trigger OnDelete() @@ -161,6 +168,8 @@ table 6909 "Expense Report Line Per Diem" TestStatusOpenOfExpenseReport(); UpdateTotalOnExpense(Rec."Line No."); + + InvalidateParentPolicy(); end; var @@ -170,6 +179,14 @@ table 6909 "Expense Report Line Per Diem" ExpenseReportLocationMissingMsg: Label '%1 is missing in Expense Report No. %2, Line No. %3.', Comment = '%1 = Expense Location Caption, %2 = Expense Report No., %3 = Line No.'; TotalReductionPercentExceededErr: Label 'Total Reduction Percent cannot exceed 100.'; + local procedure InvalidateParentPolicy() + var + ParentExpenseReportLine: Record "Expense Report Line"; + begin + if ParentExpenseReportLine.Get(Rec."Expense Report No.", Rec."Expense Report Line No.") then + ParentExpenseReportLine.InvalidatePolicyEvaluation(); + end; + local procedure UpdateExpenseReportLineInformation(ExpenseReportNo: Code[20]; ExpenseReportLineNo: Integer) begin ExpenseReportLine := ExpenseReportHelper.GetExpenseReportLine(ExpenseReportNo, ExpenseReportLineNo); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al index 0185873779a..1930f3946b4 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al @@ -480,4 +480,24 @@ table 6916 "Posted Expense Report Line" ExpenseCommentSheet.SetTableView(ExpenseReportCommentLine); ExpenseCommentSheet.RunModal(); end; + + procedure GetPolicyStatus(): Enum "Expense Policy Status" + var + PostedExpPolicyFlag: Record "Posted Exp. Policy Flag"; + begin + // Report the policy verdict captured at posting. Posted flags are an immutable audit copy, + // so - unlike the open line - currency is not re-checked here: a non-compliant flag keeps the + // line Flagged for the audit trail even if the live policy later changes. A line posted with + // no flags was never evaluated. + PostedExpPolicyFlag.SetRange("Subject System Id", Rec.SystemId); + PostedExpPolicyFlag.SetRange("Subject Type", PostedExpPolicyFlag."Subject Type"::"Expense Report Line"); + if PostedExpPolicyFlag.IsEmpty() then + exit("Expense Policy Status"::"Not Evaluated"); + + PostedExpPolicyFlag.SetRange(Compliant, false); + if not PostedExpPolicyFlag.IsEmpty() then + exit("Expense Policy Status"::Flagged); + + exit("Expense Policy Status"::Cleared); + end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpenseCategoryCard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpenseCategoryCard.Page.al index 8e6602124a8..2901998e11b 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpenseCategoryCard.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpenseCategoryCard.Page.al @@ -89,11 +89,21 @@ page 6945 "Expense Category Card" ToolTip = 'Opens the Subcategories page to view and manage subcategories for this category.'; Scope = Repeater; } + action(Policies) + { + Caption = 'Policies'; + Image = SetupList; + RunObject = Page "Expense Policies"; + RunPageLink = "Expense Category Code" = field(Code); + ToolTip = 'Opens the Policies page to view and manage policies for this category.'; + Scope = Repeater; + } } } area(Promoted) { actionref("Subcategories_Promoted"; Subcategories) { } + actionref("Policies_Promoted"; Policies) { } } } diff --git a/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpensePolicies.Page.al b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpensePolicies.Page.al new file mode 100644 index 00000000000..5194f28a382 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Pages/ExpensePolicies.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 7103 "Expense Policies" +{ + PageType = List; + SourceTable = "Expense Policy"; + Caption = 'Expense Policies'; + ApplicationArea = All; + UsageCategory = Lists; + DelayedInsert = true; + + layout + { + area(Content) + { + repeater(GroupName) + { + field("Expense Category Code"; Rec."Expense Category Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the expense category this policy applies to.'; + } + field(Description; Rec.Description) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies a short description of the policy.'; + } + field("Policy Text"; Rec."Policy Text") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the full policy text that the AI evaluates expenses against.'; + } + field(Enabled; Rec.Enabled) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies whether this policy is active for evaluation.'; + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpensePolicy.Table.al b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpensePolicy.Table.al new file mode 100644 index 00000000000..9c7a7bf2df5 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpensePolicy.Table.al @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + +table 7092 "Expense Policy" +{ + Access = Internal; + Caption = 'Expense Policy'; + DataClassification = CustomerContent; + ReplicateData = false; + + fields + { + field(1; "Subject Type"; Enum "Expense Policy Subject") + { + Caption = 'Subject Type'; + } + field(2; "Line No."; Integer) + { + Caption = 'Line No.'; + } + field(10; "Expense Category Code"; Code[20]) + { + Caption = 'Expense Category Code'; + TableRelation = "Expense Category"; + } + field(11; "Description"; Text[50]) + { + Caption = 'Description'; + } + field(12; "Policy Text"; Text[2048]) + { + Caption = 'Policy Text'; + } + field(20; "Enabled"; Boolean) + { + Caption = 'Enabled'; + InitValue = true; + } + field(21; "Version"; Integer) + { + Caption = 'Version'; + Editable = false; + } + } + + keys + { + key(PK; "Subject Type", "Line No.") + { + Clustered = true; + } + key(Category; "Expense Category Code") + { + } + } + + trigger OnInsert() + begin + if "Line No." = 0 then + "Line No." := GetNextLineNo(); + InvalidateAffectedReportLines("Subject Type", "Expense Category Code"); + end; + + trigger OnModify() + begin + // Bump the policy version on every change so flags evaluated against an earlier + // version can be detected as no longer current (see the flag's Is Current FlowField). + "Version" += 1; + + // Invalidate the previous scope as well as the new one. Moving a policy to a different + // category or subject type - or disabling it - changes which lines it affects, so lines in + // the old scope must be re-checked too; otherwise they keep a verdict from a policy that no + // longer applies to them and stay incorrectly Current. + InvalidateAffectedReportLines(xRec."Subject Type", xRec."Expense Category Code"); + if (xRec."Subject Type" <> "Subject Type") or (xRec."Expense Category Code" <> "Expense Category Code") then + InvalidateAffectedReportLines("Subject Type", "Expense Category Code"); + end; + + trigger OnDelete() + begin + // Removing a policy also alters the effective policy set for its category, so evaluated + // lines must be re-checked. Existing flags for the deleted policy are intentionally kept + // as history; the flag's Is Current FlowField already reports false once the policy is + // gone, and re-staling the lines lets the frontend re-evaluate and drop the stale verdict. + InvalidateAffectedReportLines("Subject Type", "Expense Category Code"); + end; + + local procedure InvalidateAffectedReportLines(SubjectType: Enum "Expense Policy Subject"; CategoryCode: Code[20]) + var + ExpenseReportLine: Record "Expense Report Line"; + begin + // Adding, changing, or removing a policy alters the effective policy set for its category, + // so any report line already evaluated against the old set must be re-checked. Bumping each + // evaluated line's Policy Eval Version flips its status to Stale (Needs Recheck). + // A blank category means the policy applies to every category, so invalidate all lines. + // The scan is served by the report line's PolicyInvalidation key (Expense Category, + // Policies Evaluated At). Policies change rarely, so the per-line write cost is acceptable. + if SubjectType <> SubjectType::"Expense Report Line" then + exit; + + if CategoryCode <> '' then + ExpenseReportLine.SetRange("Expense Category", CategoryCode); + ExpenseReportLine.SetFilter("Policies Evaluated At", '<>%1', 0DT); + if ExpenseReportLine.FindSet() then + repeat + ExpenseReportLine.InvalidatePolicyEvaluation(); + until ExpenseReportLine.Next() = 0; + end; + + local procedure GetNextLineNo(): Integer + var + ExpensePolicy: Record "Expense Policy"; + begin + ExpensePolicy.SetRange("Subject Type", "Subject Type"); + if ExpensePolicy.FindLast() then + exit(ExpensePolicy."Line No." + 10000) + else + exit(10000); + 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..0221197027d 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 @@ -42,6 +42,9 @@ permissionset 6953 "Expense Agent - Data" tabledata "Expense Location" = R, tabledata "Expense Rule Condition" = R, tabledata "Expense Rule Header" = R, + tabledata "Expense Policy" = R, + tabledata "Expense Policy Flag" = RIMD, + tabledata "Posted Exp. Policy Flag" = Rim, tabledata "Expense Posting Group" = r, tabledata "Expense Subcategory" = R, tabledata "Expense Agent Access Control" = 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 7923de52245..8f099286f89 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 @@ -46,6 +46,9 @@ permissionset 6952 "Expense Agent - Objects" page "Exp. Report Line Particip. API" = X, page "Exp. Report Line Per Diem API" = X, page "Exp. Rep. Rule Violations API" = X, + page "Expense Policies API" = X, + page "Expense Policy Flags API" = X, + page "Posted Exp. Policy Flags API" = X, page "Posted Exp.Rep.Line VAT Spec" = X, page "Posted Expense Reports API" = X, page "Posted Exp. Rep. Line Att. API" = X, @@ -53,6 +56,7 @@ permissionset 6952 "Expense Agent - Objects" page "Posted Exp. Report Lines API" = X, page "Tenant Feedback Setting API" = X, page "Expense Projects API" = X, + page "Exp. Policies To Eval API" = X, query "Expense Project Tasks Qry" = X, #if not CLEAN29 #pragma warning disable AL0432 // Object is obsoleted @@ -72,6 +76,7 @@ permissionset 6952 "Expense Agent - Objects" table "Expense Approval Setup" = X, table "Expense Capabilities Buffer" = X, table "Expense Project Buf" = X, + table "Exp. Policy To Eval Buffer" = X, #if not CLEAN29 #pragma warning disable AL0432 // Object is obsoleted table "Expense Agent Consumption" = X, @@ -97,6 +102,9 @@ permissionset 6952 "Expense Agent - Objects" table "Expense Rule Condition" = X, table "Expense Rule Header" = X, table "Expense Rule Violation" = X, + table "Expense Policy" = X, + table "Expense Policy Flag" = X, + table "Posted Exp. Policy Flag" = X, table "Expense Subcategory" = X, table "Expense Team" = X, table "Expense User" = X, @@ -120,6 +128,7 @@ permissionset 6952 "Expense Agent - Objects" codeunit "Expense Auto Population" = X, codeunit "Expense Capabilities Provider" = X, codeunit "Expense Projects Builder" = X, + codeunit "Exp. Policies To Eval Builder" = X, codeunit "Expense Consumption Handler" = X, codeunit "Expense Currency" = X, codeunit "Expense Doc No Visibility" = 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 df66614bf53..3018fecbbe4 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al @@ -51,6 +51,9 @@ permissionset 6904 "Expense Management - Objects" table "Expense Team" = X, table "Expense Rule Violation" = X, table "Expense Report Rule Violation" = X, + table "Expense Policy" = X, + table "Expense Policy Flag" = X, + table "Posted Exp. Policy Flag" = X, table "Tenant Feedback Setting" = X, table "EA Outbox Email" = X, table "EA KPI" = X, @@ -134,6 +137,11 @@ permissionset 6904 "Expense Management - Objects" page "Expense Billing Information" = X, page "Expense Rule Violations" = X, page "Expense Report Rule Violations" = X, + page "Expense Policies" = X, + page "Expense Policy Flags" = X, + page "Expense Policy Flag Card" = X, + page "Posted Exp. Policy Flags" = X, + page "Posted Exp. Policy Flag Card" = X, page "Expense Statistics" = X, page "Posted Expense Report SubP." = X, page "Posted Exp. Report Line Items" = X, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtAdmin.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtAdmin.PermissionSet.al index 27baf8e5e4a..4acba74fb8a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtAdmin.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtAdmin.PermissionSet.al @@ -26,6 +26,9 @@ permissionset 6905 "Expense Mgmt. Admin" tabledata "Expense Agent Status" = IMD, tabledata "Expense Rule Condition" = IMD, tabledata "Expense Rule Header" = IMD, + tabledata "Expense Policy" = IMD, + tabledata "Expense Policy Flag" = IMD, + tabledata "Posted Exp. Policy Flag" = IMD, tabledata "Expense Posting Group" = IMD, tabledata "Expense Subcategory" = IMD, tabledata "EA Email" = R, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtEdit.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtEdit.PermissionSet.al index bdaf7cfbcb8..4fb2a21f04e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtEdit.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtEdit.PermissionSet.al @@ -34,6 +34,8 @@ permissionset 6906 "Expense Mgmt. Edit" tabledata "Expense Ledger Entry" = im, tabledata "Expense Payment Method" = IMD, tabledata "Expense Rule Violation" = IMD, + tabledata "Expense Policy Flag" = IMD, + tabledata "Posted Exp. Policy Flag" = IMD, tabledata "Expense Report Rule Violation" = IMD, tabledata "Tenant Feedback Setting" = IMD, tabledata "EA KPI" = IMD, 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 a523ac177d3..10bd15806e8 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al @@ -49,6 +49,9 @@ permissionset 6907 "Expense Mgmt. Read" tabledata "Expense Team" = R, tabledata "Expense Approval Setup" = R, tabledata "Expense Rule Violation" = R, + tabledata "Expense Policy" = R, + tabledata "Expense Policy Flag" = R, + tabledata "Posted Exp. Policy Flag" = R, tabledata "Expense Report Rule Violation" = R, tabledata "Posted Exp. Rep. Line Particip" = R, tabledata "Tenant Feedback Setting" = R, 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 e422943e082..38f7e95a64e 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 @@ -50,6 +50,8 @@ codeunit 6950 "Expense Agent Privacy Subs." DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Location"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Rule Condition"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Rule Header"); + DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Policy"); + DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Policy Flag"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Posting Group"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Subcategory"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense VAT Specification"); @@ -72,6 +74,7 @@ codeunit 6950 "Expense Agent Privacy Subs." DataClassificationEvalData.SetTableFieldsToNormal(Database::"Posted Exp. Rep. Line Item"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Posted Exp. Rep. Line Per Diem"); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Posted Exp. Rep. Line VAT Spec"); + DataClassificationEvalData.SetTableFieldsToNormal(Database::"Posted Exp. Policy Flag"); DataClassificationMgt.SetFieldToPersonal( Database::"Posted Exp. Rep. Line VAT Spec", PostedExpRepLineVATSpec.FieldNo("Reclaim Approved By")); DataClassificationEvalData.SetTableFieldsToNormal(Database::"Tenant Feedback Setting"); diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al index 996f8a6b320..243a394d063 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetup.Page.al @@ -189,6 +189,22 @@ page 6996 "Expense Agent Setup" field("Use Rules"; Rec."Use Rules") { } + field("Evaluate Policies"; Rec."Evaluate Policies") + { + ToolTip = 'Specifies whether the agent evaluates expenses against the configured policies. Rules are evaluated by code, while policies are evaluated by AI, so enabling this consumes additional AI credits.'; + + trigger OnValidate() + var + ExpensePoliciesPage: Page "Expense Policies"; + begin + if Rec."Evaluate Policies" and (not xRec."Evaluate Policies") then begin + if not Confirm(ActivatePolicyEvalQst, false) then + Error(''); + ExpensePoliciesPage.Editable(true); + ExpensePoliciesPage.Run(); + end; + end; + } field("Do Not Allow Expenses Older Than"; Rec."Do Not Allow Exp. Older Than") { } @@ -435,6 +451,7 @@ page 6996 "Expense Agent Setup" var NotAuthorizedToViewSetupErr: Label 'You do not have permission to view the Expense Agent setup. Contact your administrator to be granted agent management rights.'; + ActivatePolicyEvalQst: Label 'You are about to activate automated policy evaluation. By doing this, you acknowledge that this feature will consume additional AI credits. Continue?'; local procedure ValidateSelectedMailboxExists() var diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al index 94f04d3c5ae..3b875d97b75 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Pages/ExpenseAgentSetupWizard.Page.al @@ -492,6 +492,31 @@ page 6991 "Expense Agent Setup Wizard" } } + group(EvaluatePoliciesSection) + { + Caption = 'Evaluate policies with AI'; + InstructionalText = 'Leverage AI to evaluate natural language policies defined for your organization.'; + field("Evaluate Policies"; Rec."Evaluate Policies") + { + ShowCaption = false; + + ToolTip = 'Specifies whether the agent evaluates expenses against the configured policies. Rules are evaluated by code, while policies are evaluated by AI, so enabling this consumes additional AI credits.'; + + trigger OnValidate() + var + ExpensePoliciesPage: Page "Expense Policies"; + begin + if Rec."Evaluate Policies" and (not xRec."Evaluate Policies") then begin + if not Confirm(ActivatePolicyEvalQst, false) then + Error(''); + ExpensePoliciesPage.Editable(true); + ExpensePoliciesPage.RunModal(); + end; + ConfigUpdated(); + end; + } + } + group(DetectOutdatedExpenseSection) { ShowCaption = false; @@ -935,6 +960,7 @@ page 6991 "Expense Agent Setup Wizard" NoSystemUsersErr: Label 'You must first specify a user in Business Central as expense user.'; NotAuthorizedToViewSetupErr: Label 'You do not have permission to view the Expense Agent setup. Contact your administrator to be granted agent management rights.'; ApprovalWorkflowConflictErr: Label 'You must turn off "%1" in Expense Agent Setup to enable Expense Agent.', Comment = '%1 = Field Caption'; + ActivatePolicyEvalQst: Label 'You are about to activate automated policy evaluation. By doing this, you acknowledge that this feature will consume additional AI credits. Continue?'; AgentUserNameLbl: Label 'Expense Agent', Locked = true; AgentDisplayNameLbl: Label 'Expense Agent', MaxLength = 80; AgentSummaryLbl: Label 'Processes employee expense reports by extracting receipt data, validating against company policies, and routing for approval.'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al index 38600f0e7e8..2e6d47644ac 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al @@ -307,6 +307,11 @@ table 6930 "Expense Agent Setup" Caption = 'Display anti-corruption attestation'; ToolTip = 'Specifies whether users must confirm an anti-corruption attestation before submitting expenses.'; } + field(31; "Evaluate Policies"; Boolean) + { + Caption = 'Evaluate policies'; + ToolTip = 'Specifies whether the agent automatically evaluates expenses against the configured policies. Enabling this consumes additional AI credits.'; + } field(34; "Expense User Nos."; Code[20]) { Caption = 'Expense User Nos.'; 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..af24d06acbb 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al @@ -23,6 +23,8 @@ codeunit 148318 "Expense Capabilities API Test" ConsolidatedCapabilityNameTok: Label '"capabilityname":"consolidatedprojects"', Locked = true; IsEnabledTrueTok: Label '"isenabled":true', Locked = true; IsEnabledFalseTok: Label '"isenabled":false', Locked = true; + PolicyEvalEnabledRowTok: Label '"capabilityname":"aiassistedpolicyevaluation","isenabled":true', Locked = true; + PolicyEvalDisabledRowTok: Label '"capabilityname":"aiassistedpolicyevaluation","isenabled":false', Locked = true; [Test] procedure CapabilitiesProjectsEnabledViaAPI() @@ -41,6 +43,7 @@ codeunit 148318 "Expense Capabilities API Test" ExpenseAgentSetup.Insert(); end; ExpenseAgentSetup."Enable Project Fields" := true; + ExpenseAgentSetup."Evaluate Policies" := true; ExpenseAgentSetup.Modify(); Commit(); @@ -55,7 +58,7 @@ codeunit 148318 "Expense Capabilities API Test" 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.'); + 'Response must NOT contain any isEnabled=false value when every capability is enabled.'); end; [Test] @@ -106,6 +109,9 @@ codeunit 148318 "Expense Capabilities API Test" ExpenseAgentSetup.Insert(); end; ExpenseAgentSetup."Enable Project Fields" := true; + // Enable Evaluate Policies too so the aiAssistedPolicyEvaluation capability is not reported + // disabled, keeping the "no capability disabled" assertion below valid. + ExpenseAgentSetup."Evaluate Policies" := true; ExpenseAgentSetup.Modify(); Commit(); @@ -120,6 +126,66 @@ codeunit 148318 "Expense Capabilities API Test" 'No capability must be reported disabled when project fields are enabled.'); end; + [Test] + procedure CapabilitiesPolicyEvaluationEnabledViaAPI() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + TargetURL: Text; + ResponseText: Text; + begin + // [SCENARIO] When Expense Agent Setup has "Evaluate Policies" = true, + // the capabilities API exposes a 'aiAssistedPolicyEvaluation' row with isEnabled = true. + Initialize(); + + // [GIVEN] Expense Agent Setup exists with Evaluate Policies = true. + if not ExpenseAgentSetup.Get() then begin + ExpenseAgentSetup.Init(); + ExpenseAgentSetup.Insert(); + end; + ExpenseAgentSetup."Evaluate Policies" := true; + ExpenseAgentSetup.Modify(); + Commit(); + + // [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 'aiAssistedPolicyEvaluation' row is present with isEnabled = true. + Assert.AreNotEqual(0, StrPos(ResponseText, PolicyEvalEnabledRowTok), + 'aiAssistedPolicyEvaluation row must be reported as isEnabled = true when Evaluate Policies is true.'); + end; + + [Test] + procedure CapabilitiesPolicyEvaluationDisabledViaAPI() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + TargetURL: Text; + ResponseText: Text; + begin + // [SCENARIO] When Expense Agent Setup has "Evaluate Policies" = false, + // the capabilities API exposes a 'aiAssistedPolicyEvaluation' row with isEnabled = false. + Initialize(); + + // [GIVEN] Expense Agent Setup exists with Evaluate Policies = false. + if not ExpenseAgentSetup.Get() then begin + ExpenseAgentSetup.Init(); + ExpenseAgentSetup.Insert(); + end; + ExpenseAgentSetup."Evaluate Policies" := false; + ExpenseAgentSetup.Modify(); + Commit(); + + // [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 'aiAssistedPolicyEvaluation' row is present and isEnabled = false. + Assert.AreNotEqual(0, StrPos(ResponseText, PolicyEvalDisabledRowTok), + 'aiAssistedPolicyEvaluation row must be reported as isEnabled = false when Evaluate Policies is false.'); + end; + local procedure Initialize() begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Capabilities API Test"); diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePolicyFlagTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePolicyFlagTest.Codeunit.al new file mode 100644 index 00000000000..2e2154bb4da --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePolicyFlagTest.Codeunit.al @@ -0,0 +1,999 @@ +// ------------------------------------------------------------------------------------------------ +// 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 148338 "Expense Policy Flag Test" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + var + Assert: Codeunit Assert; + LibraryExpense: Codeunit "Library - Expense"; + LibraryRandom: Codeunit "Library - Random"; + LibraryTestInitialize: Codeunit "Library - Test Initialize"; + LibraryUtility: Codeunit "Library - Utility"; + IsInitialized: Boolean; + + // --- Version-counter lifecycle ----------------------------------------------------------- + + [Test] + procedure NewReportLineStartsNotEvaluated() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + begin + // [SCENARIO] A freshly inserted report line with an applicable policy starts at Policy Eval Version 0, unevaluated, status Not Evaluated. + Initialize(); + + // [WHEN] A report line is created and a policy targets its category. + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Applies to the line category'); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [THEN] Policy Eval Version is 0, no evaluation timestamp, and the status is Not Evaluated. + Assert.AreEqual(0, ExpenseReportLine."Policy Eval Version", 'Policy Eval Version should be 0 on insert.'); + Assert.AreEqual(0DT, ExpenseReportLine."Policies Evaluated At", 'Policies Evaluated At should be blank before any evaluation.'); + Assert.AreEqual("Expense Policy Status"::"Not Evaluated", ExpenseReportLine.GetPolicyStatus(), 'A never-evaluated report line must report Not Evaluated.'); + end; + + [Test] + procedure MarkPoliciesEvaluatedClearsWhenNoFlags() + var + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO] MarkPoliciesEvaluated advances Evaluated to Current, stamps the timestamp, and (no flags) yields Cleared. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [WHEN] The report line is marked evaluated. + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [THEN] Evaluated equals Current, the timestamp is set, and the status is Cleared. + Assert.AreEqual(ExpenseReportLine."Policy Eval Version", ExpenseReportLine."Evaluated Policy Version", 'Evaluated must catch up to Policy Eval Version after MarkPoliciesEvaluated.'); + Assert.AreNotEqual(0DT, ExpenseReportLine."Policies Evaluated At", 'Policies Evaluated At must be stamped.'); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'An evaluated report line with no flags must report Cleared.'); + end; + + [Test] + procedure RelevantFieldChangeMakesStale() + var + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO] Changing a policy-relevant field after evaluation bumps Current, leaving the line Stale. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [WHEN] A policy-relevant field (Merchant Name) changes to a guaranteed-different value. + ExpenseReportLine."Merchant Name" := 'Contoso Merchant (policy-relevant change)'; + ExpenseReportLine.Modify(true); + + // [THEN] Current is bumped past Evaluated and the status is Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(1, ExpenseReportLine."Policy Eval Version", 'A relevant field change must bump Policy Eval Version.'); + Assert.AreEqual(0, ExpenseReportLine."Evaluated Policy Version", 'Evaluated must not move on a plain modify.'); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'A relevant change after evaluation must report Stale.'); + end; + + [Test] + procedure NeutralFieldChangeStaysCleared() + var + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO] Changing only a policy-neutral field after evaluation must NOT bump Current; status stays Cleared. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [WHEN] Only a neutral field (Applied Rule Id) changes. + ExpenseReportLine."Applied Rule Id" := CreateGuid(); + ExpenseReportLine.Modify(true); + + // [THEN] Current is unchanged and the status remains Cleared. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(0, ExpenseReportLine."Policy Eval Version", 'A neutral field change must not bump Policy Eval Version.'); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A neutral change must leave the evaluation Cleared.'); + end; + + [Test] + procedure InvalidatePolicyEvaluationMakesStale() + var + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO] InvalidatePolicyEvaluation bumps Current directly, leaving an evaluated line Stale. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [WHEN] The evaluation is invalidated. + ExpenseReportLine.InvalidatePolicyEvaluation(); + + // [THEN] Current is ahead of Evaluated and the status is Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(1, ExpenseReportLine."Policy Eval Version", 'InvalidatePolicyEvaluation must bump Policy Eval Version.'); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'After invalidation an evaluated line must report Stale.'); + end; + + [Test] + procedure InvalidateBeforeEvaluationIsNoOp() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + begin + // [SCENARIO] InvalidatePolicyEvaluation on a never-evaluated line is a no-op (guard against staling a held handle). + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Applies to the line category'); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [WHEN] The evaluation is invalidated before any evaluation ever happened. + ExpenseReportLine.InvalidatePolicyEvaluation(); + + // [THEN] Current is unchanged and the status stays Not Evaluated. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(0, ExpenseReportLine."Policy Eval Version", 'Invalidation before evaluation must not bump Policy Eval Version.'); + Assert.AreEqual("Expense Policy Status"::"Not Evaluated", ExpenseReportLine.GetPolicyStatus(), 'Invalidation before evaluation must leave the line Not Evaluated.'); + end; + + // --- Flag insertion + Flagged status ----------------------------------------------------- + + [Test] + procedure EvaluatedReportLineWithFlagIsFlagged() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A flag stamped at the evaluated version makes the report line report Flagged. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + + // [WHEN] A flag is added and the line is then marked evaluated. + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Receipt includes alcohol.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [THEN] The Policy Flags FlowField sees the live flag and the status is Flagged. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Flagged, ExpenseReportLine.GetPolicyStatus(), 'An evaluated line with a current-version flag must report Flagged.'); + end; + + [Test] + procedure FlagStampedWithSubjectAndPolicyVersion() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] OnInsert stamps the flag's Policy Version from the linked policy; the caller + // supplies Subject Version from the parent line's Policy Eval Version. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + + // [GIVEN] The line is evaluated then invalidated once so Policy Eval Version is 1 (not the trivial 0). + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.InvalidatePolicyEvaluation(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [GIVEN] The policy is modified once so its Version is 1 (not the trivial 0). + ExpensePolicy."Policy Text" := 'Policy text v2.'; + ExpensePolicy.Modify(true); + + // [WHEN] A flag is inserted. + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Some violation.'); + + // [THEN] Subject Version equals the line's Policy Eval Version and Policy Version equals the policy's Version. + Assert.AreEqual(ExpenseReportLine."Policy Eval Version", ExpensePolicyFlag."Subject Version", 'The flag Subject Version must match the line Policy Eval Version at insert.'); + Assert.AreEqual(1, ExpensePolicyFlag."Subject Version", 'Policy Eval Version was 1 at insert, so the flag Subject Version must be 1.'); + Assert.AreEqual(ExpensePolicy."Version", ExpensePolicyFlag."Policy Version", 'The flag Policy Version must match the policy Version at insert.'); + Assert.AreEqual(1, ExpensePolicyFlag."Policy Version", 'The policy was modified once, so the flag Policy Version must be 1.'); + end; + + [Test] + procedure FlagIsCurrentUntilPolicyChanges() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A flag reports Is Current while its stored Policy Version matches the live policy, + // and stops being current once the policy is modified. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + + // [GIVEN] A flag captured against the current policy version. + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Some violation.'); + + // [THEN] The flag is current. + ExpensePolicyFlag.CalcFields("Is Current"); + Assert.IsTrue(ExpensePolicyFlag."Is Current", 'A freshly captured flag must be current.'); + + // [WHEN] The underlying policy changes (its Version bumps). + ExpensePolicy."Policy Text" := 'Policy text v2.'; + ExpensePolicy.Modify(true); + + // [THEN] The flag captured against the older policy version is no longer current. + ExpensePolicyFlag.CalcFields("Is Current"); + Assert.IsFalse(ExpensePolicyFlag."Is Current", 'A flag captured against an older policy version must not be current.'); + end; + + [Test] + procedure FlagStampedWithPolicyTextAndTimestamp() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + PolicyText: Text[2048]; + begin + // [SCENARIO] OnInsert copies the linked policy's text and category onto the flag and stamps Flagged At. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + PolicyText := 'Meals over 50 require an itemized receipt.'; + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", PolicyText); + + // [WHEN] A flag is inserted for that policy. + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Missing itemized receipt.'); + + // [THEN] Policy Text and Expense Category Code are copied from the policy and Flagged At is set. + Assert.AreEqual(PolicyText, ExpensePolicyFlag."Policy Text", 'The flag must copy the Policy Text from the linked policy.'); + Assert.AreEqual(ExpensePolicy."Expense Category Code", ExpensePolicyFlag."Expense Category Code", 'The flag must copy the Expense Category Code from the linked policy.'); + Assert.AreNotEqual(0DT, ExpensePolicyFlag."Flagged At", 'Flagged At must be stamped on insert.'); + end; + + [Test] + procedure StaleFlagHiddenAfterReevaluation() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A flag from an earlier version is not seen after a re-evaluation at a higher version, + // yet the flag row is preserved as history. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + + // [GIVEN] An evaluated, flagged line at version 1. + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Old violation.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Flagged, ExpenseReportLine.GetPolicyStatus(), 'Precondition: the line should be Flagged at version 1.'); + + // [WHEN] A relevant change bumps the version and the line is re-evaluated with no new flags. + ExpenseReportLine."Merchant Name" := 'Contoso Merchant (policy-relevant change)'; + ExpenseReportLine.Modify(true); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [THEN] The version-1 flag is no longer live (status Cleared) but the row still exists. + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A flag from an older version must not be seen after re-evaluation.'); + ExpensePolicyFlag.SetRange("Subject System Id", ExpenseReportLine.SystemId); + Assert.RecordCount(ExpensePolicyFlag, 1); + end; + + [Test] + procedure NewFlagSeenAfterReevaluationAtHigherVersion() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] After a version bump and re-evaluation, a new flag stamped at the new version is seen, + // and both the old and new flag rows coexist as history. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + + // [GIVEN] An evaluated, flagged line at version 1. + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Version 1 violation.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [WHEN] A relevant field change bumps the version, a new flag is stamped at version 2, then re-evaluated. + ExpenseReportLine."Merchant Name" := 'Contoso Merchant (policy-relevant change)'; + ExpenseReportLine.Modify(true); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Version 2 violation.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [THEN] The line is Flagged (the version-2 flag is live) and both flag rows are preserved. + Assert.AreEqual("Expense Policy Status"::Flagged, ExpenseReportLine.GetPolicyStatus(), 'The version-2 flag must be seen after re-evaluation.'); + ExpensePolicyFlag.SetRange("Subject System Id", ExpenseReportLine.SystemId); + Assert.RecordCount(ExpensePolicyFlag, 2); + end; + + [Test] + procedure CompliantFlagLeavesLineCleared() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A good-to-go (compliant) flag is not a violation, so the line stays Cleared. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + + // [WHEN] A compliant verdict is recorded and the line is marked evaluated. + AddCompliantFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'No alcohol found - compliant.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [THEN] Has Policy Violation is false and the line reports Cleared. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + ExpenseReportLine.CalcFields("Has Policy Violation"); + Assert.IsFalse(ExpenseReportLine."Has Policy Violation", 'A compliant flag must not raise Has Policy Violation.'); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'An evaluated line with only compliant flags must report Cleared.'); + end; + + [Test] + procedure ViolationAmongCompliantFlagsFlagsLine() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicyA: Record "Expense Policy"; + ExpensePolicyB: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] With one compliant and one violation verdict at the same version, the line is Flagged. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicyA, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + CreateTestPolicy(ExpensePolicyB, ExpenseReportLine."Expense Category", 'Meals over 50 require an itemized receipt.'); + + // [WHEN] Policy A passes and Policy B is violated, then the line is marked evaluated. + AddCompliantFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicyA, 'No alcohol found - compliant.'); + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicyB, 'Missing itemized receipt.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [THEN] Has Policy Violation is true and the line reports Flagged. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + ExpenseReportLine.CalcFields("Has Policy Violation"); + Assert.IsTrue(ExpenseReportLine."Has Policy Violation", 'A single violation among compliant flags must raise Has Policy Violation.'); + Assert.AreEqual("Expense Policy Status"::Flagged, ExpenseReportLine.GetPolicyStatus(), 'A line with any violation flag must report Flagged.'); + end; + + [Test] + procedure AddingPolicyForCategoryMakesEvaluatedLineStale() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + begin + // [SCENARIO] Adding a policy for a category invalidates lines of that category that were already evaluated. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'Precondition: the line should be Cleared.'); + + // [WHEN] A policy is added for the line's category. + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + + // [THEN] The evaluated line is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(1, ExpenseReportLine."Policy Eval Version", 'Adding a policy for the category must bump the evaluated line.'); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'Adding a policy for the category must leave the evaluated line Stale.'); + end; + + [Test] + procedure ChangingPolicyMakesEvaluatedLineStale() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + begin + // [SCENARIO] Modifying an existing policy invalidates already-evaluated lines of its category. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + + // [GIVEN] The line is evaluated (Cleared) after the policy already exists. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'Precondition: the line should be Cleared.'); + + // [WHEN] The policy text changes (bumping its version). + ExpensePolicy."Policy Text" := 'No alcohol and no minibar on company expenses.'; + ExpensePolicy.Modify(true); + + // [THEN] The evaluated line is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'Changing a policy must leave the evaluated line Stale.'); + end; + + [Test] + procedure DeletingPolicyMakesEvaluatedLineStale() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] Deleting a policy invalidates already-evaluated lines of its category, and any + // flag left behind for the removed policy is no longer current. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + + // [GIVEN] The line is evaluated with a flag for the policy. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Alcohol flagged'); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [WHEN] The policy is deleted. + ExpensePolicy.Delete(true); + + // [THEN] The evaluated line is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'Deleting a policy must leave the evaluated line Stale.'); + + // [THEN] The orphaned flag is kept as history but is no longer current. + ExpensePolicyFlag.Get(ExpensePolicyFlag."Subject Type", ExpensePolicyFlag."Subject System Id", ExpensePolicyFlag."Policy System Id", ExpensePolicyFlag."Subject Version", ExpensePolicyFlag."Policy Version"); + ExpensePolicyFlag.CalcFields("Is Current"); + Assert.IsFalse(ExpensePolicyFlag."Is Current", 'A flag for a deleted policy must not be current.'); + end; + + [Test] + procedure PolicyChangeLeavesOtherCategoryLineUntouched() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + begin + // [SCENARIO] A policy for one category does not invalidate lines of a different category. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [WHEN] A policy is added for a different category than the line's. + CreateTestPolicy(ExpensePolicy, CopyStr(ExpenseReportLine."Expense Category" + 'X', 1, 20), 'Unrelated policy.'); + + // [THEN] The line for the original category stays Cleared. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A policy for another category must not invalidate this line.'); + end; + + [Test] + procedure MovingPolicyToAnotherCategoryStalesOldCategoryLine() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + OtherCategory: Record "Expense Category"; + begin + // [SCENARIO] Moving a policy to a different category invalidates the lines in the policy's + // previous category, not only the new one. Otherwise those lines keep a verdict + // from a policy that no longer applies to them and stay incorrectly Current. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] A policy for the line's category and the line evaluated (Cleared, no flags). + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'Precondition: the evaluated line should be Cleared.'); + + // [WHEN] The policy is moved to a different category. + LibraryExpense.CreateExpenseCategory(OtherCategory, OtherCategory."Reimbursement Type"::"Employee Paid", "Expense Detail Needed"::" ", ''); + ExpensePolicy."Expense Category Code" := OtherCategory.Code; + ExpensePolicy.Modify(true); + + // [THEN] The line in the policy's OLD category is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'Moving a policy out of the line''s category must invalidate the line in the old category.'); + end; + + [Test] + procedure SupersededFlagDoesNotKeepLineFlagged() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A non-compliant flag whose captured Policy Version no longer matches the live + // policy (Is Current = false) must not keep an up-to-date line Flagged. The normal + // trigger flow always re-stales a line when its applicable policy changes, so this + // superseded-flag-on-a-current-line state is forced with a raw insert to isolate the + // currency check in HasCurrentPolicyViolation (defense in depth). + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'No alcohol on company expenses.'); + + // [GIVEN] A non-compliant flag stamped at the line's version but against a superseded policy + // version (Policy Version ahead of the live policy), inserted raw to bypass the strict + // OnInsert snapshot. + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := ExpenseReportLine.SystemId; + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Subject Version" := ExpenseReportLine."Policy Eval Version"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + ExpensePolicyFlag."Policy Version" := ExpensePolicy."Version" + 1; + ExpensePolicyFlag."Compliant" := false; + ExpensePolicyFlag.Insert(false); + + ExpensePolicyFlag.CalcFields("Is Current"); + Assert.IsFalse(ExpensePolicyFlag."Is Current", 'Precondition: the flag must be non-current.'); + + // [WHEN] The line is marked evaluated so its status is read from flags. + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [THEN] The superseded flag does not count as a violation; the line reports Cleared. + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A superseded (non-current) flag must not keep the line Flagged.'); + end; + + [Test] + procedure FlagInsertRejectsUnknownReportLine() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A flag whose subject report line does not exist is rejected on insert. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + + // [WHEN] A flag references a non-existent report line. + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := CreateGuid(); + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + + // [THEN] The insert is rejected. + asserterror ExpensePolicyFlag.Insert(true); + Assert.ExpectedError('does not exist'); + end; + + [Test] + procedure FlagInsertRejectsDisabledPolicy() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] A flag for a disabled policy is rejected on insert. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + + // [GIVEN] The policy is disabled. + ExpensePolicy.Validate(Enabled, false); + ExpensePolicy.Modify(true); + + // [WHEN] A flag references the disabled policy. + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := ExpenseReportLine.SystemId; + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + + // [THEN] The insert is rejected. + asserterror ExpensePolicyFlag.Insert(true); + Assert.ExpectedError('disabled policy'); + end; + + [Test] + procedure FlagInsertRejectsInapplicablePolicy() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + OtherCategory: Record "Expense Category"; + begin + // [SCENARIO] A flag for a policy that does not apply to the line's category is rejected. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + LibraryExpense.CreateExpenseCategory(OtherCategory, OtherCategory."Reimbursement Type"::"Employee Paid", "Expense Detail Needed"::" ", ''); + CreateTestPolicy(ExpensePolicy, OtherCategory.Code, 'Policy for another category.'); + + // [WHEN] A flag references the other-category policy for this line. + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := ExpenseReportLine.SystemId; + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + + // [THEN] The insert is rejected. + asserterror ExpensePolicyFlag.Insert(true); + Assert.ExpectedError('does not apply'); + end; + + [Test] + procedure DeletingReportLineRemovesFlags() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + begin + // [SCENARIO] Deleting a report line removes its policy flags so none are orphaned. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Policy text.'); + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Some violation.'); + + // [GIVEN] Precondition: a flag exists for the line. + ExpensePolicyFlag.SetRange("Subject System Id", ExpenseReportLine.SystemId); + Assert.RecordCount(ExpensePolicyFlag, 1); + + // [WHEN] The report line is deleted. + ExpenseReportLine.Delete(true); + + // [THEN] The flag is gone. + ExpensePolicyFlag.SetRange("Subject System Id", ExpenseReportLine.SystemId); + Assert.RecordIsEmpty(ExpensePolicyFlag); + end; + + // --- Child-record invalidation ----------------------------------------------------------- + + [Test] + procedure AddingParticipantInvalidatesParent() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLineParticip: Record "Expense Report Line Particip."; + begin + // [SCENARIO] Inserting a child participant invalidates the parent line's evaluation. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'Precondition: the line should be Cleared.'); + + // [WHEN] A participant is added to the line. + CreateReportLineParticipant(ExpenseReportLineParticip, ExpenseReportLine); + + // [THEN] The parent's Policy Eval Version is bumped and the status becomes Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(1, ExpenseReportLine."Policy Eval Version", 'Adding a child participant must invalidate (bump) the parent.'); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'A child insert after evaluation must leave the parent Stale.'); + end; + + [Test] + procedure DeletingParticipantInvalidatesParent() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLineParticip: Record "Expense Report Line Particip."; + begin + // [SCENARIO] Deleting a child participant invalidates the parent line's evaluation. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateReportLineParticipant(ExpenseReportLineParticip, ExpenseReportLine); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [WHEN] The participant is deleted. + ExpenseReportLineParticip.Delete(true); + + // [THEN] The parent is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'Deleting a child participant must invalidate the parent.'); + end; + + [Test] + procedure AddingItemizationInvalidatesParent() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLineItemization: Record "Expense Report Line Item"; + ExpenseSubcategory: Record "Expense Subcategory"; + begin + // [SCENARIO] Inserting a child itemization invalidates the parent line's evaluation. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [WHEN] An itemization is added to the line. + LibraryExpense.CreateExpenseSubCategory(ExpenseSubcategory, ExpenseReportLine."Expense Category", true); + LibraryExpense.CreateExpenseReportLineItemization(ExpenseReportLineItemization, ExpenseReportLine, ExpenseReportLine."Expense Category", ExpenseSubcategory.Code, WorkDate(), LibraryRandom.RandIntInRange(10, 100), 1); + + // [THEN] The parent is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(1, ExpenseReportLine."Policy Eval Version", 'Adding a child itemization must invalidate (bump) the parent.'); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'A child itemization insert after evaluation must leave the parent Stale.'); + end; + + [Test] + procedure AddingPerDiemInvalidatesParent() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLinePerDiem: Record "Expense Report Line Per Diem"; + ExpenseSubcategory: Record "Expense Subcategory"; + begin + // [SCENARIO] Inserting a child per diem invalidates the parent line's evaluation. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [WHEN] A per diem is added to the line. + LibraryExpense.CreateExpenseSubCategory(ExpenseSubcategory, ExpenseReportLine."Expense Category", true); + LibraryExpense.CreateExpenseReportLinePerDiem(ExpenseReportLinePerDiem, ExpenseReportLine, ExpenseReportLine."Expense Category", ExpenseSubcategory.Code, '', WorkDate(), true, true, true, LibraryRandom.RandIntInRange(10, 100)); + + // [THEN] The parent is invalidated and reports Stale. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(1, ExpenseReportLine."Policy Eval Version", 'Adding a child per diem must invalidate (bump) the parent.'); + Assert.AreEqual("Expense Policy Status"::Stale, ExpenseReportLine.GetPolicyStatus(), 'A child per diem insert after evaluation must leave the parent Stale.'); + end; + + // --- Policies to evaluate endpoint ------------------------------------------------------ + + [Test] + procedure PoliciesToEvaluateListsApplicableUnevaluatedPolicies() + var + ExpenseReportLine: Record "Expense Report Line"; + MatchingPolicy: Record "Expense Policy"; + BlankCategoryPolicy: Record "Expense Policy"; + OtherCategoryPolicy: Record "Expense Policy"; + OtherCategory: Record "Expense Category"; + TempPolicyToEval: Record "Exp. Policy To Eval Buffer" temporary; + Builder: Codeunit "Exp. Policies To Eval Builder"; + begin + // [SCENARIO] The endpoint returns the enabled policies applicable to a line (its category or blank), excluding other categories. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] A policy for the line's category, a blank-category policy, and a policy for a different category. + CreateTestPolicy(MatchingPolicy, ExpenseReportLine."Expense Category", 'Matches the line category'); + CreateTestPolicy(BlankCategoryPolicy, '', 'Applies to every category'); + LibraryExpense.CreateExpenseCategory(OtherCategory, OtherCategory."Reimbursement Type"::"Employee Paid", "Expense Detail Needed"::" ", ''); + CreateTestPolicy(OtherCategoryPolicy, OtherCategory.Code, 'Belongs to another category'); + + // [WHEN] The policies-to-evaluate set is built for the line. + Builder.Build(TempPolicyToEval, Format(ExpenseReportLine.SystemId)); + + // [THEN] Only the matching and blank-category policies are returned. + Assert.AreEqual(2, TempPolicyToEval.Count(), 'Only the applicable policies must be returned.'); + Assert.IsTrue(TempPolicyToEval.Get(ExpenseReportLine.SystemId, MatchingPolicy.SystemId), 'The matching-category policy must be listed.'); + Assert.IsTrue(TempPolicyToEval.Get(ExpenseReportLine.SystemId, BlankCategoryPolicy.SystemId), 'The blank-category policy must be listed.'); + Assert.IsFalse(TempPolicyToEval.Get(ExpenseReportLine.SystemId, OtherCategoryPolicy.SystemId), 'A different-category policy must not be listed.'); + end; + + [Test] + procedure PoliciesToEvaluateExcludesCurrentlyFlaggedButReturnsAfterVersionBump() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + TempPolicyToEval: Record "Exp. Policy To Eval Buffer" temporary; + Builder: Codeunit "Exp. Policies To Eval Builder"; + begin + // [SCENARIO] A policy already flagged at the current versions is excluded, but returns once the policy is bumped to a new version. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Original policy text'); + + // [GIVEN] A flag exists for the line at the current subject and policy version. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Already evaluated'); + + // [WHEN] The set is built. + Builder.Build(TempPolicyToEval, Format(ExpenseReportLine.SystemId)); + + // [THEN] The freshly evaluated policy is not returned. + Assert.IsFalse(TempPolicyToEval.Get(ExpenseReportLine.SystemId, ExpensePolicy.SystemId), 'A policy already flagged at the current version must not be listed.'); + + // [WHEN] The policy is changed, bumping its version. + ExpensePolicy.Get(ExpensePolicy."Subject Type", ExpensePolicy."Line No."); + ExpensePolicy."Policy Text" := 'Updated policy text'; + ExpensePolicy.Modify(true); + Builder.Build(TempPolicyToEval, Format(ExpenseReportLine.SystemId)); + + // [THEN] The updated policy is listed again as needing evaluation. + Assert.IsTrue(TempPolicyToEval.Get(ExpenseReportLine.SystemId, ExpensePolicy.SystemId), 'A policy bumped to a new version must be listed for re-evaluation.'); + end; + + // --- Status of never-evaluated lines ----------------------------------------------------- + + [Test] + procedure OutstandingPolicyIsDetectedUntilFlagged() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + Builder: Codeunit "Exp. Policies To Eval Builder"; + begin + // [SCENARIO] HasOutstandingPolicies (the mark-evaluated guard) reports true while an applicable + // policy has no verdict for the current version, and false once every applicable + // policy has a flag. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] No applicable policy - nothing is outstanding. + Assert.IsFalse(Builder.HasOutstandingPolicies(ExpenseReportLine), 'A line with no applicable policy has nothing outstanding.'); + + // [WHEN] An applicable policy exists but has not been evaluated. + CreateTestPolicy(ExpensePolicy, ExpenseReportLine."Expense Category", 'Must be evaluated.'); + + // [THEN] The policy is outstanding. + Assert.IsTrue(Builder.HasOutstandingPolicies(ExpenseReportLine), 'An applicable policy without a flag must be outstanding.'); + + // [WHEN] A verdict (flag) is recorded for the policy at the current version. + AddCompliantFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, 'Compliant.'); + + // [THEN] Nothing is outstanding anymore. + Assert.IsFalse(Builder.HasOutstandingPolicies(ExpenseReportLine), 'Once every applicable policy has a flag, nothing is outstanding.'); + end; + + [Test] + procedure UnevaluatedLineWithNoApplicablePoliciesIsCleared() + var + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO] A never-evaluated line whose category has no policy is Cleared, not "Not Evaluated". + Initialize(); + + // [GIVEN] A fresh report line and no policies at all. + CreateTestReportLine(ExpenseReportLine); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + + // [THEN] The line reports Cleared because nothing needs to be evaluated against it. + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A never-evaluated line with no applicable policy must be Cleared.'); + end; + + [Test] + procedure UnevaluatedLineWithApplicablePolicyIsNotEvaluated() + var + ExpenseReportLine: Record "Expense Report Line"; + MatchingPolicy: Record "Expense Policy"; + begin + // [SCENARIO] A never-evaluated line whose category has an applicable policy is "Not Evaluated". + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] An enabled policy that targets the line's category. + CreateTestPolicy(MatchingPolicy, ExpenseReportLine."Expense Category", 'Matches the line category'); + + // [THEN] The line is "Not Evaluated" because a policy still needs to run against it. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::"Not Evaluated", ExpenseReportLine.GetPolicyStatus(), 'A never-evaluated line with an applicable policy must be Not Evaluated.'); + end; + + [Test] + procedure UnevaluatedLineWithBlankCategoryPolicyIsNotEvaluated() + var + ExpenseReportLine: Record "Expense Report Line"; + BlankCategoryPolicy: Record "Expense Policy"; + begin + // [SCENARIO] A blank-category policy applies to every line, so a never-evaluated line is "Not Evaluated". + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] An enabled policy with a blank category (applies to all categories). + CreateTestPolicy(BlankCategoryPolicy, '', 'Applies to every category'); + + // [THEN] The line is "Not Evaluated" because the blank-category policy applies to it. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::"Not Evaluated", ExpenseReportLine.GetPolicyStatus(), 'A never-evaluated line with a blank-category policy must be Not Evaluated.'); + end; + + [Test] + procedure UnevaluatedLineWithOnlyOtherCategoryPolicyIsCleared() + var + ExpenseReportLine: Record "Expense Report Line"; + OtherCategoryPolicy: Record "Expense Policy"; + OtherCategory: Record "Expense Category"; + begin + // [SCENARIO] A policy for a different category does not apply, so a never-evaluated line is Cleared. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] An enabled policy that targets a different category only. + LibraryExpense.CreateExpenseCategory(OtherCategory, OtherCategory."Reimbursement Type"::"Employee Paid", "Expense Detail Needed"::" ", ''); + CreateTestPolicy(OtherCategoryPolicy, OtherCategory.Code, 'Belongs to another category'); + + // [THEN] The line is Cleared because no policy targets its category. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A never-evaluated line with only a different-category policy must be Cleared.'); + end; + + [Test] + procedure UnevaluatedLineWithDisabledPolicyIsCleared() + var + ExpenseReportLine: Record "Expense Report Line"; + DisabledPolicy: Record "Expense Policy"; + begin + // [SCENARIO] A disabled policy is not applicable, so a never-evaluated line is Cleared. + Initialize(); + CreateTestReportLine(ExpenseReportLine); + + // [GIVEN] A policy for the line's category that is disabled. + CreateTestPolicy(DisabledPolicy, ExpenseReportLine."Expense Category", 'Disabled policy'); + DisabledPolicy.Get(DisabledPolicy."Subject Type", DisabledPolicy."Line No."); + DisabledPolicy.Enabled := false; + DisabledPolicy.Modify(true); + + // [THEN] The line is Cleared because the only matching policy is disabled. + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual("Expense Policy Status"::Cleared, ExpenseReportLine.GetPolicyStatus(), 'A never-evaluated line whose only matching policy is disabled must be Cleared.'); + end; + + // --- Fixtures ---------------------------------------------------------------------------- + + local procedure Initialize() + var + LibraryERMCountryData: Codeunit "Library - ERM Country Data"; + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Policy Flag Test"); + LibraryExpense.CleanUpBeforeTesting(); + if IsInitialized then + exit; + + LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Policy Flag Test"); + LibraryERMCountryData.CreateVATData(); + LibraryERMCountryData.UpdateGeneralPostingSetup(); + LibraryERMCountryData.CreateGeneralPostingSetupData(); + LibraryERMCountryData.UpdatePurchasesPayablesSetup(); + LibraryERMCountryData.UpdateJournalTemplMandatory(false); + LibraryExpense.SetupNumberSeriesInExpenseMgmt(); + LibraryExpense.InitializeExpenseSourceCode(); + LibraryExpense.UpdateDefaultUnitOfMeasureInAgentSetup(); + LibraryExpense.UpdateUseRulesInAgentSetup(false); + IsInitialized := true; + + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Policy Flag Test"); + end; + + local procedure CreateTestReportLine(var ExpenseReportLine: Record "Expense Report Line") + var + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpensePaymentMethod: Record "Expense Payment Method"; + begin + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", "Expense Detail Needed"::" ", ExpensePaymentMethod.Code); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + LibraryExpense.CreateExpenseReportLine(ExpenseReportLine, ExpenseReportHeader, ExpenseUser."No.", ExpenseCategory.Code, ExpensePaymentMethod.Code, true, '', LibraryRandom.RandIntInRange(10, 100)); + end; + + local procedure CreateTestPolicy(var ExpensePolicy: Record "Expense Policy"; ExpenseCategoryCode: Code[20]; PolicyText: Text[2048]) + begin + ExpensePolicy.Init(); + ExpensePolicy."Expense Category Code" := ExpenseCategoryCode; + ExpensePolicy."Policy Text" := PolicyText; + ExpensePolicy.Enabled := true; + ExpensePolicy."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicy.Insert(true); + end; + + local procedure AddFlag(var ExpensePolicyFlag: Record "Expense Policy Flag"; ExpenseReportLine: Record "Expense Report Line"; ExpensePolicy: Record "Expense Policy"; FlagDescription: Text[2048]) + begin + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, FlagDescription, false); + end; + + local procedure AddCompliantFlag(var ExpensePolicyFlag: Record "Expense Policy Flag"; ExpenseReportLine: Record "Expense Report Line"; ExpensePolicy: Record "Expense Policy"; FlagDescription: Text[2048]) + begin + AddFlag(ExpensePolicyFlag, ExpenseReportLine, ExpensePolicy, FlagDescription, true); + end; + + local procedure AddFlag(var ExpensePolicyFlag: Record "Expense Policy Flag"; ExpenseReportLine: Record "Expense Report Line"; ExpensePolicy: Record "Expense Policy"; FlagDescription: Text[2048]; Compliant: Boolean) + begin + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := ExpenseReportLine.SystemId; + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Subject Version" := ExpenseReportLine."Policy Eval Version"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + ExpensePolicyFlag.Reason := FlagDescription; + ExpensePolicyFlag."Compliant" := Compliant; + ExpensePolicyFlag.Insert(true); + end; + + local procedure CreateReportLineParticipant(var ExpenseReportLineParticip: Record "Expense Report Line Particip."; ExpenseReportLine: Record "Expense Report Line") + var + RecordRef: RecordRef; + begin + ExpenseReportLineParticip.Init(); + ExpenseReportLineParticip.Validate("Expense Report No.", ExpenseReportLine."Document No."); + ExpenseReportLineParticip.Validate("Expense Report Line No.", ExpenseReportLine."Line No."); + RecordRef.GetTable(ExpenseReportLineParticip); + ExpenseReportLineParticip.Validate("Line No.", LibraryUtility.GetNewLineNo(RecordRef, ExpenseReportLineParticip.FieldNo("Line No."))); + ExpenseReportLineParticip.Insert(true); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al index 96c5c08243e..b6e5e4698e3 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al @@ -3459,6 +3459,143 @@ codeunit 148302 "Expense Report Posting Test" StrSubstNo(ValueMustBeEqualErr, PostedExpenseReportCommentLine.FieldCaption(Comment), CommentText, PostedExpenseReportCommentLine.TableCaption())); end; + [Test] + procedure SubmitDoesNotInvalidatePolicyEvaluation() + var + Employee: Record Employee; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + CurrentUserSetup: Record "User Setup"; + FinalApproverUserSetup: Record "User Setup"; + EvaluatedVersion: Integer; + begin + // [SCENARIO] Releasing and marking a report Pending Approval must NOT invalidate policy + // evaluations - the submit path only reads lines, it never modifies them. + Initialize(); + + // [GIVEN] Approval workflow disabled so submit needs no interactive approval. + LibraryExpense.UpdateEnableApprovalWorkflowInAgentSetup(false); + + // [GIVEN] An expense user and a refundable category with an expense report line. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + ExpenseCategory.Validate(Refundable, true); + ExpenseCategory.Modify(); + Employee.Get(ExpenseUser."Employee No."); + LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + LibraryExpense.CreateExpenseReportLine( + ExpenseReportLine, ExpenseReportHeader, ExpenseCategory.Code, false, '', + ExpenseReportLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo()); + + // [GIVEN] A policy for the category and a violation flag captured on the line, then the line is marked evaluated. + ExpensePolicy.Init(); + ExpensePolicy."Expense Category Code" := ExpenseCategory.Code; + ExpensePolicy."Policy Text" := 'No alcohol on company expenses.'; + ExpensePolicy.Enabled := true; + ExpensePolicy."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicy.Insert(true); + + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := ExpenseReportLine.SystemId; + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Subject Version" := ExpenseReportLine."Policy Eval Version"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + ExpensePolicyFlag.Reason := 'Receipt includes alcohol.'; + ExpensePolicyFlag.Insert(true); + + ExpenseReportLine.MarkPoliciesEvaluated(); + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + EvaluatedVersion := ExpenseReportLine."Evaluated Policy Version"; + Assert.AreEqual("Expense Policy Status"::Flagged, ExpenseReportLine.GetPolicyStatus(), 'Precondition: the evaluated line must be Flagged.'); + + // [GIVEN] An approver chain so the report can be submitted. + CreateUserSetupsAndChainOfApprovers(CurrentUserSetup, FinalApproverUserSetup, ExpenseUser); + + // [WHEN] The report is released and marked Pending Approval (submitted). + ExpenseReportHeader.PerformManualReleaseAndPendingApproval(ExpenseUser."No."); + + // [THEN] The report is Pending Approval. + ExpenseReportHeader.Get(ExpenseReportHeader."No."); + Assert.AreEqual(ExpenseReportHeader.Status::"Pending Approval", ExpenseReportHeader.Status, 'The report must be Pending Approval after submit.'); + + // [THEN] The line's policy evaluation is untouched: both versions unchanged, status still Flagged (not Stale). + ExpenseReportLine.Get(ExpenseReportLine."Document No.", ExpenseReportLine."Line No."); + Assert.AreEqual(EvaluatedVersion, ExpenseReportLine."Policy Eval Version", 'Submit must not bump Policy Eval Version.'); + Assert.AreEqual(EvaluatedVersion, ExpenseReportLine."Evaluated Policy Version", 'Submit must not move Evaluated Policy Version.'); + Assert.AreEqual("Expense Policy Status"::Flagged, ExpenseReportLine.GetPolicyStatus(), 'Submit must leave the policy status unchanged (not Stale).'); + end; + + [Test] + [HandlerFunctions('ConfirmHandler')] + procedure PolicyFlagsCopiedToPostedExpenseReport() + var + Employee: Record Employee; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; + PostedExpenseReportLine: Record "Posted Expense Report Line"; + PostedExpPolicyFlag: Record "Posted Exp. Policy Flag"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + begin + // [SCENARIO] Policy flags on a report line are copied to the Posted Expense Policy Flag table on posting, + // and the open flags are removed with the line. + Initialize(); + + // [GIVEN] An expense user and a refundable category with an expense report line. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + ExpenseCategory.Validate(Refundable, true); + ExpenseCategory.Modify(); + Employee.Get(ExpenseUser."Employee No."); + LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); + LibraryExpense.CreateExpenseReportLine( + ExpenseReportLine, ExpenseReportHeader, ExpenseCategory.Code, false, '', + ExpenseReportLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo()); + + // [GIVEN] A policy for the category and a violation flag captured on the line, then the line is marked evaluated. + ExpensePolicy.Init(); + ExpensePolicy."Expense Category Code" := ExpenseCategory.Code; + ExpensePolicy."Policy Text" := 'No alcohol on company expenses.'; + ExpensePolicy.Enabled := true; + ExpensePolicy."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicy.Insert(true); + + ExpensePolicyFlag.Init(); + ExpensePolicyFlag."Subject System Id" := ExpenseReportLine.SystemId; + ExpensePolicyFlag."Subject Type" := "Expense Policy Subject"::"Expense Report Line"; + ExpensePolicyFlag."Subject Version" := ExpenseReportLine."Policy Eval Version"; + ExpensePolicyFlag."Policy System Id" := ExpensePolicy.SystemId; + ExpensePolicyFlag.Reason := 'Receipt includes alcohol.'; + ExpensePolicyFlag.Insert(true); + + ExpenseReportLine.MarkPoliciesEvaluated(); + + // [GIVEN] The report is released. + ExpenseReportHeader.PerformManualRelease(); + + // [WHEN] The report is posted. + ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + + // [THEN] The posted line carries the copied policy flag. + FindPostedExpenseReportLine(PostedExpenseReportLine, ExpenseUser); + PostedExpPolicyFlag.SetRange("Subject System Id", PostedExpenseReportLine.SystemId); + Assert.RecordCount(PostedExpPolicyFlag, 1); + + // [THEN] The original open flag no longer exists. + ExpensePolicyFlag.Reset(); + ExpensePolicyFlag.SetRange("Subject System Id", ExpenseReportLine.SystemId); + Assert.RecordIsEmpty(ExpensePolicyFlag); + end; + [Test] [HandlerFunctions('ConfirmHandler')] procedure MultipleCommentsCopiedFromExpenseReportToPostedExpenseReport() diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al index b1430e7a736..70e5b917746 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al @@ -538,11 +538,19 @@ codeunit 148300 "Library - Expense" ExpenseLocation: Record "Expense Location"; ExpenseUser: Record "Expense User"; ExpenseAgentSetup: Record "Expense Agent Setup"; + ExpensePolicy: Record "Expense Policy"; + ExpensePolicyFlag: Record "Expense Policy Flag"; begin ExpenseGroup.DeleteAll(); ExpenseUser.DeleteAll(); ExpenseLocation.DeleteAll(); + // Codeunit test isolation keeps data written by earlier test methods, so shared policy + // master data must be reset between tests. A leaked blank-category policy in particular + // would otherwise apply to every report line and skew policy-status assertions. + ExpensePolicyFlag.DeleteAll(); + ExpensePolicy.DeleteAll(); + // Ensure the agent is disabled so tests that toggle approval workflow don't fail // when the test environment was left with the agent enabled. if ExpenseAgentSetup.Get() then begin