diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 75fceb3..e232ac8 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -1,3 +1,33 @@ +/** ========================= + * SIDEBAR ACTIVE STATE + * Override: activate the parent listing page for manage_* sub-pages. + * main.js (theme) handles exact-match pages; this covers second-depth pages. + * ========================= */ +$(function () { + var $links = $('#sidebar').find('a.nav-link'); + var path = window.location.pathname; + + // Theme already handled it — an active link whose href matches the current path + var alreadyActive = $links.filter('.active').toArray().some(function (a) { + return a.pathname === path; + }); + if (alreadyActive) return; + + // For manage_* pages: strip "manage_" to derive the parent listing filename + var filename = path.split('/').pop(); + if (!/^manage_/.test(filename)) return; + + var dir = path.substring(0, path.lastIndexOf('/')); + var parentPath = dir + '/' + filename.replace(/^manage_/, ''); + + var $parent = $links.filter(function () { return this.pathname === parentPath; }); + if ($parent.length) { + $links.removeClass('active'); + $parent.addClass('active'); + } +}); + + /** ========================= * UTILITIES * ========================= */ diff --git a/app/assets/utils/classes/CompanySettingManager.php b/app/assets/utils/classes/CompanySettingManager.php index 9e214fc..79b954a 100644 --- a/app/assets/utils/classes/CompanySettingManager.php +++ b/app/assets/utils/classes/CompanySettingManager.php @@ -234,14 +234,14 @@ class CompanySettingManager } $sth = $this->transactionPdo->prepare( - "SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id" + "SELECT id FROM md_warehouse WHERE company_id = :company_id" ); $sth->execute([':company_id' => $this->companyId]); - $warehouses = $sth->fetchAll(PDO::FETCH_COLUMN); + $warehouse_ids = $sth->fetchAll(PDO::FETCH_COLUMN); - foreach ($warehouses as $warehouse_name) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$warehouse_name); - if ($safe !== '' && $this->tableHasCompanyRows('td_stock_' . $safe)) { + foreach ($warehouse_ids as $warehouse_id) { + $warehouse_id = (int)$warehouse_id; + if ($warehouse_id > 0 && $this->tableHasCompanyRows('td_stock_' . $warehouse_id)) { return true; } } diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php index bccb065..535a6fd 100644 --- a/app/assets/utils/classes/OrderManager.php +++ b/app/assets/utils/classes/OrderManager.php @@ -4,7 +4,7 @@ * OrderManager * * Handles all read and write operations for sales orders (td_order) - * and their downstream stock-out effects on td_stock_ tables. + * and their downstream stock-out effects on td_stock_ tables. * * Method order: * Transaction basis → getOrderList, getOrderById, generateOrderNumber, @@ -16,25 +16,24 @@ * No separate child table exists. SKU-level reporting is served by * td_stock_* rows (source='order') rather than querying items JSON. * - confirmOrder() picks racks via FIFO — oldest approved stock-in row - * still rack-occupied, ordered by td_stock_.date ASC. + * still rack-occupied, ordered by td_stock_.date ASC. * - confirmOrder() creates stock-out rows with status=0 (draft). * Warehouse staff approve them via the existing approve_stock.php * engine, which handles rack release and balance adjustment. * - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked - * stock-out rows (status → -1) across all td_stock_* tables. - * Cancellation is blocked if any active invoice (status != -1) or - * active return (status != -1) is linked to the order. - * - Cancel logic is intentionally self-contained here. status=-1 is - * an order-domain concept with no rack/balance side effects, so - * WarehouseManager and StockManager are not involved. + * stock-out rows (status → -1) across all td_stock_* tables. If linked + * stock-out rows were already approved, their rack and balance effects are + * reversed before the rows are cancelled. + * Cancellation is blocked if any active invoice (status != 4 / void) or + * active return (status != -1 / cancelled) is linked to the order. + * - Cancel logic stays in the order domain, but uses WarehouseManager for + * the same rack and balance reversal rules as manual stock-out deletion. * * Note: Write methods do NOT manage their own DB transactions. * Callers must wrap multi-step operations inside dbTransaction(). * * Security: All SQL uses PDO prepared statements with bound parameters. - * Dynamic table names (td_stock_) are sanitised with - * preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation — - * no user input ever reaches a table identifier directly. + * Dynamic stock table names are derived only from md_warehouse.id. */ class OrderManager { @@ -67,31 +66,13 @@ class OrderManager { ]; } - /** - * Resolve the td_stock_ table name for a warehouse_id. - * - * Does not filter by status — allows reading inactive warehouses - * for historical order lookups. - * - * @param int $warehouse_id - * @return string Sanitised table name e.g. "td_stock_Main". - * @throws Exception If warehouse not found. - */ - private function resolveStockTable(int $warehouse_id): string + private function stockTableNameFromWarehouseId(int $warehouse_id): string { - $sth = $this->pdo->prepare( - "SELECT warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); - $name = $sth->fetchColumn(); - - if (!$name) { - throw new Exception("Warehouse ID {$warehouse_id} not found."); + if ($warehouse_id <= 0) { + throw new Exception("Invalid warehouse id."); } - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); - return "td_stock_{$safe}"; + return 'td_stock_' . $warehouse_id; } /** @@ -108,10 +89,18 @@ class OrderManager { */ private function pickFifoRack(int $warehouse_id, string $product_sku): ?array { - $table = $this->resolveStockTable($warehouse_id); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); $sth = $this->pdo->prepare( - "SELECT s.*, r.zone, r.aisle, r.rack + "SELECT s.*, r.zone, r.aisle, r.rack, + (s.`in` - COALESCE(( + SELECT SUM(o.`out`) + FROM `{$table}` o + WHERE o.company_id = s.company_id + AND o.ref_id = s.id + AND o.`out` > 0 + AND o.status != -1 + ), 0)) AS available_qty FROM `{$table}` s INNER JOIN md_rack r ON r.company_id = s.company_id @@ -120,9 +109,10 @@ class OrderManager { AND r.product_sku IS NOT NULL WHERE s.company_id = :company_id AND s.product_sku = :product_sku - AND s.type = 'in' + AND s.`in` > 0 AND s.status = 1 - ORDER BY s.date ASC + HAVING available_qty > 0 + ORDER BY s.date ASC, s.id ASC LIMIT 1" ); $sth->execute([ @@ -354,7 +344,7 @@ class OrderManager { * * Flow per item: * 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU. - * 2. INSERT into td_stock_ with type='out', status=0, + * 2. INSERT into td_stock_ with type='out', status=0, * source='order', source_id=order_id. * 3. Write back stock_out_id into the item object in td_order.items. * @@ -396,17 +386,20 @@ class OrderManager { throw new Exception("Cannot confirm an order with no items."); } - // ── Per-item: FIFO pick + insert stock-out row ──────────────────── foreach ($items as $i => &$item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); $product_sku = $item['product_sku'] ?? ''; + $quantity = (float)($item['quantity'] ?? 0); if (!$warehouse_id || !$product_sku) { throw new Exception( "Item #{$i}: missing warehouse_id or product_sku." ); } + if ($quantity <= 0) { + throw new Exception("Item #{$i}: quantity must be greater than zero."); + } $rack_stock = $this->pickFifoRack($warehouse_id, $product_sku); @@ -417,7 +410,17 @@ class OrderManager { ); } - $table = $this->resolveStockTable($warehouse_id); + $available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0); + if ($quantity - $available_qty > 0.000001) { + $name = $item['product_name'] ?? $product_sku; + throw new Exception( + "FIFO rack {$rack_stock['zone']}-{$rack_stock['aisle']}-{$rack_stock['rack']} " . + "for \"{$name}\" has only {$available_qty} available unit(s), " . + "but the order requests {$quantity}." + ); + } + + $table = $this->stockTableNameFromWarehouseId($warehouse_id); $item_uuid = $uuid . '_' . $i; $item_log = [array_merge($logging, ['action' => 'confirm_item'])]; @@ -439,7 +442,7 @@ class OrderManager { ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $product_sku, - ':quantity' => (float)$item['quantity'], + ':quantity' => $quantity, ':zone' => $rack_stock['zone'], ':aisle' => $rack_stock['aisle'], ':rack' => $rack_stock['rack'], @@ -493,17 +496,18 @@ class OrderManager { * * Business rule: * An order can be cancelled (Draft or Confirmed) as long as no active - * downstream documents exist. Active means status != -1 (not voided / - * not cancelled). + * downstream documents exist. For invoices, active means not voided + * (status != 4). For returns, active means not cancelled (status != -1). * * Downstream documents that block cancellation: - * - td_invoice where order_id = $order_id AND status != -1 + * - td_invoice where order_id = $order_id AND status != 4 * - td_return where order_id = $order_id AND status != -1 * - * Stock-out rows are children of the order — they follow the parent - * and are never independently approved. On cancel, ALL stock-out rows - * linked to this order (any status except already -1) are soft-deleted - * (status → -1) across all td_stock_* tables. + * Stock-out rows are children of the order — they follow the parent. + * On cancel, approved rows first re-occupy the original source rack and + * reverse the stock-out balance. Then ALL stock-out rows linked to this + * order (any status except already -1) are soft-deleted (status → -1) + * across all td_stock_* tables. * * Must be called inside dbTransaction() by the caller. * @@ -541,7 +545,7 @@ class OrderManager { "SELECT COUNT(*) FROM td_invoice WHERE company_id = :company_id AND order_id = :order_id - AND status != -1" + AND status != 4" ); $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); if ((int)$sth->fetchColumn() > 0) { @@ -566,7 +570,7 @@ class OrderManager { ); } - // ── Soft-delete all linked stock-out rows ───────────────────────── + // ── Reverse approved stock-out side effects, then soft-delete rows ─ $sth = $this->pdo->prepare( "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() @@ -574,11 +578,54 @@ class OrderManager { ); $sth->execute(); $tables = $sth->fetchAll(PDO::FETCH_COLUMN); + $whMgmt = new WarehouseManager($this->pdo, $this->company_id); foreach ($tables as $table) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) { + continue; + } + + $warehouse_id = (int)$matches[1]; + + $row_sth = $this->pdo->prepare( + "SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status + FROM `{$table}` + WHERE company_id = :company_id + AND source = 'order' + AND source_id = :order_id + AND type = 'out' + AND status != -1" + ); + $row_sth->execute([ + ':company_id' => $this->company_id, + ':order_id' => $order_id, + ]); + $rows = $row_sth->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as $row) { + if ((int)$row['status'] !== 1) { + continue; + } + + $whMgmt->occupyRack( + $warehouse_id, + (string)$row['zone'], + (string)$row['aisle'], + (string)$row['rack'], + (string)$row['product_sku'], + (int)$row['ref_id'] + ); + $whMgmt->adjustBalance( + 'out', + $warehouse_id, + (string)$row['product_sku'], + (float)$row['out'], + 0 + ); + } + $this->pdo->prepare( - "UPDATE `{$safe}` SET status = -1 + "UPDATE `{$table}` SET status = -1 WHERE company_id = :company_id AND source = 'order' AND source_id = :order_id diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php index 2bfe4ac..8734ae0 100644 --- a/app/assets/utils/classes/ProductManager.php +++ b/app/assets/utils/classes/ProductManager.php @@ -358,6 +358,7 @@ class ProductManager { ':company_id' => $this->company_id, ':product_name' => $data['product_name'], ':sku' => $data['sku'], + ':uom' => $data['uom'] ?? 'pcs', ':price' => $data['price'], ':min_stock' => $data['min_stock'], ':reorder_point' => $data['reorder_point'], @@ -374,6 +375,7 @@ class ProductManager { "UPDATE md_product SET product_name = :product_name, sku = :sku, + uom = :uom, price = :price, min_stock = :min_stock, reorder_point = :reorder_point, @@ -387,10 +389,10 @@ class ProductManager { } else { $this->pdo->prepare( "INSERT INTO md_product - (company_id, product_name, sku, price, min_stock, reorder_point, + (company_id, product_name, sku, uom, price, min_stock, reorder_point, category, product_image, `description`, `status`, `log`) VALUES - (:company_id, :product_name, :sku, :price, :min_stock, :reorder_point, + (:company_id, :product_name, :sku, :uom, :price, :min_stock, :reorder_point, :category, :product_image, :description, :status, :log)" )->execute($params); } diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php new file mode 100644 index 0000000..b200b75 --- /dev/null +++ b/app/assets/utils/classes/PurchaseOrderManager.php @@ -0,0 +1,596 @@ + tables. + * + * Method order: + * Transaction basis → getPoList, getPoById, generatePoNumber, + * savePo, confirmPo, receivePo, cancelPo + * + * Key design decisions: + * - PO items are stored as a JSON array in td_purchase_order.items. + * Same pattern as td_order. No separate child table. + * - receivePo() calls StockManager::saveStockIn() for each received line + * with source='po' and source_id=po_id. This means received goods appear + * in the existing Stock In list automatically under source='po'. + * - stock_in_id is written back into the items JSON after each receive call, + * same pattern as stock_out_id in OrderManager::confirmOrder(). + * - Partial receipt is supported: receivePo() can be called multiple times + * until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed). + * - cancelPo() is blocked if any linked stock-in rows have been approved (status=1), + * because those have already modified rack and balance. + * - Write methods do NOT manage their own DB transactions. + * Callers must wrap multi-step operations inside dbTransaction(). + * + * Security: All SQL uses PDO prepared statements with bound parameters. + * Dynamic stock table names are derived only from md_warehouse.id. + */ +class PurchaseOrderManager { + + private $pdo; + private $company_id; + + public function __construct($pdo, int $company_id) { + $this->pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + private function buildLogEntry(string $action): array { + return [ + 'user_id' => $_SESSION['login_user_id'] ?? null, + 'dt' => date('Y-m-d H:i:s'), + 'login' => isset($_SESSION['otpTime']) + ? date('Y-m-d H:i:s', $_SESSION['otpTime']) + : null, + 'action' => $action, + ]; + } + + /** + * Generate next sequential PO number in PO-YYYYMMDD-XXXX format. + */ + private function generatePoNumber(): string + { + $prefix = 'PO-' . date('Ymd') . '-'; + + $sth = $this->pdo->prepare( + "SELECT po_number FROM td_purchase_order + WHERE company_id = :company_id + AND po_number LIKE :prefix + ORDER BY po_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':prefix' => $prefix . '%', + ]); + + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function stockTableNameFromWarehouseId(int $warehouse_id): string + { + if ($warehouse_id <= 0) { + throw new Exception("Invalid warehouse id."); + } + + return 'td_stock_' . $warehouse_id; + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Read + // ───────────────────────────────────────────────────────────── + + /** + * Return all POs for the company, ordered by created_at DESC. + * Joins md_contact for supplier name display. + */ + public function getPoList(): array + { + $sth = $this->pdo->prepare( + "SELECT p.*, + COALESCE(c.contact_name, '') AS contact_name + FROM td_purchase_order p + LEFT JOIN md_contact c + ON c.company_id = p.company_id + AND c.id = p.contact_id + WHERE p.company_id = :company_id + ORDER BY p.created_at DESC" + ); + $sth->execute([':company_id' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Fetch a single PO by its primary key. + * Returns the row with items decoded as a PHP array. + */ + public function getPoById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT p.*, + COALESCE(c.contact_name, '') AS contact_name + FROM td_purchase_order p + LEFT JOIN md_contact c + ON c.company_id = p.company_id + AND c.id = p.contact_id + WHERE p.company_id = :company_id + AND p.id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) return false; + + $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + return $row; + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Write + // ───────────────────────────────────────────────────────────── + + /** + * Insert a new PO (draft) or update metadata on an existing draft. + * + * Insert (id=0): generates po_number, sets status=0, payment_status=0. + * Update (id>0): only allowed while status=0 (draft). + * + * @param array $data Keys: id, contact_id, po_date, expected_date, + * warehouse_id, items (array), discount, tax, + * shipping_fee, notes. + * @param array $logging Audit entry. + * @return int New td_purchase_order.id on insert, 0 on update. + */ + public function savePo(array $data, array $logging): int + { + $id = (int)($data['id'] ?? 0); + $items = $data['items'] ?? []; + + $subtotal = array_reduce($items, fn($carry, $item) => + $carry + (float)($item['total_price'] ?? 0), 0.0 + ); + $discount = (float)($data['discount'] ?? 0); + $tax = (float)($data['tax'] ?? 0); + $shipping_fee = (float)($data['shipping_fee'] ?? 0); + $grand_total = $subtotal - $discount + $tax + $shipping_fee; + + if ($id > 0) { + + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_purchase_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Purchase order not found."); + if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited."); + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = $logging; + + $this->pdo->prepare( + "UPDATE td_purchase_order SET + contact_id = :contact_id, + po_date = :po_date, + expected_date = :expected_date, + warehouse_id = :warehouse_id, + items = :items, + subtotal = :subtotal, + discount = :discount, + tax = :tax, + shipping_fee = :shipping_fee, + grand_total = :grand_total, + notes = :notes, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':po_date' => $data['po_date'] ?? date('Y-m-d'), + ':expected_date' => $data['expected_date'] ?: null, + ':warehouse_id' => (int)($data['warehouse_id'] ?? 0), + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':shipping_fee' => $shipping_fee, + ':grand_total' => $grand_total, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + + return 0; + + } else { + + $log = [$logging]; + + $this->pdo->prepare( + "INSERT INTO td_purchase_order + (company_id, uuid, po_number, contact_id, po_date, expected_date, + warehouse_id, status, payment_status, subtotal, discount, tax, + shipping_fee, grand_total, items, notes, `log`, created_at) + VALUES + (:company_id, :uuid, :po_number, :contact_id, :po_date, :expected_date, + :warehouse_id, 0, 0, :subtotal, :discount, :tax, + :shipping_fee, :grand_total, :items, :notes, :log, :created_at)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':po_number' => $this->generatePoNumber(), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':po_date' => $data['po_date'] ?? date('Y-m-d'), + ':expected_date' => $data['expected_date'] ?: null, + ':warehouse_id' => (int)($data['warehouse_id'] ?? 0), + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':shipping_fee' => $shipping_fee, + ':grand_total' => $grand_total, + ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + return (int)$this->pdo->lastInsertId(); + } + } + + /** + * Confirm a draft PO — advance status 0 → 1. + * No stock rows are created at this stage; receipt happens via receivePo(). + * + * @throws Exception If PO not found or not in draft. + */ + public function confirmPo(int $po_id, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_purchase_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); + $po = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$po) throw new Exception("Purchase order not found."); + if ((int)$po['status'] !== 0) throw new Exception("Only draft POs can be confirmed."); + + $log = json_decode($po['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'confirm']); + + $this->pdo->prepare( + "UPDATE td_purchase_order SET + status = 1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $po_id, + ':company_id' => $this->company_id, + ]); + } + + /** + * Receive goods against a confirmed PO — creates draft stock-in rows. + * + * Flow per received line: + * 1. Calls StockManager::saveStockIn() with source='po', source_id=po_id. + * 2. Writes stock_in_id back into the PO item object (same as stock_out_id in orders). + * 3. Increments received_qty on the item. + * + * After all lines: + * 4. If all items are fully received → status = 3 (completed). + * 5. If partially received → status = 2 (partial). + * + * Can be called multiple times for partial deliveries. + * If auto_approve=true (from company settings), approveStock() is also called. + * + * @param int $po_id td_purchase_order.id + * @param array $receive_items Each item: { item_id, product_sku, warehouse_id, + * quantity, zone, aisle, rack, lot_number, + * expiry_date, serial_number, contact_id } + * @param string $uuid UUID prefix for stock-in rows. + * @param array $logging Audit entry. + * @param bool $auto_approve Auto-approve stock-in rows immediately. + */ + public function receivePo( + int $po_id, + array $receive_items, + string $uuid, + array $logging, + bool $auto_approve = false + ): void { + $sth = $this->pdo->prepare( + "SELECT * FROM td_purchase_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); + $po = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$po) throw new Exception("Purchase order not found."); + + $status = (int)$po['status']; + if ($status === -1) throw new Exception("Cannot receive against a cancelled PO."); + if ($status === 0) throw new Exception("Confirm the PO before receiving goods."); + if ($status === 3) throw new Exception("This PO is already fully received."); + + if (empty($receive_items)) { + throw new Exception("No items provided to receive."); + } + + $po_items = json_decode($po['items'] ?? '[]', true) ?: []; + + // Index PO items by item_id for quick lookup + $po_items_by_id = []; + foreach ($po_items as $i => $item) { + $po_items_by_id[(int)($item['item_id'] ?? $i)] = $i; + } + + $stock = new StockManager($this->pdo, $this->company_id); + $whMgmt = new WarehouseManager($this->pdo, $this->company_id); + + foreach ($receive_items as $j => $recv) { + $item_id = (int)($recv['item_id'] ?? -1); + $product_sku = $recv['product_sku'] ?? ''; + $warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']); + $quantity = (float)($recv['quantity'] ?? 0); + + if ($quantity <= 0) continue; + if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku."); + if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id."); + + $item_uuid = $uuid . '_' . $j; + $item_log = array_merge($logging, ['action' => 'receive_item']); + + $rack = $recv['rack'] ?? ''; + $zone = $recv['zone'] ?? ''; + $aisle = $recv['aisle'] ?? ''; + + // Simple location mode: zone and aisle must mirror the rack value + // (same convention as manage_stock_in.php). + // occupyRack() looks up md_rack WHERE zone=:zone AND aisle=:aisle AND rack=:rack, + // so all three must match — a blank zone/aisle produces "Rack --b does not exist". + if ($zone === '' && $rack !== '') $zone = $rack; + if ($aisle === '' && $rack !== '') $aisle = $rack; + + $stock_data = [ + 'id' => 0, + 'warehouse' => $warehouse_id, + 'product_sku' => $product_sku, + 'quantity' => $quantity, + 'zone' => $zone, + 'aisle' => $aisle, + 'rack' => $rack, + 'lot_number' => $recv['lot_number'] ?? '', + 'expiry_date' => $recv['expiry_date'] ?? '', + 'serial_number' => $recv['serial_number'] ?? '', + 'contact_id' => (int)($recv['contact_id'] ?? $po['contact_id'] ?? 0), + 'description' => $po['po_number'], + 'source' => 'po', + 'source_id' => $po_id, + ]; + + $new_stock_in_id = $stock->saveStockIn($stock_data, $item_log, $item_uuid); + + if ($new_stock_in_id > 0) { + // saveStockIn() does not write source/source_id — stamp them here. + // This links the stock-in row back to this PO for cancellation guards + // and makes it appear under the "PO" source tab on the Stock In list. + $table = $this->stockTableNameFromWarehouseId($warehouse_id); + $this->pdo->prepare( + "UPDATE `{$table}` SET source = 'po', source_id = :po_id + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':po_id' => $po_id, + ':id' => $new_stock_in_id, + ':company_id' => $this->company_id, + ]); + + // approveStock() handles balance updates. saveStockIn() has + // already reserved the rack so draft receipts cannot be reused. + if ($auto_approve) { + $stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt); + } + } + + // Write stock_in_id + received qty back into PO item + if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { + $idx = $po_items_by_id[$item_id]; + $already_received = (float)($po_items[$idx]['received_qty'] ?? 0); + $po_items[$idx]['received_qty'] = $already_received + $quantity; + $po_items[$idx]['stock_in_id'] = $new_stock_in_id; + $po_items[$idx]['receive_wh'] = $warehouse_id; + $po_items[$idx]['receive_zone'] = $zone; + $po_items[$idx]['receive_aisle'] = $aisle; + $po_items[$idx]['receive_rack'] = $rack; + } + } + + // Determine new PO status + $all_received = true; + foreach ($po_items as $item) { + $ordered = (float)($item['quantity'] ?? 0); + $received = (float)($item['received_qty'] ?? 0); + if ($received < $ordered) { + $all_received = false; + break; + } + } + $new_status = $all_received ? 3 : 2; + + $log = json_decode($po['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]); + + $this->pdo->prepare( + "UPDATE td_purchase_order SET + status = :status, + items = :items, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':status' => $new_status, + ':items' => json_encode($po_items, JSON_UNESCAPED_UNICODE), + ':log' => json_encode($log), + ':id' => $po_id, + ':company_id' => $this->company_id, + ]); + } + + /** + * Update payment status on a PO. + * Allowed for status >= 1 (confirmed, partial, completed). + * + * @param int $po_id td_purchase_order.id + * @param int $payment_status 0=unpaid, 1=paid, 2=partial + * @param array $logging Audit entry. + */ + public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_purchase_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); + $po = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$po) throw new Exception("Purchase order not found."); + if ((int)$po['status'] < 1) throw new Exception("Confirm the PO before updating payment."); + if ((int)$po['status'] === -1) throw new Exception("Cannot update a cancelled PO."); + + if (!in_array($payment_status, [0, 1, 2], true)) { + throw new Exception("Invalid payment status."); + } + + $log = json_decode($po['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]); + + $this->pdo->prepare( + "UPDATE td_purchase_order SET + payment_status = :payment_status, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':payment_status' => $payment_status, + ':log' => json_encode($log), + ':id' => $po_id, + ':company_id' => $this->company_id, + ]); + } + + /** + * Cancel a PO. + * + * Blocked if any linked stock-in rows have already been approved (status=1) + * because those have modified rack occupancy and balance. + * + * For draft stock-in rows (status=0), they are soft-deleted (status=-1). + * + * @throws Exception + */ + public function cancelPo(int $po_id, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_purchase_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); + $po = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$po) throw new Exception("Purchase order not found."); + + $status = (int)$po['status']; + if ($status === -1) throw new Exception("PO is already cancelled."); + if ($status === 3) throw new Exception("Cannot cancel a completed PO."); + + // Guard: block if any approved stock-in rows exist for this PO + $sth = $this->pdo->prepare( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name LIKE 'td_stock_%'" + ); + $sth->execute(); + $tables = $sth->fetchAll(PDO::FETCH_COLUMN); + + foreach ($tables as $table) { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM `{$table}` + WHERE company_id = :company_id + AND source = 'po' + AND source_id = :po_id + AND status = 1" + ); + $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception( + "Cannot cancel — stock from this PO has already been approved and received. " . + "Please adjust stock manually if needed." + ); + } + } + + $whMgmt = new WarehouseManager($this->pdo, $this->company_id); + + // Soft-delete draft stock-in rows and release their rack reservations. + foreach ($tables as $table) { + if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) { + continue; + } + $warehouse_id = (int)$m[1]; + + $sth = $this->pdo->prepare( + "SELECT id, zone, aisle, rack + FROM `{$table}` + WHERE company_id = :company_id + AND source = 'po' + AND source_id = :po_id + AND status = 0" + ); + $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); + $draft_rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + foreach ($draft_rows as $row) { + $this->pdo->prepare( + "UPDATE `{$table}` SET status = -1 + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':id' => (int)$row['id'], + ':company_id' => $this->company_id, + ]); + + $whMgmt->releaseRack( + $warehouse_id, + $row['zone'], + $row['aisle'], + $row['rack'] + ); + } + } + + $log = json_decode($po['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'cancel']); + + $this->pdo->prepare( + "UPDATE td_purchase_order SET + status = -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $po_id, + ':company_id' => $this->company_id, + ]); + } +} diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php index e693264..fda8738 100644 --- a/app/assets/utils/classes/ReportManager.php +++ b/app/assets/utils/classes/ReportManager.php @@ -16,17 +16,7 @@ * Balance summary → getWarehouseBalanceSummary * * Security: All SQL uses PDO prepared statements with bound parameters. - * Dynamic table names (td_stock_) are derived from DB-sourced - * warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) - * before interpolation. Integer parameters (warehouse_id, company_id, limit) - * are explicitly cast to (int) before use in any SQL string fragment. - * - * Security fixes applied vs. previous version: - * - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped) - * - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection; - * warehouse_name in UNION now uses $safe (was unescaped) - * - getRackLog: cross-DB join uses $mainDb injected at construction time - * - getRackOccupancy: (already safe — no dynamic identifiers) + * Dynamic stock table names are derived only from md_warehouse.id. */ class ReportManager { @@ -45,26 +35,26 @@ class ReportManager // Private helpers // ───────────────────────────────────────────────────────────── - /** - * Resolve the td_stock_ table name for a warehouse, requiring active status. - * - * Returns null if the warehouse is not found or inactive. - * The warehouse_name is sanitised before use as a table suffix. - * - * @param int $warehouse_id The md_warehouse.id to resolve. - * @return string|null Sanitised table name, or null if not active/found. - */ + private function stockTableNameFromWarehouseId(int $warehouse_id): string + { + if ($warehouse_id <= 0) { + throw new Exception("Invalid warehouse id."); + } + + return 'td_stock_' . $warehouse_id; + } + private function resolveWarehouseTable(int $warehouse_id): ?string { $sth = $this->pdo->prepare( - "SELECT warehouse_name FROM md_warehouse + "SELECT id FROM md_warehouse WHERE company_id = :company_id AND id = :id AND status = 1" ); $sth->execute([':company_id' => $this->companyId, ':id' => $warehouse_id]); - $name = $sth->fetchColumn(); - if (!$name) return null; - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); - return "td_stock_{$safe}"; + $id = $sth->fetchColumn(); + if (!$id) return null; + + return $this->stockTableNameFromWarehouseId((int)$id); } /** @@ -569,9 +559,6 @@ class ReportManager * Builds a UNION ALL across all td_stock_* tables to produce a unified * activity feed ordered by date DESC. Used by the dashboard "recent activity" widget. * - * Security fix: warehouse_name in UNION SQL now uses $safe (sanitised with - * preg_replace) instead of the raw warehouse_name string. - * * @param int $limit Maximum number of transactions to return (default 10). * @return array Activity rows with product_name, product_sku, warehouse_name, * direction ('in'|'out'), qty, type, date. @@ -587,18 +574,17 @@ class ReportManager if (empty($warehouses)) return []; - // Security: use $safe (sanitised name) for both the table identifier - // and the literal warehouse_name string in the SELECT — never raw $wh['warehouse_name']. $unions = implode("\nUNION ALL\n", array_map( function ($wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $cid = (int) $this->companyId; + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); + $cid = (int) $this->companyId; + $warehouse_name = $this->pdo->quote($wh['warehouse_name']); return "SELECT s.date, s.product_sku, s.type, ROUND(COALESCE(s.`in`, 0), 2) AS stock_in, ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, p.product_name, - '{$safe}' AS warehouse_name - FROM `td_stock_{$safe}` s + {$warehouse_name} AS warehouse_name + FROM `{$table}` s LEFT JOIN md_product p ON p.company_id = s.company_id AND p.sku = s.product_sku @@ -721,7 +707,7 @@ class ReportManager /** * Return monthly stock_in and stock_out totals for a warehouse over the last 12 months. * - * Queries the per-warehouse td_stock_ table directly (not warehouse_balance) + * Queries the per-warehouse td_stock_ table directly (not warehouse_balance) * for per-warehouse granularity. Produces chart-ready arrays with month labels. * * @param int $warehouse_id The warehouse to query. @@ -899,8 +885,6 @@ class ReportManager * * Security fix: $warehouse_id is now cast to (int) and the WHERE condition * uses a bound parameter (:warehouse_id) instead of raw string interpolation. - * warehouse_name in UNION now uses $safe variable (sanitised) not raw $wh['warehouse_name']. - * * @param int $warehouse_id Warehouse filter (0 = all warehouses). * @return array Expiry-status stock items with product, lot, location, and days_remaining. */ @@ -928,10 +912,9 @@ class ReportManager $cid = (int) $this->companyId; // cast before interpolation - // Security: use $safe (sanitised) for table identifier and literal warehouse name string $unions = implode("\nUNION ALL\n", array_map( function ($wh) use ($cid) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); return "SELECT s.id, s.product_sku, @@ -941,7 +924,7 @@ class ReportManager s.rack, ROUND(s.`in`, 2) AS quantity, {$wh['id']} AS warehouse_id - FROM `td_stock_{$safe}` s + FROM `{$table}` s WHERE s.company_id = {$cid} AND s.type = 'in' AND s.lot_number IS NOT NULL @@ -1044,8 +1027,8 @@ class ReportManager $rows = []; foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); + $warehouse_name = $this->pdo->quote($wh['warehouse_name']); $sth = $this->pdo->prepare( "SELECT @@ -1059,7 +1042,7 @@ class ReportManager ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, s.serial_number, s.description, - '{$safe}' AS warehouse_name + {$warehouse_name} AS warehouse_name FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku @@ -1109,8 +1092,7 @@ class ReportManager $cid = (int) $this->companyId; foreach ($wh_list as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $sth = $this->pdo->query( "SELECT lot_number, @@ -1195,7 +1177,10 @@ class ReportManager { if (!$rack_id) return []; - $main_db = preg_replace('/[^a-zA-Z0-9_]/', '', $this->mainDb); + $main_db = $this->mainDb; + if ($main_db === '' || !ctype_alnum(str_replace('_', '', $main_db))) { + throw new Exception("Invalid main database name."); + } $sth = $this->pdo->prepare( "SELECT @@ -1287,7 +1272,7 @@ class ReportManager } /** - * Return paginated raw stock movement transactions from td_stock_ + * Return paginated raw stock movement transactions from td_stock_ * within a date range (by month), with a balance brought forward. * * Each row is one individual transaction — no grouping. Full datetime is @@ -1412,13 +1397,15 @@ class ReportManager $bf_out = (float)($bf['bf_out'] ?? 0); $bf_bal = round($bf_in - $bf_out, 2); - // Product name + // Product name + UOM $sth = $this->pdo->prepare( - "SELECT product_name FROM md_product + "SELECT product_name, uom FROM md_product WHERE company_id = :company_id AND sku = :sku LIMIT 1" ); $sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku]); - $product_name = $sth->fetchColumn() ?: $product_sku; + $product_row = $sth->fetch(PDO::FETCH_ASSOC) ?: []; + $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 $sth = $this->pdo->prepare( @@ -1460,6 +1447,7 @@ class ReportManager return [ 'sku' => $product_sku, 'product_name' => $product_name, + 'uom' => $product_uom, 'brought_forward' => [ 'in' => $bf_in, 'out' => $bf_out, @@ -1468,4 +1456,4 @@ class ReportManager 'rows' => $rows, ]; } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php index db1f89c..9074fb7 100644 --- a/app/assets/utils/classes/ReturnManager.php +++ b/app/assets/utils/classes/ReturnManager.php @@ -12,7 +12,7 @@ * Key design decisions: * - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order) * - confirmReturn() is the single restock path for all customer returns: - * 1. INSERT td_stock_ in rows (source='return', source_id=return_id, + * 1. INSERT td_stock_ in rows (source='return', source_id=return_id, * status=1 ALWAYS — confirming a return is a final business decision, * no separate warehouse approval step needed). * 2. occupyRack() + adjustBalance() via WarehouseManager. @@ -27,7 +27,7 @@ * Callers must wrap multi-step operations inside dbTransaction(). * * Security: All SQL uses PDO prepared statements with bound parameters. - * Dynamic table names are sanitised before interpolation. + * Dynamic stock table names are derived only from md_warehouse.id. */ class ReturnManager { @@ -72,17 +72,13 @@ class ReturnManager { return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); } - private function resolveStockTable(int $warehouse_id): string + private function stockTableNameFromWarehouseId(int $warehouse_id): string { - $sth = $this->pdo->prepare( - "SELECT warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); - $name = $sth->fetchColumn(); + if ($warehouse_id <= 0) { + throw new Exception("Invalid warehouse id."); + } - if (!$name) throw new Exception("Warehouse ID {$warehouse_id} not found."); - return 'td_stock_' . preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return 'td_stock_' . $warehouse_id; } // ───────────────────────────────────────────────────────────── @@ -254,9 +250,9 @@ class ReturnManager { * Confirm a return — restock items back to warehouse + optionally create credit note. * * Flow per item: - * 1. INSERT td_stock_ in row with type='in', status=1 (force confirmed), + * 1. INSERT td_stock_ in row with type='in', status=1 (force confirmed), * source='return', source_id=return_id. - * 2. occupyRack() — place returned stock back into the original rack. + * 2. occupyRack() — place returned stock into the user-selected empty rack. * 3. adjustBalance() — update warehouse_balance. * * After all items: @@ -304,13 +300,42 @@ class ReturnManager { foreach ($items as $i => $item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $stock_out_wh = (int)($item['stock_out_warehouse_id'] ?? $warehouse_id); + $stock_out_id = (int)($item['stock_out_id'] ?? 0); $product_sku = $item['product_sku'] ?? ''; if (!$warehouse_id || !$product_sku) { throw new Exception("Item #{$i}: missing warehouse_id or product_sku."); } + if (!$stock_out_wh || !$stock_out_id) { + throw new Exception("Item #{$i}: missing linked stock-out record."); + } - $table = $this->resolveStockTable($warehouse_id); + $stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh); + $stock_out_sth = $this->pdo->prepare( + "SELECT status, lot_number, serial_number + FROM `{$stock_out_table}` + WHERE company_id = :company_id + AND id = :id + AND product_sku = :product_sku + AND type = 'out' + LIMIT 1" + ); + $stock_out_sth->execute([ + ':company_id' => $this->company_id, + ':id' => $stock_out_id, + ':product_sku' => $product_sku, + ]); + $stock_out = $stock_out_sth->fetch(PDO::FETCH_ASSOC); + + if (!$stock_out) { + throw new Exception("Item #{$i}: linked stock-out record not found."); + } + if ((int)$stock_out['status'] !== 1) { + throw new Exception("Item #{$i}: stock-out is still draft. Approve stock-out before confirming the return."); + } + + $table = $this->stockTableNameFromWarehouseId($warehouse_id); $item_uuid = $uuid . '_ret_' . $i; $item_log = [array_merge($logging, ['action' => 'confirm_return_item'])]; $quantity = (float)($item['quantity'] ?? 0); @@ -341,8 +366,8 @@ class ReturnManager { ':contact_id' => (int)$return['contact_id'], ':description' => $return['return_number'], ':log' => json_encode($item_log), - ':lot_number' => $item['lot_number'] ?? '', - ':serial_number' => $item['serial_number'] ?? '', + ':lot_number' => $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''), + ':serial_number' => $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''), ':source_id' => $return_id, ':price' => (float)($item['price'] ?? 0), ]); @@ -356,7 +381,8 @@ class ReturnManager { $item['aisle'] ?? '', $item['rack'] ?? '', $product_sku, - $stock_in_id + $stock_in_id, + true ); $whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity); } @@ -465,12 +491,11 @@ class ReturnManager { if (!$warehouse_id || !$product_sku) continue; - $table = $this->resolveStockTable($warehouse_id); - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); // Find the stock-in row created by confirmReturn() $sth = $this->pdo->prepare( - "SELECT id, zone, aisle, rack FROM `{$safe}` + "SELECT id, zone, aisle, rack FROM `{$table}` WHERE company_id = :company_id AND source = 'return' AND source_id = :return_id @@ -489,7 +514,7 @@ class ReturnManager { // Soft-delete the stock-in row $this->pdo->prepare( - "UPDATE `{$safe}` SET status = -1 + "UPDATE `{$table}` SET status = -1 WHERE id = :id AND company_id = :company_id" )->execute([ ':id' => $stock_row['id'], @@ -501,9 +526,7 @@ class ReturnManager { $warehouse_id, $stock_row['zone'], $stock_row['aisle'], - $stock_row['rack'], - $product_sku, - $stock_row['id'] + $stock_row['rack'] ); // Reverse balance @@ -526,4 +549,4 @@ class ReturnManager { ':company_id' => $this->company_id, ]); } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index 1dbcddc..4f07294 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -19,8 +19,7 @@ * Callers must wrap multi-step operations inside dbTransaction(). * * Security: All SQL uses PDO prepared statements with bound parameters. - * Dynamic table names (td_stock_) are derived from DB-sourced warehouse - * names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation. + * Dynamic stock table names are derived only from md_warehouse.id. */ class StockManager { @@ -36,34 +35,13 @@ class StockManager { // Private helpers // ───────────────────────────────────────────────────────────── - /** - * Resolve the dynamic td_stock_ table name for a warehouse_id. - * - * Looks up warehouse_name from md_warehouse (no status filter — unlike - * WarehouseManager::resolveWarehouseTable, this serves read flows that may - * need to access inactive warehouses for historical record retrieval). - * The name is sanitised with preg_replace before being used as a table - * identifier, preventing SQL injection via malicious warehouse names. - * - * @param int $warehouse_id The md_warehouse.id to resolve. - * @return string The sanitised table name, e.g. "td_stock_Main". - * @throws Exception If no warehouse is found for the given ID. - */ - private function resolveTable(int $warehouse_id): string + private function stockTableNameFromWarehouseId(int $warehouse_id): string { - $sth = $this->pdo->prepare( - "SELECT warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); - $name = $sth->fetchColumn(); - - if (!$name) { - throw new Exception("Warehouse not found."); + if ($warehouse_id <= 0) { + throw new Exception("Invalid warehouse id."); } - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); - return "td_stock_{$safe}"; + return 'td_stock_' . $warehouse_id; } // ───────────────────────────────────────────────────────────── @@ -83,14 +61,14 @@ class StockManager { */ public function getStockList(int $warehouse_id, string $type): array { - $table = $this->resolveTable($warehouse_id); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); $column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; // Transfer list: show only the outbound side (out > 0) to avoid duplicate display $extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : ''; $sth = $this->pdo->prepare( - "SELECT a.*, {$column} AS quantity, b.product_name + "SELECT a.*, {$column} AS quantity, b.product_name, b.uom FROM `{$table}` a LEFT JOIN md_product b ON a.company_id = b.company_id @@ -114,18 +92,18 @@ class StockManager { * stock in detail view. Joins md_lot to include lot expiry_date when available. * * @param int $warehouse_id The warehouse the stock_in belongs to. - * @param int $id The td_stock_.id of the stock_in row. + * @param int $id The td_stock_.id of the stock_in row. * @return array|false Full row with 'quantity', 'contact_name', 'product_name', * 'expiry_date', or false if not found. */ public function getStockInById(int $warehouse_id, int $id): array|false { - $table = $this->resolveTable($warehouse_id); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, a.in AS quantity, b.contact_name, - c.product_name, + c.product_name, c.uom, d.expiry_date FROM `{$table}` a LEFT JOIN md_contact b @@ -149,18 +127,18 @@ class StockManager { * stock out detail view. * * @param int $warehouse_id The warehouse the stock_out belongs to. - * @param int $id The td_stock_.id of the stock_out row. + * @param int $id The td_stock_.id of the stock_out row. * @return array|false Full row with 'quantity', 'contact_name', 'product_name', * or false if not found. */ public function getStockOutById(int $warehouse_id, int $id): array|false { - $table = $this->resolveTable($warehouse_id); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, a.out AS quantity, b.contact_name, - c.product_name + c.product_name, c.uom FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id @@ -182,19 +160,19 @@ class StockManager { * transfer detail view. * * @param int $warehouse_id The warehouse holding the outbound (from) row. - * @param int $id The td_stock_.id of the outbound transfer row. + * @param int $id The td_stock_.id of the outbound transfer row. * @return array|false Outbound row with 'quantity', 'contact_name', 'product_name', * and a 'ref' key containing the inbound row, or false if not found. */ public function getTransferById(int $warehouse_id, int $id): array|false { - $table = $this->resolveTable($warehouse_id); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); // Fetch the outbound (from) row $sth = $this->pdo->prepare( "SELECT a.*, a.out AS quantity, b.contact_name, - c.product_name + c.product_name, c.uom FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id @@ -209,12 +187,12 @@ class StockManager { // Resolve the inbound (to) row via ref_warehouse + uuid $to_warehouse_id = (int)$output['ref_warehouse']; - $to_table = $this->resolveTable($to_warehouse_id); + $to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, a.in AS quantity, b.contact_name, - c.product_name + c.product_name, c.uom FROM `{$to_table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id @@ -237,9 +215,9 @@ class StockManager { * * Insert flow (id = 0): * 1. Upserts md_lot if lot_number + expiry_date are provided. - * 2. Inserts the td_stock_ row. - * 3. Calls WarehouseManager::occupyRack() to mark the rack as taken. - * 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance. + * 2. Inserts the td_stock_ row. + * 3. Calls WarehouseManager::occupyRack() to reserve the rack. + * warehouse_balance is updated later by approveStock(). * * Update flow (id > 0): * - Updates contact_id, description, and log only. @@ -328,7 +306,18 @@ class StockManager { $td_stock_id = (int)$this->pdo->lastInsertId(); - // occupyRack and adjustBalance deferred — called from approveStock() only. + // Reserve the location immediately so draft stock-in rows cannot + // leave the same rack available for another receipt. Balance still + // changes only when approveStock() runs. + $whMgmt->occupyRack( + $warehouse_id, + $data['zone'], + $data['aisle'], + $data['rack'], + $data['product_sku'], + $td_stock_id + ); + return $td_stock_id; } } @@ -338,7 +327,7 @@ class StockManager { * * Insert flow (id = 0): * 1. Validates the rack is occupied with the correct SKU / lot / serial. - * 2. Inserts the td_stock_ row, copying quantity and lot info from the rack. + * 2. Inserts the td_stock_ row, copying quantity and lot info from the rack. * 3. Calls WarehouseManager::releaseRack() to free the rack slot. * 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance. * @@ -609,7 +598,7 @@ class StockManager { ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, - ':ref_warehouse' => $whMgmt->getWarehouseName($to_warehouse), + ':ref_warehouse' => $to_warehouse, ':zone' => $from_zone, ':aisle' => $from_aisle, ':rack' => $from_rack, @@ -637,7 +626,7 @@ class StockManager { ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, - ':ref_warehouse' => $whMgmt->getWarehouseName($from_warehouse), + ':ref_warehouse' => $from_warehouse, ':ref_id' => $from_stock_id, ':zone' => $to_zone, ':aisle' => $to_aisle, @@ -686,7 +675,7 @@ class StockManager { */ public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void { - $table = $this->resolveTable($warehouse_id); + $table = $this->stockTableNameFromWarehouseId($warehouse_id); // Load the row and validate ownership / status $sth = $this->pdo->prepare( @@ -724,31 +713,52 @@ class StockManager { } elseif ($type === 'out') { - // Release rack now that stock-out is approved - $whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); + $remaining_qty = 0.0; + if ((int)($row['ref_id'] ?? 0) > 0) { + $remaining_sth = $this->pdo->prepare( + "SELECT src.`in` - COALESCE(SUM(out_rows.`out`), 0) AS remaining_qty + FROM `{$table}` src + LEFT JOIN `{$table}` out_rows + ON out_rows.company_id = src.company_id + AND out_rows.ref_id = src.id + AND out_rows.`out` > 0 + AND out_rows.status != -1 + WHERE src.id = :ref_id + AND src.company_id = :company_id + GROUP BY src.id, src.`in`" + ); + $remaining_sth->execute([ + ':ref_id' => (int)$row['ref_id'], + ':company_id' => $this->company_id, + ]); + $remaining_qty = (float)$remaining_sth->fetchColumn(); + } + + // Release the rack only when the source batch is fully consumed. + if ($remaining_qty <= 0.000001) { + $whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); + } $whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']); } elseif ($type === 'transfer') { // Transfer always has two rows in two different tables: - // outbound row → td_stock_ (out > 0, ref_warehouse = to_name) - // inbound row → td_stock_ (in > 0, ref_warehouse = from_name) + // outbound row → td_stock_ (out > 0, ref_warehouse = to_id) + // inbound row → td_stock_ (in > 0, ref_warehouse = from_id) // Both rows share the same uuid. We always approve both atomically. // Identify which side we were given and derive the other. $is_outbound = (float)$row['out'] > 0; // The outbound row lives in the from-warehouse table (already loaded as $row/$table). - // The inbound row lives in td_stock_. + // The inbound row lives in td_stock_. $from_row = $is_outbound ? $row : null; $from_table = $is_outbound ? $table : null; $from_wh_id = $is_outbound ? $warehouse_id : null; - // Resolve the paired table from ref_warehouse - $paired_wh_name = $row['ref_warehouse']; - $paired_safe = preg_replace('/[^a-zA-Z0-9_]/', '', $paired_wh_name); - $paired_table = "td_stock_{$paired_safe}"; - $paired_wh_id = $whMgmt->getWarehouseIdByName($paired_wh_name); + // Resolve the paired table from ref_warehouse id + $paired_wh_id = (int)$row['ref_warehouse']; + $paired_table = $this->stockTableNameFromWarehouseId($paired_wh_id); // If we received the inbound side, swap so $from_* is always outbound if (!$is_outbound) { @@ -816,4 +826,4 @@ class StockManager { } } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index 552aee0..09e323d 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -17,9 +17,7 @@ * Callers must wrap multi-step operations inside dbTransaction(). * * Security: All SQL uses PDO prepared statements with bound parameters. - * Dynamic table names (td_stock_) are derived exclusively from - * DB-sourced warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) - * before interpolation — no user input ever reaches a table identifier directly. + * Dynamic stock table names are derived only from md_warehouse.id. */ class WarehouseManager { @@ -35,27 +33,26 @@ class WarehouseManager { // Private helpers // ───────────────────────────────────────────────────────────── - /** - * Resolve the td_stock_ table name for a warehouse, requiring active status. - * - * Returns null if the warehouse is not found or is inactive (status != 1). - * Used by Zone/Aisle/Rack out-query methods where inactive warehouses - * should not contribute available stock locations. - * - * @param int $warehouse_id The md_warehouse.id to resolve. - * @return string|null Sanitised table name, or null if not active/found. - */ + private function stockTableNameFromWarehouseId(int $warehouse_id): string + { + if ($warehouse_id <= 0) { + throw new Exception("Invalid warehouse id."); + } + + return 'td_stock_' . $warehouse_id; + } + private function resolveWarehouseTable(int $warehouse_id): ?string { $sth = $this->pdo->prepare( - "SELECT warehouse_name FROM md_warehouse + "SELECT id FROM md_warehouse WHERE company_id = :company_id AND id = :id AND status = 1" ); $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); - $name = $sth->fetchColumn(); - if (!$name) return null; - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); - return "td_stock_{$safe}"; + $id = $sth->fetchColumn(); + if (!$id) return null; + + return $this->stockTableNameFromWarehouseId((int)$id); } /** @@ -330,6 +327,90 @@ class WarehouseManager { ]); } + /** + * Soft-delete rack logs created by a stock movement and its reversal. + * + * md_rack_log follows the same soft-delete convention as other tenant rows: + * negate company_id so normal company_id filters hide the records. + */ + private function softDeleteRackLogs( + $warehouse_id, + string $zone, + string $aisle, + string $rack, + array $actions, + string $product_sku, + array $td_stock_ids = [], + ?string $since = null + ): void { + if (empty($actions)) { + return; + } + + $rack_sth = $this->pdo->prepare( + "SELECT id FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + LIMIT 1" + ); + $rack_sth->execute([ + ':company_id' => $this->company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ]); + $rack_id = (int)$rack_sth->fetchColumn(); + if (!$rack_id) { + return; + } + + $params = [ + ':company_id' => $this->company_id, + ':md_rack_id' => $rack_id, + ':product_sku' => $product_sku, + ]; + + $action_placeholders = []; + foreach (array_values($actions) as $idx => $action) { + $key = ':action_' . $idx; + $action_placeholders[] = $key; + $params[$key] = $action; + } + + $td_stock_ids = array_values(array_filter(array_map('intval', $td_stock_ids))); + $td_stock_cond = ''; + if (!empty($td_stock_ids)) { + $td_placeholders = []; + foreach ($td_stock_ids as $idx => $td_stock_id) { + $key = ':td_stock_id_' . $idx; + $td_placeholders[] = $key; + $params[$key] = $td_stock_id; + } + $td_stock_cond = 'AND td_stock_id IN (' . implode(', ', $td_placeholders) . ')'; + } + + $since_cond = ''; + if ($since !== null && $since !== '') { + $since_cond = 'AND dt >= :since'; + $params[':since'] = $since; + } + + $sql = "UPDATE md_rack_log + SET company_id = company_id * -1 + WHERE company_id = :company_id + AND md_rack_id = :md_rack_id + AND product_sku = :product_sku + AND action IN (" . implode(', ', $action_placeholders) . ") + {$td_stock_cond} + {$since_cond}"; + + $this->pdo->prepare($sql)->execute($params); + } + /** * Validate that the occupied racks under a storage_id all fall inside a new range. * @@ -340,15 +421,14 @@ class WarehouseManager { * @param array $range Keys: aisle_from, aisle_to, rack_from, rack_to. * @throws Exception If occupied racks would fall outside the new range. */ - private function validateRangeChange(int $storage_id, array $range): void + private function validateRangeChange(int $storage_id, array $range, bool $advanced_location): void { - // Build the valid sets from the new range $valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']); $valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']); // Fetch all occupied racks under this storage_id $sth = $this->pdo->prepare( - "SELECT aisle, rack FROM md_rack + "SELECT zone, aisle, rack FROM md_rack WHERE company_id = :company_id AND storage_id = :storage_id AND product_sku IS NOT NULL" @@ -359,10 +439,18 @@ class WarehouseManager { ]); foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $z = trim($row['zone']); $a = trim($row['aisle']); $r = trim($row['rack']); - if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) { + if ($advanced_location) { + if (in_array($a, $valid_aisles, true) && in_array($r, $valid_racks, true)) { + continue; + } + throw new Exception("Cannot shrink range — some racks still have stock"); + } + + if ($z !== $a || $a !== $r || !in_array($r, $valid_racks, true)) { throw new Exception("Cannot shrink range — some racks still have stock"); } } @@ -377,7 +465,7 @@ class WarehouseManager { * @param int $storage_id The md_storage.id this range belongs to. * @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to. */ - private function insertRacksInRange(int $storage_id, array $range): void + private function insertRacksInRange(int $storage_id, array $range, bool $advanced_location): void { $sql = "INSERT IGNORE INTO md_rack (company_id, warehouse, storage_id, zone, aisle, rack) @@ -388,6 +476,20 @@ class WarehouseManager { $aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']); $racks = $this->rangeToArray($range['rack_from'], $range['rack_to']); + if (!$advanced_location) { + foreach ($racks as $location) { + $sth->execute([ + ":company_id" => $this->company_id, + ":warehouse" => $range['warehouse'], + ":storage_id" => $storage_id, + ":zone" => $location, + ":aisle" => $location, + ":rack" => $location, + ]); + } + return; + } + foreach ($aisles as $a) { foreach ($racks as $r) { $sth->execute([ @@ -481,41 +583,43 @@ class WarehouseManager { return $sth->fetchColumn(); } - /** - * Reverse lookup — return warehouse_id for a given warehouse_name. - * Used by approveStock() to resolve the destination warehouse on transfers. - * - * @param string $name The warehouse_name stored in td_stock.ref_warehouse. - * @return int|null The warehouse id, or null if not found. - */ - public function getWarehouseIdByName(string $name): ?int - { - $sth = $this->pdo->prepare( - "SELECT id FROM md_warehouse - WHERE company_id = :company_id AND warehouse_name = :name - LIMIT 1" - ); - $sth->execute([':company_id' => $this->company_id, ':name' => $name]); - $id = $sth->fetchColumn(); - return $id !== false ? (int)$id : null; - } - /** * Insert a new warehouse or update an existing one. * - * On insert, creates a new per-warehouse stock table (td_stock_) - * using the td_stock template via CREATE TABLE LIKE. - * The warehouse_name is sanitised before use as a table name suffix. + * On insert, returns the new warehouse id so the caller can create the + * per-warehouse stock table outside the DB transaction. * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, warehouse_name, location, manager, description, status. * @param array $logging Audit entry to append to the log column. + * @return int|null New warehouse id on insert, null on update. */ - public function saveWarehouse(array $data, array $logging): void + public function saveWarehouse(array $data, array $logging): ?int { $id = (int)($data['id'] ?? 0); + $warehouse_name = trim((string)($data['warehouse_name'] ?? '')); + + if ($warehouse_name === '') { + throw new Exception("Warehouse name is required."); + } + + $duplicate = $this->pdo->prepare( + "SELECT COUNT(*) FROM md_warehouse + WHERE company_id = :company_id + AND warehouse_name = :warehouse_name + AND id != :id" + ); + $duplicate->execute([ + ':company_id' => $this->company_id, + ':warehouse_name' => $warehouse_name, + ':id' => $id, + ]); + + if ((int)$duplicate->fetchColumn() > 0) { + throw new Exception("Warehouse name already exists. Please use a different warehouse name."); + } $sth = $this->pdo->prepare( "SELECT `log` FROM md_warehouse @@ -527,7 +631,7 @@ class WarehouseManager { $params = [ ':company_id' => $this->company_id, - ':warehouse_name' => $data['warehouse_name'] ?? '', + ':warehouse_name' => $warehouse_name, ':location' => $data['location'] ?? '', ':manager' => (int)($data['manager'] ?? 0), ':description' => $data['description'] ?? '', @@ -555,12 +659,16 @@ class WarehouseManager { (:company_id, :warehouse_name, :location, :manager, :description, :status, :log)" )->execute($params); - // Create the per-warehouse stock table using td_stock as template - if (!empty($data['warehouse_name'])) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $data['warehouse_name']); - $this->pdo->exec("CREATE TABLE `td_stock_{$safe}` LIKE `td_stock`"); - } + return (int)$this->pdo->lastInsertId(); } + + return null; + } + + public function createStockTableForWarehouse(int $warehouse_id): void + { + $table = $this->stockTableNameFromWarehouseId($warehouse_id); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `td_stock`"); } /** @@ -682,10 +790,43 @@ class WarehouseManager { * rack_from, rack_to, description, status. * @param array $logging Audit entry to append to the log column. */ - public function saveStorage(array $data, array $logging): void + public function saveStorage(array $data, array $logging, bool $advanced_location = false): void { $id = (int)($data['id'] ?? 0); + $warehouse = trim((string)($data['warehouse'] ?? '')); + $zone = trim((string)($data['zone'] ?? '')); + $aisle_from = trim((string)($data['aisle_from'] ?? '')); + $aisle_to = trim((string)($data['aisle_to'] ?? '')); + $rack_from = trim((string)($data['rack_from'] ?? '')); + $rack_to = trim((string)($data['rack_to'] ?? '')); + + if ($warehouse === '') { + throw new Exception("Warehouse is required."); + } + + if ($advanced_location) { + if ($zone === '' || $aisle_from === '' || $aisle_to === '' || $rack_from === '' || $rack_to === '') { + throw new Exception("Zone, aisle range, and rack range are required."); + } + } else { + $location_from = $rack_from !== '' ? $rack_from : $aisle_from; + $location_to = $rack_to !== '' ? $rack_to : $aisle_to; + + if ($location_from === '') { + throw new Exception("Location From is required."); + } + if ($location_to === '') { + $location_to = $location_from; + } + + $zone = $location_from; + $aisle_from = $location_from; + $aisle_to = $location_to; + $rack_from = $location_from; + $rack_to = $location_to; + } + $sth = $this->pdo->prepare( "SELECT `log` FROM md_storage WHERE company_id = :company_id AND id = :id" @@ -696,12 +837,12 @@ class WarehouseManager { $params = [ ':company_id' => $this->company_id, - ':warehouse' => $data['warehouse'] ?? '', - ':zone' => $data['zone'] ?? '', - ':aisle_from' => $data['aisle_from'] ?? '', - ':aisle_to' => $data['aisle_to'] ?? '', - ':rack_from' => $data['rack_from'] ?? '', - ':rack_to' => $data['rack_to'] ?? '', + ':warehouse' => $warehouse, + ':zone' => $zone, + ':aisle_from' => $aisle_from, + ':aisle_to' => $aisle_to, + ':rack_from' => $rack_from, + ':rack_to' => $rack_to, ':description' => $data['description'] ?? '', ':status' => (int)($data['status'] ?? 1), ':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE), @@ -739,13 +880,13 @@ class WarehouseManager { // Sync md_rack rows to exactly match the new aisle × rack range $this->syncRacks($storage_id, [ - 'warehouse' => $data['warehouse'], - 'zone' => $data['zone'], - 'aisle_from' => $data['aisle_from'], - 'aisle_to' => $data['aisle_to'], - 'rack_from' => $data['rack_from'], - 'rack_to' => $data['rack_to'], - ]); + 'warehouse' => $warehouse, + 'zone' => $zone, + 'aisle_from' => $aisle_from, + 'aisle_to' => $aisle_to, + 'rack_from' => $rack_from, + 'rack_to' => $rack_to, + ], $advanced_location); } /** @@ -938,7 +1079,7 @@ class WarehouseManager { // Only return lots that have at least one active td_stock row. // Lots whose stock was fully soft-deleted (company_id negated) are excluded. $sth = $this->pdo->prepare( - "SELECT id, warehouse_name FROM md_warehouse + "SELECT id FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); @@ -950,8 +1091,8 @@ class WarehouseManager { // Build UNION EXISTS subquery across all td_stock_* tables $unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - return "SELECT lot_number FROM `td_stock_{$safe}` + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); + return "SELECT lot_number FROM `{$table}` WHERE company_id = {$cid} AND lot_number IS NOT NULL"; }, $warehouses)); @@ -987,7 +1128,7 @@ class WarehouseManager { public function getActiveLots(string $product_sku): array { $sth = $this->pdo->prepare( - "SELECT id, warehouse_name FROM md_warehouse + "SELECT id FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); @@ -996,8 +1137,7 @@ class WarehouseManager { $active_lots = []; foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $sth = $this->pdo->prepare( "SELECT DISTINCT s.lot_number, l.expiry_date @@ -1008,7 +1148,7 @@ class WarehouseManager { AND l.lot_number = s.lot_number WHERE s.company_id = :company_id AND s.product_sku = :product_sku - AND s.type = 'in' + AND s.`in` > 0 AND s.status = 1 AND s.lot_number IS NOT NULL AND EXISTS ( @@ -1047,7 +1187,7 @@ class WarehouseManager { public function getActiveSerials(string $product_sku, string $lot_number = ''): array { $sth = $this->pdo->prepare( - "SELECT id, warehouse_name FROM md_warehouse + "SELECT id FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); @@ -1056,15 +1196,14 @@ class WarehouseManager { $active_serials = []; foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $wh_id = $wh['id']; $sql = "SELECT DISTINCT s.serial_number FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku - AND s.type = 'in' + AND s.`in` > 0 AND s.status = 1 AND s.serial_number IS NOT NULL AND EXISTS ( @@ -1104,8 +1243,8 @@ class WarehouseManager { * Resolve the per-warehouse stock table name and optionally fetch a specific row. * * Core helper used throughout engine files and StockManager to get: - * - 'table': the td_stock_ table name - * - 'name': the raw warehouse_name string + * - 'table': the td_stock_ table name + * - 'name': the raw warehouse_name string for display * - 'row': the specific stock row (or empty array if $id = 0) * * Pass $id = 0 to get just the table name without fetching a row. @@ -1113,15 +1252,17 @@ class WarehouseManager { * historical rows that need reading. * * @param int|string $warehouse_id The md_warehouse.id. - * @param int|string $id The td_stock_.id to fetch, or 0 for table-only. + * @param int|string $id The td_stock_.id to fetch, or 0 for table-only. * @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]). */ public function getStockContext($warehouse_id, $id) { + $warehouse_id = (int)$warehouse_id; $name = $this->getWarehouseName($warehouse_id); + if (!$name) { + throw new Exception("Warehouse ID {$warehouse_id} not found."); + } - // Sanitise table suffix to prevent SQL injection via warehouse names - $safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name); - $table = "td_stock_" . $safe_name; + $table = $this->stockTableNameFromWarehouseId($warehouse_id); if (empty($id)) { return [ @@ -1166,10 +1307,10 @@ class WarehouseManager { * @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to. * @throws Exception If occupied racks would be removed by the new range. */ - public function syncRacks(int $storage_id, array $range): void { + public function syncRacks(int $storage_id, array $range, bool $advanced_location): void { // Guard: refuse if any occupied rack under this storage_id falls outside the new range - $this->validateRangeChange($storage_id, $range); + $this->validateRangeChange($storage_id, $range, $advanced_location); // Wipe all empty racks belonging to this storage_id $sql = "DELETE FROM md_rack @@ -1184,14 +1325,14 @@ class WarehouseManager { ]); // Reinsert the full range fresh (INSERT IGNORE skips any occupied racks already there) - $this->insertRacksInRange($storage_id, $range); + $this->insertRacksInRange($storage_id, $range, $advanced_location); } /** * Fetch the stock record currently occupying a rack. * * Under the 1:1 rack model, each occupied rack's md_rack.td_stock_id points - * to exactly one td_stock_ row. This method resolves that pointer and + * to exactly one td_stock_ row. This method resolves that pointer and * returns the full stock row, or null if the rack is empty or the link is broken. * * Used by saveStockOut and saveStockTransfer to validate rack contents before @@ -1227,9 +1368,17 @@ class WarehouseManager { return null; // Rack is empty or doesn't exist } - // Fetch the full stock row from the per-warehouse table + // Fetch the full stock row from the per-warehouse table. Source + // movements can only consume approved stock-in rows; draft stock-in + // rows may reserve a rack but are not available inventory yet. $context = $this->getStockContext($warehouse_id, $td_stock_id); - return $context['row'] ?: null; + $row = $context['row'] ?: null; + + if (!$row || $row['type'] !== 'in' || (int)$row['status'] !== 1) { + return null; + } + + return $row; } /** @@ -1274,20 +1423,24 @@ class WarehouseManager { if (empty($warehouses)) return; foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; + $wh_id = (int)$wh['id']; + $table = $this->stockTableNameFromWarehouseId($wh_id); // Find any stock_in row for this SKU that is still rack-linked (active) // and matches the lot/serial combination being validated. // The exclude_id skips the just-inserted row to avoid false self-conflict. + // r.warehouse = :warehouse_id scopes the rack check to THIS warehouse only — + // td_stock_id values are per-table integers, not globally unique, so without + // this filter a rack in a different warehouse could cause a false positive. $sql = "SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku - AND s.type = 'in' + AND s.`in` > 0 AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = :company_id2 + AND r.warehouse = :warehouse_id AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL )"; @@ -1303,9 +1456,10 @@ class WarehouseManager { } $params = [ - ':company_id' => $cid, - ':company_id2' => $cid, - ':product_sku' => $product_sku, + ':company_id' => $cid, + ':company_id2' => $cid, + ':warehouse_id' => $wh_id, + ':product_sku' => $product_sku, ]; if (!empty($lot_number)) $params[':lot_number'] = $lot_number; if (!empty($serial_number)) $params[':serial_number'] = $serial_number; @@ -1332,7 +1486,8 @@ class WarehouseManager { * Assign a product to an empty rack and link it to a td_stock row. * * Uses FOR UPDATE to lock the rack row and prevent concurrent occupancy. - * Also calls validateStockUnique to enforce no-duplicate-serial rules. + * Also calls validateStockUnique to enforce no-duplicate-serial rules unless + * the caller is replaying a validated source transaction, such as a return. * Updates md_rack state (product_sku + td_stock_id) in a single UPDATE * and appends a rack log entry. * @@ -1343,12 +1498,12 @@ class WarehouseManager { * @param string $aisle Aisle identifier. * @param string $rack Rack identifier. * @param string $product_sku SKU to assign to this rack. - * @param int $td_stock_id The td_stock_.id to link. + * @param int $td_stock_id The td_stock_.id to link. * @throws Exception If the rack does not exist or is already occupied. */ - public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void { + public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id, bool $skip_unique_validation = false): void { - $sql = "SELECT id, product_sku FROM md_rack + $sql = "SELECT id, product_sku, td_stock_id FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone @@ -1371,22 +1526,31 @@ class WarehouseManager { } if ($current['product_sku'] !== null) { + if ( + $current['product_sku'] === $product_sku + && (int)$current['td_stock_id'] === (int)$td_stock_id + ) { + return; + } + throw new Exception( "Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}" ); } - // Validate sku + lot + serial uniqueness before linking the rack - $ctx = $this->getStockContext($warehouse_id, $td_stock_id); - $row = $ctx['row'] ?? null; + if (!$skip_unique_validation) { + // Validate sku + lot + serial uniqueness before linking the rack + $ctx = $this->getStockContext($warehouse_id, $td_stock_id); + $row = $ctx['row'] ?? null; - if ($row) { - $this->validateStockUnique( - $product_sku, - $row['lot_number'] ?? null, - $row['serial_number'] ?? null, - $td_stock_id // exclude self so insert flows don't self-conflict - ); + if ($row) { + $this->validateStockUnique( + $product_sku, + $row['lot_number'] ?? null, + $row['serial_number'] ?? null, + $td_stock_id // exclude self so insert flows don't self-conflict + ); + } } // Atomic UPDATE: set state and link in one statement @@ -1674,7 +1838,8 @@ class WarehouseManager { SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 AND s.product_sku = :product_sku2 - AND s.type = 'in' + AND s.`in` > 0 + AND s.status = 1 {$lot_cond} {$serial_cond} ) @@ -1745,7 +1910,8 @@ class WarehouseManager { SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 AND s.product_sku = :product_sku2 - AND s.type = 'in' + AND s.`in` > 0 + AND s.status = 1 {$lot_cond} {$serial_cond} ) @@ -1841,7 +2007,8 @@ class WarehouseManager { SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 AND s.product_sku = :product_sku2 - AND s.type = 'in' + AND s.`in` > 0 + AND s.status = 1 {$lot_cond} {$serial_cond} ) @@ -1861,12 +2028,12 @@ class WarehouseManager { * Steps: * 1. Validates this is the globally latest transaction for the SKU. * 2. Soft-deletes the td_stock row (negates company_id). - * 3. Releases the occupied rack back to empty. - * 4. Reverses the balance (adjustBalance with new_qty = 0). + * 3. Releases the reserved/occupied rack back to empty. + * 4. Reverses the balance only if the row was approved. * * Must be called inside a DB transaction by the caller. * - * @param int $stock_id The td_stock_.id of the row to delete. + * @param int $stock_id The td_stock_.id of the row to delete. * @param int $warehouse_id The warehouse the row belongs to. * @throws Exception If not the latest transaction or record not found. */ @@ -1892,9 +2059,19 @@ class WarehouseManager { WHERE id = :id AND company_id = :company_id" )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); - // Only reverse side effects if the row was approved — draft rows never had them applied + // Stock-in reserves the rack at creation time, even while draft. + // Balance is only applied for approved rows. + $this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); + $this->softDeleteRackLogs( + $warehouse_id, + $row['zone'], $row['aisle'], $row['rack'], + ['occupy', 'release'], + $product_sku, + [$stock_id], + $row['date'] + ); + if ((int)$row['status'] === 1) { - $this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0); } } @@ -1910,7 +2087,7 @@ class WarehouseManager { * * Must be called inside a DB transaction by the caller. * - * @param int $stock_id The td_stock_.id of the row to delete. + * @param int $stock_id The td_stock_.id of the row to delete. * @param int $warehouse_id The warehouse the row belongs to. * @throws Exception If not the latest transaction or record not found. */ @@ -1945,6 +2122,14 @@ class WarehouseManager { $product_sku, (int)$row['ref_id'] ); + $this->softDeleteRackLogs( + $warehouse_id, + $row['zone'], $row['aisle'], $row['rack'], + ['release', 'occupy'], + $product_sku, + [(int)$row['ref_id']], + $row['date'] + ); $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0); } } @@ -2020,6 +2205,22 @@ class WarehouseManager { $product_sku, $from_stock_id ); + $this->softDeleteRackLogs( + $to_warehouse, + $to_row['zone'], $to_row['aisle'], $to_row['rack'], + ['occupy', 'release'], + $product_sku, + [$ref_id], + $from_row['date'] + ); + $this->softDeleteRackLogs( + $from_warehouse_id, + $from_row['zone'], $from_row['aisle'], $from_row['rack'], + ['release', 'occupy'], + $product_sku, + [], + $from_row['date'] + ); // Reverse balances on both sides $quantity = (int)$from_row['out']; @@ -2056,8 +2257,7 @@ class WarehouseManager { $cid = (int)$this->company_id; foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $wh_id = (int)$wh['id']; $sth = $this->pdo->prepare( @@ -2065,7 +2265,7 @@ class WarehouseManager { WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.lot_number = :lot_number - AND s.type = 'in' + AND s.`in` > 0 AND s.status = 1 AND EXISTS ( SELECT 1 FROM md_rack r @@ -2108,6 +2308,52 @@ class WarehouseManager { */ public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array { + if (!$id && $type === 'from') { + if ($product_sku === '') { + return []; + } + + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND status = 1 + ORDER BY warehouse_name" + ); + $sth->execute([':company_id' => $this->company_id]); + $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); + + $result = []; + foreach ($warehouses as $wh) { + $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); + + $stock = $this->pdo->prepare( + "SELECT 1 + FROM md_rack r + INNER JOIN `{$table}` s + ON s.company_id = r.company_id + AND s.id = r.td_stock_id + WHERE r.company_id = :company_id + AND r.warehouse = :warehouse + AND r.product_sku = :product_sku + AND s.product_sku = :product_sku2 + AND s.`in` > 0 + AND s.status = 1 + LIMIT 1" + ); + $stock->execute([ + ':company_id' => $this->company_id, + ':warehouse' => (int)$wh['id'], + ':product_sku' => $product_sku, + ':product_sku2' => $product_sku, + ]); + + if ($stock->fetchColumn() !== false) { + $result[] = $wh; + } + } + + return $result; + } + $product_sku_filter = ''; $params = [':company_id' => $this->company_id]; diff --git a/app/dashboard/low_stock_products.php b/app/dashboard/low_stock_products.php index 058ae6d..1725cfe 100644 --- a/app/dashboard/low_stock_products.php +++ b/app/dashboard/low_stock_products.php @@ -254,7 +254,7 @@ ${item.product_sku} ${item.warehouse_name} - ${item.balance} + ${item.balance} ${item.uom || 'pcs'} ${item.min_stock} ${item.reorder_point} ${badge} diff --git a/app/ics/api/engine/manage_stock_in.php b/app/ics/api/engine/manage_stock_in.php index 82c4ac5..876cde4 100644 --- a/app/ics/api/engine/manage_stock_in.php +++ b/app/ics/api/engine/manage_stock_in.php @@ -5,15 +5,19 @@ require '../../../assets/utils/classes/WarehouseManager.php'; require '../../../assets/utils/classes/CompanySettingManager.php'; - $csm = new CompanySettingManager($pdo1, $company_id); - $auto_approve = (int)$csm->get('default_stock_status') === 1; + $csm = new CompanySettingManager($pdo1, $company_id); + $auto_approve = (int)$csm->get('default_stock_status') === 1; - try { - $new_id = null; + try { + // Ensure the per-warehouse stock table exists (older warehouses may not have it yet) + $whBoot = new WarehouseManager($pdo2, $company_id); + $whBoot->createStockTableForWarehouse((int)$data['warehouse']); - dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) { - $stock = new StockManager($pdo, $company_id); - $whMgmt = new WarehouseManager($pdo, $company_id); + $new_id = null; + + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) { + $stock = new StockManager($pdo, $company_id); + $whMgmt = new WarehouseManager($pdo, $company_id); $new_id = $stock->saveStockIn($data, $logging, $uuid); if ($auto_approve && $new_id > 0) { @@ -35,5 +39,5 @@ http_response_code(400); } - exit(json_encode($answer)); -?> \ No newline at end of file + exit(json_encode($answer)); + ?> diff --git a/app/ics/api/engine/manage_stock_out.php b/app/ics/api/engine/manage_stock_out.php index 450a4f4..bb96e50 100644 --- a/app/ics/api/engine/manage_stock_out.php +++ b/app/ics/api/engine/manage_stock_out.php @@ -5,15 +5,19 @@ require '../../../assets/utils/classes/WarehouseManager.php'; require '../../../assets/utils/classes/CompanySettingManager.php'; - $csm = new CompanySettingManager($pdo1, $company_id); - $auto_approve = (int)$csm->get('default_stock_status') === 1; + $csm = new CompanySettingManager($pdo1, $company_id); + $auto_approve = (int)$csm->get('default_stock_status') === 1; - try { - $new_id = null; + try { + // Ensure the per-warehouse stock table exists (older warehouses may not have it yet) + $whBoot = new WarehouseManager($pdo2, $company_id); + $whBoot->createStockTableForWarehouse((int)$data['warehouse']); - dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) { - $stock = new StockManager($pdo, $company_id); - $whMgmt = new WarehouseManager($pdo, $company_id); + $new_id = null; + + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) { + $stock = new StockManager($pdo, $company_id); + $whMgmt = new WarehouseManager($pdo, $company_id); $new_id = $stock->saveStockOut($data, $logging, $uuid); if ($auto_approve && $new_id > 0) { @@ -35,5 +39,5 @@ http_response_code(400); } - exit(json_encode($answer)); -?> \ No newline at end of file + exit(json_encode($answer)); + ?> diff --git a/app/ics/api/engine/manage_stock_transfer.php b/app/ics/api/engine/manage_stock_transfer.php index a116223..e424e5f 100644 --- a/app/ics/api/engine/manage_stock_transfer.php +++ b/app/ics/api/engine/manage_stock_transfer.php @@ -5,15 +5,20 @@ require '../../../assets/utils/classes/WarehouseManager.php'; require '../../../assets/utils/classes/CompanySettingManager.php'; - $csm = new CompanySettingManager($pdo1, $company_id); - $auto_approve = (int)$csm->get('default_stock_status') === 1; + $csm = new CompanySettingManager($pdo1, $company_id); + $auto_approve = (int)$csm->get('default_stock_status') === 1; - try { - $new_id = null; + try { + // Ensure the per-warehouse stock tables exist (older warehouses may not have them yet) + $whBoot = new WarehouseManager($pdo2, $company_id); + $whBoot->createStockTableForWarehouse((int)$data['warehouse_from']); + $whBoot->createStockTableForWarehouse((int)$data['warehouse_to']); - dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) { - $stock = new StockManager($pdo, $company_id); - $whMgmt = new WarehouseManager($pdo, $company_id); + $new_id = null; + + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) { + $stock = new StockManager($pdo, $company_id); + $whMgmt = new WarehouseManager($pdo, $company_id); $new_id = $stock->saveStockTransfer($data, $logging, $uuid); if ($auto_approve && $new_id > 0) { @@ -36,4 +41,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index 9eeb468..aafdbde 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -104,6 +104,7 @@
+
@@ -161,9 +162,15 @@ function manage_stock_in() { - // In simple mode, mirror rack value to zone and aisle + var rack_val = $('#rack').val(); + var zone_val = advanced ? $('#zone').val() : rack_val; + var aisle_val = advanced ? $('#aisle').val() : rack_val; + var warehouse_val = $('#warehouse').val(); + var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val(); + var quantity_val = $('#quantity').val(); + + // Mirror to hidden inputs for autoPrepare consistency (simple mode) if (!advanced) { - var rack_val = $('#rack').val(); $('#zone').val(rack_val); $('#aisle').val(rack_val); } @@ -178,8 +185,12 @@ 'lot_number': $('#lot_number').val(), 'expiry_date': $('#expiry_date').val(), 'serial_number': $('#serial_number').val(), - 'zone': $('#zone').val(), - 'aisle': $('#aisle').val(), + 'zone': zone_val, + 'aisle': aisle_val, + 'rack': rack_val, + 'product_sku': sku_val, + 'quantity': quantity_val, + 'warehouse': warehouse_val, }, action: 'manage', onSuccess: function() { @@ -189,6 +200,30 @@ } + function approve_current_stock() { + bootbox.confirm({ + message: 'Approve this stock-in record?', + buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } }, + callback: function(result) { + if (!result) return; + ajax_request({ + url: 'ics/api/engine/approve_stock.php', + autoPrepare: true, + checkRequired: 0, + action: 'update', + data: { + id: '', + warehouse: '', + type: 'in' + }, + onSuccess: function() { + window.location.reload(); + } + }); + } + }); + } + function retrieve_warehouse(type) { @@ -309,7 +344,7 @@ var data = res.output; - $('#warehouse').val('').attr('disabled', true); + $('#warehouse').val('').attr('disabled', true); function populate_fields() { $.each(data, function(key, item) { @@ -336,6 +371,9 @@ $('#product_sku, #quantity').prop('disabled', true); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); + if (data.status == 0) { + $('#approve_stock_btn').removeClass('d-none'); + } } if (advanced) { @@ -465,4 +503,4 @@ - \ No newline at end of file + diff --git a/app/ics/manage_stock_out.php b/app/ics/manage_stock_out.php index 7ab153f..4f82482 100644 --- a/app/ics/manage_stock_out.php +++ b/app/ics/manage_stock_out.php @@ -99,6 +99,7 @@
+
@@ -149,8 +150,11 @@ } function manage_stock_out() { + var rack_val = $('#rack').val(); + var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val(); + var wh_val = $('#warehouse').val(); + if (!advanced) { - var rack_val = $('#rack').val(); $('#zone').val(rack_val); $('#aisle').val(rack_val); } @@ -161,9 +165,12 @@ checkRequired: 1, debugMode: 0, data: { - 'contact_id': $('#contact').attr('data-id'), - 'zone': $('#zone').val(), - 'aisle': $('#aisle').val(), + 'contact_id': $('#contact').attr('data-id'), + 'zone': advanced ? $('#zone').val() : rack_val, + 'aisle': advanced ? $('#aisle').val() : rack_val, + 'rack': rack_val, + 'product_sku': sku_val, + 'warehouse': wh_val, }, action: 'manage', onSuccess: function() { @@ -172,6 +179,30 @@ }); } + function approve_current_stock() { + bootbox.confirm({ + message: 'Approve this stock-out record?', + buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } }, + callback: function(result) { + if (!result) return; + ajax_request({ + url: 'ics/api/engine/approve_stock.php', + autoPrepare: true, + checkRequired: 0, + action: 'update', + data: { + id: '', + warehouse: '', + type: 'out' + }, + onSuccess: function() { + window.location.reload(); + } + }); + } + }); + } + // ── Step 1: Product selected → load active lots ─────────────────────── function retrieve_active_lot() { @@ -242,7 +273,11 @@ autoPrepare: true, checkRequired: 0, noLoading: true, - data: { "type": 'from', "lot_number": $('#lot_number').val() }, + data: { + "type": 'from', + "product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(), + "lot_number": $('#lot_number').val() + }, action: 'read', onSuccess: function(res) { var option = ''; @@ -264,6 +299,7 @@ checkRequired: 0, noLoading: true, data: { + "warehouse": $('#warehouse').val(), "type": 'from', "product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(), "lot_number": $('#lot_number').val(), @@ -289,6 +325,8 @@ checkRequired: 0, noLoading: true, data: { + "warehouse": $('#warehouse').val(), + "zone": $('#zone').val(), "type": 'from', "product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(), "lot_number": $('#lot_number').val(), @@ -314,6 +352,9 @@ checkRequired: 0, noLoading: true, data: { + "warehouse": $('#warehouse').val(), + "zone": $('#zone').val(), + "aisle": $('#aisle').val(), "type": 'from', "product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(), "lot_number": $('#lot_number').val(), @@ -379,6 +420,9 @@ $('#product_sku').attr('secondary', data.product_sku).prop('disabled', true); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); + if (data.status == 0) { + $('#approve_stock_btn').removeClass('d-none'); + } } if (advanced) { @@ -462,4 +506,4 @@ - \ No newline at end of file + diff --git a/app/ics/manage_stock_transfer.php b/app/ics/manage_stock_transfer.php index 555043d..f09d664 100644 --- a/app/ics/manage_stock_transfer.php +++ b/app/ics/manage_stock_transfer.php @@ -131,6 +131,7 @@
+
@@ -171,6 +172,7 @@ if (advanced) { $('#group_zone_from, #group_zone_to').show(); $('#group_aisle_from, #group_aisle_to').show(); + $('#zone_from, #zone_to, #aisle_from, #aisle_to').prop('required', true); $('#label_zone_from').text('From ' + label_zone); $('#label_zone_to').text('To ' + label_zone); $('#label_aisle_from').text('From ' + label_aisle); @@ -184,6 +186,7 @@ } else { $('#group_zone_from, #group_zone_to').hide(); $('#group_aisle_from, #group_aisle_to').hide(); + $('#zone_from, #zone_to, #aisle_from, #aisle_to').prop('required', false).removeClass('is-invalid is-valid'); $('#label_rack_from').text('From ' + label_rack); $('#label_rack_to').text('To ' + label_rack); $('#rack_from, #rack_to').find('option:first').text('Please select ' + label_rack.toLowerCase()); @@ -194,10 +197,12 @@ // ── Submit ──────────────────────────────────────────────────────────────── function manage_stock_transfer() { + var rack_from_val = $('#rack_from').val(); + var rack_to_val = $('#rack_to').val(); + var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val(); + // Simple mode — mirror rack value to zone and aisle before submit if (!advanced) { - var rack_from_val = $('#rack_from').val(); - var rack_to_val = $('#rack_to').val(); $('#zone_from').val(rack_from_val); $('#aisle_from').val(rack_from_val); $('#zone_to').val(rack_to_val); @@ -208,7 +213,18 @@ url: 'ics/api/engine/manage_stock_transfer.php', autoPrepare: true, checkRequired: 1, - data: { 'contact_id': $('#contact').attr('data-id') }, + data: { + 'contact_id': $('#contact').attr('data-id'), + 'warehouse_from': $('#warehouse_from').val(), + 'warehouse_to': $('#warehouse_to').val(), + 'zone_from': advanced ? $('#zone_from').val() : rack_from_val, + 'aisle_from': advanced ? $('#aisle_from').val() : rack_from_val, + 'rack_from': rack_from_val, + 'zone_to': advanced ? $('#zone_to').val() : rack_to_val, + 'aisle_to': advanced ? $('#aisle_to').val() : rack_to_val, + 'rack_to': rack_to_val, + 'product_sku': sku_val, + }, action: 'manage', onSuccess: function() { window.location.href = 'ics/stock_transfer.php'; @@ -216,6 +232,30 @@ }); } + function approve_current_stock() { + bootbox.confirm({ + message: 'Approve this stock-transfer record?', + buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } }, + callback: function(result) { + if (!result) return; + ajax_request({ + url: 'ics/api/engine/approve_stock.php', + autoPrepare: true, + checkRequired: 0, + action: 'update', + data: { + id: '', + warehouse: '', + type: 'transfer' + }, + onSuccess: function() { + window.location.reload(); + } + }); + } + }); + } + // ── Step 1: Product selected → load active lots + from-warehouses ───────── function retrieve_active_lot() { @@ -273,7 +313,11 @@ autoPrepare: true, checkRequired: 0, noLoading: true, - data: { 'type': type, 'lot_number': type === 'from' ? $('#lot_number').val() : '' }, + data: { + 'type': type, + 'product_sku': type === 'from' ? ($('#product_sku').attr('secondary') || $('#product_sku').val()) : '', + 'lot_number': type === 'from' ? $('#lot_number').val() : '' + }, action: 'read', onSuccess: function(res) { var option = ''; @@ -288,13 +332,16 @@ // ── Zone / Aisle / Rack loaders ─────────────────────────────────────────── function retrieve_zone(type, callback) { - var extra = {}; + var extra = { + 'warehouse_from': $('#warehouse_from').val(), + 'warehouse_to': $('#warehouse_to').val() + }; if (type === 'from') { - extra = { + extra = Object.assign(extra, { 'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(), 'lot_number': $('#lot_number').val(), 'serial_number': $('#serial_number').val() - }; + }); } return ajax_request({ url: 'ics/api/engine/retrieve_zone.php', @@ -315,13 +362,18 @@ } function retrieve_aisle(type, callback) { - var extra = {}; + var extra = { + 'warehouse_from': $('#warehouse_from').val(), + 'warehouse_to': $('#warehouse_to').val(), + 'zone_from': $('#zone_from').val(), + 'zone_to': $('#zone_to').val() + }; if (type === 'from') { - extra = { + extra = Object.assign(extra, { 'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(), 'lot_number': $('#lot_number').val(), 'serial_number': $('#serial_number').val() - }; + }); } return ajax_request({ url: 'ics/api/engine/retrieve_aisle.php', @@ -342,13 +394,20 @@ } function retrieve_rack(type, callback) { - var extra = {}; + var extra = { + 'warehouse_from': $('#warehouse_from').val(), + 'warehouse_to': $('#warehouse_to').val(), + 'zone_from': $('#zone_from').val(), + 'zone_to': $('#zone_to').val(), + 'aisle_from': $('#aisle_from').val(), + 'aisle_to': $('#aisle_to').val() + }; if (type === 'from') { - extra = { + extra = Object.assign(extra, { 'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(), 'lot_number': $('#lot_number').val(), 'serial_number': $('#serial_number').val() - }; + }); } return ajax_request({ url: 'ics/api/engine/retrieve_rack.php', @@ -400,9 +459,17 @@ $('#serial_number').html('') .val(data.serial_number || '').prop('disabled', true); + $('#product_name').val(data.product_name); + $('#product_sku').attr('secondary', data.product_sku).prop('disabled', true); + + retrieve_warehouse('from').then(function() { + return retrieve_warehouse('to'); + }).then(function() { + $('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true); + $('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true); + if (advanced) { // Full cascade for From - $('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true); retrieve_zone('from', function() { $('select[name="zone"][role="from"]').val(data.zone).prop('disabled', true); retrieve_aisle('from', function() { @@ -415,7 +482,6 @@ }); } else { // Simple mode — skip zone/aisle cascade, load rack directly - $('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true); retrieve_rack('from', function() { $('select[name="rack"][role="from"]').val(data.rack).prop('disabled', true); populate_to_side_simple(data, to_wh); @@ -432,16 +498,17 @@ $('#contact').val(data.contact_name) .attr('secondary', data.contact_name) .attr('data-id', data.contact_id); - $('#product_name').val(data.product_name); - $('#product_sku').attr('secondary', data.product_sku).prop('disabled', true); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); + if (data.status == 0) { + $('#approve_stock_btn').removeClass('d-none'); + } + }); } }); } function populate_to_side(data, to_wh) { - $('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true); retrieve_zone('to', function() { $('select[name="zone"][role="to"]').val(data.ref.zone).prop('disabled', true); retrieve_aisle('to', function() { @@ -454,7 +521,6 @@ } function populate_to_side_simple(data, to_wh) { - $('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true); retrieve_rack('to', function() { $('select[name="rack"][role="to"]').val(data.ref.rack).prop('disabled', true); }); @@ -465,7 +531,9 @@ $(async function() { try { await load_location_config(apply_location_labels); + await retrieve_warehouse('to'); + await retrieve_for_edit(); } catch(e) { console.log(e); @@ -526,4 +594,4 @@ - \ No newline at end of file + diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php index c23d370..dac715b 100644 --- a/app/ics/stock_in.php +++ b/app/ics/stock_in.php @@ -31,10 +31,18 @@
-
+
-
+
+ +
+
+ +
+
@@ -50,7 +58,8 @@ Product Lot Number Serial Number - Quantity + Quantity + Source Zone Aisle Rack @@ -103,6 +112,70 @@ return `${item['rack'] || ''}`; } + function escape_html(value) { + return String(value ?? '').replace(/[&<>"']/g, function(char) { + return { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char]; + }); + } + + function source_label(source) { + source = source || ''; + if (!source) return 'Manual'; + return source.charAt(0).toUpperCase() + source.slice(1).replace(/_/g, ' '); + } + + function source_cell(source) { + var label = escape_html(source_label(source)); + return source + ? `${label}` + : `${label}`; + } + + function populate_source_filter(rows) { + var current = $('#source').val() || ''; + var sources = {}; + + $.each(rows, function(_, item) { + sources[item.source || '__manual__'] = item.source || ''; + }); + + var option = ``; + $.each(Object.keys(sources).sort(), function(_, key) { + var value = sources[key]; + var option_value = value || '__manual__'; + option += ``; + }); + + $('#source').html(option).val(current); + if ($('#source').val() !== current) $('#source').val(''); + } + + function apply_source_filter() { + var selected = $('#source').val() || ''; + var rows = alasql.tables.stock_in_all ? alasql.tables.stock_in_all.data : []; + + if (!selected) { + alasql.tables.stock_in.data = rows; + } else { + alasql.tables.stock_in.data = rows.filter(function(item) { + var source = item.source || ''; + return selected === '__manual__' ? !source : source === selected; + }); + } + + var table_id = 'stock_in'; + var total_records = alasql.tables.stock_in.data.length; + var page = generate_pagination(table_id, total_records); + $(`table#${table_id} tfoot`).html(page); + change_page_stock_in(1); + } + function retrieve_stock_in() { return ajax_request({ @@ -114,22 +187,12 @@ onSuccess: function(res) { // Register the array as a table name + alasql('CREATE TABLE IF NOT EXISTS stock_in_all'); alasql('CREATE TABLE IF NOT EXISTS stock_in'); - alasql.tables.stock_in.data = res.output || []; + alasql.tables.stock_in_all.data = res.output || []; - var total_records = alasql.tables.stock_in.data.length; - - var table_id = 'stock_in'; - - var page = generate_pagination(table_id, total_records); - $(`table#${table_id} tfoot`).html(page); - - // dynamic function name - if (typeof window[`change_page_${table_id}`] === "function") { - window[`change_page_${table_id}`](1); - } else { - console.error(`Function change_page_${table_id} does not exist.`); - } + populate_source_filter(alasql.tables.stock_in_all.data); + apply_source_filter(); } }); @@ -153,19 +216,19 @@ ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'} - ${item['quantity']} + +
+ ${format_number(item['quantity'], 2)} + ${item['uom'] || ''} +
+ + ${source_cell(item['source'])} ${location_cells(item)} ${item['status'] == 1 ? 'Approved' : 'Draft'} - ${item['status'] == 0 - ? (auto_approve - ? '' - : '' - ) - : ''} @@ -225,6 +288,9 @@ console.log(e); } }); + + $(document).on('change', '#source', apply_source_filter); + $(document).on('change', '#warehouse', retrieve_stock_in); // ── Stock approval ──────────────────────────────────────────────────────── var auto_approve = false; // set from company_setting on load @@ -258,11 +324,13 @@ autoPrepare: true, checkRequired: 0, action: 'update', - id: id, - warehouse: warehouse_id, - type: type, + data: { + id: id, + warehouse: warehouse_id, + type: type + }, onSuccess: function(res) { - retrieve_stock_list(); + retrieve_stock_in(); } }); } diff --git a/app/ics/stock_out.php b/app/ics/stock_out.php index ca60001..bfa0758 100644 --- a/app/ics/stock_out.php +++ b/app/ics/stock_out.php @@ -31,10 +31,18 @@
-
+
-
+
+ +
+
+ +
+
@@ -50,7 +58,8 @@ Product Lot Number Serial Number - Quantity + Quantity + Source Zone Aisle Rack @@ -103,6 +112,70 @@ return `${item['rack'] || ''}`; } + function escape_html(value) { + return String(value ?? '').replace(/[&<>"']/g, function(char) { + return { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char]; + }); + } + + function source_label(source) { + source = source || ''; + if (!source) return 'Manual'; + return source.charAt(0).toUpperCase() + source.slice(1).replace(/_/g, ' '); + } + + function source_cell(source) { + var label = escape_html(source_label(source)); + return source + ? `${label}` + : `${label}`; + } + + function populate_source_filter(rows) { + var current = $('#source').val() || ''; + var sources = {}; + + $.each(rows, function(_, item) { + sources[item.source || '__manual__'] = item.source || ''; + }); + + var option = ``; + $.each(Object.keys(sources).sort(), function(_, key) { + var value = sources[key]; + var option_value = value || '__manual__'; + option += ``; + }); + + $('#source').html(option).val(current); + if ($('#source').val() !== current) $('#source').val(''); + } + + function apply_source_filter() { + var selected = $('#source').val() || ''; + var rows = alasql.tables.stock_out_all ? alasql.tables.stock_out_all.data : []; + + if (!selected) { + alasql.tables.stock_out.data = rows; + } else { + alasql.tables.stock_out.data = rows.filter(function(item) { + var source = item.source || ''; + return selected === '__manual__' ? !source : source === selected; + }); + } + + var table_id = 'stock_out'; + var total_records = alasql.tables.stock_out.data.length; + var page = generate_pagination(table_id, total_records); + $(`table#${table_id} tfoot`).html(page); + change_page_stock_out(1); + } + function retrieve_stock_out() { return ajax_request({ @@ -114,22 +187,12 @@ onSuccess: function(res) { // Register the array as a table name + alasql('CREATE TABLE IF NOT EXISTS stock_out_all'); alasql('CREATE TABLE IF NOT EXISTS stock_out'); - alasql.tables.stock_out.data = res.output || []; + alasql.tables.stock_out_all.data = res.output || []; - var total_records = alasql.tables.stock_out.data.length; - - var table_id = 'stock_out'; - - var page = generate_pagination(table_id, total_records); - $(`table#${table_id} tfoot`).html(page); - - // dynamic function name - if (typeof window[`change_page_${table_id}`] === "function") { - window[`change_page_${table_id}`](1); - } else { - console.error(`Function change_page_${table_id} does not exist.`); - } + populate_source_filter(alasql.tables.stock_out_all.data); + apply_source_filter(); } }); @@ -153,19 +216,19 @@ ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'} - ${item['quantity']} + +
+ ${format_number(item['quantity'], 2)} + ${item['uom'] || ''} +
+ + ${source_cell(item['source'])} ${location_cells(item)} ${item['status'] == 1 ? 'Approved' : 'Draft'}
- ${item['status'] == 0 - ? (auto_approve - ? '' - : '' - ) - : ''} @@ -225,6 +288,9 @@ console.log(e); } }); + + $(document).on('change', '#source', apply_source_filter); + $(document).on('change', '#warehouse', retrieve_stock_out); // ── Stock approval ──────────────────────────────────────────────────────── var auto_approve = false; // set from company_setting on load @@ -258,11 +324,13 @@ autoPrepare: true, checkRequired: 0, action: 'update', - id: id, - warehouse: warehouse_id, - type: type, + data: { + id: id, + warehouse: warehouse_id, + type: type + }, onSuccess: function(res) { - retrieve_stock_list(); + retrieve_stock_out(); } }); } diff --git a/app/ics/stock_transfer.php b/app/ics/stock_transfer.php index 98f9db0..43f9fd7 100644 --- a/app/ics/stock_transfer.php +++ b/app/ics/stock_transfer.php @@ -48,7 +48,7 @@ Date Product - Quantity + Quantity Zone Aisle Rack @@ -149,19 +149,18 @@ body += ` ${format_date(item["date"])} ${item["product_sku"]}: ${item['product_name']} - ${item['quantity']} + +
+ ${format_number(item['quantity'], 2)} + ${item['uom'] || ''} +
+ ${location_cells(item)} ${item['status'] == 1 ? 'Approved' : 'Draft'}
- ${item['status'] == 0 - ? (auto_approve - ? '' - : '' - ) - : ''} @@ -223,6 +222,8 @@ console.log(e); } }); + + $(document).on('change', '#warehouse', retrieve_stock_transfer); // ── Stock approval ──────────────────────────────────────────────────────── var auto_approve = false; // set from company_setting on load @@ -256,11 +257,13 @@ autoPrepare: true, checkRequired: 0, action: 'update', - id: id, - warehouse: warehouse_id, - type: type, + data: { + id: id, + warehouse: warehouse_id, + type: type + }, onSuccess: function(res) { - retrieve_stock_list(); + retrieve_stock_transfer(); } }); } diff --git a/app/include_sidebar.php b/app/include_sidebar.php index b100acf..c5ff603 100644 --- a/app/include_sidebar.php +++ b/app/include_sidebar.php @@ -75,7 +75,7 @@
  • - Orders + Sales Orders
  • @@ -89,7 +89,14 @@
  • - Invoices + Sales Invoices + +
  • + +
  • + + + Purchase Orders
  • diff --git a/app/inventory/api/engine/manage_product.php b/app/inventory/api/engine/manage_product.php index 8e102dc..6ad4776 100644 --- a/app/inventory/api/engine/manage_product.php +++ b/app/inventory/api/engine/manage_product.php @@ -15,9 +15,9 @@ exit(json_encode($answer)); } - if ($min_stock >= $reorder_point) { + if ($min_stock > $reorder_point) { $answer['success'] = 0; - $answer['message'] = "Min stock ({$min_stock}) must be less than reorder point ({$reorder_point})."; + $answer['message'] = "Min stock ({$min_stock}) must be less than or equal to reorder point ({$reorder_point})."; http_response_code(400); exit(json_encode($answer)); } diff --git a/app/inventory/api/engine/manage_storage.php b/app/inventory/api/engine/manage_storage.php index b48ba9c..1f58dd9 100644 --- a/app/inventory/api/engine/manage_storage.php +++ b/app/inventory/api/engine/manage_storage.php @@ -2,11 +2,15 @@ session_start(); require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/classes/WarehouseManager.php'; + require '../../../assets/utils/classes/CompanySettingManager.php'; try { - dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) { + $setting = new CompanySettingManager($pdo1, $company_id, $pdo2); + $advanced_location = (int)$setting->get('advanced_location') === 1; + + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $advanced_location) { $wh = new WarehouseManager($pdo, $company_id); - $wh->saveStorage($data, $logging); + $wh->saveStorage($data, $logging, $advanced_location); }); $answer['success'] = 1; @@ -23,4 +27,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/inventory/api/engine/manage_warehouse.php b/app/inventory/api/engine/manage_warehouse.php index 7bbf114..518de95 100644 --- a/app/inventory/api/engine/manage_warehouse.php +++ b/app/inventory/api/engine/manage_warehouse.php @@ -4,17 +4,29 @@ require '../../../assets/utils/classes/WarehouseManager.php'; try { - dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) { + $newWarehouseId = null; + + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$newWarehouseId) { $wh = new WarehouseManager($pdo, $company_id); - $wh->saveWarehouse($data, $logging); + $newWarehouseId = $wh->saveWarehouse($data, $logging); }); + if (!empty($newWarehouseId)) { + $wh = new WarehouseManager($pdo2, $company_id); + $wh->createStockTableForWarehouse((int)$newWarehouseId); + } + $answer['success'] = 1; } catch (PDOException $e) { $answer['success'] = 0; - $answer['message'] = 'Database error, please try again'; - http_response_code(500); + if ($e->getCode() === '23000' && strpos($e->getMessage(), 'company_id_warehouse_name') !== false) { + $answer['message'] = 'Warehouse name already exists. Please use a different warehouse name.'; + http_response_code(400); + } else { + $answer['message'] = 'Database error, please try again'; + http_response_code(500); + } } catch (Exception $e) { $answer['success'] = 0; @@ -23,4 +35,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/inventory/manage_product.php b/app/inventory/manage_product.php index 1e79c6e..5a7ff16 100644 --- a/app/inventory/manage_product.php +++ b/app/inventory/manage_product.php @@ -39,10 +39,29 @@
    -
    +
    +
    + + + + +
    @@ -53,7 +72,7 @@
    - Must be greater than min stock + Must be greater than or equal to min stock
    @@ -119,6 +138,7 @@ } formData.append('keep_files', $('#current_file_label').val()) + formData.append('uom', $('#uom').val() || 'pcs') return ajax_request({ url: "inventory/api/engine/manage_product.php", @@ -236,8 +256,8 @@ return false; } - if (min_stock >= reorder_point) { - flag('reorder_point', 'Must be greater than min stock (' + min_stock + ').'); + if (min_stock > reorder_point) { + flag('reorder_point', 'Must be greater than or equal to min stock (' + min_stock + ').'); return false; } diff --git a/app/inventory/manage_storage.php b/app/inventory/manage_storage.php index 8bd8fda..8b44ffa 100644 --- a/app/inventory/manage_storage.php +++ b/app/inventory/manage_storage.php @@ -172,6 +172,16 @@ var isNumeric = function(v) { return /^[0-9]+$/.test(v); }; var isAlpha = function(v) { return /^[A-Z]$/.test(v); }; + if (!zone) { + alert('Please enter a ' + label_zone + ' value.'); return; + } + if (!aisle_from || !aisle_to) { + alert('Please enter the full ' + label_aisle + ' range.'); return; + } + if (!rack_from || !rack_to) { + alert('Please enter the full ' + label_rack + ' range.'); return; + } + if (aisle_from && aisle_to) { if (isNumeric(aisle_from) !== isNumeric(aisle_to)) { alert(label_aisle + ' From and To must both be numbers or both be letters.'); return; @@ -303,4 +313,4 @@ - \ No newline at end of file + diff --git a/app/inventory/product.php b/app/inventory/product.php index fd85114..1645d62 100644 --- a/app/inventory/product.php +++ b/app/inventory/product.php @@ -197,6 +197,7 @@ Product SKU + UOM Current Stock Min Stock Reorder Point @@ -344,7 +345,7 @@ if (filtered.length === 0) { $("table#product > tbody").html( - 'No products match your search.' + 'No products match your search.' ); return; } @@ -355,11 +356,19 @@ $.each(page_data, function(key, item) { body += ` - ${item['product_name']} + ${item['product_image'] + ? `${item['product_name']}` + : `
    `} ${item['product_name']} ${item['sku']} - ${format_number(item['product_balance'], 4)} + ${item['uom'] || 'pcs'} + +
    + ${format_number(item['product_balance'], 4)} + ${item['uom'] || 'pcs'} +
    + ${format_number(item['min_stock'], 2)} ${format_number(item['reorder_point'], 2)} diff --git a/app/order/api/engine/cancel_order.php b/app/order/api/engine/cancel_order.php index a86f7cc..9bbe7f5 100644 --- a/app/order/api/engine/cancel_order.php +++ b/app/order/api/engine/cancel_order.php @@ -2,6 +2,7 @@ session_start(); require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/classes/OrderManager.php'; + require '../../../assets/utils/classes/WarehouseManager.php'; $id = (int)($data['id'] ?? 0); @@ -30,4 +31,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/retrieve_order.php b/app/order/api/engine/retrieve_order.php index 77a080b..6a2db79 100644 --- a/app/order/api/engine/retrieve_order.php +++ b/app/order/api/engine/retrieve_order.php @@ -54,11 +54,42 @@ $already_returned = $returned_qty[$i] ?? 0; $remaining = (float)$item['quantity'] - $already_returned; if ($remaining > 0) { + $stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0); + $stock_out_id = (int)($item['stock_out_id'] ?? 0); + + if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) { + $table = 'td_stock_' . $stock_out_warehouse_id; + $sth = $pdo2->prepare( + "SELECT id, status, zone, aisle, rack, lot_number, serial_number + FROM `{$table}` + WHERE company_id = :company_id + AND id = :id + AND type = 'out' + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':id' => $stock_out_id, + ]); + $stock_out = $sth->fetch(PDO::FETCH_ASSOC); + + if ($stock_out) { + $item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? ''); + $item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? ''); + $item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? ''); + $item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''); + $item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''); + $item['stock_out_status'] = (int)$stock_out['status']; + } + } + $returnable[] = array_merge($item, [ - 'item_id' => $i, - 'original_qty' => (float)$item['quantity'], - 'returned_qty' => $already_returned, - 'returnable_qty' => $remaining, + 'item_id' => $i, + 'original_qty' => (float)$item['quantity'], + 'returned_qty' => $already_returned, + 'returnable_qty' => $remaining, + 'stock_out_warehouse_id' => $stock_out_warehouse_id, + 'stock_out_status' => (int)($item['stock_out_status'] ?? 0), ]); } } @@ -70,4 +101,4 @@ $answer['success'] = 1; exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/update_payment_status.php b/app/order/api/engine/update_payment_status.php index 6a9a425..a65f9f0 100644 --- a/app/order/api/engine/update_payment_status.php +++ b/app/order/api/engine/update_payment_status.php @@ -11,9 +11,8 @@ exit(json_encode($answer)); } - // Validate payment_status range - // 0=unpaid 1=paid 2=partial 3=refunded - if (!in_array($payment_status, [0, 1, 2, 3], true)) { + // Validate payment_status range: 0=unpaid 1=paid 2=partial + if (!in_array($payment_status, [0, 1, 2], true)) { $answer['message'] = 'Invalid payment status.'; http_response_code(400); exit(json_encode($answer)); @@ -66,4 +65,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/manage_invoice.php b/app/order/manage_invoice.php index 71701c1..4e79d32 100644 --- a/app/order/manage_invoice.php +++ b/app/order/manage_invoice.php @@ -123,6 +123,11 @@ Issue Invoice + +
    @@ -345,6 +350,12 @@ } + function print_invoice() { + if (!invoice_id) return; + window.open('order/print_invoice.php?id=' + invoice_id, '_blank'); + } + + $(function() { if (invoice_id) { retrieve_invoice(); diff --git a/app/order/manage_order.php b/app/order/manage_order.php index 6271a63..e8344a0 100644 --- a/app/order/manage_order.php +++ b/app/order/manage_order.php @@ -202,7 +202,6 @@ -
    @@ -185,7 +184,6 @@ '0': 'Unpaid', '1': 'Paid', '2': 'Partial', - '3': 'Refunded', }; return map[status] ?? '—'; } diff --git a/app/order/print_invoice.php b/app/order/print_invoice.php new file mode 100644 index 0000000..1fff665 --- /dev/null +++ b/app/order/print_invoice.php @@ -0,0 +1,523 @@ +getInvoiceById($invoice_id); + + if (!$inv) { + http_response_code(404); + exit('Invoice not found.'); + } + + // ── Load company profile ────────────────────────────────────────────────── + $sth = $pdo1->prepare( + "SELECT company_name, company_name2, company_logo, address, address2, + tax_id, phone, email, fax, branch, branch_no + FROM company_list + WHERE company_id = :company_id + LIMIT 1" + ); + $sth->execute([':company_id' => $company_id]); + $co = $sth->fetch(PDO::FETCH_ASSOC) ?: []; + + // ── Helpers ─────────────────────────────────────────────────────────────── + function h(mixed $v): string { + return htmlspecialchars((string)($v ?? ''), ENT_QUOTES, 'UTF-8'); + } + + function fmt(mixed $v, int $dp = 2): string { + return number_format((float)$v, $dp); + } + + function fmt_date(string $d): string { + if (!$d) return '—'; + $ts = strtotime($d); + return $ts ? date('d/m/Y', $ts) : h($d); + } + + $items = $inv['items'] ?? []; + $logo_url = !empty($co['company_logo']) + ? $server_url . 'uploads/company/' . $co['company_logo'] + : ''; + + $doc_labels = [ + 'invoice' => 'INVOICE', + 'credit_note' => 'CREDIT NOTE', + 'debit_note' => 'DEBIT NOTE', + ]; + $doc_label = $doc_labels[$inv['doc_type'] ?? 'invoice'] ?? 'INVOICE'; + + $status_labels = [ + '0' => 'Draft', + '1' => 'Issued', + '2' => 'Paid', + '3' => 'Overdue', + '4' => 'Void', + ]; + $status_label = $status_labels[strval($inv['status'] ?? 0)] ?? ''; +?> + + + + + + <?= h($doc_label) ?> — <?= h($inv['invoice_number']) ?> + + + + + + + +
    +
    + + +
    +
    + + Logo + +
    + +
    + +
    + ', array_map('h', $co_lines)); + ?> +
    +
    + +
    +
    +
    +
    +
    Issued
    + +
    Due
    + + +
    Order
    + +
    + 'status-draft', + '1' => 'status-issued', + '2' => 'status-paid', + '3' => 'status-overdue', + '4' => 'status-void', + ]; + $sc = $status_classes[strval($inv['status'] ?? 0)] ?? 'status-draft'; + ?> +
    +
    +
    + + +
    +
    +
    Bill To
    +
    + +
    + + +
    Tax ID:
    + +
    +
    + + + + + + + + + + + + + + $item): ?> + + + + + + + + + + + + +
    #ProductQtyUnit PriceAmount
    + + +
    + +
    No items
    + + +
    + + + + + + + + + + + +
    Grand Total
    +
    + + + +
    +
    Notes
    +
    +
    + + + + + +
    +
    + + + \ No newline at end of file diff --git a/app/po/api/engine/cancel_po.php b/app/po/api/engine/cancel_po.php new file mode 100644 index 0000000..46797fd --- /dev/null +++ b/app/po/api/engine/cancel_po.php @@ -0,0 +1,33 @@ +cancelPo($id, $logging); + }); + + $answer['success'] = 1; + $answer['message'] = 'Purchase order cancelled.'; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/po/api/engine/confirm_po.php b/app/po/api/engine/confirm_po.php new file mode 100644 index 0000000..f2fc716 --- /dev/null +++ b/app/po/api/engine/confirm_po.php @@ -0,0 +1,33 @@ +confirmPo($id, $logging); + }); + + $answer['success'] = 1; + $answer['message'] = 'Purchase order confirmed.'; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/po/api/engine/manage_po.php b/app/po/api/engine/manage_po.php new file mode 100644 index 0000000..ff475f2 --- /dev/null +++ b/app/po/api/engine/manage_po.php @@ -0,0 +1,31 @@ +savePo($data, $logging); + }); + + $answer['success'] = 1; + if ($new_id) { + $answer['new_id'] = $new_id; + } + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/po/api/engine/receive_po.php b/app/po/api/engine/receive_po.php new file mode 100644 index 0000000..f60d2e0 --- /dev/null +++ b/app/po/api/engine/receive_po.php @@ -0,0 +1,48 @@ +get('default_stock_status') === 1; + + try { + dbTransaction($pdo2, function($pdo) use ($id, $receive_items, $company_id, $logging, $uuid, $auto_approve) { + $po = new PurchaseOrderManager($pdo, $company_id); + $po->receivePo($id, $receive_items, $uuid, $logging, $auto_approve); + }); + + $answer['success'] = 1; + $answer['message'] = 'Goods received successfully.'; + $answer['auto_approved'] = $auto_approve; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/po/api/engine/retrieve_po.php b/app/po/api/engine/retrieve_po.php new file mode 100644 index 0000000..30db79b --- /dev/null +++ b/app/po/api/engine/retrieve_po.php @@ -0,0 +1,43 @@ + 0) { + $result = $po->getPoById($id); + if (!$result) { + http_response_code(404); + $answer['message'] = 'Purchase order not found.'; + exit(json_encode($answer)); + } + $answer['output'] = $result; + + // Calculate receivable items — PO items minus already received qty + $po_items = $result['items'] ?? []; + $receivable = []; + foreach ($po_items as $i => $item) { + $ordered = (float)($item['quantity'] ?? 0); + $received = (float)($item['received_qty'] ?? 0); + $remaining = $ordered - $received; + if ($remaining > 0) { + $receivable[] = array_merge($item, [ + 'item_id' => (int)($item['item_id'] ?? $i), + 'ordered_qty' => $ordered, + 'received_qty' => $received, + 'remaining_qty' => $remaining, + ]); + } + } + $answer['receivable_items'] = $receivable; + + } else { + $answer['output'] = $po->getPoList(); + } + + $answer['success'] = 1; + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/po/api/engine/update_po_payment.php b/app/po/api/engine/update_po_payment.php new file mode 100644 index 0000000..f71aeaf --- /dev/null +++ b/app/po/api/engine/update_po_payment.php @@ -0,0 +1,34 @@ +updatePaymentStatus($id, $payment_status, $logging); + }); + + $answer['success'] = 1; + $answer['message'] = 'Payment status updated.'; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/po/manage_po.php b/app/po/manage_po.php new file mode 100644 index 0000000..da4f24f --- /dev/null +++ b/app/po/manage_po.php @@ -0,0 +1,1011 @@ + + + + + + +
    +
    + + +
    +
    +
    +
    +

    + +

    +

    + +

    +
    + +
    +
    +
    + +
    + + +
    + + +
    +
    +

    Order Information

    +
    + +
    + + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    +
    +
    + + +
    +
    +
    +

    Items to Order

    + +
    + +
    + + + + + + + + + + + +
    ProductQtyUnit PriceTotal
    +
    + +
    +
    + + +
    +
    +
    +
    +

    Receive Goods

    +

    Enter quantities received and assign rack locations for each line

    +
    + +
    +
    +
    +
    + +
    + + +
    + + +
    +
    +

    Status

    +
    + + +
    +

    +

    +
    +
    + + +
    +
    +

    Summary

    +
    + Subtotal + 0.00 +
    +
    + Discount + +
    +
    + Tax + +
    +
    + Shipping + +
    +
    +
    + Grand Total + 0.00 +
    +
    +
    + + +
    +
    + + + + + + + +
    + +
    + + +
    +
    + + + +
    +
    + +
    + +
    +
    +
    + + + + + + + diff --git a/app/po/po.php b/app/po/po.php new file mode 100644 index 0000000..f41b50f --- /dev/null +++ b/app/po/po.php @@ -0,0 +1,298 @@ + + + + + + +
    +
    + + +
    +
    +
    +
    +

    Purchase Orders

    +

    Manage and track all purchase orders from suppliers

    +
    + + + New PO + +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +

    Total POs

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Draft

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Confirmed

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Completed

    +

    —

    +
    +
    +
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + +
    PO #DateSupplierItemsGrand TotalStatusPaymentExpectedAction
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + \ No newline at end of file diff --git a/app/reports/stock_movement.php b/app/reports/stock_movement.php index 93bb04f..fc8eaf5 100644 --- a/app/reports/stock_movement.php +++ b/app/reports/stock_movement.php @@ -134,10 +134,11 @@ Type SKU Lot - Location + Location Contact Stock In Stock Out + UOM Running Total @@ -170,54 +171,54 @@ @@ -528,4 +535,4 @@ - + \ No newline at end of file