Explorar el Código

Improvements to invoice calculations in accordance with discussion with Frank; obsoleted Product.Charge and added Product.DefaultMarkup and ProductInstance.Charge

Kenric Nugteren hace 3 semanas
padre
commit
938bd9303f

+ 1 - 1
prs.classes/Entities/Customer/CustomerActivity.cs

@@ -36,7 +36,7 @@ namespace Comal.Classes
         protected override void Configure()
         {
             AddTable<CustomerActivity>();
-            AddTable<Assignment>()
+            AddTable<Activity>()
                 .AddConstant(x => x.Customer.ID, Guid.Empty)
                 .AddConstant(x=>x.Activity.ID, new Column<Activity>(x=>x.ID));
         }

+ 4 - 32
prs.classes/Entities/Customer/CustomerProduct.cs

@@ -3,12 +3,11 @@ using InABox.Core;
 
 namespace Comal.Classes
 {
-    
     public interface ICustomerProduct
     {
         CustomerLink Customer { get; set; }
         ProductLink Product { get; set; }
-        ProductCharge Charge { get; set; }
+        double Discount { get; set; }
     }
     
     [UserTracking(typeof(Product))]
@@ -28,38 +27,11 @@ namespace Comal.Classes
         [EditorSequence(2)]
         public ProductLink Product { get; set; }
 
-        [ProductChargeEditor]
         [EditorSequence(3)]
-        public ProductCharge Charge { get; set; }
-    }
-    
-    public class CustomerProductUnionGenerator : AutoEntityUnionGenerator<ICustomerProduct>
-    {
-        protected override void Configure()
-        {
-            AddTable<CustomerProduct>();
-            AddTable<Product>()
-                .AddConstant(x => x.Customer.ID, Guid.Empty)
-                .AddConstant(x=>x.Product.ID, new Column<Product>(x=>x.ID));
-        }
-
-        public override bool Distinct => false;
-
-        public override Column<ICustomerProduct>[] IDColumns => new Column<ICustomerProduct>[]
-        {
-            new Column<ICustomerProduct>(x => x.Customer.ID),
-            new Column<ICustomerProduct>(x => x.Product.ID)
-        };
-    }
-
-    [AutoEntity(typeof(CustomerProductUnionGenerator))]
-    public class CustomerProductSummary : Entity, IRemotable, IPersistent, ICustomerProduct, ILicense<CoreLicense>
-    {
-        public CustomerLink Customer { get; set; }
-
-        public ProductLink Product { get; set; }
+        [Comment("Discount for this product, given as a percentage.")]
+        public double Discount { get; set; }
 
+        [Obsolete("Replaced by Discount")]
         public ProductCharge Charge { get; set; }
     }
-    
 }

+ 1 - 2
prs.classes/Entities/Job/Requisitions/JobRequisitionItem.cs

@@ -423,8 +423,7 @@ namespace Comal.Classes
         [DoubleEditor(Editable = Editable.Disabled)]
         [EditorSequence(17)]
         public double Issued { get; set; }
-        
-                
+
         private class TotalIssuedFormula : ComplexFormulaGenerator<JobRequisitionItem, double>
         {
             public override IComplexFormulaNode<JobRequisitionItem, double> GetFormula() =>

+ 82 - 1
prs.classes/Entities/PickingList/PickingListItem.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Linq.Expressions;
 using InABox.Core;
@@ -145,7 +146,7 @@ namespace Comal.Classes
         {
             LinkedProperties.Register<PickingListItem, ProductLink, String>(x => x.Product, x => x.Code, x => x.Code);
             LinkedProperties.Register<PickingListItem, ProductLink, String>(x => x.Product, x => x.Name, x => x.Description);
-            LinkedProperties.Register<PickingListItem, ProductLink, bool>(x => x.Product, x => x.Charge.Chargeable, x => x.Charge.Chargeable);
+            LinkedProperties.Register<PickingListItem, ProductLink, bool>(x => x.Product, x => x.Chargeable, x => x.Charge.Chargeable);
             LinkedProperties.Register<PickingListItem, ProductStyleLink, Guid>(x=>x.Product.DefaultInstance.Style, x => x.ID, x => x.Style.ID);
             LinkedProperties.Register<PickingListItem, StockLocationLink, Guid>(x=>x.Product.DefaultLocation, x => x.ID, x => x.Location.ID);
             StockEntity.LinkStockDimensions<PickingListItem>();
@@ -154,5 +155,85 @@ namespace Comal.Classes
             LinkedProperties.Register<PickingListItem, JobScopeLink, Guid>(x=>x.PickingList.JobScope, x => x.ID, x => x.JobScope.ID);
             Classes.JobScope.LinkScopeProperties<PickingListItem>();
         }
+
+        public class PickingListItemChargeData
+        {
+            public Guid[] ProductIDs { get; set; } = Array.Empty<Guid>();
+            
+            public ProductInstance[] Instances { get; set; } = Array.Empty<ProductInstance>();
+        }
+        
+        public static void UpdateCharge(IEnumerable<PickingListItem> items, Dictionary<string, object?> changes, PickingListItemChargeData? data = null)
+        {
+            
+            void UpdateValue<TType>(PickingListItem item, Expression<Func<PickingListItem, TType>> property, TType value)
+            {
+                CoreUtils.MonitorChanges(
+                    item, 
+                    () => CoreUtils.SetPropertyValue(item, CoreUtils.GetFullPropertyName(property, "."), value),
+                    changes
+                );
+            }
+
+            var _items = items.AsArray();
+            var productids = _items.Where(x => x.Product.ID != Guid.Empty).Select(x => x.Product.ID).ToArray();
+            
+            if(productids.Length == 0)
+                return;
+            
+            var jobids = _items.Where(x => x.Product.ID != Guid.Empty).Select(x => x.Job.ID).ToList().Append(Guid.Empty).Distinct().ToArray();
+            
+            var needProducts = data is null || productids.Any(x => !data.ProductIDs.Contains(x));
+            
+            ProductInstance[] productInstances;
+            
+            if(needProducts)
+            {
+                var query = new MultiQuery();
+
+                if(needProducts)
+                {
+                    query.Add(
+                        Filter<ProductInstance>.Where(x =>x.Product.ID).InList(productids),
+                        Columns.None<ProductInstance>().Add(x=>x.Product.ID)
+                            .Add(x => x.Style.ID)
+                            .AddDimensionsColumns(x => x.Dimensions, Classes.Dimensions.ColumnsType.Local)
+                            .Add(x => x.Charge.Chargeable)
+                    );
+                }
+                
+                query.Query();
+
+                productInstances = needProducts
+                    ? query.Get<ProductInstance>().ToArray<ProductInstance>()
+                    : data!.Instances;
+
+                if(data != null)
+                {
+                    if (needProducts)
+                    {
+                        data.ProductIDs = productids;
+                        data.Instances = productInstances;
+                    }
+                }
+            }
+            else
+            {
+                productInstances = data!.Instances;
+            }
+
+            foreach (var item in items)
+            {
+                // Check Specific Product Instance
+                var productInstance = productInstances.FirstOrDefault(x =>
+                    x.Product.ID == item.Product.ID
+                    && x.Style.ID == item.Style.ID
+                    && x.Dimensions.Equals(item.Dimensions));
+                if (productInstance != null)
+                    UpdateValue<bool>(item, x => x.Charge.Chargeable, productInstance.Charge.Chargeable);
+                else
+                    UpdateValue<bool>(item, x => x.Charge.Chargeable, item.Product.Chargeable);
+            }
+        }
     }
 }

+ 3 - 1
prs.classes/Entities/Product/Instance/ProductInstance.cs

@@ -103,11 +103,13 @@ namespace Comal.Classes
         [EditorSequence(10)]
         [LoggableProperty]
         public double LastCost { get; set; }
+        
+        [ProductChargeEditor]
+        public ProductCharge Charge { get; set; }
 
         [NullEditor]
         [Obsolete("", true)]
         public double Parameter { get; set; }
-        
     }
     
     

+ 10 - 2
prs.classes/Entities/Product/Product.cs

@@ -139,9 +139,17 @@ namespace Comal.Classes
         [EditorSequence("Pricing", 13)]
         public CostSheetSectionLink CostSheetSection { get; set; }
         
-        [ProductChargeEditor]
-        [EditorSequence("Pricing", 14)]
+        [NullEditor]
+        [Obsolete("Replaced with ProductInstance.Charge")]
         public ProductCharge Charge { get; set; }
+
+        [EditorSequence("Pricing", 14)]
+        public bool Chargeable { get; set; }
+
+        [EditorSequence("Pricing", 15)]
+        [Comment("Default markup (%) when invoicing stock movements.")]
+        [DoubleEditor(ToolTip = "Default markup (%) when invoicing stock movements. Can be overriden by product instances.")]
+        public double DefaultMarkUp { get; set; }
         
         /// <summary>
         ///     Flag to indicate whether stock movements are to be tracked for this item.

+ 5 - 0
prs.classes/Entities/Product/ProductLink.cs

@@ -88,8 +88,13 @@ namespace Comal.Classes
         public CostCentreLink CostCentre { get; set; }
         
         [NullEditor]
+        [Obsolete("Replaced with ProductInstance.Charge")]
         public ProductCharge Charge { get; set; }
 
+        public bool Chargeable { get; set; }
+
+        public double DefaultMarkUp { get; set; }
+
         [NullEditor]
         public DigitalFormLink DigitalForm { get; set; }
         

+ 1 - 1
prs.classes/Entities/Requisition/RequisitionItem.cs

@@ -155,7 +155,7 @@ namespace Comal.Classes
         {
             LinkedProperties.Register<RequisitionItem, ProductLink, String>(x => x.Product, x => x.Code, x => x.Code);
             LinkedProperties.Register<RequisitionItem, ProductLink, String>(x => x.Product, x => x.Name, x => x.Description);
-            LinkedProperties.Register<RequisitionItem, ProductLink, bool>(x => x.Product, x => x.Charge.Chargeable, x => x.Charge.Chargeable);
+            LinkedProperties.Register<RequisitionItem, ProductLink, bool>(x => x.Product, x => x.Chargeable, x => x.Charge.Chargeable);
             LinkedProperties.Register<RequisitionItem, ProductStyleLink, Guid>(x=>x.Product.DefaultInstance.Style, x => x.ID, x => x.Style.ID);
             LinkedProperties.Register<RequisitionItem, StockLocationLink, Guid>(x=>x.Product.DefaultLocation, x => x.ID, x => x.Location.ID);
             StockEntity.LinkStockDimensions<RequisitionItem>();

+ 3 - 1
prs.classes/Entities/Stock/StockMovement/StockMovement.cs

@@ -106,7 +106,9 @@ namespace Comal.Classes
         [DoubleEditor(Editable = Editable.Hidden, Summary = Summary.Sum)]
         public double Qty { get; set; }
         
-        
+        /// <summary>
+        /// Cost per unit, not cost per Dimensions.Value, so that Value = Units * Cost
+        /// </summary>
         [CurrencyEditor(Visible = Visible.Default)]
         [EditorSequence(9)]
         public double Cost { get; set; } = 0.0;

+ 75 - 73
prs.desktop/Panels/Invoices/InvoiceLineGrid.cs

@@ -10,97 +10,99 @@ using InABox.WPF;
 using PRS.Shared;
 using PRSDesktop.Utils;
 
-namespace PRSDesktop
+namespace PRSDesktop;
+
+internal class InvoiceLineGrid : DynamicDataGrid<InvoiceLine>
 {
-    internal class InvoiceLineGrid : DynamicDataGrid<InvoiceLine>
+    private Job? _job;
+    public Job? Job
     {
-        private Job? _job;
-        public Job? Job
+        get => _job;
+        set
         {
-            get => _job;
-            set
-            {
-                _job = value;
-                Reconfigure();
-            }
+            _job = value;
+            Reconfigure();
         }
+    }
+
+    public InvoicePanelSettings InvoiceSettings { get; set; }
+
+    protected override void Init()
+    {
+        base.Init();
+        AddEditButton("Calculate", PRSDesktop.Resources.costcentre.AsBitmapImage(), CalculateLines,
+            isVisible: options => Job is not null && Job.JobType == JobType.Service);
+    }
 
-        protected override void Init()
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+        options.RecordCount = true;
+        options.AddRows = true;
+        options.DeleteRows = true;
+        options.EditRows = true;
+        options.SelectColumns = true;
+        options.MultiSelect = true;
+    }
+
+    public Invoice? Invoice { get; set; }
+
+    private bool CalculateLines(Button sender, CoreRow[] rows)
+    {
+        if(Invoice is null)
         {
-            base.Init();
-            AddEditButton("Calculate", PRSDesktop.Resources.costcentre.AsBitmapImage(), CalculateLines,
-                isVisible: options => Job is not null && Job.JobType == JobType.Service);
+            MessageBox.Show("Please Select or Create an Invoice First!");
+            return false;
         }
-
-        protected override void DoReconfigure(DynamicGridOptions options)
+        InvoiceCalculationSelector selector = new InvoiceCalculationSelector()
         {
-            base.DoReconfigure(options);
-            options.RecordCount = true;
-            options.AddRows = true;
-            options.DeleteRows = true;
-            options.EditRows = true;
-            options.SelectColumns = true;
-            options.MultiSelect = true;
+            TimeCalculation = InvoiceTimeCalculation.Activity,
+            MaterialCalculation = InvoiceMaterialCalculation.Product,
+            ExpensesCalculation = InvoiceExpensesCalculation.Detailed
+        };
+        if (selector.ShowDialog() == true)
+        {
+            var time = selector.TimeCalculation;
+            var materials = selector.MaterialCalculation;
+            var expenses = selector.ExpensesCalculation;
+            Progress.ShowModal("Calculating Invoice", progress =>
+                InvoiceUtilities.GenerateInvoiceLines(Invoice.ID, time, materials, expenses, InvoiceSettings.InvoicingStrategy, progress));
+            return true;
         }
-
-        public Invoice? Invoice { get; set; }
-
-        private bool CalculateLines(Button sender, CoreRow[] rows)
+        else
         {
-            if(Invoice is null)
-            {
-                MessageBox.Show("Please Select or Create an Invoice First!");
-                return false;
-            }
-            InvoiceCalculationSelector selector = new InvoiceCalculationSelector()
-            {
-                TimeCalculation = InvoiceTimeCalculation.Activity,
-                MaterialCalculation = InvoiceMaterialCalculation.Product,
-                ExpensesCalculation = InvoiceExpensesCalculation.Detailed
-            };
-            if (selector.ShowDialog() == true)
-            {
-                var time = selector.TimeCalculation;
-                var materials = selector.MaterialCalculation;
-                var expenses = selector.ExpensesCalculation;
-                Progress.ShowModal("Calculating Invoice", progress => InvoiceUtilities.GenerateInvoiceLines(Invoice.ID, time, materials, expenses, progress));
-                return true;
-            }
-            else
-            {
-                return false;
-            }
+            return false;
         }
+    }
 
-        protected override void Reload(
-        	Filters<InvoiceLine> criteria, Columns<InvoiceLine> columns, ref SortOrder<InvoiceLine>? sort,
-        	CancellationToken token, Action<CoreTable?, Exception?> action)
+    protected override void Reload(
+    	Filters<InvoiceLine> criteria, Columns<InvoiceLine> columns, ref SortOrder<InvoiceLine>? sort,
+    	CancellationToken token, Action<CoreTable?, Exception?> action)
+    {
+        if(Invoice is null)
         {
-            if(Invoice is null)
-            {
-                criteria.Add(Filter.None<InvoiceLine>());
-            }
-            else
-            {
-                criteria.Add(Filter<InvoiceLine>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID));
-            }
-            base.Reload(criteria, columns, ref sort, token, action);
+            criteria.Add(Filter.None<InvoiceLine>());
         }
-
-        protected override bool CanCreateItems()
+        else
         {
-            return Invoice is not null;
+            criteria.Add(Filter<InvoiceLine>.Where(x => x.Invoice.ID).IsEqualTo(Invoice.ID));
         }
+        base.Reload(criteria, columns, ref sort, token, action);
+    }
 
-        public override InvoiceLine CreateItem()
+    protected override bool CanCreateItems()
+    {
+        return Invoice is not null;
+    }
+
+    public override InvoiceLine CreateItem()
+    {
+        var result = base.CreateItem();
+        if(Invoice is not null)
         {
-            var result = base.CreateItem();
-            if(Invoice is not null)
-            {
-                result.Invoice.CopyFrom(Invoice);
-                result.SellGL.ID = Invoice.SellGL.ID;
-            }
-            return result;
+            result.Invoice.CopyFrom(Invoice);
+            result.SellGL.ID = Invoice.SellGL.ID;
         }
+        return result;
     }
 }

+ 5 - 6
prs.desktop/Panels/Invoices/InvoicePanel.xaml.cs

@@ -12,15 +12,10 @@ using InABox.Core;
 using InABox.Core.Postable;
 using InABox.DynamicGrid;
 using InABox.Wpf;
+using PRS.Shared;
 
 namespace PRSDesktop;
 
-public enum InvoicingStrategy
-{
-    InvoiceOnIssue,
-    InvoiceOnPurchase
-}
-
 public class InvoicePanelSettings : BaseObject, IGlobalConfigurationSettings
 {
     [EditorSequence(1)]
@@ -58,6 +53,9 @@ public partial class InvoicePanel : UserControl, IPanel<Invoice>, IMasterDetailC
         Parts.InvoiceGridSettings = _gridSettings;
         Bills.InvoiceGridSettings = _gridSettings;
 
+        Parts.InvoiceSettings = _settings;
+        Lines.InvoiceSettings = _settings;
+
         _gridSettings.PropertyChanged += GridSettings_PropertyChanged;
 
         SplitPanel.View = _userSettings.View;
@@ -104,6 +102,7 @@ public partial class InvoicePanel : UserControl, IPanel<Invoice>, IMasterDetailC
                 {
                     new GlobalConfiguration<InvoicePanelSettings>().Save(_settings);
                     Parts.InvoiceSettings = _settings;
+                    Lines.InvoiceSettings = _settings;
                 }
             } 
         });

+ 5 - 1
prs.desktop/Panels/Invoices/InvoiceStockMovementGrid.cs

@@ -14,6 +14,7 @@ using InABox.Core;
 using InABox.DynamicGrid;
 using InABox.Wpf;
 using InABox.WPF;
+using PRS.Shared;
 
 namespace PRSDesktop;
 
@@ -26,7 +27,10 @@ public class InvoiceStockMovementGrid : InvoiceableGrid<StockMovement>, ISpecifi
         set
         {
             _invoiceSettings = value;
-            Refresh(false, true);
+            if (IsReady)
+            {
+                Refresh(false, true);
+            }
         }
     }
 

+ 17 - 0
prs.desktop/Panels/Jobs/Picking Lists/PickingListItemGrid.cs

@@ -83,6 +83,7 @@ internal class PickingListItemGrid : DynamicDataGrid<PickingListItem>, ISpecific
         HiddenColumns.Add(x => x.Product.ID);
         HiddenColumns.Add(x => x.Product.NonStock);
         HiddenColumns.Add(x => x.Product.DefaultInstance.Style.ID);
+        HiddenColumns.Add(x => x.Product.Chargeable);
         
         HiddenColumns.Add(x => x.Style.ID);
         HiddenColumns.Add(x => x.Style.Code);
@@ -345,6 +346,22 @@ internal class PickingListItemGrid : DynamicDataGrid<PickingListItem>, ISpecific
         result.Quantity = 1;
         return result;
     }
+
+    private PickingListItem.PickingListItemChargeData? _chargeData;
+    
+    protected override void OnAfterEditorValueChanged(DynamicEditorGrid? grid, PickingListItem[] items, AfterEditorValueChangedArgs args, Dictionary<string, object?> changes)
+    {
+        base.OnAfterEditorValueChanged(grid, items, args, changes);
+        if (args.ColumnName.Equals("Product.ID") || args.ColumnName.Equals("Dimensions") || args.ColumnName.StartsWith("Dimensions.") || args.ColumnName.Equals("Style.ID"))
+        {
+            _chargeData ??= new();
+            PickingListItem.UpdateCharge(
+                items, 
+                changes,
+                data: _chargeData
+            );
+        }
+    }
     
     protected override bool CanDeleteItems(params CoreRow[] rows)
     {

+ 61 - 48
prs.desktop/Panels/Quotes/Quote Cost Sheets/QuoteCostSheetItemGrid.cs

@@ -78,63 +78,76 @@ namespace PRSDesktop
             var updates = new List<QuoteCostSheetItem>();
 
             Progress.SetMessage("Loading Kits");
-            var kits = new Client<CostSheetKit>().Query(
-                Filter<CostSheetKit>.Where(x => x.CostSheet.ID).IsEqualTo(costsheetid),
-                null,
-                new SortOrder<CostSheetKit>(x => x.Sequence)
-            );
+
+            var results = Client.QueryMultiple(
+                new KeyedQueryDef<CostSheetKit>(
+                    Filter<CostSheetKit>.Where(x => x.CostSheet.ID).IsEqualTo(costsheetid)
+                        .And(x => x.Kit.ID).IsNotEqualTo(Guid.Empty),
+                    Columns.None<CostSheetKit>()
+                        .Add(x => x.ID)
+                        .Add(x => x.Kit.ID)
+                        .Add(x => x.Kit.Description),
+                    new SortOrder<CostSheetKit>(x => x.Sequence)),
+                new KeyedQueryDef<KitProduct>(
+                    Filter<KitProduct>.Where(x => x.Kit.ID).InQuery(
+                        Filter<CostSheetKit>.Where(x => x.CostSheet.ID).IsEqualTo(costsheetid)
+                            .And(x => x.Kit.ID).IsNotEqualTo(Guid.Empty),
+                        x => x.Kit.ID),
+                    Columns.None<KitProduct>()
+                        .Add(x => x.Multiplier)
+                        .Add(x => x.Kit.ID)
+                        .Add(x => x.Product.ID)
+                        .Add(x => x.Product.Name)
+                        .Add(x => x.Product.TaxCode.ID)
+                        .Add(x => x.Product.TaxCode.Rate)
+                        .Add(x => x.Product.Chargeable)
+                        .Add(x => x.Product.NettCost)
+                        .Add(x => x.Product.DefaultMarkUp),
+                    new SortOrder<KitProduct>(x => x.Sequence)));
+            var kitProducts = results.GetObjects<KitProduct>()
+                .GroupByDictionary(x => x.Kit.ID);
 
             var bFirst = true;
-            foreach (var kit in kits.Rows)
+            foreach (var kit in results.GetObjects<CostSheetKit>())
             {
-                Progress.SetMessage(string.Format("Processing {0}", kit.Get<CostSheetKit, string>(x => x.Kit.Description)));
-                var kitid = kit.EntityLinkID<CostSheetKit, KitLink>(x => x.Kit) ?? Guid.Empty;
-                if (kitid != Guid.Empty)
-                {
-                    if (!bFirst)
-                    {
-                        var blank = new QuoteCostSheetItem();
-                        blank.Type = QuoteCostSheetItemLineType.Unused;
-                        blank.CostSheet.ID = quotecostsheetid;
-                        updates.Add(blank);
-                    }
+                Progress.SetMessage(string.Format("Processing {0}", kit.Kit.Description));
+                if (kit.Kit.ID == Guid.Empty) continue;
 
-                    bFirst = false;
+                if (!bFirst)
+                {
+                    var blank = new QuoteCostSheetItem();
+                    blank.Type = QuoteCostSheetItemLineType.Unused;
+                    blank.CostSheet.ID = quotecostsheetid;
+                    updates.Add(blank);
+                }
 
-                    var header = new QuoteCostSheetItem();
-                    header.Type = QuoteCostSheetItemLineType.Header;
-                    header.CostSheet.ID = quotecostsheetid;
-                    header.Description = kit.Get<CostSheetKit, string>(x => x.Kit.Description);
-                    updates.Add(header);
+                bFirst = false;
 
-                    var products = new Client<KitProduct>().Query(
-                        Filter<KitProduct>.Where(x => x.Kit.ID).IsEqualTo(kitid),
-                        null,
-                        new SortOrder<KitProduct>(x => x.Sequence)
-                    );
+                var header = new QuoteCostSheetItem();
+                header.Type = QuoteCostSheetItemLineType.Header;
+                header.CostSheet.ID = quotecostsheetid;
+                header.Description = kit.Kit.Description;
+                updates.Add(header);
 
-                    foreach (var product in products.Rows)
+                var products = kitProducts.GetValueOrDefault(kit.ID) ?? [];
+                foreach (var product in products)
+                {
+                    var line = new QuoteCostSheetItem();
+                    line.Type = QuoteCostSheetItemLineType.Costing;
+                    line.CostSheet.ID = quotecostsheetid;
+                    line.Product.ID = product.Product.ID;
+                    line.Description = product.Product.Name;
+                    line.TaxCode.ID = product.Product.TaxCode.ID;
+                    line.TaxCode.Rate = product.Product.TaxCode.Rate;
+                    //line.TaxRate = line.TaxCode.Rate;
+                    line.Qty = product.Multiplier;
+
+                    if (product.Product.Chargeable)
                     {
-                        var line = new QuoteCostSheetItem();
-                        line.Type = QuoteCostSheetItemLineType.Costing;
-                        line.CostSheet.ID = quotecostsheetid;
-                        line.Product.ID = product.Get<KitProduct, Guid>(x => x.Product.ID);
-                        line.Description = product.Get<KitProduct, string>(x => x.Product.Name);
-                        line.TaxCode.ID = product.Get<KitProduct, Guid>(x => x.Product.TaxCode.ID);
-                        line.TaxCode.Rate = product.Get<KitProduct, double>(x => x.Product.TaxCode.Rate);
-                        //line.TaxRate = line.TaxCode.Rate;
-                        line.Qty = product.Get<KitProduct, double>(x => x.Multiplier);
-
-                        if (product.Get<KitProduct, bool>(x => x.Product.Charge.Chargeable))
-                        {
-                            if (product.Get<KitProduct, ProductPriceType>(x => x.Product.Charge.PriceType) == ProductPriceType.CostPlus)
-                                line.Cost = product.Get<KitProduct, double>(x => x.Product.NettCost) * (100.0F + product.Get<KitProduct, double>(x => x.Product.Charge.Price) / 100.0F);
-                            else
-                                line.Cost = product.Get<KitProduct, double>(x => x.Product.Charge.Price);
-                        }
-
-                        updates.Add(line);
+                        line.Cost = product.Product.NettCost * (product.Product.DefaultMarkUp + 100) / 100;
                     }
+
+                    updates.Add(line);
                 }
             }
 

+ 50 - 29
prs.licensing/Engine/LicensingHandler.cs

@@ -46,54 +46,75 @@ public class LicensingHandler : Handler<LicensingHandlerProperties>
         var query = new MultiQuery();
         
         query.Add(
-            Filter<Product>.Where(x =>x.ID).InList(productids),
-            Columns.None<Product>().Add(x=>x.ID)
-                .Add(x=>x.NettCost)
-                .Add(x=>x.Charge.Chargeable)
-                .Add(x=>x.Charge.PriceType)
-                .Add(x=>x.Charge.Markup)
-                .Add(x=>x.Charge.Price)
-            );
+            Filter<Product>.Where(x => x.ID).InList(productids),
+            Columns.None<Product>()
+                .Add(x => x.ID)
+                .Add(x => x.NettCost));
+        query.Add(
+            Filter<ProductInstance>.Where(x => x.ID).InList(productids),
+            Columns.None<ProductInstance>()
+                .Add(x => x.Product.ID)
+                .Add(x => x.Charge.Chargeable)
+                .Add(x => x.Charge.PriceType)
+                .Add(x => x.Charge.Price)
+                .Add(x => x.Charge.Markup));
         
         if (lsr.RegistrationID != Guid.Empty)
             query.Add(
                 Filter<CustomerProduct>.Where(x =>x.Customer.ID).IsEqualTo(lsr.RegistrationID).And(x=>x.Product.ID).InList(productids),
-                Columns.None<CustomerProduct>().Add(x=>x.Product.ID)
-                    .Add(x=>x.Product.NettCost)
-                    .Add(x=>x.Charge.Chargeable)
-                    .Add(x=>x.Charge.PriceType)
-                    .Add(x=>x.Charge.Markup)
-                    .Add(x=>x.Charge.Price)
+                Columns.None<CustomerProduct>()
+                    .Add(x => x.Product.ID)
+                    .Add(x => x.Discount)
             );
         
         query.Query();
 
-        var products = query.Get<Product>().Rows.Select(x => x.ToObject<Product>()).ToArray();
-        
+        var products = query.Get<Product>()
+            .ToObjects<Product>()
+            .ToDictionary(x => x.ID);
+
         var customerproducts = lsr.RegistrationID != Guid.Empty
-            ? query.Get<CustomerProduct>().Rows.Select(x => x.ToObject<CustomerProduct>()).ToArray()
-            : new CustomerProduct[] { };
+            ? query.Get<CustomerProduct>()
+                .ToObjects<CustomerProduct>()
+                .GroupBy(x => x.Product.ID)
+                .ToDictionary(x => x.Key, x => x.First())
+            : [];
+        var productInstances = query.Get<ProductInstance>()
+            .ToObjects<ProductInstance>()
+            .GroupBy(x => x.Product.ID)
+            .ToDictionary(x => x.Key, x => x.First());
 
         var result = new LicenseFeeResponse();
         foreach (var mapping in _properties.EngineProperties.Mappings)
         {
+            if(!products.TryGetValue(mapping.Product.ID, out var product))
+            {
+                Logger.Send(LogType.Information, "", $"No product found for ID {mapping.Product.ID}");
+                continue;
+            }
+            if(!productInstances.TryGetValue(mapping.Product.ID, out var productInstance))
+            {
+                result.LicenseFees[mapping.License] = 0;
+                Logger.Send(LogType.Information, "", $"No product instance for product {mapping.Product.ID}");
+                continue;
+            }
 
-            var customer = customerproducts.FirstOrDefault(x => x.Product.ID == mapping.Product.ID);
-            if (customer != null)
+            double cost;
+            if(productInstance.Charge.PriceType == ProductPriceType.CostPlus)
             {
-                result.LicenseFees[mapping.License] = customer.Charge.PriceType == ProductPriceType.CostPlus
-                    ? customer.Product.NettCost * (100F + customer.Charge.Markup) / 100F
-                    : customer.Charge.Price;
+                cost = product.NettCost * (100 + productInstance.Charge.Markup) / 100;
             }
             else
             {
-                var product = products.FirstOrDefault(x => x.ID == mapping.Product.ID);
-                result.LicenseFees[mapping.License] = product != null
-                    ? product.Charge.PriceType == ProductPriceType.CostPlus
-                        ? product.NettCost * (100F + product.Charge.Markup) / 100F
-                        : product.Charge.Price
-                    : 0.0F;
+                cost = productInstance.Charge.Price;
+            }
+
+            if(customerproducts.TryGetValue(mapping.Product.ID, out var customerProduct))
+            {
+                cost = cost * (100F - customerProduct.Discount) / 100F;
             }
+
+            result.LicenseFees[mapping.License] = cost;
         }
 
         foreach (var timediscount in _properties.EngineProperties.TimeDiscounts)

+ 75 - 35
prs.shared/Utilities/InvoiceUtilities.cs

@@ -2,6 +2,7 @@ using Comal.Classes;
 using InABox.Clients;
 using InABox.Core;
 using PRSDimensionUtils;
+using Syncfusion.Windows.Controls.Grid;
 
 namespace PRS.Shared;
 
@@ -27,6 +28,12 @@ public enum InvoiceExpensesCalculation
     Collapsed,
 }
 
+public enum InvoicingStrategy
+{
+    InvoiceOnIssue,
+    InvoiceOnPurchase
+}
+
 public static class InvoiceUtilities
 {
     
@@ -120,12 +127,8 @@ public static class InvoiceUtilities
                 var chargeperiod = !activity.Charge.ChargePeriod.Equals(TimeSpan.Zero)
                     ? activity.Charge.ChargePeriod
                     : TimeSpan.FromHours(1);
-                
-                // Here we adjust the period, essentially; should this update the actual 'quantity' we are using for this line?
-                // It seems no, but just checking.
 
-                // Yes, round up actual quantity.
-                var rounded = quantity.Ceiling(chargeperiod);
+                quantity = quantity.Ceiling(chargeperiod);
 
                 // Rate is charge per hour, so we must divide by the charge period time, to get dollars per hour, rather than dollars per period
                 // $/hr = ($/pd) * (pd/hr) = ($/pd) / (hr/pd)
@@ -133,7 +136,7 @@ public static class InvoiceUtilities
                 var rate = activity.Charge.ChargeRate / chargeperiod.TotalHours;
                 
                 charge = Math.Max(
-                    activity.Charge.FixedCharge + (rounded.TotalHours * rate),
+                    activity.Charge.FixedCharge + (quantity.TotalHours * rate),
                     activity.Charge.MinimumCharge);
             }
 
@@ -162,39 +165,59 @@ public static class InvoiceUtilities
             return update;
         });
     }
-    private static async Task<InvoiceLine[]> PartLines(Invoice invoice, InvoiceMaterialCalculation partsummary)
+    private static async Task<InvoiceLine[]> PartLines(Invoice invoice, InvoiceMaterialCalculation partsummary, InvoicingStrategy strategy)
     {
-        var productsTask = Task.Run(() =>
+        var customerProductsTask = Task.Run(() =>
         {
             return Client.Query(
-                Filter<CustomerProductSummary>.Where(x => x.Customer.ID).InList(invoice.Customer.ID, Guid.Empty),
-                Columns.None<CustomerProductSummary>()
+                Filter<CustomerProduct>.Where(x => x.Customer.ID).InList(invoice.Customer.ID, Guid.Empty),
+                Columns.None<CustomerProduct>()
                     .Add(x => x.Customer.ID)
                     .Add(x => x.Product.ID)
-                    .Add(x => x.Product.Code)
-                    .Add(x => x.Product.Name)
-                    .Add(x => x.Product.TaxCode.ID)
-                    .Add(x => x.Product.TaxCode.Rate)
-                    .Add(x => x.Charge.Chargeable)
+                    .Add(x => x.Discount))
+                .ToObjects<CustomerProduct>()
+                .GroupByDictionary(x => (CustomerID: x.Customer.ID, ProductID: x.Product.ID));
+        });
+        var stockMovementFilter = Filter<StockMovement>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID)
+            .And(x => x.Charge.Chargeable).IsEqualTo(true);
+        var productInstancesTask = Task.Run(() =>
+        {
+            return Client.Query(
+                Filter<ProductInstance>.Where(x => x.Charge.Chargeable).IsEqualTo(true)
+                    .And(x => x.Product.ID).InQuery(
+                        stockMovementFilter,
+                        x => x.Product.ID),
+                Columns.None<ProductInstance>()
+                    .Add(x => x.Product.ID)
+                    .Add(x => x.Style.ID)
+                    .AddDimensionsColumns(x => x.Dimensions, Dimensions.ColumnsType.Local)
                     .Add(x => x.Charge.PriceType)
                     .Add(x => x.Charge.Price)
                     .Add(x => x.Charge.Markup))
-                .ToObjects<CustomerProductSummary>()
-                .GroupByDictionary(x => (CustomerID: x.Customer.ID, ProductID: x.Product.ID));
+                .ToObjects<ProductInstance>()
+                .GroupBy(x => (ProductID: x.Product.ID, StyleID: x.Style.ID, x.Dimensions))
+                .ToDictionary(x => x.Key, x => x.First());
         });
         var movementsTask = Task.Run(() =>
         {
             return Client.Query(
-                Filter<StockMovement>.Where(x => x.Invoice.ID).IsEqualTo(invoice.ID).And(x => x.Charge.Chargeable).IsEqualTo(true),
+                stockMovementFilter,
                 Columns.None<StockMovement>()
                     .Add(x => x.ID)
                     .Add(x => x.Qty)
+                    .Add(x => x.Units)
+                    .Add(x => x.Cost)
                     .Add(x => x.Product.ID)
                     .Add(x => x.Product.Name)
                     .Add(x => x.Product.CostCentre.ID)
                     .Add(x => x.Product.CostCentre.Description)
+                    .Add(x => x.Product.DefaultMarkUp)
+                    .Add(x => x.Product.TaxCode.ID)
+                    .Add(x => x.Product.TaxCode.Rate)
+                    .Add(x => x.Product.DefaultMarkUp)
+                    .Add(x => x.Style.ID)
                     .Add(x => x.Style.Code)
-                    .Add(x => x.Dimensions.UnitSize)
+                    .AddDimensionsColumns(x => x.Dimensions, Dimensions.ColumnsType.Local)
                     .Add(x => x.Charge.OverrideCharge)
                     .Add(x => x.Charge.Charge)
                     .Add(x => x.Charge.OverrideQuantity)
@@ -204,7 +227,8 @@ public static class InvoiceUtilities
         
         var partlines = new Dictionary<Guid, InvoiceLineDetail>();
 
-        var products = await productsTask;
+        var customerProducts = await customerProductsTask;
+        var productInstances = await productInstancesTask;
         foreach (var item in await movementsTask)
         {
             var id = partsummary switch
@@ -223,16 +247,17 @@ public static class InvoiceUtilities
                 _ => "Materials"
             };
 
-            // Quantity only to be used for the actual invoice line quantity if in Detailed version.
-            
+            var instance =
+                productInstances.GetValueOrDefault((item.Product.ID, item.Style.ID, item.Dimensions));
+            var customerProduct =
+                customerProducts.GetValueOrDefault((invoice.Customer.ID, item.Product.ID))?.FirstOrDefault();
+
+            // Quantity only to be used for the actual invoice line quantity if in Detailed version, otherwise we just use '1' as the quantity for the invoice line.
             var quantity = item.Charge.OverrideQuantity
                 ? item.Charge.Quantity
-                : item.Qty; // Should this be 'Cost' instead? Also, this will give negative cost for transfer outs and issues. Doesn't seem right. If InvoiceOnIssue, make it negative.
-            
-            var product = 
-                products.GetValueOrDefault((invoice.Customer.ID, item.Product.ID))?.FirstOrDefault()
-                ?? products.GetValueOrDefault((Guid.Empty, item.Product.ID))?.FirstOrDefault()
-                ?? new CustomerProductSummary();
+                : item.Units * (strategy == InvoicingStrategy.InvoiceOnIssue ? -1 : 1);
+
+            var discountMultiplier = 1 - (customerProduct?.Discount ?? 0) / 100;
 
             double charge;
             if (item.Charge.OverrideCharge)
@@ -241,11 +266,18 @@ public static class InvoiceUtilities
             }
             else
             {
-                charge = quantity * (product.Charge.PriceType switch
+                if(instance is not null)
+                {
+                    charge = quantity * (instance.Charge.PriceType switch
+                    {
+                        ProductPriceType.CostPlus => item.Cost * (1 + instance.Charge.Markup / 100),
+                        _ => instance.Charge.Price
+                    }) * discountMultiplier;
+                }
+                else
                 {
-                    ProductPriceType.CostPlus => 1 + product.Charge.Markup / 100,
-                    _ => product.Charge.Price
-                });
+                    charge = quantity * item.Cost * item.Product.DefaultMarkUp * discountMultiplier;
+                }
             }
 
             if(!partlines.TryGetValue(id, out var partline))
@@ -254,11 +286,18 @@ public static class InvoiceUtilities
                 {
                     Description = description
                 };
-                partline.TaxCode.CopyFrom(product.Product.TaxCode);
+                if(partsummary != InvoiceMaterialCalculation.Detailed)
+                {
+                    partline.Quantity = 1;
+                }
+                partline.TaxCode.CopyFrom(item.Product.TaxCode);
                 partlines.Add(id, partline);
             }
 
-            partline.Quantity += quantity;
+            if(partsummary == InvoiceMaterialCalculation.Detailed)
+            {
+                partline.Quantity += quantity;
+            }
             partline.Charge += charge;               
         }
 
@@ -353,6 +392,7 @@ public static class InvoiceUtilities
         InvoiceTimeCalculation timesummary,
         InvoiceMaterialCalculation partsummary,
         InvoiceExpensesCalculation expensesSummary,
+        InvoicingStrategy strategy,
         IProgress<String>? progress
     )
     {
@@ -378,7 +418,7 @@ public static class InvoiceUtilities
         });
 
         var timeLinesTask = TimeLines(invoice, timesummary);
-        var partLinesTask = PartLines(invoice, partsummary);
+        var partLinesTask = PartLines(invoice, partsummary, strategy);
         var expenseLinesTask = ExpenseLines(invoice, expensesSummary);
 
         progress?.Report("Calculating...");