using PX.Common; using PX.Data; using PX.Objects.GL; using PX.Objects.IN; using PX.Objects.PM; using System; using System.Collections.Generic; using System.Linq; namespace ProjectEntryLaborAdjust { // ===================== GRAPH EXTENSION (RULE-DRIVEN) ===================== public class ProjectEntryExt : PXGraphExtension { private const string SlotLaborUpdate = "RevBudSetLabor"; // List of rules to process — add new classes here in Initialize() private readonly List _rules = new List(); // Cache InventoryID lookups for rule CDs private readonly Dictionary _invIdCache = new Dictionary(StringComparer.OrdinalIgnoreCase); protected bool Skip => Base.IsImport || Base.IsContractBasedAPI || Base.IsCopyPasteContext; public override void Initialize() { 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 e) { var row = (PMRevenueBudget)e.Row; var old = (PMRevenueBudget)e.OldRow; if (row == null || old == null || Skip) return; if (PXContext.GetSlot(SlotLaborUpdate) == true) return; // Only react when Original Budgeted Amount changed if (e.Cache.ObjectsEqual(row, old)) return; // Build once string[] laborCDs = _rules.Select(r => r.InventoryCD).ToArray(); int?[] laborIDs = laborCDs.Select(GetInventoryIdCached).ToArray(); // If editing any labor line, skip to avoid recursion if (row.InventoryID != null && laborIDs.Contains(row.InventoryID)) return; // Exclude ALL labor lines from the job-size total decimal totalRevised = GetRevisedRevenueTotalUI(laborCDs); // 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); DateTime? projectCreatedDate = proj?.CreatedDateTime; PXContext.SetSlot(SlotLaborUpdate, true); try { foreach (var rule in _rules) { // Per-rule exclusion check if (rule.IsExcludedFor(branchCD, templateCD, billRule, projectCreatedDate)) { continue; } decimal hours = rule.GetHours(totalRevised, projectCreatedDate); ; UpsertLaborLine(rule, hours, row); } } finally { PXContext.SetSlot(SlotLaborUpdate, false); } } // Re-assert fixed rates right before save protected void _(Events.RowPersisting e) { var row = (PMRevenueBudget)e.Row; if (row == null || e.Operation == PXDBOperation.Delete) return; if (Skip || PXContext.GetSlot(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; 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(row, desiredRate); break; // at most one match } } } protected void _(Events.FieldUpdated 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; int? cached; if (_invIdCache.TryGetValue(inventoryCD, out cached)) return cached; 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); if (laborLine != null) { bool qtyChanged = laborLine.Qty != hours; if (qtyChanged) cache.SetValueExt(laborLine, hours); // 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(laborLine, desiredRate); } else if (qtyChanged) { // No explicit rate: re-apply pricing defaults when qty changed cache.SetDefaultExt(laborLine); } 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 ); } } private string ResolveBranchCD(int? branchID) { if (branchID == null) return null; Branch br = PXSelect>>> .Select(Base, branchID); return br?.BranchCD; } private string ResolveTemplateCD(int? templateProjectID) { 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; InventoryItem inv = InventoryItem.UK.Find(Base, inventoryCD); if (inv == null) return null; int? projID = Base.Project.Current?.ContractID; if (projID == null) return null; var cached = Base.RevenueBudget.Cache.Cached .Cast() .FirstOrDefault(r => r.ProjectID == projID && r.InventoryID == inv.InventoryID && r.Type == AccountType.Income); if (cached != null) return cached; var db = PXSelectReadonly>, And>, And>>>> .Select(Base, projID, inv.InventoryID) .RowCast() .FirstOrDefault(); return db; } public PMRevenueBudget CreateRevenueBudgetLine( int? inventoryID = null, string inventoryCD = null, int? taskID = null, string taskCD = null, int? accountGroupID = null, string accountGroupCD = null, decimal? qty = null, decimal? rate = null, 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 int? projectID = Base.Project.Current?.ContractID; if (projectID == null) throw new PXException(); if (inventoryID == null && !string.IsNullOrEmpty(inventoryCD)) inventoryID = InventoryItem.UK.Find(Base, inventoryCD)?.InventoryID; if (taskID == null && !string.IsNullOrEmpty(taskCD)) taskID = PXSelect>, And>>>> .Select(Base, projectID, taskCD) ?.FirstOrDefault()?.GetItem()?.TaskID; if (accountGroupID == null && !string.IsNullOrEmpty(accountGroupCD)) accountGroupID = PMAccountGroup.UK.Find(Base, accountGroupCD)?.GroupID; if (qty == null) qty = 0m; 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; var cache = Base.RevenueBudget.Cache; var line = new PMRevenueBudget { ProjectID = projectID, Type = AccountType.Income }; line = (PMRevenueBudget)cache.Insert(line); if (taskID != null) cache.SetValueExt(line, taskID); if (accountGroupID != null) cache.SetValueExt(line, accountGroupID); if (inventoryID != null) cache.SetValueExt(line, inventoryID); if (costCodeID != null) cache.SetValueExt(line, costCodeID); if (!string.IsNullOrEmpty(uom)) cache.SetValueExt(line, uom); if (qty != null) cache.SetValueExt(line, qty); if (!string.IsNullOrEmpty(description)) cache.SetValueExt(line, description); // Unit rate + amount logic mirrors the single-code pattern if (rate.HasValue && rate.Value != 0m) { cache.SetValueExt(line, rate.Value); if (amount.HasValue) cache.SetValueExt(line, amount.Value); } else { // No explicit rate: let AR Sales Price default cache.SetDefaultExt(line); } line = (PMRevenueBudget)cache.Update(line); return line; } private decimal GetRevisedRevenueTotalUI(params string[] excludeCDs) { var skip = new HashSet( (excludeCDs ?? new string[0]) .Select(cd => InventoryItem.UK.Find(Base, cd)?.InventoryID) .Where(id => id != null) .Cast() ); decimal total = 0m; foreach (PMRevenueBudget line in Base.RevenueBudget.Select().RowCast()) { if (line.Type == AccountType.Income && !skip.Contains(line.InventoryID)) total += line.CuryRevisedAmount ?? 0m; } return total; } public static bool IsActive() { return true; } } }