瀏覽代碼

Updated Project

Multiple updates.  removed codes, implemented date pivot.
kparker 5 月之前
父節點
當前提交
a23bd7f180
共有 3 個文件被更改,包括 471 次插入516 次删除
  1. 62 69
      LaborRuleBase.cs
  2. 67 106
      LaborRules.cs
  3. 342 341
      ProjectEntryExt.cs

+ 62 - 69
LaborRuleBase.cs

@@ -1,69 +1,62 @@
-using System;
-using System.Collections.Generic;
-
-namespace ProjectEntryLaborAdjust
-{
-    public abstract class LaborRuleBase
-    {
-        public string InventoryCD { get; }
-        public string Description { get; protected set; }
-
-        public HashSet<string> ExcludedBranchCDs { get; }
-        public HashSet<string> ExcludedTemplateCDs { get; }
-        public HashSet<string> ExcludedBillRules { get; }
-
-        /// <summary>
-        /// Optional project creation cutoff date. If set, any project whose CreatedDateTime
-        /// is strictly before this date will be excluded (rule will be skipped).
-        /// Date part is compared (time-of-day ignored).
-        /// </summary>
-        public DateTime? ProjectDate { get; set; }
-
-
-        /// <summary>
-        /// Optional fixed unit rate. If null, the AR pricing engine (defaults) should apply.
-        /// </summary>
-        public virtual decimal? FixedUnitRate
-        {
-            get { return null; }
-        }
-
-        protected LaborRuleBase(string inventoryCD, string description = null)
-        {
-            if (string.IsNullOrWhiteSpace(inventoryCD))
-                throw new ArgumentException("inventoryCD");
-
-            InventoryCD = inventoryCD.Trim();
-            Description = string.IsNullOrWhiteSpace(description)
-                ? "Auto-added Labor (" + InventoryCD + ")"
-                : description.Trim();
-
-            ExcludedBranchCDs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
-            ExcludedTemplateCDs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
-            ExcludedBillRules = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
-        }
-
-        /// <summary>Compute the quantity (hours) for this labor item from the job size.</summary>
-        public abstract decimal GetHours(decimal jobSize);
-
-        /// <summary>Check if this rule should be skipped under the provided context.</summary>
-        public virtual bool IsExcludedFor(string branchCD, string templateCD, string billRule, DateTime? projectCreatedDate)
-        {
-            if (!string.IsNullOrWhiteSpace(branchCD) && ExcludedBranchCDs.Contains(branchCD.Trim())) return true;
-            if (!string.IsNullOrWhiteSpace(templateCD) && ExcludedTemplateCDs.Contains(templateCD.Trim())) return true;
-            if (!string.IsNullOrWhiteSpace(billRule) && ExcludedBillRules.Contains(billRule.Trim())) return true;
-
-            if (ProjectDate.HasValue && projectCreatedDate.HasValue)
-            {
-                // Compare by date part only
-                if (projectCreatedDate.Value.Date < ProjectDate.Value.Date)
-                    return true;
-            }
-
-            return false;
-        }
-    }
-
-
-
-}
+using System;
+using System.Collections.Generic;
+
+namespace ProjectEntryLaborAdjust
+{
+    public abstract class LaborRuleBase
+    {
+        public string InventoryCD { get; }
+        public string Description { get; protected set; }
+
+        public HashSet<string> ExcludedBranchCDs { get; }
+        public HashSet<string> ExcludedTemplateCDs { get; }
+        public HashSet<string> ExcludedBillRules { get; }
+
+        /// <summary>
+        /// Optional project creation cutoff date. If set, any project whose CreatedDateTime
+        /// is strictly before this date will be excluded (rule will be skipped).
+        /// Date part is compared (time-of-day ignored).
+        /// </summary>
+        public DateTime? ProjectDate { get; set; }
+
+
+        /// <summary>
+        /// Optional fixed unit rate. If null, the AR pricing engine (defaults) should apply.
+        /// </summary>
+        public virtual decimal? FixedUnitRate
+        {
+            get { return null; }
+        }
+
+        protected LaborRuleBase(string inventoryCD, string description = null)
+        {
+            if (string.IsNullOrWhiteSpace(inventoryCD))
+                throw new ArgumentException("inventoryCD");
+
+            InventoryCD = inventoryCD.Trim();
+            Description = string.IsNullOrWhiteSpace(description)
+                ? "Auto-added Labor (" + InventoryCD + ")"
+                : description.Trim();
+
+            ExcludedBranchCDs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+            ExcludedTemplateCDs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+            ExcludedBillRules = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        }
+
+        /// <summary>Compute the quantity (hours) for this labor item from the job size.</summary>
+        public abstract decimal GetHours(decimal jobSize, DateTime? projectCreatedDate);
+
+        /// <summary>Check if this rule should be skipped under the provided context.</summary>
+        public virtual bool IsExcludedFor(string branchCD, string templateCD, string billRule, DateTime? projectCreatedDate)
+        {
+            if (!string.IsNullOrWhiteSpace(branchCD) && ExcludedBranchCDs.Contains(branchCD.Trim())) return true;
+            if (!string.IsNullOrWhiteSpace(templateCD) && ExcludedTemplateCDs.Contains(templateCD.Trim())) return true;
+            if (!string.IsNullOrWhiteSpace(billRule) && ExcludedBillRules.Contains(billRule.Trim())) return true;
+
+            return false;
+        }
+    }
+
+
+
+}

+ 67 - 106
LaborRules.cs

@@ -1,106 +1,67 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace ProjectEntryLaborAdjust
-{
-    // ===================== RULE (ALL SETTINGS LIVE HERE) =====================
-    public sealed class L023PmLaborRule : LaborRuleBase
-    {
-        public L023PmLaborRule() : base("L023", "Auto-added PM Labor")
-        {
-            ExcludedBranchCDs.Add("301");
-            ExcludedBranchCDs.Add("112");
-            ExcludedTemplateCDs.Add("ENVIRONMENTAL");
-            ExcludedBillRules.Add("PROGRESS");
-            ProjectDate = new DateTime(2025, 9, 02);
-        }
-
-        // Leave null => use AR Sales Prices (no fixed rate)
-        public override decimal? FixedUnitRate
-        {
-            get { return null; }
-        }
-
-        public override decimal GetHours(decimal jobSize)
-        {
-            if (jobSize < 0m) return 0m;
-
-            if (jobSize <= 615m) return 0.5m;
-            if (jobSize <= 1000m) return 1.0m;
-            if (jobSize <= 2500m) return 1.5m;
-            if (jobSize <= 5000m) return 3.0m;
-            if (jobSize <= 7500m) return 3.5m;
-            if (jobSize <= 10000m) return 5.0m;
-            if (jobSize <= 25000m) return 6.0m;
-            if (jobSize <= 50000m) return 7.0m;
-            return 10.5m;
-        }
-
-
-
-
-    }
-
-    // second rule that DOES supply a fixed rate ->DROP this code, implement updated table in L001 Bullet point one.  Currently Inactive and removed for processesing per Mario.
-    public sealed class L014LaborRule : LaborRuleBase
-    {
-        public L014LaborRule() : base("L014", "Auto-added Coordinator Labor")
-        {
-            ExcludedBranchCDs.Add("303");
-            ExcludedBranchCDs.Add("112");
-            ExcludedBillRules.Add("PROGRESS");
-            ExcludedTemplateCDs.Add("ENVIRONMENTAL");
-        }
-
-        public override decimal? FixedUnitRate
-        {
-            get { return 40m; } // supply a fixed unit rate
-        }
-
-        public override decimal GetHours(decimal jobSize)
-        {
-            if (jobSize <= 1000m) return 0m;
-            if (jobSize <= 2500m) return .5m;
-            if (jobSize <= 7500m) return 1m;
-            if (jobSize <= 25000m) return 1.5m;
-
-            return 1m;
-        }
-    }
-
-    public sealed class L001LaborRule : LaborRuleBase    //Date exclusion only this code. drop this code, only one code in production
-    {
-        public L001LaborRule() : base("L001", "Auto-added Admin Labor")
-        {
-            ExcludedBranchCDs.Add("301");
-            //ExcludedBranchCDs.Add("112");
-            ExcludedBillRules.Add("PROGRESS");
-            ExcludedTemplateCDs.Add("RENTAL");
-            // Exclude projects created before 8/24 of the current year
-            
-            ProjectDate = new DateTime(2025, 8, 24);
-        }
-        public override decimal? FixedUnitRate
-        {
-            get { return 40m; } // supply a fixed unit rate
-        }
-
-        public override decimal GetHours(decimal jobSize)
-        {
-            if (jobSize <= 616m) return 0m;   // $0–$616
-            if (jobSize <= 1000m) return 1m;   // $617–$1,000
-            if (jobSize <= 2500m) return 2m;   // $1,001–$2,500
-            if (jobSize <= 7500m) return 2.5m; // $2,501–$7,500
-            if (jobSize <= 10000m) return 3m;   // $7,501–$10,000
-            if (jobSize <= 25000m) return 4.5m; // $10,001–$25,000
-            if (jobSize <= 50000m) return 4m;   // $25,001–$50,000
-            return 4m;                           // $50,001+
-        }
-    }
-
-
-
-}
+using System;
+
+
+namespace ProjectEntryLaborAdjust
+{
+    // ===================== RULE (ALL SETTINGS LIVE HERE) =====================
+    public sealed class L023PmLaborRule : LaborRuleBase
+    {
+        public L023PmLaborRule() : base("L023", "Auto-added PM Labor")
+        {
+            ExcludedBranchCDs.Add("301");
+            ExcludedBranchCDs.Add("112");
+            ExcludedTemplateCDs.Add("ENVIRONMENTAL");
+            ExcludedBillRules.Add("PROGRESS");
+            ProjectDate = new DateTime(2025, 9, 5); // rule applies only to projects created on/after this date
+        }
+
+        // Leave null => use AR Sales Prices (no fixed rate)
+        public override decimal? FixedUnitRate
+        {
+            get { return null; }
+        }
+
+        public override decimal GetHours(decimal jobSize, DateTime? projectCreatedDate)
+        {
+            if (jobSize < 0m) return 0m;
+
+            // Decide matrix by date (unknown => new matrix)
+            var pivot = ProjectDate?.Date;
+            var created = projectCreatedDate?.Date;
+            bool useOldMatrix = pivot.HasValue && created.HasValue && created.Value < pivot.Value;
+
+            if (useOldMatrix)
+            {
+                // ---- OLD MATRIX ----
+                if (jobSize <= 615m) return 0.5m;
+                if (jobSize <= 1000m) return 1.0m;
+                if (jobSize <= 2500m) return 1.5m;
+                if (jobSize <= 5000m) return 3.0m;
+                if (jobSize <= 7500m) return 3.5m;
+                if (jobSize <= 10000m) return 5.0m;
+                if (jobSize <= 25000m) return 6.0m;
+                if (jobSize <= 50000m) return 7.0m;
+                return 10.5m;
+            }
+
+            // ---- NEW MATRIX ---- 
+            if (jobSize <= 616m) return 0.5m;
+            if (jobSize <= 1000m) return 1.0m;
+            if (jobSize <= 2500m) return 2.5m;
+            if (jobSize <= 5000m) return 4.5m;
+            if (jobSize <= 7500m) return 5.0m;
+            if (jobSize <= 10000m) return 7.0m;
+            if (jobSize <= 25000m) return 8.5m;
+            if (jobSize <= 50000m) return 9.5m;
+            return 15.0m;
+        }
+    }
+}
+
+    
+
+
+
+
+
+

+ 342 - 341
ProjectEntryExt.cs

@@ -1,341 +1,342 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using PX.Common;
-using PX.Data;
-using PX.Objects.GL;
-using PX.Objects.IN;
-using PX.Objects.PM;
-
-namespace ProjectEntryLaborAdjust
-{
-    // ===================== GRAPH EXTENSION (RULE-DRIVEN) =====================
-    public class ProjectEntryExt : PXGraphExtension<ProjectEntry>
-    {
-        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;
-
-        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<PMRevenueBudget> e)
-        {
-            var row = (PMRevenueBudget)e.Row;
-            var old = (PMRevenueBudget)e.OldRow;
-            if (row == null || old == null || Skip) return;
-
-            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();
-
-            // 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<bool?>(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);
-                    UpsertLaborLine(rule, hours, row);
-                }
-            }
-            finally
-            {
-                PXContext.SetSlot<bool?>(SlotLaborUpdate, false);
-            }
-        }
-
-        // Re-assert fixed rates right before save (mirrors the single-code pattern you showed)
-        protected void _(Events.RowPersisting<PMRevenueBudget> e)
-        {
-            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;
-
-                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);
-
-                    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;
-
-            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<PMRevenueBudget.qty>(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<PMRevenueBudget.curyUnitRate>(laborLine, desiredRate);
-                }
-                else if (qtyChanged)
-                {
-                    // No explicit rate: re-apply pricing defaults when qty changed
-                    cache.SetDefaultExt<PMRevenueBudget.curyUnitRate>(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<Branch,
-                            Where<Branch.branchID, Equal<Required<Branch.branchID>>>>
-                        .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<PMRevenueBudget>()
-                            .FirstOrDefault(r => r.ProjectID == projID
-                                              && r.InventoryID == inv.InventoryID
-                                              && r.Type == AccountType.Income);
-            if (cached != null) return cached;
-
-            var db = PXSelectReadonly<PMRevenueBudget,
-                        Where<PMRevenueBudget.projectID, Equal<Required<PMRevenueBudget.projectID>>,
-                          And<PMRevenueBudget.inventoryID, Equal<Required<PMRevenueBudget.inventoryID>>,
-                          And<PMRevenueBudget.type, Equal<AccountType.income>>>>>
-                     .Select(Base, projID, inv.InventoryID)
-                     .RowCast<PMRevenueBudget>()
-                     .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<PMTask,
-                            Where<PMTask.projectID, Equal<Required<PMTask.projectID>>,
-                              And<PMTask.taskCD, Equal<Required<PMTask.taskCD>>>>>
-                         .Select(Base, projectID, taskCD)
-                         ?.FirstOrDefault()?.GetItem<PMTask>()?.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<PMRevenueBudget.projectTaskID>(line, taskID);
-
-            if (accountGroupID != null)
-                cache.SetValueExt<PMRevenueBudget.accountGroupID>(line, accountGroupID);
-
-            if (inventoryID != null)
-                cache.SetValueExt<PMRevenueBudget.inventoryID>(line, inventoryID);
-
-            if (costCodeID != null)
-                cache.SetValueExt<PMRevenueBudget.costCodeID>(line, costCodeID);
-
-            if (!string.IsNullOrEmpty(uom))
-                cache.SetValueExt<PMRevenueBudget.uOM>(line, uom);
-
-            if (qty != null)
-                cache.SetValueExt<PMRevenueBudget.qty>(line, qty);
-
-            if (!string.IsNullOrEmpty(description))
-                cache.SetValueExt<PMRevenueBudget.description>(line, description);
-
-            // 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);
-            }
-
-            line = (PMRevenueBudget)cache.Update(line);
-
-            return line;
-        }
-
-        private decimal GetRevisedRevenueTotalUI(params string[] excludeCDs)
-        {
-            var skip = new HashSet<int?>(
-                (excludeCDs ?? new string[0])
-                .Select(cd => InventoryItem.UK.Find(Base, cd)?.InventoryID)
-                .Where(id => id != null)
-                .Cast<int?>()
-            );
-
-            decimal total = 0m;
-
-            foreach (PMRevenueBudget line in Base.RevenueBudget.Select().RowCast<PMRevenueBudget>())
-            {
-                if (line.Type == AccountType.Income && !skip.Contains(line.InventoryID))
-                    total += line.CuryRevisedAmount ?? 0m;
-            }
-
-            return total;
-        }
-
-        public static bool IsActive()
-        {
-            return true;
-        }
-    }
-}
+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<ProjectEntry>
+    {
+        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;
+
+        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<PMRevenueBudget> e)
+        {
+            var row = (PMRevenueBudget)e.Row;
+            var old = (PMRevenueBudget)e.OldRow;
+            if (row == null || old == null || Skip) return;
+
+            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();
+
+            // 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<bool?>(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<bool?>(SlotLaborUpdate, false);
+            }
+        }
+
+        // Re-assert fixed rates right before save (mirrors the single-code pattern you showed)
+        protected void _(Events.RowPersisting<PMRevenueBudget> e)
+        {
+            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;
+
+                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);
+
+                    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;
+
+            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<PMRevenueBudget.qty>(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<PMRevenueBudget.curyUnitRate>(laborLine, desiredRate);
+                }
+                else if (qtyChanged)
+                {
+                    // No explicit rate: re-apply pricing defaults when qty changed
+                    cache.SetDefaultExt<PMRevenueBudget.curyUnitRate>(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<Branch,
+                            Where<Branch.branchID, Equal<Required<Branch.branchID>>>>
+                        .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<PMRevenueBudget>()
+                            .FirstOrDefault(r => r.ProjectID == projID
+                                              && r.InventoryID == inv.InventoryID
+                                              && r.Type == AccountType.Income);
+            if (cached != null) return cached;
+
+            var db = PXSelectReadonly<PMRevenueBudget,
+                        Where<PMRevenueBudget.projectID, Equal<Required<PMRevenueBudget.projectID>>,
+                          And<PMRevenueBudget.inventoryID, Equal<Required<PMRevenueBudget.inventoryID>>,
+                          And<PMRevenueBudget.type, Equal<AccountType.income>>>>>
+                     .Select(Base, projID, inv.InventoryID)
+                     .RowCast<PMRevenueBudget>()
+                     .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<PMTask,
+                            Where<PMTask.projectID, Equal<Required<PMTask.projectID>>,
+                              And<PMTask.taskCD, Equal<Required<PMTask.taskCD>>>>>
+                         .Select(Base, projectID, taskCD)
+                         ?.FirstOrDefault()?.GetItem<PMTask>()?.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<PMRevenueBudget.projectTaskID>(line, taskID);
+
+            if (accountGroupID != null)
+                cache.SetValueExt<PMRevenueBudget.accountGroupID>(line, accountGroupID);
+
+            if (inventoryID != null)
+                cache.SetValueExt<PMRevenueBudget.inventoryID>(line, inventoryID);
+
+            if (costCodeID != null)
+                cache.SetValueExt<PMRevenueBudget.costCodeID>(line, costCodeID);
+
+            if (!string.IsNullOrEmpty(uom))
+                cache.SetValueExt<PMRevenueBudget.uOM>(line, uom);
+
+            if (qty != null)
+                cache.SetValueExt<PMRevenueBudget.qty>(line, qty);
+
+            if (!string.IsNullOrEmpty(description))
+                cache.SetValueExt<PMRevenueBudget.description>(line, description);
+
+            // 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);
+            }
+
+            line = (PMRevenueBudget)cache.Update(line);
+
+            return line;
+        }
+
+        private decimal GetRevisedRevenueTotalUI(params string[] excludeCDs)
+        {
+            var skip = new HashSet<int?>(
+                (excludeCDs ?? new string[0])
+                .Select(cd => InventoryItem.UK.Find(Base, cd)?.InventoryID)
+                .Where(id => id != null)
+                .Cast<int?>()
+            );
+
+            decimal total = 0m;
+
+            foreach (PMRevenueBudget line in Base.RevenueBudget.Select().RowCast<PMRevenueBudget>())
+            {
+                if (line.Type == AccountType.Income && !skip.Contains(line.InventoryID))
+                    total += line.CuryRevisedAmount ?? 0m;
+            }
+
+            return total;
+        }
+
+        public static bool IsActive()
+        {
+            return true;
+        }
+
+    }
+}