pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Check whether a contact is referenced by any active stock transaction * across all td_stock_* warehouse tables. * * Used as a pre-delete guard to prevent orphaning stock records. * Scans information_schema to discover all td_stock_* tables dynamically. * Table names from information_schema are backtick-quoted for safety. * * @param int $contact_id The md_contact.id to check. * @return bool true if at least one stock row references this contact, false if clear. */ private function hasActiveStock(int $contact_id): bool { $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) { // table name comes from information_schema (trusted system table), // and is backtick-quoted — no user input reaches the table identifier. $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM `$table` WHERE company_id = :company_id AND contact_id = :contact_id" ); $sth->execute([ ':company_id' => $this->company_id, ':contact_id' => $contact_id, ]); if ($sth->fetchColumn() > 0) { return true; } } return false; } /** * Build a standard audit log entry array for append-to-JSON log columns. * * Captures the acting user_id, current datetime, session login time, * and a short action label (e.g. 'delete', 'update'). * * Usage: * $log[] = $this->buildLogEntry('delete'); * $params[':log'] = json_encode($log); * * @param string $action Short label describing the operation (e.g. 'delete'). * @return array Associative array ready to be appended to a log array. */ 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, ]; } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Contact Type // ───────────────────────────────────────────────────────────── /** * Return all contact types for the company. * * Used to populate dropdowns and the contact type listing page. * * @return array All rows from md_contact_type for this company. */ public function getContactTypeList(): array { $sth = $this->pdo->prepare( "SELECT * FROM md_contact_type WHERE company_id = :company_id" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single contact type row by its primary key. * * Used to pre-fill the edit form on the manage contact type page. * * @param int $id The md_contact_type.id to fetch. * @return array|false Associative row, or false if not found. */ public function getContactTypeById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT * FROM md_contact_type WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Insert a new contact type or update an existing one. * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * The $logging array is appended to the row's JSON log column * (caller constructs this from session/request context). * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, contact_type, description, status. * @param array $logging Audit entry to append to the log column. */ public function saveContactType(array $data, array $logging): void { $id = (int)($data['id'] ?? 0); $sth = $this->pdo->prepare( "SELECT `log` FROM md_contact_type WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: []; $table_log[] = $logging; $params = [ ':company_id' => $this->company_id, ':contact_type' => $data['contact_type'], ':description' => $data['description'], ':status' => (int)$data['status'], ':log' => json_encode($table_log), ]; if ($id > 0) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE md_contact_type SET `contact_type` = :contact_type, `description` = :description, `status` = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); } else { $this->pdo->prepare( "INSERT INTO md_contact_type (company_id, contact_type, `description`, `status`, `log`) VALUES (:company_id, :contact_type, :description, :status, :log)" )->execute($params); } } /** * Soft-delete a contact type by negating its company_id. * * Blocks deletion if any md_contact row is still assigned to this type, * preventing orphaned contacts. * * Must be called inside dbTransaction() by the caller. * * @param int $type_id The md_contact_type.id to delete. * @throws Exception If the type is not found or has contacts assigned to it. */ public function deleteContactType(int $type_id): void { $sth = $this->pdo->prepare( "SELECT id, contact_type, `log` FROM md_contact_type WHERE company_id = :company_id AND id = :id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $type_id, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Contact type not found."); } // Block if any contact references this type $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_contact WHERE company_id = :company_id AND contact_type = :type_id" ); $sth->execute([ ':company_id' => $this->company_id, ':type_id' => $type_id, ]); if ($sth->fetchColumn() > 0) { throw new Exception( "Cannot delete — contact type \"{$row['contact_type']}\" " . "still has contacts assigned to it." ); } // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); // Soft-delete: negate company_id so row is hidden but recoverable $this->pdo->prepare( "UPDATE md_contact_type SET company_id = company_id * -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $type_id, ':company_id' => $this->company_id, ]); } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Contact // ───────────────────────────────────────────────────────────── /** * Return all contacts for the company. * * Used to populate the contact listing page and bulk dropdowns. * * @return array All rows from md_contact for this company. */ public function getContactList(): array { $sth = $this->pdo->prepare( "SELECT * FROM md_contact WHERE company_id = :company_id" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single contact row by its primary key. * * Used to pre-fill the edit form on the manage contact page. * * @param int $id The md_contact.id to fetch. * @return array|false Associative row, or false if not found. */ public function getContactById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT * FROM md_contact WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Search contacts by name keyword — for live autocomplete on stock forms. * * Returns up to 50 matches. The keyword is safely bound as a LIKE parameter; * no wildcard escaping is needed here since '%' wrapping is the intended behaviour. * * @param string $keyword Partial contact name to match. * @return array Matching md_contact rows. */ public function searchContact(string $keyword): array { $sth = $this->pdo->prepare( "SELECT * FROM md_contact WHERE company_id = :company_id AND contact_name LIKE :keyword LIMIT 50" ); $sth->execute([ ':company_id' => $this->company_id, ':keyword' => '%' . $keyword . '%', ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Insert a new contact or update an existing one. * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * $contact_image is the resolved filename/path from FileUploader — may be * the existing image when no new file was uploaded. * The $logging array is appended to the row's JSON log column. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, contact_name, tax_id, organization, branch, * contact_type, billing_address, shipping_location, * shipping_address, remark, status. * @param array $logging Audit entry to append to the log column. * @param string $contact_image Stored filename for the contact's profile image. */ public function saveContact(array $data, array $logging, string $contact_image): void { $id = (int)($data['id'] ?? 0); $sth = $this->pdo->prepare( "SELECT `log` FROM md_contact WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: []; $table_log[] = $logging; $params = [ ':company_id' => $this->company_id, ':contact_name' => $data['contact_name'], ':tax_id' => $data['tax_id'], ':organization' => $data['organization'], ':branch' => $data['branch'], ':contact_type' => (int)$data['contact_type'], ':billing_address' => $data['billing_address'], ':shipping_location' => $data['shipping_location'], ':shipping_address' => $data['shipping_address'], ':remark' => $data['remark'], ':contact_image' => $contact_image, ':status' => (int)$data['status'], ':log' => json_encode($table_log), ]; if ($id > 0) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE md_contact SET `contact_name` = :contact_name, `tax_id` = :tax_id, `organization` = :organization, `branch` = :branch, `contact_type` = :contact_type, `billing_address` = :billing_address, `shipping_location` = :shipping_location, `shipping_address` = :shipping_address, `remark` = :remark, `contact_image` = :contact_image, `status` = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); } else { $this->pdo->prepare( "INSERT INTO md_contact (company_id, contact_name, tax_id, organization, branch, contact_type, billing_address, shipping_location, shipping_address, remark, contact_image, status, log) VALUES (:company_id, :contact_name, :tax_id, :organization, :branch, :contact_type, :billing_address, :shipping_location, :shipping_address, :remark, :contact_image, :status, :log)" )->execute($params); } } /** * Soft-delete a contact by negating its company_id. * * Blocks deletion if the contact is referenced in any active stock * transaction across all td_stock_* warehouse tables, preventing * broken foreign key references in transaction history. * * Must be called inside dbTransaction() by the caller. * * @param int $contact_id The md_contact.id to delete. * @throws Exception If the contact is not found or has active stock references. */ public function deleteContact(int $contact_id): void { $sth = $this->pdo->prepare( "SELECT id, contact_name, `log` FROM md_contact WHERE company_id = :company_id AND id = :id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $contact_id, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Contact not found."); } // Block if contact is referenced in any active stock transaction if ($this->hasActiveStock($contact_id)) { throw new Exception( "Cannot delete — \"{$row['contact_name']}\" " . "is referenced in active stock transactions." ); } // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); // Soft-delete: negate company_id so row is hidden but recoverable $this->pdo->prepare( "UPDATE md_contact SET company_id = company_id * -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $contact_id, ':company_id' => $this->company_id, ]); } }