pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Resolve warehouse name by ID and return the td_stock_ table name. * Throws if warehouse not found. */ private function resolveTable(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."); } $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); return "td_stock_{$safe}"; } // ───────────────────────────────────────────────────────────── // Stock list queries // ───────────────────────────────────────────────────────────── /** * List all stock records of a given type for a warehouse. * type: 'in' | 'out' | 'transfer' */ public function getStockList(int $warehouse_id, string $type): array { $table = $this->resolveTable($warehouse_id); $column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; // transfer list shows outbound side only (out > 0) $extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : ''; $sth = $this->pdo->prepare( "SELECT a.*, {$column} AS quantity, b.product_name FROM `{$table}` a LEFT JOIN md_product b ON a.company_id = b.company_id AND a.product_sku = b.sku WHERE a.company_id = :company_id AND a.type = :type {$extra_cond} ORDER BY a.date DESC" ); $sth->execute([ ':company_id' => $this->company_id, ':type' => $type, ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } // ───────────────────────────────────────────────────────────── // Single record retrieval // ───────────────────────────────────────────────────────────── /** * Fetch a single stock_in record with product and contact name, * plus lot expiry date if applicable. */ public function getStockInById(int $warehouse_id, int $id): array|false { $table = $this->resolveTable($warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, a.in AS quantity, b.contact_name, c.product_name, d.expiry_date FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = c.company_id AND a.product_sku = c.sku LEFT JOIN md_lot d ON a.company_id = d.company_id AND a.product_sku = d.product_sku AND a.lot_number = d.lot_number WHERE a.company_id = :company_id AND a.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Fetch a single stock_out record with product and contact name. */ public function getStockOutById(int $warehouse_id, int $id): array|false { $table = $this->resolveTable($warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, a.out AS quantity, b.contact_name, c.product_name FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = c.company_id AND a.product_sku = c.sku WHERE a.company_id = :company_id AND a.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Fetch a transfer record pair — the outbound row plus its paired * inbound row resolved via ref_warehouse + uuid. * Returns the outbound row with a 'ref' key containing the inbound row. */ public function getTransferById(int $warehouse_id, int $id): array|false { $table = $this->resolveTable($warehouse_id); // Fetch the outbound (from) row $sth = $this->pdo->prepare( "SELECT a.*, a.out AS quantity, b.contact_name, c.product_name FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = c.company_id AND a.product_sku = c.sku WHERE a.company_id = :company_id AND a.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $output = $sth->fetch(PDO::FETCH_ASSOC); if (!$output) return false; // Resolve the inbound (to) row via ref_warehouse + uuid $to_warehouse_id = (int)$output['ref_warehouse']; $to_table = $this->resolveTable($to_warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, a.in AS quantity, b.contact_name, c.product_name FROM `{$to_table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = b.company_id AND a.product_sku = c.sku WHERE a.company_id = :company_id AND a.uuid = :uuid" ); $sth->execute([':company_id' => $this->company_id, ':uuid' => $output['uuid']]); $output['ref'] = $sth->fetch(PDO::FETCH_ASSOC); return $output; } // ───────────────────────────────────────────────────────────── // Stock write operations // ───────────────────────────────────────────────────────────── /** * Insert or update a stock_in record. * On insert: upserts md_lot, inserts td_stock row, occupies rack, adjusts balance. * On update: updates metadata (contact, description, log) only. * Must be called inside dbTransaction() by the caller. */ public function saveStockIn(array $data, array $logging, string $uuid): void { $id = (int)$data['id']; $warehouse_id = (int)$data['warehouse']; $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); $row = $ctx['row'] ?? []; $table = $ctx['table']; $table_log = json_decode($row['log'] ?? '[]', true) ?: []; $table_log[] = $logging; if ($id > 0) { $this->pdo->prepare( "UPDATE `$table` SET `contact_id` = :contact_id, `description` = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':id' => $id, ':company_id' => $this->company_id, ':contact_id' => (int)$data['contact_id'], ':description' => $data['description'], ':log' => json_encode($table_log), ]); } else { $lot_number = $data['lot_number'] ?: null; $expiry_date = $data['expiry_date'] ?: null; // Upsert md_lot if lot + expiry provided if ($lot_number && $expiry_date) { $this->pdo->prepare( "INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date) VALUES (:company_id, :product_sku, :lot_number, :expiry_date) ON DUPLICATE KEY UPDATE expiry_date = expiry_date" )->execute([ ':company_id' => $this->company_id, ':product_sku' => $data['product_sku'], ':lot_number' => $lot_number, ':expiry_date' => $expiry_date, ]); } $this->pdo->prepare( "INSERT INTO `$table` (uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack, contact_id, `description`, `log`, `type`, lot_number, serial_number) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, :contact_id, :description, :log, 'in', :lot_number, :serial_number)" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $data['quantity'], ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':rack' => $data['rack'], ':contact_id' => (int)$data['contact_id'], ':description' => $data['description'], ':log' => json_encode($table_log), ':lot_number' => $lot_number, ':serial_number' => $data['serial_number'] ?: null, ]); $td_stock_id = (int)$this->pdo->lastInsertId(); $whMgmt->occupyRack( $warehouse_id, $data['zone'], $data['aisle'], $data['rack'], $data['product_sku'], $td_stock_id ); $whMgmt->adjustBalance( 'in', $warehouse_id, $data['product_sku'], $row['in'] ?? 0, $data['quantity'] ); } } /** * Insert or update a stock_out record. * On insert: validates rack, inserts td_stock row, releases rack, adjusts balance. * On update: updates metadata (contact, description, log) only. * Must be called inside dbTransaction() by the caller. */ public function saveStockOut(array $data, array $logging, string $uuid): void { $id = (int)$data['id']; $warehouse_id = (int)$data['warehouse']; $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); $row = $ctx['row'] ?? []; $table = $ctx['table']; $table_log = json_decode($row['log'] ?? '[]', true) ?: []; $table_log[] = $logging; if ($id > 0) { $this->pdo->prepare( "UPDATE `$table` SET `contact_id` = :contact_id, `description` = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':id' => $id, ':company_id' => $this->company_id, ':contact_id' => (int)$data['contact_id'], ':description' => $data['description'], ':log' => json_encode($table_log), ]); } else { $source_stock = $whMgmt->getRackStock( $warehouse_id, $data['zone'], $data['aisle'], $data['rack'] ); if (!$source_stock) { throw new Exception( "Rack {$data['zone']}-{$data['aisle']}-{$data['rack']} is empty — nothing to take out." ); } if ($source_stock['product_sku'] !== $data['product_sku']) { throw new Exception( "Rack holds {$source_stock['product_sku']}, not {$data['product_sku']}." ); } if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) { throw new Exception( "Rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'." ); } if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) { throw new Exception( "Rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'." ); } $quantity = (int)$source_stock['in']; $ref_id = (int)$source_stock['id']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; $this->pdo->prepare( "INSERT INTO `$table` (uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack, contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, :contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number)" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':rack' => $data['rack'], ':contact_id' => (int)$data['contact_id'], ':description' => $data['description'], ':log' => json_encode($table_log), ':ref_id' => $ref_id, ':lot_number' => $lot_number, ':serial_number' => $serial_number, ]); $whMgmt->releaseRack( $warehouse_id, $data['zone'], $data['aisle'], $data['rack'] ); $whMgmt->adjustBalance( 'out', $warehouse_id, $data['product_sku'], $row['out'] ?? 0, $quantity ); } } /** * Insert or update a stock transfer record pair. * On insert: validates source rack, inserts paired td_stock rows, moves rack state, adjusts both balances. * On update: updates metadata (contact, description, log) on both rows only. * Must be called inside dbTransaction() by the caller. */ public function saveStockTransfer(array $data, array $logging, string $uuid): void { $id = (int)($data['id'] ?? 0); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $contact_id = (int)($data['contact_id'] ?? 0); $description = ($data['description'] ?? ''); if ($id > 0) { $from_warehouse = (int)($data['warehouse_from'] ?? 0); $to_warehouse = (int)($data['warehouse_to'] ?? 0); $from_ctx = $whMgmt->getStockContext($from_warehouse, $id); $from_row = $from_ctx['row']; if (!$from_row) { throw new Exception("Transfer record not found."); } $to_id = (int)$from_row['ref_id']; $to_ctx = $whMgmt->getStockContext($to_warehouse, $to_id); $to_row = $to_ctx['row']; if (!$to_row) { throw new Exception("Paired destination record missing — data integrity issue."); } $from_log = json_decode($from_row['log'] ?? '[]', true) ?: []; $from_log[] = $logging; $to_log = json_decode($to_row['log'] ?? '[]', true) ?: []; $to_log[] = $logging; $this->pdo->prepare( "UPDATE `{$from_ctx['table']}` SET contact_id = :contact_id, description = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($from_log), ':id' => $id, ':company_id' => $this->company_id, ]); $this->pdo->prepare( "UPDATE `{$to_ctx['table']}` SET contact_id = :contact_id, description = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($to_log), ':id' => $to_id, ':company_id' => $this->company_id, ]); return; } $from_warehouse = (int)$data['warehouse_from']; $from_zone = $data['zone_from']; $from_aisle = $data['aisle_from']; $from_rack = $data['rack_from']; $to_warehouse = (int)$data['warehouse_to']; $to_zone = $data['zone_to']; $to_aisle = $data['aisle_to']; $to_rack = $data['rack_to']; $source_stock = $whMgmt->getRackStock( $from_warehouse, $from_zone, $from_aisle, $from_rack ); if (!$source_stock) { throw new Exception("Source rack {$from_zone}-{$from_aisle}-{$from_rack} is empty."); } if ($source_stock['product_sku'] !== $data['product_sku']) { throw new Exception( "Source rack holds {$source_stock['product_sku']}, not {$data['product_sku']}." ); } if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) { throw new Exception( "Source rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'." ); } if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) { throw new Exception( "Source rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'." ); } $quantity = (int)$source_stock['in']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; $from_table = $whMgmt->getStockContext($from_warehouse, 0)['table']; $to_table = $whMgmt->getStockContext($to_warehouse, 0)['table']; $table_log = [$logging]; $this->pdo->prepare( "INSERT INTO `$from_table` (uuid, company_id, `date`, product_sku, `out`, ref_warehouse, zone, aisle, rack, contact_id, `description`, `log`, `type`, lot_number, serial_number) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :ref_warehouse, :zone, :aisle, :rack, :contact_id, :description, :log, 'transfer', :lot_number, :serial_number)" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':ref_warehouse' => $to_warehouse, ':zone' => $from_zone, ':aisle' => $from_aisle, ':rack' => $from_rack, ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($table_log), ':lot_number' => $lot_number, ':serial_number' => $serial_number, ]); $from_stock_id = (int)$this->pdo->lastInsertId(); $this->pdo->prepare( "INSERT INTO `$to_table` (uuid, company_id, `date`, product_sku, `in`, ref_warehouse, ref_id, zone, aisle, rack, contact_id, `description`, `log`, `type`, lot_number, serial_number) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :ref_warehouse, :ref_id, :zone, :aisle, :rack, :contact_id, :description, :log, 'transfer', :lot_number, :serial_number)" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':ref_warehouse' => $from_warehouse, ':ref_id' => $from_stock_id, ':zone' => $to_zone, ':aisle' => $to_aisle, ':rack' => $to_rack, ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($table_log), ':lot_number' => $lot_number, ':serial_number' => $serial_number, ]); $to_stock_id = (int)$this->pdo->lastInsertId(); // Back-fill ref_id on from row so both rows point at each other $this->pdo->prepare( "UPDATE `$from_table` SET ref_id = :ref_id WHERE id = :id AND company_id = :company_id" )->execute([ ':ref_id' => $to_stock_id, ':id' => $from_stock_id, ':company_id' => $this->company_id, ]); $whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack); $whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id); $whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity); $whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity); } } ?>