diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php index 8734ae0..a3ba21c 100644 --- a/app/assets/utils/classes/ProductManager.php +++ b/app/assets/utils/classes/ProductManager.php @@ -360,6 +360,7 @@ class ProductManager { ':sku' => $data['sku'], ':uom' => $data['uom'] ?? 'pcs', ':price' => $data['price'], + ':cost_price' => (float)($data['cost_price'] ?? 0), ':min_stock' => $data['min_stock'], ':reorder_point' => $data['reorder_point'], ':category' => $data['category'], @@ -377,6 +378,7 @@ class ProductManager { sku = :sku, uom = :uom, price = :price, + cost_price = :cost_price, min_stock = :min_stock, reorder_point = :reorder_point, category = :category, @@ -389,10 +391,10 @@ class ProductManager { } else { $this->pdo->prepare( "INSERT INTO md_product - (company_id, product_name, sku, uom, price, min_stock, reorder_point, + (company_id, product_name, sku, uom, price, cost_price, min_stock, reorder_point, category, product_image, `description`, `status`, `log`) VALUES - (:company_id, :product_name, :sku, :uom, :price, :min_stock, :reorder_point, + (:company_id, :product_name, :sku, :uom, :price, :cost_price, :min_stock, :reorder_point, :category, :product_image, :description, :status, :log)" )->execute($params); } @@ -499,4 +501,4 @@ class ProductManager { $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php index b200b75..95041d5 100644 --- a/app/assets/utils/classes/PurchaseOrderManager.php +++ b/app/assets/utils/classes/PurchaseOrderManager.php @@ -366,11 +366,19 @@ class PurchaseOrderManager { if ($zone === '' && $rack !== '') $zone = $rack; if ($aisle === '' && $rack !== '') $aisle = $rack; + // Resolve unit_price: prefer the receive-time override, fall back to PO item price + $po_item_price = 0; + if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { + $po_item_price = (float)($po_items[$po_items_by_id[$item_id]]['unit_price'] ?? 0); + } + $unit_price = (float)($recv['unit_price'] ?? $po_item_price); + $stock_data = [ 'id' => 0, 'warehouse' => $warehouse_id, 'product_sku' => $product_sku, 'quantity' => $quantity, + 'price' => $unit_price, 'zone' => $zone, 'aisle' => $aisle, 'rack' => $rack, diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php index fda8738..5ec3cfa 100644 --- a/app/assets/utils/classes/ReportManager.php +++ b/app/assets/utils/classes/ReportManager.php @@ -276,13 +276,14 @@ class ReportManager a.product_sku, SUM(a.total_in) AS total_in, SUM(a.total_out) AS total_out, - b.min_stock + b.min_stock, + b.cost_price FROM warehouse_balance a LEFT JOIN md_product b ON a.company_id = b.company_id AND a.product_sku = b.sku WHERE a.company_id = :company_id - GROUP BY a.product_sku, b.min_stock"; + GROUP BY a.product_sku, b.min_stock, b.cost_price"; return $this->fetchAll($sql); } @@ -324,6 +325,7 @@ class ReportManager mp.min_stock, mp.reorder_point, mp.product_image, + mp.cost_price, ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance FROM warehouse_balance wb INNER JOIN md_product mp @@ -336,7 +338,7 @@ class ReportManager AND mp.reorder_point > 0 GROUP BY wb.warehouse_id, wb.product_sku, mw.warehouse_name, - mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image + mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price HAVING balance <= mp.reorder_point ORDER BY mp.product_name ASC, mw.warehouse_name ASC"; @@ -352,6 +354,7 @@ class ReportManager 'product_sku' => $row['product_sku'], 'product_name' => $row['product_name'], 'product_image' => $row['product_image'], + 'cost_price' => (float) ($row['cost_price'] ?? 0), 'balance' => max(0, round($balance, 2)), 'min_stock' => (float) $row['min_stock'], 'reorder_point' => (float) $row['reorder_point'], @@ -1407,7 +1410,9 @@ class ReportManager $product_name = $product_row['product_name'] ?: $product_sku; $product_uom = $product_row['uom'] ?? ''; - // All transactions for this SKU in range, sorted by date then id + // All transactions for this SKU in range, sorted by date then id. + // For stock-out rows, si.price is the purchase cost from the source stock-in (via ref_id). + // p.cost_price is the product-level fallback when si.price is 0 or ref_id is absent. $sth = $this->pdo->prepare( "SELECT s.id, @@ -1421,12 +1426,20 @@ class ReportManager s.rack, ROUND(COALESCE(s.`in`, 0), 2) AS stock_in, ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, + ROUND(s.price, 4) AS price, + ROUND(COALESCE(NULLIF(si.price, 0), p.cost_price, 0), 4) AS unit_cost, c.contact_name, s.description FROM `{$table}` s + LEFT JOIN `{$table}` si + ON si.id = s.ref_id + AND s.`out` > 0 + LEFT JOIN md_product p + ON p.company_id = s.company_id + AND p.sku = s.product_sku LEFT JOIN md_contact c - ON c.company_id = s.company_id - AND c.id = s.contact_id + ON c.company_id = s.company_id + AND c.id = s.contact_id WHERE s.company_id = :company_id AND s.product_sku = :sku AND s.status = 1 @@ -1436,11 +1449,18 @@ class ReportManager $sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from, ':date_to' => $date_to]); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); - // Append running_total to each row (carried forward from brought-forward balance) + // Append running_total and margin to each row. + // Margin is only meaningful for stock-out rows with both sell price and cost. $running = $bf_bal; foreach ($rows as &$row) { - $running += (float)$row['stock_in'] - (float)$row['stock_out']; - $row['running_total'] = round($running, 2); + $running += (float)$row['stock_in'] - (float)$row['stock_out']; + $row['running_total'] = round($running, 2); + + $sell = (float)$row['price']; + $cost = (float)$row['unit_cost']; + $row['margin'] = ($row['stock_out'] > 0 && $sell > 0) + ? round(($sell - $cost) / $sell * 100, 2) + : null; } unset($row); diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index 4f07294..882acf1 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -283,10 +283,10 @@ class StockManager { $this->pdo->prepare( "INSERT INTO `$table` - (uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack, + (uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack, contact_id, `description`, `log`, `type`, lot_number, serial_number, status) VALUES - (:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, + (:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack, :contact_id, :description, :log, 'in', :lot_number, :serial_number, 0)" )->execute([ ':uuid' => $uuid, @@ -294,6 +294,7 @@ class StockManager { ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $data['quantity'], + ':price' => (float)($data['price'] ?? 0), ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':rack' => $data['rack'], diff --git a/app/dashboard/low_stock_products.php b/app/dashboard/low_stock_products.php index 1725cfe..bb71a24 100644 --- a/app/dashboard/low_stock_products.php +++ b/app/dashboard/low_stock_products.php @@ -254,7 +254,12 @@ ${item.product_sku} ${item.warehouse_name} - ${item.balance} ${item.uom || 'pcs'} + + ${item.balance} ${item.uom || 'pcs'} + ${item.cost_price > 0 + ? `
Value: ${format_number(item.balance * item.cost_price, 2)}
` + : ''} + ${item.min_stock} ${item.reorder_point} ${badge} @@ -308,4 +313,4 @@ - \ No newline at end of file + diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index aafdbde..854aeb8 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -39,11 +39,17 @@ -
+
+ +
+ + +
+
@@ -368,7 +374,7 @@ if (expiryPicker) { expiryPicker.clear(); } } $('#product_sku').attr('secondary', data.product_sku); - $('#product_sku, #quantity').prop('disabled', true); + $('#product_sku, #quantity, #price').prop('disabled', true); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); if (data.status == 0) { diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php index dac715b..c98971a 100644 --- a/app/ics/stock_in.php +++ b/app/ics/stock_in.php @@ -59,6 +59,7 @@ Lot Number Serial Number Quantity + Unit Price Source Zone Aisle @@ -222,6 +223,7 @@ ${item['uom'] || ''}
+ ${item['price'] > 0 ? format_number(item['price'], 2) : '—'} ${source_cell(item['source'])} ${location_cells(item)} diff --git a/app/inventory/manage_product.php b/app/inventory/manage_product.php index 5a7ff16..1d3a613 100644 --- a/app/inventory/manage_product.php +++ b/app/inventory/manage_product.php @@ -39,10 +39,21 @@
-
+
+
+ + +
+
+ +
+ + % +
+
-
+
@@ -138,6 +149,7 @@ } formData.append('keep_files', $('#current_file_label').val()) + formData.append('cost_price', $('#cost_price').val() || 0) formData.append('uom', $('#uom').val() || 'pcs') return ajax_request({ @@ -209,8 +221,8 @@ }); $(`button[type=submit]`).text('Update'); - $(`button[type=reset]`).hide(); + update_margin(); } }); @@ -267,9 +279,22 @@ // Live feedback as the user types $('#min_stock, #reorder_point').on('input', validate_stock_thresholds); + function update_margin() { + var price = parseFloat($('#price').val()) || 0; + var cost_price = parseFloat($('#cost_price').val()) || 0; + if (price > 0) { + var margin = (price - cost_price) / price * 100; + $('#margin_display').val(margin.toFixed(2)); + } else { + $('#margin_display').val(''); + } + } + + $('#price, #cost_price').on('input', update_margin); + - \ No newline at end of file + diff --git a/app/inventory/product.php b/app/inventory/product.php index 1645d62..f83fa69 100644 --- a/app/inventory/product.php +++ b/app/inventory/product.php @@ -197,6 +197,8 @@ Product SKU + Price / Cost + Margin UOM Current Stock Min Stock @@ -345,7 +347,7 @@ if (filtered.length === 0) { $("table#product > tbody").html( - 'No products match your search.' + 'No products match your search.' ); return; } @@ -362,6 +364,17 @@ ${item['product_name']} ${item['sku']} + +
${format_number(item['price'], 2)}
+ ${format_number(item['cost_price'], 2)} + + + ${(item['price'] > 0 && item['cost_price'] > 0) + ? ` + ${format_number((item['price'] - item['cost_price']) / item['price'] * 100, 1)}% + ` + : '—'} + ${item['uom'] || 'pcs'}
@@ -472,4 +485,4 @@ - \ No newline at end of file + diff --git a/app/reports/stock_movement.php b/app/reports/stock_movement.php index fc8eaf5..1b6f7b1 100644 --- a/app/reports/stock_movement.php +++ b/app/reports/stock_movement.php @@ -139,6 +139,8 @@ Stock In Stock Out UOM + Price / Cost + Margin Running Total @@ -291,7 +293,7 @@ .addClass(net >= 0 ? 'text-success' : 'text-danger'); if (!d.skus || d.skus.length === 0) { - $('#tbl_body').html('No movement data found.'); + $('#tbl_body').html('No movement data found.'); return; } @@ -391,7 +393,7 @@ // SKU header row var header = ` - + ${d.sku} ${d.product_name} ${d.uom || 'pcs'} @@ -407,6 +409,8 @@ ${bf_in_val} ${bf_out_val} ${d.uom || 'pcs'} + + ${fmt(bf.balance)} `; @@ -414,13 +418,28 @@ var body = ''; if (rows.length === 0) { body = ` - No transactions in range. + No transactions in range. `; } else { $.each(rows, function(i, row) { var in_val = row.stock_in > 0 ? fmt(row.stock_in) : '—'; var out_val = row.stock_out > 0 ? fmt(row.stock_out) : '—'; var rt_cls = row.running_total >= 0 ? 'text-success' : 'text-danger'; + + var price_cell = '—'; + if (row.stock_in > 0 && parseFloat(row.price) > 0) { + price_cell = `${fmt(row.price)}`; + } else if (row.stock_out > 0 && parseFloat(row.price) > 0) { + price_cell = `
${fmt(row.price)}
+ ${fmt(row.unit_cost)}`; + } + + var margin_cell = '—'; + if (row.margin !== null && row.margin !== undefined) { + var m_cls = row.margin >= 0 ? 'text-success' : 'text-danger'; + margin_cell = `${parseFloat(row.margin).toFixed(1)}%`; + } + body += ` ${row.date} ${type_badge[row.type] || row.type} @@ -431,6 +450,8 @@ ${in_val} ${out_val} ${d.uom || 'pcs'} + ${price_cell} + ${margin_cell} ${fmt(row.running_total)} `; });