Browse Source

Merge remote-tracking branch 'origin/kenric' into frank

frogsoftware 1 year ago
parent
commit
eaf7c7b1e4

+ 5 - 0
prs.classes/Entities/Product/Instance/ProductInstance.cs

@@ -65,5 +65,10 @@ namespace Comal.Classes
         [LoggableProperty]
         public double LastCost { get; set; }
 
+        [EditorSequence(6)]
+        [LoggableProperty]
+        [DoubleEditor]
+        public double Parameter { get; set; }
+
     }
 }

+ 1 - 1
prs.desktop/Panels/Factory/FactoryPanel.xaml.cs

@@ -672,7 +672,7 @@ namespace PRSDesktop
                 item.Dimensions.Length = treatment.Parameter == 0.0F ? 1.0F : treatment.Parameter;
 
                 var jobprice = supprods.FirstOrDefault(x => x.Job.ID.Equals(item.Job.ID));
-                item.Cost = jobprice != null ? jobprice.CostPrice : stdcost;
+                item.Cost = (jobprice != null ? jobprice.CostPrice : stdcost) * treatment.Parameter;
 
                 var description = new List<string>();
                 description.Add(string.Format("{0} x {1} - {2}", packet.BarcodeQty, packet.Serial, packet.Title));

+ 217 - 305
prs.desktop/Panels/Products/Locations/StockHoldingGrid.cs

@@ -9,6 +9,8 @@ using InABox.Core;
 using InABox.DynamicGrid;
 using InABox.Wpf;
 using InABox.WPF;
+using PRSDesktop.Panels.Products.Locations;
+using Syncfusion.Windows.Controls.RichTextBoxAdv;
 using Exception = System.Exception;
 
 namespace PRSDesktop;
@@ -264,6 +266,21 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
             .EndUpdate();
     }
 
+    private IEnumerable<JobRequisitionItem> LoadRequisitionItems(StockHolding holding)
+    {
+        var items = Client.Query(
+            new Filter<JobRequisitionItem>(x => x.ID).InQuery(StockHolding.GetFilter(holding), x => x.JobRequisitionItem.ID),
+            new Columns<JobRequisitionItem>(x => x.ID)
+                .Add(x => x.Job.JobNumber)
+                .Add(x => x.Requisition.Number)
+                .Add(x => x.Requisition.Description)
+                .Add(x => x.Qty))
+            .ToObjects<JobRequisitionItem>();
+        if (!holding.Available.EqualsWithTolerance(0.0F))
+            items = CoreUtils.One(new JobRequisitionItem() { Qty = holding.Available }).Concat(items);
+        return items;
+    }
+
     private void BuildMenu(DynamicMenuColumn column, CoreRow? row)
     {
         if (row is null) return;
@@ -276,46 +293,9 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
         
         column.AddSeparator();
 
-        var requiitems = new Client<JobRequisitionItem>()
-            .Query(
-                new Filter<JobRequisitionItem>(x => x.ID).InQuery(StockHolding.GetFilter(holding), x => x.JobRequisitionItem.ID),
-                new Columns<JobRequisitionItem>(x=>x.ID)
-                    .Add(x=>x.Job.JobNumber)
-                    .Add(x=>x.Requisition.Number)
-                    .Add(x=>x.Requisition.Description)
-                    .Add(x=>x.Qty)
-            ).ToObjects<JobRequisitionItem>()
-            .ToList();
-        if (!holding.Available.EqualsWithTolerance(0.0F))
-            requiitems.Insert(0, new JobRequisitionItem() { Qty = holding.Available });
-        
-        if (requiitems.Count <= 1)
-            column.AddItem("Relocate Items", null, r => RelocateItems(holding, requiitems.ToArray()));
-        else
-        {
-            var header = column.AddItem("Relocate Items", null, null);
-            column.AddItem("All Items", null, r => RelocateItems(holding, requiitems.ToArray()), header);
-            column.AddSeparator(header);
-            
-            if (!holding.Available.EqualsWithTolerance(0.0F))
-                column.AddItem("Un-Requisitioned Items", null, r => RelocateItems(holding, requiitems.Where(x=>x.ID == Guid.Empty).ToArray()), header);
-            
-            foreach (var requiitem in requiitems)
-            {
-                string name =
-                    $"{requiitem.Job.JobNumber}:{requiitem.Requisition.Number} {requiitem.Requisition.Description} ({requiitem.Qty})";
-                column.AddItem(name, null, r => RelocateItems(holding, requiitems.Where(x=>x.ID == requiitem.ID).ToArray()), header);
-            }
-        }
-    }
-
-    private class StockLocationSelection : BaseObject
-    {
-        [EditorSequence(1)]
-        public StockLocationLink Location { get; set; }
+        var requiitems = LoadRequisitionItems(holding).ToList();
         
-        [EditorSequence(2)]
-        public double Qty { get; set; }
+        column.AddItem("Relocate Items", null, r => RelocateItems(holding, requiitems.ToArray()));
     }
 
     private class StockJobSelection : BaseObject
@@ -326,48 +306,38 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
         [EditorSequence(2)]
         public double Qty { get; set; }
     }
-
     
     private void RelocateItems(StockHolding holding, JobRequisitionItem[] requiitems)
     {
-        var max = requiitems.Aggregate(0.0d, (t, x) => t += x.Qty);
-        var grid = new DynamicItemsListGrid<StockLocationSelection>();
-        grid.OnValidate += (sender, items, errors) =>
-        {
-            var total = items.Aggregate(0.0d, (t, x) => t += x.Qty);
-            if (total > max)
-                errors.Add($"Qty must not exceed {max}");
-        };
-        grid.OnCustomiseEditor += (sender, items, column, editor) =>
-        {
-            if (column.ColumnName.Equals(nameof(StockLocationSelection.Qty)) && requiitems?.Length != 1)
-                editor.Editable = Editable.Disabled;
-        };
-        var selection = new StockLocationSelection() { Qty = max };
-        if (grid.EditItems(new StockLocationSelection[] { selection }))
+        var win = new StockHoldingRelocationWindow(holding, requiitems);
+        if (win.ShowDialog() == true)
         {
+            var quantities = win.GetQuantities();
+            var target = win.GetTargetLocation();
+
             List<StockMovement> updates = new List<StockMovement>();
             foreach (var requiitem in requiitems)
             {
+                if (!quantities.TryGetValue(requiitem.ID, out var qty)) continue;
+
                 var mout = new StockMovement();
                 mout.Location.ID = holding.Location.ID;
                 mout.Product.ID = holding.Product.ID;
                 mout.Style.ID = holding.Style.ID;
                 mout.Dimensions.CopyFrom(holding.Dimensions);
                 mout.Job.ID = holding.Job.ID;
-                mout.Issued = Math.Min(requiitem.Qty, selection.Qty);
+                mout.Issued = Math.Min(requiitem.Qty, qty);
                 mout.Cost = holding.AverageValue;
                 mout.JobRequisitionItem.ID = requiitem.ID;
-                mout.Transaction = Guid.NewGuid();
                 mout.Type = StockMovementType.TransferOut;
                 mout.Date = DateTime.Now;
                 mout.IsTransfer = true;
                 mout.Employee.ID = App.EmployeeID;
-                mout.Notes = $"Moved to {selection.Location.Code} by {App.EmployeeName}";
+                mout.Notes = $"Moved to {target.Code} by {App.EmployeeName}";
                 updates.Add(mout);
                 
                 var min = new StockMovement();
-                min.Location.ID = selection.Location.ID;
+                min.Location.ID = target.ID;
                 min.Product.ID = holding.Product.ID;
                 min.Style.ID = holding.Style.ID;
                 min.Dimensions.CopyFrom(holding.Dimensions);
@@ -383,9 +353,10 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
                 min.Notes = $"Moved From {holding.Location.Code} by {App.EmployeeName}";
                 updates.Add(min);
             }
-            new Client<StockMovement>().Save(updates, "Relocated from Stock Locations Screen");
+            SaveBatch(StockMovementBatchType.Transfer, updates);
+            DoChanged();
+            Refresh(false, true);
         }
-    
     }
 
     private void ViewRequisitions_Click(CoreRow? row)
@@ -412,125 +383,6 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
         DynamicGridUtils.CreateGridWindow("Job Requisition Items for stock holding", grid).ShowDialog();
     }
 
-    public IStockLocation Location { get; set; }
-
-    protected override void SelectItems(CoreRow[]? rows)
-    {
-        base.SelectItems(rows);
-
-        ReceiveButton.IsEnabled = Location != null && Location.ID != Guid.Empty;
-        IssueButton.IsEnabled = Location != null && Location.ID != Guid.Empty && rows?.Any() == true;
-        TransferButton.IsEnabled = Location != null && Location.ID != Guid.Empty && rows?.Any() == true;
-    }
-
-    private DynamicDataGrid<StockMovement> CheckStockMovementGrid(MovementAction action, StockHolding holding)
-    {
-        _action = action;
-        _holding = holding;
-        if (smg == null)
-        {
-            smg = new DynamicDataGrid<StockMovement>();
-            smg.OnCustomiseEditor += StockMovementCustomiseEditor;
-            smg.OnValidate += StockMovementValidate;
-            smg.OnEditorValueChanged += StockMovementValueChanged;
-        }
-        return smg;
-    }
-    
-    private Dictionary<string, object?> StockMovementValueChanged(IDynamicEditorForm form, string name, object value)
-    {
-        var result = new Dictionary<string, object?>();
-        if (name.Equals("Location.Job.ID"))
-        {
-            var editor = form.FindEditor("Job.ID");
-            if (!value.Equals(Guid.Empty))
-                result = DynamicGridUtils.UpdateEditorValue(form.Items, "Job.ID", value);
-            else
-                foreach (StockMovement item in form.Items)
-                    result = DynamicGridUtils.UpdateEditorValue(new[] { item }, "Job.ID",
-                        item.Job.HasOriginalValue("ID") ? item.Job.GetOriginalValue(x => x.ID) : item.Job.ID);
-            editor.IsEnabled = value.Equals(Guid.Empty);
-        }
-
-        return result;
-    }
-    
-    private void StockMovementValidate(object sender, StockMovement[] items, List<string> errors)
-    {
-        if (items.Any(x => x.Received == 0 && x.Issued == 0))
-        {
-            errors.Add("Quantity may not be zero");
-        }
-        else if(_action == MovementAction.Issue && _holding is not null && items.Any(x => x.Issued > _holding.Available))
-        {
-            errors.Add($"Quantity may not be greater than available stock ({_holding.Available})");
-        }
-
-        if (items.Any(x => x.Product.ID == Guid.Empty))
-            errors.Add("Product may not be blank");
-        if (items.Any(x => x.Location.ID == Guid.Empty))
-            errors.Add("Location may not be blank");
-
-        if (!errors.Any() && _action == MovementAction.Transfer)
-            foreach (var item in items)
-            {
-                var changes = new List<string>();
-                if (item.Location.HasOriginalValue(x => x.ID))
-                    changes.Add(item.Location.GetOriginalValue(x => x.Code));
-
-                if (item.Job.HasOriginalValue(x => x.ID))
-                {
-                    var job = item.Job.GetOriginalValue(x => x.JobNumber);
-                    if (string.IsNullOrEmpty(job))
-                        job = "General Stock";
-                    changes.Add(job);
-                }
-
-                if (item.Style.HasOriginalValue(x => x.ID))
-                    changes.Add(item.Style.GetOriginalValue(x => x.Code));
-
-                if (changes.Any())
-                    item.Notes = string.Format("Transferred from {0}{1}{2}",
-                        string.Join(" / ", changes.Where(x => !string.IsNullOrWhiteSpace(x))),
-                        string.IsNullOrWhiteSpace(item.Notes) ? "" : "\n", item.Notes);
-                else
-                    errors.Add("Transfers must change either Location, Style or Job");
-            }
-    }
-
-    private void StockMovementCustomiseEditor(IDynamicEditorForm sender, StockMovement[]? items, DynamicGridColumn column, BaseEditor editor)
-    {
-        if (column.ColumnName.Equals("Location.ID"))
-            editor.Editable = _action == MovementAction.Transfer ? Editable.Enabled : Editable.Hidden;
-        
-        if (column.ColumnName.Equals("Product.ID"))
-            editor.Editable = _action == MovementAction.Receive ? Editable.Enabled : Editable.Disabled;
-        
-        if (column.ColumnName.Equals("Product.NettCost"))
-            editor.Editable = Editable.Hidden;
-        
-        if (column.ColumnName.Equals("Style.ID"))
-            editor.Editable = _action == MovementAction.Receive || _action == MovementAction.Transfer ? Editable.Enabled : Editable.Hidden;
-
-        if (column.ColumnName.Equals("UnitSize"))
-            editor.Editable = _action == MovementAction.Receive ? Editable.Enabled : Editable.Hidden;
-        
-        if (column.ColumnName.Equals("Job.ID"))
-            editor.Editable = _action == MovementAction.Receive && items?.FirstOrDefault()?.Job.IsValid() == true ? Editable.Disabled : Editable.Enabled;
-        
-        if (column.ColumnName.Equals(nameof(StockMovement.Received)))
-        {
-            editor.Editable = _action == MovementAction.Receive  ? Editable.Enabled : Editable.Hidden;
-            editor.Caption = "Quantity";
-        }
-        if (column.ColumnName.Equals(nameof(StockMovement.Issued)))
-        {
-            editor.Editable = _action == MovementAction.Issue || _action == MovementAction.Transfer ? Editable.Enabled : Editable.Hidden;
-            editor.Caption = "Quantity";
-        }
-        
-    }
-
     private bool ReceiveStock(Button arg1, CoreRow[] rows)
     {
         var movement = new StockMovement();
@@ -560,7 +412,7 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
         return result;
     }
 
-    private void SaveBatch(StockMovementBatchType type, StockMovement[] movements)
+    private static void SaveBatch(StockMovementBatchType type, IList<StockMovement> movements)
     {
         var batch = new StockMovementBatch();
         batch.Type = type;
@@ -585,150 +437,89 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
         }
         
         var holding = rows.First().ToObject<StockHolding>();
-        SelectAllocation(holding, (h, items) => DoIssue(h, items));
+        var items = LoadRequisitionItems(holding).AsArray();
+
+        DoIssue(holding, items);
         return false;
-        
     }
 
-    private void DoIssue(StockHolding holding, JobRequisitionItem[] requiitems)
+    private IEnumerable<StockMovement> CreateIssue(StockHolding holding, double qty, Guid jobID, Guid requiID)
     {
+        var issue = CreateMovementFromHolding(holding);
+        issue.Job.ID = jobID;
+        issue.Type = StockMovementType.Issue;
+        issue.JobRequisitionItem.ID = requiID;
+        issue.Issued = qty;
+        issue.Notes = $"Issued by {App.EmployeeName}";
+        yield return issue;
+
+        if (holding.Job.ID != issue.Job.ID)
+        {
+            var xferout = CreateMovementFromHolding(holding);
+            xferout.Type = StockMovementType.TransferOut;
+            xferout.JobRequisitionItem.ID = requiID;
+            xferout.Issued = qty;
+            xferout.Transaction = issue.Transaction;
+            xferout.IsTransfer = true;
+            xferout.Notes = $"Issued by {App.EmployeeName}";
+            yield return xferout;
+            
+            var xferin = CreateMovementFromHolding(holding);
+            xferin.Job.ID = issue.Job.ID;
+            xferin.Type = StockMovementType.TransferIn;
+            xferin.JobRequisitionItem.ID = requiID;
+            xferin.Received = qty;
+            xferin.Transaction = issue.Transaction;
+            xferin.IsTransfer = true;
+            xferin.Notes = $"Issued by {App.EmployeeName}";
+            yield return xferin;
+        }
+    }
 
+    private void DoIssue(StockHolding holding, JobRequisitionItem[] requiitems)
+    {
         var updates = new List<StockMovement>();
-        // At this point we either have
-        // 1. All Items (Multiple requiitems) -> no editing available
-        // 2. UnRequid Items (single requiitem, requiid = empty) - Full Editor
-        // 3. Requi'd Item (single requiitem, requiid != empty - Quantity only
 
-        if (requiitems.Length > 1)
+        if(requiitems.Length > 0 || requiitems.Any(x => x.ID != Guid.Empty))
         {
-            if (MessageWindow.ShowOKCancel(
-                    "This will issue everything from this holding!\nAre you sure you wish to continue?",
-                    "Confirm Issue", null) == true)
+            var win = new StockHoldingRelocationWindow(holding, requiitems)
             {
-                foreach (var requiitem in requiitems)
+                IsTargetEditable = false
+            };
+            if (win.ShowDialog() == true)
+            {
+                var quantities = win.GetQuantities();
+                var target = win.GetTargetLocation();
+
+                foreach(var requi in requiitems)
                 {
-                    var movement = CreateMovementFromHolding(holding);
-                    movement.Type = StockMovementType.Issue;
-                    movement.JobRequisitionItem.ID = requiitem.ID;
-                    movement.Issued = requiitem.Qty;
-                    movement.Transaction = Guid.NewGuid();
-                    movement.Notes = $"Issued by {App.EmployeeName}";
-                    updates.Add(movement);
+                    if (!quantities.TryGetValue(requi.ID, out var qty)) continue;
+
+                    updates.AddRange(CreateIssue(holding, qty, requi.ID != Guid.Empty ? requi.Job.ID : holding.Job.ID, requi.ID));
                 }
-                SaveBatch(StockMovementBatchType.Issue, updates.ToArray());
                 DoChanged();
                 Refresh(false,true);
+                SaveBatch(StockMovementBatchType.Issue, updates.ToArray());
             }
-            return;
-        }
-        
-        var sjs = new StockJobSelection();
-        sjs.Job.ID = holding.Job.ID;
-        sjs.Qty = requiitems[0].Qty;
-        
-        var sjg = new DynamicItemsListGrid<StockJobSelection>();
-        sjg.OnValidate += (sender, items, errors) =>
-        {
-            if (items[0].Qty > requiitems[0].Qty)
-                    errors.Add($"Qty must not exceed {requiitems[0].Qty}");
-        };
-        sjg.OnCustomiseEditor += (sender, items, column, editor) =>
-        {
-            if (column.ColumnName.Equals(CoreUtils.GetFullPropertyName<StockJobSelection, Guid>(x => x.Job.ID, ".")))
-                editor.Editable = requiitems[0].ID == Guid.Empty
-                    ? Editable.Enabled
-                    : Editable.Disabled;
-        };
-        if (sjg.EditItems(new StockJobSelection[] { sjs }))
-        {
-            var issue = CreateMovementFromHolding(holding);
-            issue.Job.ID = sjs.Job.ID;
-            issue.Type = StockMovementType.Issue;
-            issue.JobRequisitionItem.ID = requiitems[0].ID;
-            issue.Issued = sjs.Qty;
-            issue.Transaction = Guid.NewGuid();
-            issue.Notes = $"Issued by {App.EmployeeName}";
-            updates.Add(issue);
-            if (holding.Job.ID != issue.Job.ID)
-            {
-                var xferout = CreateMovementFromHolding(holding);
-                xferout.Type = StockMovementType.TransferOut;
-                xferout.JobRequisitionItem.ID = requiitems[0].ID;
-                xferout.Issued = sjs.Qty;
-                xferout.Transaction = issue.Transaction;
-                xferout.IsTransfer = true;
-                xferout.Notes = $"Issued by {App.EmployeeName}";
-                updates.Add(xferout);
-                
-                var xferin = CreateMovementFromHolding(holding);
-                xferin.Job.ID = sjs.Job.ID;
-                xferin.Type = StockMovementType.TransferOut;
-                xferin.JobRequisitionItem.ID = requiitems[0].ID;
-                xferin.Received = sjs.Qty;
-                xferin.Transaction = issue.Transaction;
-                xferin.IsTransfer = true;
-                xferin.Notes = $"Issued by {App.EmployeeName}";
-                updates.Add(xferin);
-                
-            }
-            SaveBatch(StockMovementBatchType.Issue, updates.ToArray());
-            DoChanged();
-            Refresh(false,true);
         }
-    }
-
-    private void SelectAllocation(StockHolding holding, Action<StockHolding,JobRequisitionItem[]> action)
-    {
-        
-        var requiitems = new Client<JobRequisitionItem>()
-            .Query(
-                new Filter<JobRequisitionItem>(x => x.ID).InQuery(StockHolding.GetFilter(holding), x => x.JobRequisitionItem.ID),
-                new Columns<JobRequisitionItem>(x=>x.ID)
-                    .Add(x=>x.Job.JobNumber)
-                    .Add(x=>x.Requisition.Number)
-                    .Add(x=>x.Requisition.Description)
-                    .Add(x=>x.Qty)
-            ).ToObjects<JobRequisitionItem>()
-            .ToList();
-        if (!holding.Available.EqualsWithTolerance(0.0F))
-            requiitems.Insert(0, new JobRequisitionItem() { Qty = holding.Available });
-        
-        if (requiitems.Count <= 1)
-            action(holding, requiitems.ToArray());
         else
         {
+            var sjs = new StockJobSelection();
+            sjs.Job.ID = holding.Job.ID;
+            sjs.Qty = requiitems[0].Qty;
             
-            ContextMenu menu = new ContextMenu();
-            
-            MenuItem all = new MenuItem();
-            all.Header =
-                $"All Items ({holding.Units})";
-            all.Click += (sender, args) => action(holding,requiitems.ToArray());
-            menu.Items.Add(all);
-            menu.Items.Add(new Separator());
-
-            if (!holding.Available.EqualsWithTolerance(0.0F))
+            var sjg = new DynamicItemsListGrid<StockJobSelection>();
+            sjg.OnValidate += (sender, items, errors) =>
             {
-                MenuItem item = new MenuItem();
-                item.Header =
-                    $"Un-Requisitioned Items ({holding.Available})";
-                item.Click += (sender, args) =>
-                    action(holding, requiitems.Where(x => x.ID == Guid.Empty).ToArray());
-                menu.Items.Add(item);
-            }
-
-            foreach (var requiitem in requiitems)
+                if (items[0].Qty > requiitems[0].Qty)
+                        errors.Add($"Qty must not exceed {requiitems[0].Qty}");
+            };
+            if (sjg.EditItems(new StockJobSelection[] { sjs }))
             {
-                MenuItem item = new MenuItem();
-                item.Header =
-                    $"{requiitem.Job.JobNumber}:{requiitem.Requisition.Number} {requiitem.Requisition.Description} ({requiitem.Qty})";
-                item.Click += (sender, args) => action(holding, requiitems.Where(x=>x.ID == requiitem.ID).ToArray());
-                menu.Items.Add(item);
-
+                var mvts = CreateIssue(holding, sjs.Qty, sjs.Job.ID, Guid.Empty);
+                SaveBatch(StockMovementBatchType.Issue, mvts.AsArray());
             }
-            menu.IsOpen = true;
         }
-        
     }
     
     private bool TransferStock(Button arg1, CoreRow[] rows)
@@ -737,7 +528,9 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
             return false;
         
         var holding = rows.First().ToObject<StockHolding>();
-        SelectAllocation(holding, (h,items) => DoTransfer(h, items) );
+        var items = LoadRequisitionItems(holding).AsArray();
+
+        DoTransfer(holding, items);
         return false;
     }
 
@@ -798,7 +591,7 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
             DoChanged();
             SaveBatch(StockMovementBatchType.Transfer, mvts.ToArray());
         }
-        Refresh(true,false);
+        Refresh(false, true);
     }
 
     private StockMovement CreateMovementFromHolding(StockHolding holding)
@@ -819,6 +612,125 @@ public class StockHoldingGrid : DynamicDataGrid<StockHolding>
         movement.CommitChanges();
         return movement;
     }
+    public IStockLocation Location { get; set; }
+
+    protected override void SelectItems(CoreRow[]? rows)
+    {
+        base.SelectItems(rows);
+
+        ReceiveButton.IsEnabled = Location != null && Location.ID != Guid.Empty;
+        IssueButton.IsEnabled = Location != null && Location.ID != Guid.Empty && rows?.Any() == true;
+        TransferButton.IsEnabled = Location != null && Location.ID != Guid.Empty && rows?.Any() == true;
+    }
+
+    private DynamicDataGrid<StockMovement> CheckStockMovementGrid(MovementAction action, StockHolding holding)
+    {
+        _action = action;
+        _holding = holding;
+        if (smg == null)
+        {
+            smg = new DynamicDataGrid<StockMovement>();
+            smg.OnCustomiseEditor += StockMovementCustomiseEditor;
+            smg.OnValidate += StockMovementValidate;
+            smg.OnEditorValueChanged += StockMovementValueChanged;
+        }
+        return smg;
+    }
+    
+    private Dictionary<string, object?> StockMovementValueChanged(IDynamicEditorForm form, string name, object value)
+    {
+        var result = new Dictionary<string, object?>();
+        if (name.Equals("Location.Job.ID"))
+        {
+            var editor = form.FindEditor("Job.ID");
+            if (!value.Equals(Guid.Empty))
+                result = DynamicGridUtils.UpdateEditorValue(form.Items, "Job.ID", value);
+            else
+                foreach (StockMovement item in form.Items)
+                    result = DynamicGridUtils.UpdateEditorValue(new[] { item }, "Job.ID",
+                        item.Job.HasOriginalValue("ID") ? item.Job.GetOriginalValue(x => x.ID) : item.Job.ID);
+            editor.IsEnabled = value.Equals(Guid.Empty);
+        }
+
+        return result;
+    }
+    
+    private void StockMovementValidate(object sender, StockMovement[] items, List<string> errors)
+    {
+        if (items.Any(x => x.Received == 0 && x.Issued == 0))
+        {
+            errors.Add("Quantity may not be zero");
+        }
+        else if(_action == MovementAction.Issue && _holding is not null && items.Any(x => x.Issued > _holding.Available))
+        {
+            errors.Add($"Quantity may not be greater than available stock ({_holding.Available})");
+        }
+
+        if (items.Any(x => x.Product.ID == Guid.Empty))
+            errors.Add("Product may not be blank");
+        if (items.Any(x => x.Location.ID == Guid.Empty))
+            errors.Add("Location may not be blank");
+
+        if (!errors.Any() && _action == MovementAction.Transfer)
+            foreach (var item in items)
+            {
+                var changes = new List<string>();
+                if (item.Location.HasOriginalValue(x => x.ID))
+                    changes.Add(item.Location.GetOriginalValue(x => x.Code));
+
+                if (item.Job.HasOriginalValue(x => x.ID))
+                {
+                    var job = item.Job.GetOriginalValue(x => x.JobNumber);
+                    if (string.IsNullOrEmpty(job))
+                        job = "General Stock";
+                    changes.Add(job);
+                }
+
+                if (item.Style.HasOriginalValue(x => x.ID))
+                    changes.Add(item.Style.GetOriginalValue(x => x.Code));
+
+                if (changes.Any())
+                    item.Notes = string.Format("Transferred from {0}{1}{2}",
+                        string.Join(" / ", changes.Where(x => !string.IsNullOrWhiteSpace(x))),
+                        string.IsNullOrWhiteSpace(item.Notes) ? "" : "\n", item.Notes);
+                else
+                    errors.Add("Transfers must change either Location, Style or Job");
+            }
+    }
+
+    private void StockMovementCustomiseEditor(IDynamicEditorForm sender, StockMovement[]? items, DynamicGridColumn column, BaseEditor editor)
+    {
+        if (column.ColumnName.Equals("Location.ID"))
+            editor.Editable = _action == MovementAction.Transfer ? Editable.Enabled : Editable.Hidden;
+        
+        if (column.ColumnName.Equals("Product.ID"))
+            editor.Editable = _action == MovementAction.Receive ? Editable.Enabled : Editable.Disabled;
+        
+        if (column.ColumnName.Equals("Product.NettCost"))
+            editor.Editable = Editable.Hidden;
+        
+        if (column.ColumnName.Equals("Style.ID"))
+            editor.Editable = _action == MovementAction.Receive || _action == MovementAction.Transfer ? Editable.Enabled : Editable.Hidden;
+
+        if (column.ColumnName.Equals("UnitSize"))
+            editor.Editable = _action == MovementAction.Receive ? Editable.Enabled : Editable.Hidden;
+        
+        if (column.ColumnName.Equals("Job.ID"))
+            editor.Editable = _action == MovementAction.Receive && items?.FirstOrDefault()?.Job.IsValid() == true ? Editable.Disabled : Editable.Enabled;
+        
+        if (column.ColumnName.Equals(nameof(StockMovement.Received)))
+        {
+            editor.Editable = _action == MovementAction.Receive  ? Editable.Enabled : Editable.Hidden;
+            editor.Caption = "Quantity";
+        }
+        if (column.ColumnName.Equals(nameof(StockMovement.Issued)))
+        {
+            editor.Editable = _action == MovementAction.Issue || _action == MovementAction.Transfer ? Editable.Enabled : Editable.Hidden;
+            editor.Caption = "Quantity";
+        }
+        
+    }
+
 
     protected override void Reload(Filters<StockHolding> criteria, Columns<StockHolding> columns, ref SortOrder<StockHolding>? sort, Action<CoreTable?, Exception?> action)
     {

+ 127 - 0
prs.desktop/Panels/Products/Locations/StockHoldingRelocationWindow.xaml

@@ -0,0 +1,127 @@
+<Window x:Class="PRSDesktop.Panels.Products.Locations.StockHoldingRelocationWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:local="clr-namespace:PRSDesktop.Panels.Products.Locations"
+        xmlns:syncfusion="http://schemas.syncfusion.com/wpf"
+        xmlns:WPF="clr-namespace:InABox.WPF;assembly=InABox.Wpf"
+        mc:Ignorable="d"
+        Title="Relocate Items" Height="300" Width="600"
+        x:Name="Window">
+    <Window.Resources>
+        <WPF:BooleanToVisibilityConverter x:Key="boolToVisibilityConverter" TrueValue="Visible" FalseValue="Collapsed"/>
+    </Window.Resources>
+    <Grid DataContext="{Binding ElementName=Window}">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+        <DockPanel Grid.Row="0" LastChildFill="False" Height="35">
+            <Border DockPanel.Dock="Left" Margin="5,0,0,0">
+                <TextBlock VerticalAlignment="Center" Text="Location: " FontWeight="Bold"/>
+            </Border>
+            <TextBox IsEnabled="False" Text="{Binding From.Location.Code}" Width="100"
+                     VerticalAlignment="Stretch"
+                     DockPanel.Dock="Left" Margin="5" VerticalContentAlignment="Center"/>
+            <TextBox IsEnabled="False" Text="{Binding To.Description}" Width="150"
+                     Visibility="{Binding IsTargetEditable,Converter={StaticResource boolToVisibilityConverter}}"
+                     VerticalAlignment="Stretch"
+                     DockPanel.Dock="Right" Margin="5" VerticalContentAlignment="Center"/>
+            <Button x:Name="ToButton" Click="ToButton_Click"
+                    VerticalAlignment="Stretch"
+                    Visibility="{Binding IsTargetEditable,Converter={StaticResource boolToVisibilityConverter}}"
+                    Content="..." VerticalContentAlignment="Center"
+                    Padding="5,0"
+                    Margin="5,5,0,5" DockPanel.Dock="Right"/>
+            <TextBox x:Name="ToBox" Text="{Binding To.Code}" Width="100"
+                     VerticalAlignment="Stretch"
+                     Visibility="{Binding IsTargetEditable,Converter={StaticResource boolToVisibilityConverter}}"
+                     Background="LightYellow"
+                     LostFocus="ToBox_LostFocus"
+                     DockPanel.Dock="Right" Margin="5,5,0,5"
+                     VerticalContentAlignment="Center"/>
+            <Border DockPanel.Dock="Right">
+                <TextBlock VerticalAlignment="Center" FontWeight="Bold" Text="To:"
+                           Visibility="{Binding IsTargetEditable,Converter={StaticResource boolToVisibilityConverter}}"/>
+            </Border>
+        </DockPanel>
+        <Border Grid.Row="1"
+                Margin="2" BorderBrush="LightGray" BorderThickness="1" Padding="2">
+            <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Hidden">
+                <Grid>
+                    <Grid.RowDefinitions>
+                        <RowDefinition Height="Auto"/>
+                        <RowDefinition Height="Auto"/>
+                    </Grid.RowDefinitions>
+                    <ItemsControl ItemsSource="{Binding Items}"
+                                  Grid.Row="0">
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate DataType="local:StockHoldingRelocationItem">
+                                <Border BorderBrush="LightGray" BorderThickness="0,0,0,1" Padding="2">
+                                    <Grid>
+                                        <Grid.ColumnDefinitions>
+                                            <ColumnDefinition Width="*"/>
+                                            <ColumnDefinition Width="60"/>
+                                            <ColumnDefinition Width="40"/>
+                                            <ColumnDefinition Width="20"/>
+                                            <ColumnDefinition Width="60"/>
+                                            <ColumnDefinition Width="20"/>
+                                            <ColumnDefinition Width="40"/>
+                                        </Grid.ColumnDefinitions>
+                                        <TextBlock Grid.Column="0" VerticalAlignment="Center">
+                                            <Run Text="{Binding ItemNumber}" FontWeight="Bold"/>
+                                            <Run Text="{Binding Text}"/>
+                                        </TextBlock>
+                                        <TextBlock Grid.Column="1" Text="{Binding Quantity}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
+                                        <Button Grid.Column="2" Content="None" Tag="{Binding}" Click="None_Click"
+                                                Margin="2,0"/>
+                                        <Button Grid.Column="3" Content="-" Tag="{Binding}" Click="Minus_Click"
+                                                Margin="2,0"/>
+                                        <syncfusion:DoubleTextBox Grid.Column="4" Value="{Binding Issued}" MinValue="0" MaxValue="{Binding Quantity}"
+                                                                  HorizontalContentAlignment="Center"/>
+                                        <Button Grid.Column="5" Content="+" Tag="{Binding}" Click="Plus_Click"
+                                                Margin="2,0,0,0"/>
+                                        <Button Grid.Column="6" Content="All" Tag="{Binding}" Click="All_Click"
+                                                Margin="2,0"/>
+                                    </Grid>
+                                </Border>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                        <ItemsControl.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <StackPanel/>
+                            </ItemsPanelTemplate>
+                        </ItemsControl.ItemsPanel>
+                    </ItemsControl>
+                    <Border Padding="2" Grid.Row="1">
+                        <Grid>
+                            <Grid.ColumnDefinitions>
+                                <ColumnDefinition Width="*"/>
+                                <ColumnDefinition Width="60"/>
+                                <ColumnDefinition Width="40"/>
+                                <ColumnDefinition Width="100"/>
+                                <ColumnDefinition Width="40"/>
+                            </Grid.ColumnDefinitions>
+                            <TextBlock Grid.Column="0" Text="Total to Transfer/Issue" FontWeight="Bold" VerticalAlignment="Center"/>
+                            <TextBlock Grid.Column="1" Text="{Binding TotalQuantity}" FontWeight="Bold" VerticalAlignment="Center" HorizontalAlignment="Center"/>
+                            <TextBlock Grid.Column="3" Text="{Binding TotalIssued}" FontWeight="Bold" VerticalAlignment="Center" HorizontalAlignment="Center"/>
+                        </Grid>
+                    </Border>
+                </Grid>
+            </ScrollViewer>
+        </Border>
+        <DockPanel Grid.Row="2" LastChildFill="False">
+            <Button x:Name="CancelButton" Click="CancelButton_Click"
+                    Content="Cancel"
+                    Margin="5" Padding="5" MinWidth="60"
+                    DockPanel.Dock="Right"/>
+            <Button x:Name="OKButton" Click="OKButton_Click"
+                    Content="OK"
+                    Margin="5,5,0,5" Padding="5" MinWidth="60"
+                    DockPanel.Dock="Right"
+                    IsEnabled="{Binding CanSave}"/>
+        </DockPanel>
+    </Grid>
+</Window>

+ 290 - 0
prs.desktop/Panels/Products/Locations/StockHoldingRelocationWindow.xaml.cs

@@ -0,0 +1,290 @@
+using Comal.Classes;
+using InABox.Clients;
+using InABox.Core;
+using InABox.DynamicGrid;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Shapes;
+
+namespace PRSDesktop.Panels.Products.Locations;
+
+public class StockHoldingRelocationItem : INotifyPropertyChanged
+{
+    public string ItemNumber { get; set; }
+
+    public string Text { get; set; }
+
+    public double Quantity { get; set; }
+
+    private double _issued;
+    public double Issued
+    {
+        get => _issued;
+        set
+        {
+            _issued = value;
+            OnPropertyChanged();
+        }
+    }
+
+    public Guid ID { get; set; }
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+    {
+        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+    }
+}
+
+/// <summary>
+/// Interaction logic for StockHoldingRelocationWindow.xaml
+/// </summary>
+public partial class StockHoldingRelocationWindow : Window, INotifyPropertyChanged
+{
+    public ObservableCollection<StockHoldingRelocationItem> Items { get; set; } = new();
+
+    private double _totalQuantity;
+    public double TotalQuantity
+    {
+        get => _totalQuantity;
+        set
+        {
+            _totalQuantity = value;
+            OnPropertyChanged();
+        }
+    }
+
+
+    private double _totalIssued;
+    public double TotalIssued
+    {
+        get => _totalIssued;
+        set
+        {
+            _totalIssued = value;
+            OnPropertyChanged();
+            OnPropertyChanged(nameof(CanSave));
+        }
+    }
+
+    public StockHolding From { get; set; }
+
+    private bool _isTargetEditable = true;
+    public bool IsTargetEditable
+    {
+        get => _isTargetEditable;
+        set
+        {
+            _isTargetEditable = value;
+            OnPropertyChanged();
+        }
+    }
+
+    private StockLocation? _to;
+    public StockLocation? To
+    {
+        get => _to;
+        set
+        {
+            _to = value;
+            OnPropertyChanged();
+            OnPropertyChanged(nameof(CanSave));
+            OnPropertyChanged($"{nameof(To)}.{nameof(To.Code)}");
+            OnPropertyChanged($"{nameof(To)}.{nameof(To.Description)}");
+        }
+    }
+
+    public bool CanSave => (!IsTargetEditable || To is not null) && TotalIssued > 0;
+
+    public StockHoldingRelocationWindow(StockHolding from, IEnumerable<JobRequisitionItem> items)
+    {
+        Client.EnsureColumns(from, new Columns<StockHolding>(x => x.Location.Code));
+
+        From = from;
+
+        InitializeComponent();
+
+        SetRequisitionItems(items);
+
+        Items.CollectionChanged += (o, e) => Recalculate();
+    }
+
+    private bool _observing = false;
+    public void SetObserving(bool observing)
+    {
+        if(_observing != observing)
+        {
+            _observing = observing;
+            if (_observing)
+            {
+                Recalculate();
+            }
+        }
+    }
+
+    public void SetRequisitionItems(IEnumerable<JobRequisitionItem> items)
+    {
+        SetObserving(false);
+
+        var rItems = items.AsIList();
+
+        var rIDs = rItems.Select(x => x.ID).Where(x => x != Guid.Empty).ToArray();
+        var quantities = Client.Query(
+            StockHolding.GetFilter(From)
+                .Combine(new Filter<StockMovement>(x => x.JobRequisitionItem.ID).InList(rIDs)),
+            new Columns<StockMovement>(x => x.Units).Add(x => x.JobRequisitionItem.ID))
+            .ToObjects<StockMovement>().GroupBy(x => x.JobRequisitionItem.ID).ToDictionary(x => x.Key, x => x.Sum(x => x.Units));
+
+        var requidItems = new List<StockHoldingRelocationItem>();
+        foreach(var item in rItems)
+        {
+            var qty = item.ID != Guid.Empty ? quantities.GetValueOrDefault(item.ID) : item.Qty;
+            var newItem = new StockHoldingRelocationItem
+            {
+                Issued = 0,
+                Quantity = qty,
+                Text = item.ID == Guid.Empty
+                    ? "Unrequisitioned Items"
+                    : $"{item.Job.JobNumber}:{item.Requisition.Number} {item.Requisition.Description} ({qty})",
+                ID = item.ID
+            };
+            newItem.PropertyChanged += (o, e) => Recalculate();
+            if(item.ID == Guid.Empty)
+            {
+                Items.Add(newItem);
+            }
+            else
+            {
+                requidItems.Add(newItem);
+            }
+        }
+
+        int i = 1;
+        foreach(var item in requidItems)
+        {
+            item.ItemNumber = $"{i}. ";
+            Items.Add(item);
+            ++i;
+        }
+        SetObserving(true);
+    }
+
+    public Dictionary<Guid, double> GetQuantities()
+    {
+        return Items.ToDictionary(x => x.ID, x => x.Issued);
+    }
+    public StockLocation GetTargetLocation()
+    {
+        return To ?? new StockLocation();
+    }
+
+    private void Recalculate()
+    {
+        if (!_observing) return;
+
+        TotalQuantity = Items.Sum(x => x.Quantity);
+        TotalIssued = Items.Sum(x => x.Issued);
+    }
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+    {
+        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+    }
+
+    private void Minus_Click(object sender, RoutedEventArgs e)
+    {
+        if (sender is not FrameworkElement element || element.Tag is not StockHoldingRelocationItem item) return;
+
+        item.Issued = Math.Max(0, item.Issued - 1);
+    }
+
+    private void Plus_Click(object sender, RoutedEventArgs e)
+    {
+        if (sender is not FrameworkElement element || element.Tag is not StockHoldingRelocationItem item) return;
+
+        item.Issued = Math.Min(item.Issued + 1, item.Quantity);
+    }
+    private void None_Click(object sender, RoutedEventArgs e)
+    {
+        if (sender is not FrameworkElement element || element.Tag is not StockHoldingRelocationItem item) return;
+
+        item.Issued = 0;
+    }
+    private void All_Click(object sender, RoutedEventArgs e)
+    {
+        if (sender is not FrameworkElement element || element.Tag is not StockHoldingRelocationItem item) return;
+
+        item.Issued = item.Quantity;
+    }
+
+    private bool DoLookup(string? column, string? value)
+    {
+        var grid = new MultiSelectDialog<StockLocation>(
+            LookupFactory.DefineFilter<StockLocation>(),
+            new Columns<StockLocation>(x => x.ID).Add(x => x.Code).Add(x => x.Description),
+            multiselect: false);
+        if (grid.ShowDialog(column, value))
+        {
+            To = grid.Data().Rows.First().ToObject<StockLocation>();
+            return true;
+        }
+        else
+        {
+            return false;
+        }
+    }
+
+    private void ToButton_Click(object sender, RoutedEventArgs e)
+    {
+        DoLookup(null, null);
+    }
+
+    private void OKButton_Click(object sender, RoutedEventArgs e)
+    {
+        DialogResult = true;
+        Close();
+    }
+
+    private void CancelButton_Click(object sender, RoutedEventArgs e)
+    {
+        DialogResult = false;
+        Close();
+    }
+
+    private void ToBox_LostFocus(object sender, RoutedEventArgs e)
+    {
+        if (ToBox.Text.IsNullOrWhiteSpace() || ToBox.Text == To?.Code) return;
+
+        var location = Client.Query(
+            new Filter<StockLocation>(x => x.Code).IsEqualTo(ToBox.Text),
+            new Columns<StockLocation>(x => x.ID).Add(x => x.Code).Add(x => x.Description))
+            .ToObjects<StockLocation>().FirstOrDefault();
+        if(location is not null)
+        {
+            To = location;
+        }
+        else
+        {
+            if(!DoLookup(nameof(To.Code), ToBox.Text))
+            {
+                To = null;
+            }
+        }
+    }
+}

+ 2 - 0
prs.shared/Posters/Timberline/BillTimberlinePoster.cs

@@ -309,6 +309,7 @@ public class Module
                     .Add(x => x.PurchaseOrderLink.PONumber)
                     .Add(x => x.Job.JobNumber)
                     .Add(x => x.Qty)
+                    .Add(x => x.Description)
                     .Add(x => x.Cost)
                     .Add(x => x.PostedReference)
                     );
@@ -417,6 +418,7 @@ public class Module
                             }
                             apdf.Units = poItem.Qty;
                             apdf.UnitCost = poItem.Cost;
+                            apdf.Description = poItem.Description.NotWhiteSpaceOr(apdf.Description);
                         }
                         else
                         {

+ 25 - 2
prs.stores/PurchaseOrderItemStore.cs

@@ -80,7 +80,8 @@ internal class PurchaseOrderItemStore : BaseStore<PurchaseOrderItem>
                     x => x.Dimensions.Unit.Formula,
                     x => x.Dimensions.Unit.Format,
                     x => x.Dimensions.Unit.Code,
-                    x => x.Dimensions.Unit.Description
+                    x => x.Dimensions.Unit.Description,
+                    x => x.Batch.ID
                 )
             ).Rows.Select(x => x.ToObject<StockMovement>()).ToList();
             if (!result.Any())
@@ -283,9 +284,19 @@ internal class PurchaseOrderItemStore : BaseStore<PurchaseOrderItem>
             }
         }
 
+        StockMovementBatch? batch = null;
         foreach (var movement in movements)
         {
-            movement.Batch.Type = StockMovementBatchType.Receipt;
+            if(movement.Batch.ID == Guid.Empty)
+            {
+                batch ??= new StockMovementBatch
+                {
+                    Type = StockMovementBatchType.Receipt,
+                    TimeStamp = DateTime.Now,
+                    Notes = $"Received on PO"
+                };
+            }
+
             movement.Date = entity.ReceivedDate;
             movement.Product.ID = entity.Product.ID;
             movement.Received = entity.Qty;
@@ -309,6 +320,18 @@ internal class PurchaseOrderItemStore : BaseStore<PurchaseOrderItem>
             movement.JobRequisitionItem.ID = jobRequisitionItemID;
         }
 
+        if(batch is not null)
+        {
+            FindSubStore<StockMovementBatch>().Save(batch, "Received on PO");
+            foreach(var movement in movements)
+            {
+                if(movement.Batch.ID == Guid.Empty)
+                {
+                    movement.Batch.ID = batch.ID;
+                }
+            }
+        }
+
         var updates = movements.Where(x => x.IsChanged()).ToList();
         if (updates.Any())
             FindSubStore<StockMovement>().Save(updates, "Updated by Purchase Order Modification");