|
|
@@ -1,203 +1,216 @@
|
|
|
using System;
|
|
|
using System.Collections.Generic;
|
|
|
-using System.ComponentModel;
|
|
|
using System.Linq;
|
|
|
using PX.Common;
|
|
|
using PX.Data;
|
|
|
-using PX.Objects.CM;
|
|
|
using PX.Objects.GL;
|
|
|
using PX.Objects.IN;
|
|
|
using PX.Objects.PM;
|
|
|
|
|
|
-//AR202000 use sale prices. Price Code is the customer. Customer is the project customer. Qty * Price= Amount
|
|
|
namespace ProjectEntryLaborAdjust
|
|
|
{
|
|
|
+
|
|
|
|
|
|
-
|
|
|
+
|
|
|
+
|
|
|
+ // ===================== GRAPH EXTENSION (RULE-DRIVEN) =====================
|
|
|
public class ProjectEntryExt : PXGraphExtension<ProjectEntry>
|
|
|
{
|
|
|
- private const string SlotLaborUpdate = "RevBudSetLabor"; // <-- new flag
|
|
|
- private const string LaborInvCD = "L023"; //"L023";
|
|
|
- private static readonly HashSet<string> ExcludedBranchCDs = new HashSet<string>{"301"};
|
|
|
- private static readonly HashSet<string> ExcludedTemplateCDs = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "ENVIRONMENTAL" };
|
|
|
- private const string ExcludedBillRule = "PROGRESS";
|
|
|
- private int? _laborInvID;
|
|
|
- private int? LaborInvID
|
|
|
- {
|
|
|
- get
|
|
|
- {
|
|
|
- if (_laborInvID == null)
|
|
|
- _laborInvID = InventoryItem.UK.Find(Base, LaborInvCD)?.InventoryID;
|
|
|
- return _laborInvID;
|
|
|
- }
|
|
|
- }
|
|
|
+ private const string SlotLaborUpdate = "RevBudSetLabor";
|
|
|
+
|
|
|
+ // List of rules to process — add new classes here in Initialize()
|
|
|
+ private readonly List<LaborRuleBase> _rules = new List<LaborRuleBase>();
|
|
|
+
|
|
|
+ // Cache InventoryID lookups for rule CDs
|
|
|
+ private readonly Dictionary<string, int?> _invIdCache =
|
|
|
+ new Dictionary<string, int?>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
protected bool Skip => Base.IsImport || Base.IsContractBasedAPI || Base.IsCopyPasteContext;
|
|
|
|
|
|
- protected void _(Events.RowUpdated<PMRevenueBudget> e)
|
|
|
+ public override void Initialize()
|
|
|
{
|
|
|
- if (IsExcludedProject()) // ← add this line
|
|
|
- return;
|
|
|
+ base.Initialize();
|
|
|
|
|
|
+ _rules.Clear();
|
|
|
+ _rules.Add(new L023PmLaborRule()); // uses AR pricing (no fixed rate)
|
|
|
+ _rules.Add(new L014LaborRule()); // fixed rate example
|
|
|
+ _rules.Add(new L001LaborRule());
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------------------- Event Handlers --------------------
|
|
|
+ protected void _(Events.RowUpdated<PMRevenueBudget> e)
|
|
|
+ {
|
|
|
var row = (PMRevenueBudget)e.Row;
|
|
|
var old = (PMRevenueBudget)e.OldRow;
|
|
|
if (row == null || old == null || Skip) return;
|
|
|
|
|
|
- // Prevent recursion
|
|
|
if (PXContext.GetSlot<bool?>(SlotLaborUpdate) == true) return;
|
|
|
|
|
|
+ // Only react when Original Budgeted Amount changed
|
|
|
+ if (e.Cache.ObjectsEqual<PMRevenueBudget.curyAmount>(row, old)) return;
|
|
|
|
|
|
+ // Build once
|
|
|
+ string[] laborCDs = _rules.Select(r => r.InventoryCD).ToArray();
|
|
|
+ int?[] laborIDs = laborCDs.Select(GetInventoryIdCached).ToArray();
|
|
|
|
|
|
- // Only react when Original Budgeted Amount changed
|
|
|
- if (e.Cache.ObjectsEqual<PMRevenueBudget.curyAmount>(row, old))
|
|
|
- {
|
|
|
+ // If editing any labor line, skip to avoid recursion
|
|
|
+ if (row.InventoryID != null && laborIDs.Contains(row.InventoryID))
|
|
|
return;
|
|
|
- }
|
|
|
- // Ignore if user is editing the labor line itself
|
|
|
|
|
|
- if (LaborInvID != null && row.InventoryID == LaborInvID) return;
|
|
|
+ // Exclude ALL labor lines from the job-size total
|
|
|
+ decimal totalRevised = GetRevisedRevenueTotalUI(laborCDs);
|
|
|
|
|
|
- // ---- Calculate totals/hours (exclude labor line) -----------------
|
|
|
- decimal totalRevised = GetRevisedRevenueTotalUI(LaborInvCD);
|
|
|
- decimal pmHours = GetPMHours(totalRevised);
|
|
|
+ // Resolve project context for per-rule exclusions
|
|
|
+ PMProject proj = Base.Project.Current;
|
|
|
+ string branchCD = ResolveBranchCD(proj?.DefaultBranchID);
|
|
|
+ string templateCD = ResolveTemplateCD(proj?.TemplateID);
|
|
|
+ string billRule = ResolveBillRule(proj);
|
|
|
|
|
|
PXContext.SetSlot<bool?>(SlotLaborUpdate, true);
|
|
|
try
|
|
|
{
|
|
|
- var cache = Base.RevenueBudget.Cache;
|
|
|
- PMRevenueBudget laborLine = FindRevenueLineByInventoryCd(LaborInvCD);
|
|
|
-
|
|
|
- if (laborLine != null)
|
|
|
+ foreach (var rule in _rules)
|
|
|
{
|
|
|
- var c = Base.RevenueBudget.Cache;
|
|
|
+ // Per-rule exclusion check
|
|
|
+ if (rule.IsExcludedFor(branchCD, templateCD, billRule))
|
|
|
+ continue;
|
|
|
|
|
|
- bool qtyChanged = laborLine.Qty != pmHours;
|
|
|
- if (qtyChanged)
|
|
|
- c.SetValueExt<PMRevenueBudget.qty>(laborLine, pmHours);
|
|
|
-
|
|
|
- // Re-run pricing defaults so rate comes from AR prices
|
|
|
- if (qtyChanged)
|
|
|
- c.SetDefaultExt<PMRevenueBudget.curyUnitRate>(laborLine);
|
|
|
-
|
|
|
- c.Update(laborLine);
|
|
|
- }
|
|
|
- else
|
|
|
- {
|
|
|
- CreateRevenueBudgetLine(
|
|
|
- inventoryCD: LaborInvCD,
|
|
|
- taskID: row.ProjectTaskID,
|
|
|
- accountGroupID: row.AccountGroupID,
|
|
|
- qty: pmHours,
|
|
|
- // rate: PMRate,
|
|
|
- // amount: pmHours * PMRate,
|
|
|
- uom: row.UOM,
|
|
|
- description: "Auto-added PM Labor"
|
|
|
- );
|
|
|
+ decimal hours = rule.GetHours(totalRevised);
|
|
|
+ UpsertLaborLine(rule, hours, row);
|
|
|
}
|
|
|
}
|
|
|
finally
|
|
|
{
|
|
|
PXContext.SetSlot<bool?>(SlotLaborUpdate, false);
|
|
|
}
|
|
|
-
|
|
|
- // ---- Popup & save -----------------------------------
|
|
|
- /*ShowPopupOnce(totalOrig);*/
|
|
|
- // Base.Actions.PressSave();
|
|
|
}
|
|
|
|
|
|
+ // Re-assert fixed rates right before save (mirrors the single-code pattern you showed)
|
|
|
protected void _(Events.RowPersisting<PMRevenueBudget> e)
|
|
|
{
|
|
|
- if (IsExcludedProject()) // ← add this line
|
|
|
- return;
|
|
|
-
|
|
|
var row = (PMRevenueBudget)e.Row;
|
|
|
if (row == null || e.Operation == PXDBOperation.Delete) return;
|
|
|
if (Skip || PXContext.GetSlot<bool?>(SlotLaborUpdate) == true) return;
|
|
|
|
|
|
- }
|
|
|
-
|
|
|
+ // if this row corresponds to a rule WITH a fixed rate, ensure the rate is set
|
|
|
+ foreach (var rule in _rules)
|
|
|
+ {
|
|
|
+ var invID = GetInventoryIdCached(rule.InventoryCD);
|
|
|
+ if (invID == null) continue;
|
|
|
|
|
|
- private bool IsExcludedProject()
|
|
|
- {
|
|
|
- PMProject proj = Base.Project.Current;
|
|
|
- if (proj == null) return false;
|
|
|
+ if (row.InventoryID == invID && rule.FixedUnitRate.HasValue)
|
|
|
+ {
|
|
|
+ decimal desiredRate = rule.FixedUnitRate.Value;
|
|
|
|
|
|
+ // Using SetValue (not SetValueExt) at persist-time avoids extra field events
|
|
|
+ if (row.CuryUnitRate != desiredRate)
|
|
|
+ e.Cache.SetValue<PMRevenueBudget.curyUnitRate>(row, desiredRate);
|
|
|
|
|
|
- if (proj.TemplateID != null)
|
|
|
- {
|
|
|
- // Any of these 3 options work;
|
|
|
- // 1) PK.Find (newer builds):
|
|
|
- PMProject tmpl = PMProject.PK.Find(Base, proj.TemplateID);
|
|
|
-
|
|
|
- // 2) Fluent BQL (uncomment if you prefer):
|
|
|
- // PMProject tmpl = SelectFrom<PMProject>
|
|
|
- // .Where<PMProject.contractID.IsEqual<@P.AsInt>>.View
|
|
|
- // .Select(Base, proj.TemplateID);
|
|
|
-
|
|
|
- // 3) Classic PXSelectReadonly (also fine):
|
|
|
- // PMProject tmpl = PXSelectReadonly<PMProject,
|
|
|
- // Where<PMProject.contractID, Equal<Required<PMProject.contractID>>>>
|
|
|
- // .Select(Base, proj.TemplateID)
|
|
|
- // ?.FirstOrDefault()?.GetItem<PMProject>();
|
|
|
-
|
|
|
- if (tmpl?.ContractCD != null &&
|
|
|
- ExcludedTemplateCDs.Contains(tmpl.ContractCD.Trim()))
|
|
|
- return true;
|
|
|
+ break; // at most one match
|
|
|
+ }
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
+ protected void _(Events.FieldUpdated<PMRevenueBudget, PMRevenueBudget.curyUnitRate> e)
|
|
|
+ {
|
|
|
+ var r = (PMRevenueBudget)e.Row;
|
|
|
+ if (r == null) return;
|
|
|
+ PXTrace.WriteInformation($"curyUnitRate UPDATED -> {r.CuryUnitRate} (Inv={r.InventoryID})");
|
|
|
+ }
|
|
|
|
|
|
+ // -------------------- Core Helpers --------------------
|
|
|
+ private int? GetInventoryIdCached(string inventoryCD)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(inventoryCD)) return null;
|
|
|
|
|
|
- // Branch check (BranchCD = 301) --------------------------------
|
|
|
- if (proj.DefaultBranchID != null)
|
|
|
- {
|
|
|
- Branch br = PXSelect<Branch,
|
|
|
- Where<Branch.branchID, Equal<Required<Branch.branchID>>>>
|
|
|
- .Select(Base, proj.DefaultBranchID);
|
|
|
+ int? cached;
|
|
|
+ if (_invIdCache.TryGetValue(inventoryCD, out cached))
|
|
|
+ return cached;
|
|
|
|
|
|
- if (br?.BranchCD != null && ExcludedBranchCDs.Contains(br.BranchCD.Trim()))
|
|
|
- return true;
|
|
|
- }
|
|
|
+ var id = InventoryItem.UK.Find(Base, inventoryCD)?.InventoryID;
|
|
|
+ _invIdCache[inventoryCD] = id;
|
|
|
+ return id;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void UpsertLaborLine(LaborRuleBase rule, decimal hours, PMRevenueBudget contextRow)
|
|
|
+ {
|
|
|
+ var cache = Base.RevenueBudget.Cache;
|
|
|
+ var laborLine = FindRevenueLineByInventoryCd(rule.InventoryCD);
|
|
|
|
|
|
- // Billing‑rule check (BillingRule = PROGRESS) ------------------
|
|
|
- // Field name differs by Acumatica build; common ones shown.
|
|
|
- string billRule = proj.BillingID; // e.g., 2021 R1+
|
|
|
+ if (laborLine != null)
|
|
|
+ {
|
|
|
+ bool qtyChanged = laborLine.Qty != hours;
|
|
|
+ if (qtyChanged)
|
|
|
+ cache.SetValueExt<PMRevenueBudget.qty>(laborLine, hours);
|
|
|
|
|
|
- if (!string.IsNullOrEmpty(billRule) &&
|
|
|
- string.Equals(billRule.Trim(), ExcludedBillRule,
|
|
|
- StringComparison.OrdinalIgnoreCase))
|
|
|
- return true;
|
|
|
+ // Rate logic (safe, minimal events):
|
|
|
+ if (rule.FixedUnitRate.HasValue)
|
|
|
+ {
|
|
|
+ // Explicit fixed rate supplied by the rule
|
|
|
+ decimal desiredRate = rule.FixedUnitRate.Value;
|
|
|
+ if (laborLine.CuryUnitRate != desiredRate)
|
|
|
+ cache.SetValueExt<PMRevenueBudget.curyUnitRate>(laborLine, desiredRate);
|
|
|
+ }
|
|
|
+ else if (qtyChanged)
|
|
|
+ {
|
|
|
+ // No explicit rate: re-apply pricing defaults when qty changed
|
|
|
+ cache.SetDefaultExt<PMRevenueBudget.curyUnitRate>(laborLine);
|
|
|
+ }
|
|
|
|
|
|
- return false;
|
|
|
+ cache.Update(laborLine);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // Creating a new line — pass rate if provided, and compute amount if we have both qty & rate
|
|
|
+ decimal? rate = rule.FixedUnitRate;
|
|
|
+ decimal? amount = rate.HasValue ? (decimal?)(hours * rate.Value) : null;
|
|
|
+
|
|
|
+ CreateRevenueBudgetLine(
|
|
|
+ inventoryID: null,
|
|
|
+ inventoryCD: rule.InventoryCD,
|
|
|
+ taskID: contextRow.ProjectTaskID,
|
|
|
+ accountGroupID: contextRow.AccountGroupID,
|
|
|
+ qty: hours,
|
|
|
+ rate: rate, // if null → pricing defaults
|
|
|
+ amount: amount, // if null → formula
|
|
|
+ uom: contextRow.UOM,
|
|
|
+ description: rule.Description
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-
|
|
|
- public static bool IsActive()
|
|
|
+ private string ResolveBranchCD(int? branchID)
|
|
|
{
|
|
|
- return true;
|
|
|
+ if (branchID == null) return null;
|
|
|
+ Branch br = PXSelect<Branch,
|
|
|
+ Where<Branch.branchID, Equal<Required<Branch.branchID>>>>
|
|
|
+ .Select(Base, branchID);
|
|
|
+ return br?.BranchCD;
|
|
|
}
|
|
|
|
|
|
-
|
|
|
- protected void _(Events.FieldUpdated<PMRevenueBudget, PMRevenueBudget.curyUnitRate> e)
|
|
|
+ private string ResolveTemplateCD(int? templateProjectID)
|
|
|
{
|
|
|
- var r = (PMRevenueBudget)e.Row;
|
|
|
- if (r == null) return;
|
|
|
- PXTrace.WriteInformation($"curyUnitRate UPDATED -> {r.CuryUnitRate} (Inv={r.InventoryID})");
|
|
|
+ if (templateProjectID == null) return null;
|
|
|
+ PMProject tmpl = PMProject.PK.Find(Base, templateProjectID);
|
|
|
+ return tmpl?.ContractCD;
|
|
|
}
|
|
|
|
|
|
+ private string ResolveBillRule(PMProject proj)
|
|
|
+ {
|
|
|
+ return proj?.BillingID;
|
|
|
+ }
|
|
|
|
|
|
private PMRevenueBudget FindRevenueLineByInventoryCd(string inventoryCD)
|
|
|
{
|
|
|
if (string.IsNullOrEmpty(inventoryCD))
|
|
|
return null;
|
|
|
|
|
|
- // Resolve InventoryID from the CD
|
|
|
InventoryItem inv = InventoryItem.UK.Find(Base, inventoryCD);
|
|
|
if (inv == null) return null;
|
|
|
|
|
|
int? projID = Base.Project.Current?.ContractID;
|
|
|
if (projID == null) return null;
|
|
|
|
|
|
- // 1) Cache (includes unsaved rows already on screen)
|
|
|
var cached = Base.RevenueBudget.Cache.Cached
|
|
|
.Cast<PMRevenueBudget>()
|
|
|
.FirstOrDefault(r => r.ProjectID == projID
|
|
|
@@ -205,7 +218,6 @@ namespace ProjectEntryLaborAdjust
|
|
|
&& r.Type == AccountType.Income);
|
|
|
if (cached != null) return cached;
|
|
|
|
|
|
- // 2) DB (readonly, excludes unsaved edits)
|
|
|
var db = PXSelectReadonly<PMRevenueBudget,
|
|
|
Where<PMRevenueBudget.projectID, Equal<Required<PMRevenueBudget.projectID>>,
|
|
|
And<PMRevenueBudget.inventoryID, Equal<Required<PMRevenueBudget.inventoryID>>,
|
|
|
@@ -223,13 +235,13 @@ namespace ProjectEntryLaborAdjust
|
|
|
int? accountGroupID = null, string accountGroupCD = null,
|
|
|
decimal? qty = null,
|
|
|
decimal? rate = null,
|
|
|
- decimal? amount = null, // if null, amount = qty * rate (when both provided)
|
|
|
+ decimal? amount = null, // if null, amount = qty * rate (when both provided) or by formula
|
|
|
string uom = null,
|
|
|
string description = null,
|
|
|
int? costCodeID = null // optional
|
|
|
- )
|
|
|
+ )
|
|
|
{
|
|
|
- // Resolve keys ----------------------------------------------------
|
|
|
+ // Resolve keys
|
|
|
int? projectID = Base.Project.Current?.ContractID;
|
|
|
if (projectID == null)
|
|
|
throw new PXException();
|
|
|
@@ -248,20 +260,19 @@ namespace ProjectEntryLaborAdjust
|
|
|
accountGroupID = PMAccountGroup.UK.Find(Base, accountGroupCD)?.GroupID;
|
|
|
|
|
|
if (qty == null) qty = 0m;
|
|
|
- if (rate == null) rate = 0m;
|
|
|
- if (amount == null) amount = qty * rate;
|
|
|
+ if (rate == null) rate = 0m; // safe default; logic below decides how to set rate
|
|
|
+ if (amount == null && qty.HasValue && rate.HasValue && rate.Value != 0m)
|
|
|
+ amount = qty * rate;
|
|
|
|
|
|
- // Insert skeleton row --------------------------------------------
|
|
|
var cache = Base.RevenueBudget.Cache;
|
|
|
var line = new PMRevenueBudget
|
|
|
{
|
|
|
ProjectID = projectID,
|
|
|
- Type = AccountType.Income // revenue
|
|
|
+ Type = AccountType.Income
|
|
|
};
|
|
|
|
|
|
line = (PMRevenueBudget)cache.Insert(line);
|
|
|
|
|
|
- // Set fields via SetValueExt so defaults/validations run ----------
|
|
|
if (taskID != null)
|
|
|
cache.SetValueExt<PMRevenueBudget.projectTaskID>(line, taskID);
|
|
|
|
|
|
@@ -283,41 +294,26 @@ namespace ProjectEntryLaborAdjust
|
|
|
if (!string.IsNullOrEmpty(description))
|
|
|
cache.SetValueExt<PMRevenueBudget.description>(line, description);
|
|
|
|
|
|
- cache.SetDefaultExt<PMRevenueBudget.curyUnitRate>(line);
|
|
|
+ // Unit rate + amount logic mirrors the single-code pattern
|
|
|
+ if (rate.HasValue && rate.Value != 0m)
|
|
|
+ {
|
|
|
+ cache.SetValueExt<PMRevenueBudget.curyUnitRate>(line, rate.Value);
|
|
|
+ if (amount.HasValue)
|
|
|
+ cache.SetValueExt<PMRevenueBudget.curyAmount>(line, amount.Value);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // No explicit rate: let AR Sales Price default
|
|
|
+ cache.SetDefaultExt<PMRevenueBudget.curyUnitRate>(line);
|
|
|
+ }
|
|
|
|
|
|
- // Final update so cache/state is consistent
|
|
|
line = (PMRevenueBudget)cache.Update(line);
|
|
|
|
|
|
-
|
|
|
-
|
|
|
return line;
|
|
|
}
|
|
|
|
|
|
-
|
|
|
- private decimal GetPMHours(decimal jobSize)
|
|
|
- {
|
|
|
- if (jobSize < 0m) return 0m; // or throw
|
|
|
-
|
|
|
- switch (jobSize)
|
|
|
- {
|
|
|
- case var n when n <= 615m: return 0.5m;
|
|
|
- case var n when n <= 1000m: return 1.0m;
|
|
|
- case var n when n <= 2500m: return 1.5m;
|
|
|
- case var n when n <= 5000m: return 3.0m;
|
|
|
- case var n when n <= 7500m: return 3.5m;
|
|
|
- case var n when n <= 10000m: return 5.0m;
|
|
|
- case var n when n <= 25000m: return 6.0m;
|
|
|
- case var n when n <= 50000m: return 7.0m;
|
|
|
- default: return 10.5m;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
private decimal GetRevisedRevenueTotalUI(params string[] excludeCDs)
|
|
|
{
|
|
|
- // Resolve the IDs to skip
|
|
|
var skip = new HashSet<int?>(
|
|
|
(excludeCDs ?? new string[0])
|
|
|
.Select(cd => InventoryItem.UK.Find(Base, cd)?.InventoryID)
|
|
|
@@ -327,22 +323,18 @@ namespace ProjectEntryLaborAdjust
|
|
|
|
|
|
decimal total = 0m;
|
|
|
|
|
|
- // Select() returns rows currently in cache + remaining DB rows for the view
|
|
|
foreach (PMRevenueBudget line in Base.RevenueBudget.Select().RowCast<PMRevenueBudget>())
|
|
|
{
|
|
|
if (line.Type == AccountType.Income && !skip.Contains(line.InventoryID))
|
|
|
- total += line.CuryRevisedAmount ?? 0m; // or CuryAmount if you want "original"
|
|
|
+ total += line.CuryRevisedAmount ?? 0m;
|
|
|
}
|
|
|
|
|
|
return total;
|
|
|
}
|
|
|
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
+ public static bool IsActive()
|
|
|
+ {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
}
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
}
|