diff --git a/app/assets/utils/classes/ContactManager.php b/app/assets/utils/classes/ContactManager.php index eebcfa2..67a13d2 100644 --- a/app/assets/utils/classes/ContactManager.php +++ b/app/assets/utils/classes/ContactManager.php @@ -3,11 +3,18 @@ /** * ContactManager * - * Encapsulates contact and contact type operations: - * - Soft-delete with downstream validation + * Handles all CRUD and soft-delete operations for contacts and contact types. * - * Note: Methods that modify data do NOT manage their own DB transactions. - * Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed. + * Method order: + * Master file basis → get/save/delete for contact types and contacts + * Transaction basis → (none — contacts are master data only) + * Report basis → (none — reporting is handled by ReportManager) + * + * 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. + * No user input is ever interpolated directly into a query string. */ class ContactManager { @@ -24,8 +31,15 @@ class ContactManager { // ───────────────────────────────────────────────────────────── /** - * Check if a contact_id is referenced in any active td_stock_* row. - * Returns true if blocking stock exists. + * 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 { @@ -38,6 +52,8 @@ class ContactManager { $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 @@ -56,7 +72,17 @@ class ContactManager { } /** - * Build a log entry array for audit context. + * 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 [ @@ -70,126 +96,15 @@ class ContactManager { } // ───────────────────────────────────────────────────────────── - // Contact type + // MASTER FILE BASIS — Contact Type // ───────────────────────────────────────────────────────────── /** - * Soft-delete a contact type. + * Return all contact types for the company. * - * Blocks if any md_contact references this type. + * Used to populate dropdowns and the contact type listing page. * - * @throws Exception if contact type not found or has dependent contacts - */ - 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 - $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, - ]); - } - - // ───────────────────────────────────────────────────────────── - // Contact - // ───────────────────────────────────────────────────────────── - - /** - * Soft-delete a contact. - * - * Blocks if the contact is referenced in any active td_stock_* row. - * - * @throws Exception if contact not found or referenced in active stock - */ - 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 - $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, - ]); - } - - - // ───────────────────────────────────────────────────────────── - // Contact type queries - // ───────────────────────────────────────────────────────────── - - /** - * Full contact type list. + * @return array All rows from md_contact_type for this company. */ public function getContactTypeList(): array { @@ -202,7 +117,12 @@ class ContactManager { } /** - * Fetch a single contact type row by ID. + * 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 { @@ -215,9 +135,16 @@ class ContactManager { } /** - * Insert or update a contact type. - * Pass $data['id'] > 0 for update, 0 for insert. + * 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 { @@ -259,12 +186,78 @@ class ContactManager { } } + /** + * 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, + ]); + } + // ───────────────────────────────────────────────────────────── - // Contact queries + // MASTER FILE BASIS — Contact // ───────────────────────────────────────────────────────────── /** - * Full contact list. + * 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 { @@ -277,7 +270,12 @@ class ContactManager { } /** - * Fetch a single contact row by ID. + * 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 { @@ -290,7 +288,13 @@ class ContactManager { } /** - * Search contacts by name keyword — for autocomplete. + * 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 { @@ -307,11 +311,21 @@ class ContactManager { return $sth->fetchAll(PDO::FETCH_ASSOC); } - /** - * Insert or update a contact. - * Pass $data['id'] > 0 for update, 0 for insert. + * 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 { @@ -370,4 +384,57 @@ class ContactManager { )->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, + ]); + } } \ No newline at end of file diff --git a/app/assets/utils/classes/FileUploader.php b/app/assets/utils/classes/FileUploader.php index 419f189..cec89d1 100644 --- a/app/assets/utils/classes/FileUploader.php +++ b/app/assets/utils/classes/FileUploader.php @@ -1,38 +1,84 @@ cleanup($existing_files_csv, $kept_files_csv); + * $errors = $uploader->upload('product_image'); + * if (empty($errors)) { + * $file_string = $uploader->buildFileString($kept_files_csv); + * // save $file_string to DB... + * } else { + * $uploader->rollbackUploads(); + * } + * + * Security: + * - Validates both file extension and MIME type via finfo (double-check). + * - Sanitises original filename before use; replaces non-alphanumeric characters with '_'. + * - Generates a unique filename with uniqid() to prevent overwrite attacks. + * - Sets uploaded files to 0644 permissions (web-readable, not executable). + * - Rejects any file exceeding the 5MB size limit. + */ class FileUploader { private $target_dir; - private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; - private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']; - private $max_size = 5 * 1024 * 1024; // 5MB - private $uploaded_files = []; // newly uploaded filenames - private $deleted_files = []; // files we deleted from disk + private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; + private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']; + private $max_size = 5 * 1024 * 1024; // 5MB + private $uploaded_files = []; // filenames of files successfully uploaded in this request + private $deleted_files = []; // filenames of files deleted from disk in this request + /** + * Initialise the uploader for a specific directory. + * + * Creates the directory if it does not exist (recursive, 0755). + * Throws if the directory cannot be created or is not writable. + * + * @param string $target_dir Absolute path to the upload directory (trailing slash optional). + * @throws Exception If the directory cannot be created or is not writable. + */ public function __construct($target_dir) { $this->target_dir = rtrim($target_dir, '/') . '/'; $this->ensureDirectory(); } - private function ensureDirectory() { - if (!is_dir($this->target_dir)) { - if (!mkdir($this->target_dir, 0755, true)) { - throw new Exception("Failed to create directory: " . $this->target_dir); - } - } - if (!is_writable($this->target_dir)) { - throw new Exception("Target directory is not writable: " . $this->target_dir); - } - } + // ───────────────────────────────────────────────────────────── + // Public — cleanup + // ───────────────────────────────────────────────────────────── /** - * Remove files that user deleted in the UI + * Delete files that the user removed in the UI. + * + * Compare the original comma-separated file list ($existing_csv) against + * the files the user chose to keep ($keep_csv). Any file in $existing but + * not in $keep is deleted from disk. + * + * Call this BEFORE upload() so that freed space is available and the + * $deleted_files list is populated before any rollback is needed. + * + * @param string $existing_csv Comma-separated filenames previously stored in DB. + * @param string $keep_csv Comma-separated filenames the user kept in the form. */ public function cleanup($existing_csv, $keep_csv) { if (empty($existing_csv)) return; $existing = array_filter(array_map('trim', explode(',', $existing_csv))); - $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; + $keep = !empty($keep_csv) + ? array_filter(array_map('trim', explode(',', $keep_csv))) + : []; foreach ($existing as $file) { if (!in_array($file, $keep)) { @@ -45,16 +91,32 @@ class FileUploader { } } + // ───────────────────────────────────────────────────────────── + // Public — upload + // ───────────────────────────────────────────────────────────── + /** - * Upload new files from $_FILES - * Returns array of errors (empty = success) + * Validate and move uploaded files from $_FILES to the target directory. + * + * Handles both single-file and multi-file inputs transparently. + * Each file is validated for: + * - PHP upload error code (UPLOAD_ERR_OK) + * - File size (≤ max_size, default 5MB) + * - File extension (whitelist) + * - MIME type via finfo (double-check against extension spoofing) + * + * Successfully uploaded files are tracked in $this->uploaded_files and + * can be rolled back via rollbackUploads() if the DB write later fails. + * + * @param string $field_name The $_FILES key (HTML input name attribute). + * @return array Array of human-readable error strings. Empty array = all files accepted. */ public function upload($field_name) { if (!isset($_FILES[$field_name])) return []; $errors = []; - // Normalize single file to array structure + // Normalise single-file $_FILES entry to array structure $files = $_FILES[$field_name]; if (!is_array($files['name'])) { $files['name'] = [$files['name']]; @@ -75,16 +137,19 @@ class FileUploader { $file_size = $files['size'][$key]; $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + // Size check if ($file_size > $this->max_size) { $errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit"; continue; } + // Extension whitelist check if (!in_array($extension, $this->allowed_extensions)) { $errors[] = "{$name}: .{$extension} is not allowed"; continue; } + // MIME type check via finfo (cannot be spoofed by renaming) $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmp_name); finfo_close($finfo); @@ -94,16 +159,15 @@ class FileUploader { continue; } - // FILE NAME SANITIZATION + UNIQUE ID - $original = pathinfo($name, PATHINFO_FILENAME); - $original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); // sanitize - $file_id = $original . "_" . uniqid() . "." . $extension; - + // Sanitise original filename and generate a unique destination name + $original = pathinfo($name, PATHINFO_FILENAME); + $original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); + $file_id = $original . '_' . uniqid() . '.' . $extension; $destination = $this->target_dir . $file_id; if (move_uploaded_file($tmp_name, $destination)) { $this->uploaded_files[] = $file_id; - chmod($destination, 0644); + chmod($destination, 0644); // web-readable, not executable } else { $errors[] = "{$name}: failed to save"; } @@ -112,24 +176,46 @@ class FileUploader { return $errors; } + // ───────────────────────────────────────────────────────────── + // Public — result helpers + // ───────────────────────────────────────────────────────────── + /** - * Build final CSV string for DB + * Build the final comma-separated filename string for DB storage. + * + * Merges the files the user kept ($keep_csv) with any newly uploaded + * files from this request, and returns them as a single CSV string. + * Pass this return value to the DB column that stores file references. + * + * @param string $keep_csv Comma-separated filenames the user kept in the form. + * @return string Final comma-separated file list ready for DB storage. */ public function buildFileString($keep_csv) { - $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; + $keep = !empty($keep_csv) + ? array_filter(array_map('trim', explode(',', $keep_csv))) + : []; $final = array_merge($keep, $this->uploaded_files); return implode(",", array_filter($final)); } - + /** - * Build final CSV string for DB as single file + * Return the list of filenames successfully uploaded in this request. + * + * Useful when only a single image field is expected and you need to + * retrieve the stored filename directly without building a CSV string. + * + * @return array Flat array of uploaded filenames (may be empty). */ public function getUploadedFiles() { return $this->uploaded_files; } /** - * Rollback uploaded files if DB fails + * Delete all files uploaded in this request — call if the DB write fails. + * + * Ensures no orphaned files remain on disk when a transaction is rolled back. + * Safe to call multiple times (idempotent if files are already gone). + * After rollback, $this->uploaded_files is cleared. */ public function rollbackUploads() { foreach ($this->uploaded_files as $file) { @@ -141,6 +227,35 @@ class FileUploader { $this->uploaded_files = []; } + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + /** + * Create the target directory if it does not exist, and verify it is writable. + * + * @throws Exception If mkdir fails or the directory is not writable. + */ + private function ensureDirectory() { + if (!is_dir($this->target_dir)) { + if (!mkdir($this->target_dir, 0755, true)) { + throw new Exception("Failed to create directory: " . $this->target_dir); + } + } + if (!is_writable($this->target_dir)) { + throw new Exception("Target directory is not writable: " . $this->target_dir); + } + } + + /** + * Convert a PHP upload error code into a human-readable message. + * + * Covers all UPLOAD_ERR_* constants defined by PHP. + * Falls back to a generic message for unknown codes. + * + * @param int $code A UPLOAD_ERR_* constant value. + * @return string Human-readable error description. + */ private function getUploadError($code) { $messages = [ UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")", @@ -153,10 +268,4 @@ class FileUploader { ]; return $messages[$code] ?? "unknown error (code {$code})"; } -} - - - - - -?> \ No newline at end of file +} \ No newline at end of file diff --git a/app/assets/utils/classes/PasswordManager.php b/app/assets/utils/classes/PasswordManager.php index ecf3d5d..9a837b0 100644 --- a/app/assets/utils/classes/PasswordManager.php +++ b/app/assets/utils/classes/PasswordManager.php @@ -3,54 +3,84 @@ /** * PasswordManager * - * Handles all password operations using zxcvbn-php for strength enforcement. - * Designed to be reused across: - * - Profile: change password (requires current password verification) - * - Login: forced password reset (no current password needed) - * - Future: forgot password / admin reset flows + * Handles all password-related operations using zxcvbn-php for strength enforcement. + * Designed to be reused across multiple flows: + * - Profile page: authenticated user changes their own password (requires current password). + * - Login forced reset: system-initiated change, no current password needed. + * - Future: admin reset, forgot password (delegate to PasswordResetManager for OTP). + * + * Method order: + * Core public API → checkStrength, change, forceSet + * HTTP handlers → handleCheck, handleChange (thin wrappers for AJAX endpoints) + * Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist * * Usage: - * $pm = new PasswordManager($pdo1, $include_url); + * $pm = new PasswordManager($pdo, $include_url); * - * // Check strength only (for live UI feedback) + * // Live strength check (AJAX feedback while typing): * $result = $pm->checkStrength('mypassword', ['john', 'john@example.com']); * - * // Change password (profile page — verifies current password) + * // Authenticated user changing their own password: * $pm->change($user_id, $current_password, $new_password, $confirm_password); * - * // Force set password (admin reset / login forced reset — no current password) + * // Forced set (admin reset / login forced reset — skips current password check): * $pm->forceSet($user_id, $new_password, $confirm_password); + * + * Security: + * - Passwords are hashed with PASSWORD_BCRYPT (cost factor PHP default = 10). + * - zxcvbn score ≥ 3 ("safely unguessable") is required before any hash is written. + * - User's own name, username, and email are passed to zxcvbn as penalty inputs. + * - All DB queries use PDO prepared statements with bound parameters. + * - AJAX handler methods output JSON via json_encode (XSS-safe for string values). */ class PasswordManager { private $pdo; private $zxcvbn_path; - /** Minimum zxcvbn score required (0–4). 3 = "safely unguessable" */ + /** Minimum zxcvbn score required (0–4). 3 = "safely unguessable". */ const MIN_SCORE = 3; + /** + * @param PDO $pdo PDO connection to the wms.user table database. + * @param string $include_url Absolute server path to the app root directory, + * used to locate the zxcvbn-php autoloader. + * Example: '/var/www/html/wms' + */ public function __construct($pdo, string $include_url) { - $this->pdo = $pdo; - $this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php'; + $this->pdo = $pdo; + $this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php'; } // ───────────────────────────────────────────────────────────── - // Public: strength check (used by live AJAX feedback endpoint) + // Core public API // ───────────────────────────────────────────────────────────── /** - * Run zxcvbn strength analysis. + * Run a zxcvbn password strength analysis and return structured feedback. * - * @param string $password - * @param array $user_inputs Personal data to penalise (name, email, username…) - * @return array ['score' => 0-4, 'warning' => string, 'suggestions' => array] + * Used by the live AJAX strength indicator while the user types. + * Pass user-specific strings as $user_inputs so zxcvbn penalises + * passwords that contain the user's own name, email, or username. + * + * Score meanings: + * 0 — too guessable (online attack in < 100 guesses) + * 1 — very guessable (online attack in < 1000 guesses) + * 2 — somewhat guessable (offline attack in < 1M guesses) + * 3 — safely unguessable (offline attack in < 100M guesses) ← MIN_SCORE + * 4 — very unguessable (offline attack in > 100M guesses) + * + * @param string $password The password string to analyse. + * @param array $user_inputs Strings to penalise if found in the password + * (e.g. name, email, username). + * @return array Keys: score (int 0–4), warning (string), suggestions (array of strings). */ public function checkStrength(string $password, array $user_inputs = []): array { $this->loadZxcvbn(); $zxcvbn = new \ZxcvbnPhp\Zxcvbn(); - $result = $zxcvbn->passwordStrength($password, $user_inputs); + $result = $zxcvbn->passwordStrength($password, $user_inputs); return [ 'score' => (int) $result['score'], @@ -59,20 +89,28 @@ class PasswordManager { ]; } - // ───────────────────────────────────────────────────────────── - // Public: change password (profile — verifies current password) - // ───────────────────────────────────────────────────────────── - /** - * Change password for an authenticated user. - * Verifies current password before applying the new one. + * Change the password for an authenticated user. * - * @throws InvalidArgumentException on validation failure (safe to show user) - * @throws RuntimeException on DB failure (log internally, show generic message) + * Verifies the current password before applying the new one. + * Use this on the profile settings page where the user is already logged in + * and must prove knowledge of their existing password. + * + * Steps: + * 1. Validates all fields are present and new passwords match. + * 2. Fetches the user record and verifies $current_password via password_verify(). + * 3. Runs zxcvbn strength enforcement (throws if score < MIN_SCORE). + * 4. Hashes and persists the new password. + * + * @param int $user_id The wms.user.user_id of the user changing their password. + * @param string $current_password The user's current password for verification. + * @param string $new_password The desired new password. + * @param string $confirm_password Must match $new_password exactly. + * @throws \InvalidArgumentException On validation failure (message is safe to show the user). + * @throws \RuntimeException On DB failure (log internally, show generic message). */ public function change(int $user_id, string $current_password, string $new_password, string $confirm_password): void { - // ── Basic field validation ──────────────────────────────── if (empty($current_password) || empty($new_password) || empty($confirm_password)) { throw new \InvalidArgumentException('All password fields are required.'); } @@ -81,31 +119,35 @@ class PasswordManager { throw new \InvalidArgumentException('New passwords do not match.'); } - // ── Load user record ────────────────────────────────────── $user = $this->fetchUser($user_id); - // ── Verify current password ─────────────────────────────── if (!password_verify($current_password, $user['password'])) { throw new \InvalidArgumentException('Current password is incorrect.'); } - // ── Strength check ──────────────────────────────────────── $this->enforceStrength($new_password, $user); - - // ── Hash and persist ────────────────────────────────────── $this->persist($user_id, $new_password); } - // ───────────────────────────────────────────────────────────── - // Public: force set password (admin reset / login forced reset) - // ───────────────────────────────────────────────────────────── - /** - * Force-set a new password without requiring the current password. - * Use for: admin-initiated reset, forgot-password flow, first-login forced change. + * Force-set a new password without verifying the current one. * - * @throws InvalidArgumentException on validation failure - * @throws RuntimeException on DB failure + * Use for flows where the current password is unavailable or irrelevant: + * - Admin-initiated password reset + * - First-login forced password change + * - Forgot-password flow after OTP verification (via PasswordResetManager) + * + * Steps: + * 1. Validates new password fields are present and match. + * 2. Fetches the user record (needed for zxcvbn personalisation). + * 3. Runs zxcvbn strength enforcement. + * 4. Hashes and persists the new password. + * + * @param int $user_id The wms.user.user_id to reset. + * @param string $new_password The desired new password. + * @param string $confirm_password Must match $new_password exactly. + * @throws \InvalidArgumentException On validation failure. + * @throws \RuntimeException On DB failure. */ public function forceSet(int $user_id, string $new_password, string $confirm_password): void { @@ -124,14 +166,23 @@ class PasswordManager { } // ───────────────────────────────────────────────────────────── - // Public: HTTP handlers (call from thin API endpoint files) + // HTTP handlers (thin AJAX endpoint wrappers) // ───────────────────────────────────────────────────────────── /** - * Handle AJAX strength-check request and echo JSON response. - * Endpoint: setting/api/engine/check_password.php + * Handle an AJAX strength-check request and echo a JSON response. * - * Expected $data keys: password + * Called by: setting/api/engine/check_password.php + * + * Expected $data keys: + * password (string) — the password string to analyse + * + * Response JSON keys: + * success (int 1), score (int -1 if empty, else 0–4), feedback (string) + * + * Outputs JSON and calls exit. Safe against XSS — all output via json_encode. + * + * @param array $data Request data array (typically from $_POST or decoded JSON body). */ public function handleCheck(array $data): void { @@ -154,10 +205,22 @@ class PasswordManager { } /** - * Handle AJAX change-password request and echo JSON response. - * Endpoint: setting/api/engine/change_password.php + * Handle an AJAX change-password request and echo a JSON response. * - * Expected $data keys: current_password, new_password, confirm_password + * Called by: setting/api/engine/change_password.php + * + * Expected $data keys: + * current_password, new_password, confirm_password (all strings) + * + * On success: destroys the session (forces re-login with new password), + * returns { success: 1, message: "..." } + * On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." } + * On other Exception (system error): HTTP 500, generic message (error logged server-side) + * + * Outputs JSON and calls exit. Safe against XSS — all output via json_encode. + * + * @param int $user_id The wms.user.user_id of the currently authenticated user. + * @param array $data Request data array containing the password fields. */ public function handleChange(int $user_id, array $data): void { @@ -170,6 +233,9 @@ class PasswordManager { $data['confirm_password'] ?? '' ); + // Destroy session: user must re-authenticate with new password. + // The db_auth OTP would invalidate naturally on next request + // (hash changed), but explicit destroy is immediate. session_destroy(); echo json_encode(['success' => 1, 'message' => 'Password changed successfully.']); @@ -190,6 +256,15 @@ class PasswordManager { // Private helpers // ───────────────────────────────────────────────────────────── + /** + * Load the zxcvbn-php autoloader. + * + * Called lazily (only when a strength check is actually needed). + * Throws a RuntimeException if the autoloader file is missing, + * so misconfiguration is caught clearly rather than as a silent failure. + * + * @throws \RuntimeException If the autoloader file is not found. + */ private function loadZxcvbn(): void { if (!file_exists($this->zxcvbn_path)) { throw new \RuntimeException('zxcvbn autoloader not found at: ' . $this->zxcvbn_path); @@ -197,6 +272,16 @@ class PasswordManager { require_once $this->zxcvbn_path; } + /** + * Fetch a user record by user_id from the wms.user table. + * + * Returns the full user row including the hashed password (needed for + * password_verify) and personal fields (needed for zxcvbn penalisation). + * + * @param int $user_id The wms.user.user_id to fetch. + * @return array Associative user row: user_id, username, name, surname, email, password. + * @throws \RuntimeException If no user is found for the given ID. + */ private function fetchUser(int $user_id): array { $sth = $this->pdo->prepare( 'SELECT user_id, username, name, surname, email, password @@ -213,8 +298,16 @@ class PasswordManager { } /** - * Run zxcvbn and throw if score is below MIN_SCORE. - * Passes personal fields so zxcvbn penalises name/email use. + * Run zxcvbn strength check and throw if the score is below MIN_SCORE. + * + * Passes user personal fields (name, surname, username, email) to zxcvbn + * so that passwords containing the user's own details are penalised. + * The first actionable feedback string from zxcvbn is included in the + * exception message shown to the user. + * + * @param string $password The new password to evaluate. + * @param array $user User row from fetchUser() — provides personalisation data. + * @throws \InvalidArgumentException If score < MIN_SCORE, with zxcvbn feedback. */ private function enforceStrength(string $password, array $user): void { @@ -234,10 +327,16 @@ class PasswordManager { } /** - * Hash and write the new password to the DB. - * The OTP session will invalidate automatically on the next - * request because db_auth.php re-derives the OTP from the - * stored password hash — changing it forces re-login. + * Hash the password with bcrypt and write it to the wms.user table. + * + * Uses PASSWORD_BCRYPT with PHP's default cost factor. + * After this write, the OTP-based auth in db_auth.php will invalidate + * on the next request because it re-derives the OTP from the stored hash — + * changing the hash implicitly forces a fresh login. + * + * @param int $user_id The wms.user.user_id to update. + * @param string $password The plain-text new password (will be hashed here). + * @throws \RuntimeException If the UPDATE affected 0 rows (user not found or no change). */ private function persist(int $user_id, string $password): void { $hashed = password_hash($password, PASSWORD_BCRYPT); diff --git a/app/assets/utils/classes/PasswordResetManager.php b/app/assets/utils/classes/PasswordResetManager.php index 806228d..7da4e35 100644 --- a/app/assets/utils/classes/PasswordResetManager.php +++ b/app/assets/utils/classes/PasswordResetManager.php @@ -3,21 +3,34 @@ /** * PasswordResetManager * - * Handles the full OTP-based password reset flow. + * Handles the full OTP-based password reset flow for the WMS application. * Reusable across: * - Profile page: "Forgot your current password?" (authenticated user) - * - Login page: "Forgot password?" (unauthenticated user) + * - Login page: "Forgot password?" (unauthenticated user — user_id resolved by caller) * - * Flow: - * 1. requestOtp($user_id) — generate OTP, email it, store in session - * 2. confirmReset($user_id, ...) — verify OTP, call PasswordManager::forceSet() + * Reset flow: + * 1. requestOtp($user_id, $company_id) — generate TOTP, email it, store in session. + * 2. confirmReset($user_id, $otp, ...) — verify OTP, call PasswordManager::forceSet(). * - * HTTP handler methods for thin endpoint wrappers: - * handleRequestOtp($user_id) + * The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1, + * scoped to a 3-minute time step. It cannot be replayed after the window expires. + * A human-readable reference number (6 uppercase letters) is also generated and emailed + * so the user can confirm they received the correct OTP request. + * + * HTTP handler methods for thin AJAX endpoint wrappers: + * handleRequestOtp($user_id, $company_id) * handleConfirmReset($user_id, $data) * - * Session keys used (prefixed to avoid collision with login OTP): + * Session keys used (prefixed with 'reset_' to avoid collision with login OTP): * reset_otp, reset_otp_time, reset_reference, reset_user_id + * + * Security: + * - OTP is HMAC-derived from the current password hash — it changes when the password changes. + * - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession(). + * - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse. + * - Session is fully destroyed on successful reset, forcing re-authentication. + * - All DB queries use PDO prepared statements with bound parameters. + * - AJAX handler methods output JSON via json_encode (XSS-safe). */ class PasswordResetManager { @@ -27,15 +40,17 @@ class PasswordResetManager { private $SMTP; private $pinkey; - /** OTP validity window in minutes — matches login OTP */ + /** OTP validity window in minutes — matches the login OTP window. */ const OTP_EXPIRY_MINUTES = 5; /** - * @param PDO $pdo1 Main DB (user table) - * @param PDO $pdo2 Company DB (smtp_setting table) - * @param string $include_url Absolute server path to app root (for requires) - * @param array $SMTP System default SMTP config from config.php - * @param string $pinkey Encryption key from config.php + * @param PDO $pdo1 PDO connection to the wms database (user table). + * @param PDO $pdo2 PDO connection to the company database (smtp_setting table). + * @param string $include_url Absolute server path to the app root (for require_once paths). + * Example: '/var/www/html/wms' + * @param array $SMTP System default SMTP configuration array from config.php. + * Used as fallback when the company has no custom SMTP. + * @param string $pinkey Encryption key from config.php, passed to the mailer. */ public function __construct($pdo1, $pdo2, string $include_url, array $SMTP, string $pinkey) { $this->pdo1 = $pdo1; @@ -46,22 +61,29 @@ class PasswordResetManager { } // ───────────────────────────────────────────────────────────── - // Public: request OTP + // Core public API // ───────────────────────────────────────────────────────────── /** - * Generate OTP, send it to the user's registered email, store in session. + * Generate a TOTP, send it to the user's registered email, and store it in session. * - * @param int $user_id From session (profile) or looked-up by email/username (login) - * @param int $company_id For SMTP fallback lookup - * @return array ['masked_email' => string, 'reference' => string] + * The OTP is derived from the user's current password hash so it is unique per user + * and automatically invalidated if the password is changed by any other means. + * A reference number (6 uppercase letters) is included in the email so the user + * can verify the request is legitimate. * - * @throws \RuntimeException if user not found or email missing - * @throws \Exception if mailer fails (mailer calls exit internally) + * Call this from the "request OTP" endpoint. The user_id must be resolved by the + * caller (from $_SESSION for authenticated flows, or from a username/email lookup + * for unauthenticated login-page flows). + * + * @param int $user_id The wms.user.user_id whose password is being reset. + * @param int $company_id Used to look up company SMTP settings (0 = use system default). + * @return array Keys: masked_email (string), reference (string 6-letter code). + * @throws \RuntimeException If user not found, email missing, or mailer fails. */ public function requestOtp(int $user_id, int $company_id = 0): array { - // ── Fetch user ──────────────────────────────────────────── + // Fetch user — need email (to send to) and password hash (for OTP derivation) $sth = $this->pdo1->prepare( 'SELECT user_id, email, password FROM user WHERE user_id = :id LIMIT 1' ); @@ -72,12 +94,12 @@ class PasswordResetManager { throw new \RuntimeException('No email address found for this account.'); } - // ── Generate OTP ────────────────────────────────────────── + // Generate 6-digit TOTP and a human-readable 6-letter reference number $otp_time = time(); $otp = $this->generateOTP($user['password'], $otp_time); $reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time)); - // ── Send email ──────────────────────────────────────────── + // Send via the mailer module (uses company SMTP or falls back to system default) require_once $this->include_url . '/assets/utils/module/mailer.php'; $mailer = new mailer(['pdo1' => $this->pdo1, 'pdo2' => $this->pdo2]); @@ -97,81 +119,94 @@ class PasswordResetManager { 'key' => $this->pinkey, ]); - // ── Store in session ────────────────────────────────────── + // Persist in session so confirmReset() can verify against it $_SESSION['reset_otp'] = $otp; $_SESSION['reset_otp_time'] = $otp_time; $_SESSION['reset_reference'] = $reference_number; $_SESSION['reset_user_id'] = $user_id; - // ── Return masked email for UI display ──────────────────── return [ 'masked_email' => $this->maskEmail($user['email']), 'reference' => $reference_number, ]; } - // ───────────────────────────────────────────────────────────── - // Public: confirm reset - // ───────────────────────────────────────────────────────────── - /** - * Verify OTP then force-set new password via PasswordManager. + * Verify the OTP and force-set a new password via PasswordManager. * - * @param int $user_id - * @param string $otp_input - * @param string $new_password - * @param string $confirm_password + * Validates: + * - Active session with a stored OTP and timestamp. + * - Session reset_user_id matches the $user_id being reset (prevents cross-user reuse). + * - OTP is within the OTP_EXPIRY_MINUTES window. + * - Submitted OTP matches the stored value exactly. * - * @throws \InvalidArgumentException OTP invalid/expired, passwords weak/mismatch - * @throws \RuntimeException DB or session state error + * On success: + * - Delegates to PasswordManager::forceSet() for strength enforcement and hashing. + * - Clears reset session keys and destroys the full session (forces re-login). + * + * @param int $user_id The wms.user.user_id being reset. + * @param string $otp_input The OTP submitted by the user. + * @param string $new_password The desired new password. + * @param string $confirm_password Must match $new_password exactly. + * @throws \InvalidArgumentException OTP missing, expired, incorrect, or passwords invalid. + * @throws \RuntimeException Session integrity error or DB failure. */ public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void { - // ── Validate session state ──────────────────────────────── + // Validate reset session exists if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) { throw new \InvalidArgumentException('No active reset request. Please request a new OTP.'); } - // ── Verify ownership ────────────────────────────────────── + // Verify this OTP belongs to the user making the request (prevents session swap attacks) if ((int)$_SESSION['reset_user_id'] !== $user_id) { throw new \RuntimeException('Invalid reset request.'); } - // ── Check expiry ────────────────────────────────────────── + // Check OTP has not expired $elapsed_minutes = (time() - (int)$_SESSION['reset_otp_time']) / 60; if ($elapsed_minutes > self::OTP_EXPIRY_MINUTES) { $this->clearSession(); throw new \InvalidArgumentException('OTP has expired. Please request a new one.'); } - // ── Verify OTP value ────────────────────────────────────── + // Verify OTP value if (trim($otp_input) !== $_SESSION['reset_otp']) { throw new \InvalidArgumentException('Incorrect OTP. Please try again.'); } - // ── Force-set password via PasswordManager ──────────────── + // Delegate to PasswordManager for strength enforcement and persistence require_once $this->include_url . '/assets/utils/classes/PasswordManager.php'; $pm = new PasswordManager($this->pdo1, $this->include_url); $pm->forceSet($user_id, $new_password, $confirm_password); - // ── Clear full session on success ──────────────────────── - // Destroys the login session so the user must re-authenticate - // with their new password. The OTP in db_auth would invalidate - // naturally on next request anyway (hash changed), but clearing - // here is immediate and explicit. + // Clear reset keys and destroy session — user must log in with new password. + // The OTP in db_auth would invalidate naturally (hash changed), but + // explicit destroy is immediate and leaves no dangling session state. $this->clearSession(); session_destroy(); } // ───────────────────────────────────────────────────────────── - // Public: HTTP handlers (thin endpoint wrappers call these) + // HTTP handlers (thin AJAX endpoint wrappers) // ───────────────────────────────────────────────────────────── /** - * Handle AJAX request-OTP call and echo JSON. - * Endpoint: setting/api/engine/request_reset_otp.php - * login/api/engine/request_reset_otp.php + * Handle an AJAX request-OTP call and echo a JSON response. + * + * Called by: + * setting/api/engine/request_reset_otp.php (profile page) + * login/api/engine/request_reset_otp.php (login page) + * + * On success: { success: 1, masked_email: "...", reference: "ABCDEF" } + * On RuntimeException (user/mail error): HTTP 500, { success: 0, message: "..." } + * On other Exception: HTTP 500, generic message (error logged server-side) + * + * Outputs JSON and calls exit. XSS-safe — all output via json_encode. + * + * @param int $user_id The wms.user.user_id to send the OTP to. + * @param int $company_id Company SMTP scope (0 = use system default). */ public function handleRequestOtp(int $user_id, int $company_id = 0): void { @@ -200,11 +235,23 @@ class PasswordResetManager { } /** - * Handle AJAX confirm-reset call and echo JSON. - * Endpoint: setting/api/engine/reset_password_otp.php - * login/api/engine/reset_password_otp.php + * Handle an AJAX confirm-reset call and echo a JSON response. * - * Expected $data keys: otp, new_password, confirm_password + * Called by: + * setting/api/engine/reset_password_otp.php (profile page) + * login/api/engine/reset_password_otp.php (login page) + * + * Expected $data keys: + * otp (string), new_password (string), confirm_password (string) + * + * On success: { success: 1, message: "Password reset successfully." } + * On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." } + * On other Exception: HTTP 500, generic message (error logged server-side) + * + * Outputs JSON and calls exit. XSS-safe — all output via json_encode. + * + * @param int $user_id The wms.user.user_id being reset. + * @param array $data Request data array with otp, new_password, confirm_password. */ public function handleConfirmReset(int $user_id, array $data): void { @@ -236,6 +283,24 @@ class PasswordResetManager { // Private helpers // ───────────────────────────────────────────────────────────── + /** + * Generate a TOTP-style numeric OTP from a secret key and timestamp. + * + * Implements a simplified TOTP algorithm (RFC 6238 subset): + * - Counter = floor(time / time_step) — changes every $time_step seconds (default 180) + * - HMAC-SHA1 over an 8-byte counter using $secret_key + * - Dynamic truncation to extract a 31-bit value + * - Modulo 10^$length to produce a $length-digit OTP (default 6) + * + * The OTP is derived from $secret_key, so using the user's password hash + * means the OTP is unique per user and automatically invalidated on password change. + * + * @param string $secret_key HMAC key — use user's password hash for per-user uniqueness. + * @param int $otp_time Unix timestamp at which the OTP was generated (store in session). + * @param int $time_step Window size in seconds (default 180 = 3 minutes per step). + * @param int $length Number of digits in the OTP (default 6). + * @return string Zero-padded OTP string of $length digits. + */ private function generateOTP(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string { $counter = floor($otp_time / $time_step); $data = pack('NN', 0, $counter); @@ -246,6 +311,16 @@ class PasswordResetManager { return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); } + /** + * Convert a positive integer into a base-26 uppercase letter string. + * + * Used to turn the numeric reference OTP into a human-friendly 6-letter + * reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email. + * The result is left-padded with 'A' to always return a 6-character string. + * + * @param int $num Positive integer to convert. + * @return string 6-character uppercase string (e.g. "AAAABC"). + */ private function numberToLetters(int $num): string { $result = ''; while ($num > 0) { @@ -256,13 +331,33 @@ class PasswordResetManager { return str_pad($result, 6, 'A', STR_PAD_LEFT); } + /** + * Mask an email address for display in the UI. + * + * Shows the first 2 characters of the local part, replaces the remainder + * with asterisks, and keeps the full domain. The masked email is returned + * to the frontend as confirmation that the OTP was sent to the right address + * without revealing it in full. + * + * Example: "john.doe@example.com" → "jo******@example.com" + * + * @param string $email The full email address to mask. + * @return string Masked email address. + */ private function maskEmail(string $email): string { - $at = strpos($email, '@'); + $at = strpos($email, '@'); return substr($email, 0, 2) . str_repeat('*', max(1, $at - 2)) . substr($email, $at); } + /** + * Remove reset-related keys from the current session. + * + * Called on OTP expiry (to invalidate the request) and on successful + * reset (before session_destroy). Does not destroy the full session — + * only the 4 reset-specific keys are unset. + */ private function clearSession(): void { unset( $_SESSION['reset_otp'], diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php index eca4a3d..2bfe4ac 100644 --- a/app/assets/utils/classes/ProductManager.php +++ b/app/assets/utils/classes/ProductManager.php @@ -3,11 +3,18 @@ /** * ProductManager * - * Encapsulates product and product category operations: - * - Soft-delete with downstream validation + * Handles all CRUD and soft-delete operations for products and product categories. * - * Note: Methods that modify data do NOT manage their own DB transactions. - * Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed. + * Method order: + * Master file basis → get/save/delete for product categories and products + * Transaction basis → (none — products are master data only) + * Report basis → getRackOccupancy (cross-warehouse physical inventory view) + * + * 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. + * No user input is ever interpolated directly into a query string. */ class ProductManager { @@ -24,8 +31,16 @@ class ProductManager { // ───────────────────────────────────────────────────────────── /** - * Check if a SKU has any active stock across all td_stock_* tables. - * Returns the first blocking table name found, or null if clear. + * Scan all td_stock_* warehouse tables for any active stock row + * that references the given SKU. + * + * Used as a pre-delete guard on products: a product cannot be removed + * while stock exists for it in any warehouse. + * Table names come from information_schema (trusted system table) + * and are backtick-quoted — no user input reaches the identifier. + * + * @param string $sku The product SKU to search for. + * @return string|null The first blocking table name found, or null if clear. */ private function findActiveStock(string $sku): ?string { @@ -56,7 +71,17 @@ class ProductManager { } /** - * Build a log entry array for audit context. + * 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 [ @@ -70,180 +95,17 @@ class ProductManager { } // ───────────────────────────────────────────────────────────── - // Product category + // MASTER FILE BASIS — Product Category // ───────────────────────────────────────────────────────────── /** - * Soft-delete a product category. + * Return all product categories with their product count. * - * Blocks if any md_product references this category. + * Used to populate the category listing page and category dropdowns. + * The LEFT JOIN count lets the UI show how many products are in each category. * - * @throws Exception if category not found or has dependent products - */ - public function deleteCategory(int $category_id): void { - - $sth = $this->pdo->prepare( - "SELECT id, category, `log` FROM md_product_category - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':id' => $category_id, - ]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$row) { - throw new Exception("Product category not found."); - } - - // Block if any product references this category - $sth = $this->pdo->prepare( - "SELECT COUNT(*) FROM md_product - WHERE company_id = :company_id - AND category = :category_id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':category_id' => $category_id, - ]); - - if ($sth->fetchColumn() > 0) { - throw new Exception( - "Cannot delete — category \"{$row['category']}\" " . - "still has products assigned to it." - ); - } - - // Append delete event to log - $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = $this->buildLogEntry('delete'); - - // Soft-delete - $this->pdo->prepare( - "UPDATE md_product_category - SET company_id = company_id * -1, - `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':log' => json_encode($log), - ':id' => $category_id, - ':company_id' => $this->company_id, - ]); - } - - // ───────────────────────────────────────────────────────────── - // Product - // ───────────────────────────────────────────────────────────── - - /** - * Soft-delete a product. - * - * Blocks if the product SKU has any active stock across any td_stock_* table. - * - * @throws Exception if product not found or has active stock - */ - public function deleteProduct(int $product_id): void { - - $sth = $this->pdo->prepare( - "SELECT id, product_name, sku, `log` FROM md_product - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':id' => $product_id, - ]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$row) { - throw new Exception("Product not found."); - } - - // Block if active stock exists for this SKU in any warehouse - $blocking_table = $this->findActiveStock($row['sku']); - if ($blocking_table !== null) { - throw new Exception( - "Cannot delete — \"{$row['product_name']}\" " . - "still has active stock in the system." - ); - } - - // Append delete event to log - $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = $this->buildLogEntry('delete'); - - // Soft-delete - $this->pdo->prepare( - "UPDATE md_product - SET company_id = company_id * -1, - `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':log' => json_encode($log), - ':id' => $product_id, - ':company_id' => $this->company_id, - ]); - } - - - // ───────────────────────────────────────────────────────────── - // Product queries - // ───────────────────────────────────────────────────────────── - - /** - * Full product list with current warehouse balance. - */ - public function getProductList(): array - { - $sth = $this->pdo->prepare( - "SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance - FROM md_product a - LEFT JOIN warehouse_balance b - ON a.company_id = b.company_id - AND a.sku = b.product_sku - WHERE a.company_id = :company_id - GROUP BY a.id" - ); - $sth->execute([':company_id' => $this->company_id]); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * Fetch a single product row by ID. - */ - public function getProductById(int $id): array|false - { - $sth = $this->pdo->prepare( - "SELECT * FROM md_product - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $this->company_id, ':id' => $id]); - return $sth->fetch(PDO::FETCH_ASSOC); - } - - /** - * Search products by SKU keyword — for autocomplete. - */ - public function searchProduct(string $keyword): array - { - $sth = $this->pdo->prepare( - "SELECT * FROM md_product - WHERE company_id = :company_id - AND sku LIKE :keyword - LIMIT 50" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':keyword' => '%' . $keyword . '%', - ]); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - // ───────────────────────────────────────────────────────────── - // Product category queries - // ───────────────────────────────────────────────────────────── - - /** - * Full category list with product count per category. + * @return array All md_product_category rows for this company, + * each augmented with a 'product_count' field. */ public function getCategoryList(): array { @@ -261,7 +123,12 @@ class ProductManager { } /** - * Fetch a single category row by ID. + * Fetch a single product category row by its primary key. + * + * Used to pre-fill the edit form on the manage category page. + * + * @param int $id The md_product_category.id to fetch. + * @return array|false Associative row, or false if not found. */ public function getCategoryById(int $id): array|false { @@ -274,11 +141,16 @@ class ProductManager { } /** - * Insert or update a product category. - * Pass $data['id'] > 0 for update, 0 for insert. + * Insert a new product category 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. + * * Must be called inside dbTransaction() by the caller. * - * @throws Exception on validation failure + * @param array $data Keys: id, category, slug, description, status. + * @param array $logging Audit entry to append to the log column. + * @throws Exception On validation failure (e.g. duplicate slug). */ public function saveCategory(array $data, array $logging): void { @@ -322,15 +194,153 @@ class ProductManager { } } + /** + * Soft-delete a product category by negating its company_id. + * + * Blocks deletion if any md_product row is still assigned to this category, + * preventing products from losing their category reference. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $category_id The md_product_category.id to delete. + * @throws Exception If the category is not found or has products assigned to it. + */ + public function deleteCategory(int $category_id): void { + + $sth = $this->pdo->prepare( + "SELECT id, category, `log` FROM md_product_category + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $category_id, + ]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception("Product category not found."); + } + + // Block if any product references this category + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM md_product + WHERE company_id = :company_id + AND category = :category_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':category_id' => $category_id, + ]); + + if ($sth->fetchColumn() > 0) { + throw new Exception( + "Cannot delete — category \"{$row['category']}\" " . + "still has products 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_product_category + SET company_id = company_id * -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $category_id, + ':company_id' => $this->company_id, + ]); + } // ───────────────────────────────────────────────────────────── - // Product write + // MASTER FILE BASIS — Product // ───────────────────────────────────────────────────────────── /** - * Insert or update a product. - * Pass $data['id'] > 0 for update, 0 for insert. + * Return all products with their current aggregate warehouse balance. + * + * The balance is the sum of (total_in - total_out) across all warehouses + * from the warehouse_balance table. Products with no balance rows show 0. + * + * Used to populate the product listing page. + * + * @return array All md_product rows for this company, each with a 'product_balance' field. + */ + public function getProductList(): array + { + $sth = $this->pdo->prepare( + "SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance + FROM md_product a + LEFT JOIN warehouse_balance b + ON a.company_id = b.company_id + AND a.sku = b.product_sku + WHERE a.company_id = :company_id + GROUP BY a.id" + ); + $sth->execute([':company_id' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Fetch a single product row by its primary key. + * + * Used to pre-fill the edit form on the manage product page. + * + * @param int $id The md_product.id to fetch. + * @return array|false Associative row, or false if not found. + */ + public function getProductById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT * FROM md_product + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + return $sth->fetch(PDO::FETCH_ASSOC); + } + + /** + * Search products by SKU 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 SKU to match. + * @return array Matching md_product rows. + */ + public function searchProduct(string $keyword): array + { + $sth = $this->pdo->prepare( + "SELECT * FROM md_product + WHERE company_id = :company_id + AND sku LIKE :keyword + LIMIT 50" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':keyword' => '%' . $keyword . '%', + ]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Insert a new product or update an existing one. + * + * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. + * $product_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, product_name, sku, price, min_stock, + * reorder_point, category, description, status. + * @param array $logging Audit entry to append to the log column. + * @param string $product_image Stored filename for the product image. */ public function saveProduct(array $data, array $logging, string $product_image): void { @@ -386,9 +396,77 @@ class ProductManager { } } + /** + * Soft-delete a product by negating its company_id. + * + * Blocks deletion if the product SKU has any active stock across any + * td_stock_* warehouse table. This prevents the product master record + * from disappearing while physical inventory still exists for it. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $product_id The md_product.id to delete. + * @throws Exception If the product is not found or has active stock. + */ + public function deleteProduct(int $product_id): void { + + $sth = $this->pdo->prepare( + "SELECT id, product_name, sku, `log` FROM md_product + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $product_id, + ]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception("Product not found."); + } + + // Block if active stock exists for this SKU in any warehouse + $blocking_table = $this->findActiveStock($row['sku']); + if ($blocking_table !== null) { + throw new Exception( + "Cannot delete — \"{$row['product_name']}\" " . + "still has active stock in the system." + ); + } + + // 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_product + SET company_id = company_id * -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $product_id, + ':company_id' => $this->company_id, + ]); + } + + // ───────────────────────────────────────────────────────────── + // REPORT BASIS + // ───────────────────────────────────────────────────────────── /** - * Full rack list with warehouse name, product info and occupancy status. + * Return all rack slots across all warehouses with occupancy status, + * warehouse name, and product name. + * + * Used by the rack occupancy dashboard to visualise which racks are + * empty vs. occupied, and which product is in each slot. + * Results are ordered by warehouse → zone → aisle → rack, with + * numeric-first sorting via CAST so e.g. "2" sorts before "10". + * + * Security fix: was previously using $this->companyId (undefined property), + * corrected to $this->company_id. + * + * @return array All md_rack rows joined to warehouse and product, with 'status' field. */ public function getRackOccupancy(): array { @@ -416,7 +494,7 @@ class ProductManager { CAST(r.aisle AS UNSIGNED), r.aisle, CAST(r.rack AS UNSIGNED), r.rack" ); - $sth->execute([':company_id' => $this->companyId]); + $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } } \ No newline at end of file diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php index 930aa90..dc63290 100644 --- a/app/assets/utils/classes/ReportManager.php +++ b/app/assets/utils/classes/ReportManager.php @@ -1,5 +1,33 @@ ) 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: DB name from SELECT DATABASE() bound via PDO (was raw interpolation) + * - getRackOccupancy: (already safe — no dynamic identifiers) + */ class ReportManager { private PDO $pdo; @@ -11,9 +39,18 @@ class ReportManager $this->companyId = $companyId; } + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + /** - * Resolve the td_stock_ table name for a given warehouse_id. - * Returns null if warehouse not found. + * 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 resolveWarehouseTable(int $warehouse_id): ?string { @@ -28,7 +65,15 @@ class ReportManager return "td_stock_{$safe}"; } - // These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database. + /** + * Execute a simple SELECT COUNT(*) or scalar query and return one value. + * + * Used by the stat-count methods that only need a single integer result. + * The SQL must contain a :company_id placeholder. + * + * @param string $sql Prepared SQL with :company_id placeholder. + * @return mixed The first column of the first result row. + */ private function fetchScalar(string $sql) { $sth = $this->pdo->prepare($sql); @@ -36,6 +81,15 @@ class ReportManager return $sth->fetchColumn(); } + /** + * Execute a SELECT query and return all rows as an associative array. + * + * Used by list-style reporting methods that need multiple rows. + * The SQL must contain a :company_id placeholder. + * + * @param string $sql Prepared SQL with :company_id placeholder. + * @return array All result rows as PDO::FETCH_ASSOC arrays. + */ private function fetchAll(string $sql): array { $sth = $this->pdo->prepare($sql); @@ -43,8 +97,13 @@ class ReportManager return $sth->fetchAll(PDO::FETCH_ASSOC); } + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Master data summaries + // ───────────────────────────────────────────────────────────── + /** - * Master Data Summary + * Total number of product categories (all statuses) for this company. + * Used by the reports_stats dashboard tile. */ public function getTotalCategory(): int { @@ -54,6 +113,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Number of active (status = 1) product categories for this company. + * Used by the reports_stats dashboard tile. + */ public function getActiveCategory(): int { $sql = "SELECT COUNT(*) @@ -63,6 +126,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Number of inactive (status = 0) product categories for this company. + * Used by the reports_stats dashboard tile. + */ public function getInactiveCategory(): int { $sql = "SELECT COUNT(*) @@ -72,6 +139,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Number of active warehouses (status = 1) for this company. + * Used by the reports_stats dashboard tile. + */ public function getTotalWarehouse(): int { $sql = "SELECT COUNT(*) @@ -81,6 +152,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Number of distinct warehouse locations for this company's active warehouses. + * Used by the reports_stats dashboard tile. + */ public function getTotalLocation(): int { $sql = "SELECT COUNT(DISTINCT `location`) @@ -90,6 +165,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Total rack capacity (all racks across all warehouses) for this company. + * Used by the reports_stats dashboard tile. + */ public function getTotalCapacity(): int { $sql = "SELECT COUNT(*) @@ -98,6 +177,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Number of occupied racks (product_sku IS NOT NULL) for this company. + * Used by the reports_stats dashboard tile. + */ public function getSpaceUsed(): int { $sql = "SELECT COUNT(*) @@ -107,6 +190,10 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Total number of products (all statuses) for this company. + * Used by the reports_stats dashboard tile. + */ public function getTotalProduct(): int { $sql = "SELECT COUNT(*) @@ -115,6 +202,11 @@ class ReportManager return (int) $this->fetchScalar($sql); } + /** + * Number of distinct SKUs with a positive running balance across all warehouses. + * "In stock" means (total_in - total_out) > 0 in warehouse_balance. + * Used by the reports_stats dashboard tile. + */ public function getTotalProductInStock(): int { $sql = "SELECT COUNT(DISTINCT product_sku) @@ -124,19 +216,68 @@ class ReportManager return (int) $this->fetchScalar($sql); } - public function getLowStockCount(): int + /** + * Number of contact types (all statuses) for this company. + * Used by the reports_stats dashboard tile. + */ + public function getTotalContactCategory(): int { - $products = $this->getStockBalance(); - $count = 0; - foreach ($products as $product) { - $balance = (float) $product["total_in"] - (float) $product["total_out"]; - if ($balance < (float) $product["min_stock"]) { - $count++; - } - } - return $count; + $sql = "SELECT COUNT(*) + FROM md_contact_type + WHERE company_id = :company_id"; + return (int) $this->fetchScalar($sql); } + /** + * Number of active (status = 1) contact types for this company. + * Used by the reports_stats dashboard tile. + */ + public function getActiveContactCategory(): int + { + $sql = "SELECT COUNT(*) + FROM md_contact_type + WHERE company_id = :company_id + AND `status` = 1"; + return (int) $this->fetchScalar($sql); + } + + /** + * Number of inactive (status = 0) contact types for this company. + * Used by the reports_stats dashboard tile. + */ + public function getInactiveContactCategory(): int + { + $sql = "SELECT COUNT(*) + FROM md_contact_type + WHERE company_id = :company_id + AND `status` = 0"; + return (int) $this->fetchScalar($sql); + } + + /** + * Total number of contacts for this company. + * Used by the reports_stats dashboard tile. + */ + public function getTotalContact(): int + { + $sql = "SELECT COUNT(*) + FROM md_contact + WHERE company_id = :company_id"; + return (int) $this->fetchScalar($sql); + } + + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Stock summaries + // ───────────────────────────────────────────────────────────── + + /** + * Return aggregated total_in, total_out, and min_stock for every SKU + * across all warehouses from warehouse_balance. + * + * Used internally by getLowStockCount and as a general balance query. + * + * @return array Rows with product_sku, total_in, total_out, min_stock. + */ public function getStockBalance(): array { $sql = "SELECT @@ -153,6 +294,34 @@ class ReportManager return $this->fetchAll($sql); } + /** + * Count how many SKUs are currently below their min_stock threshold. + * + * Computed in PHP from getStockBalance() to avoid complex SQL HAVING with JOIN. + * + * @return int Number of SKUs with balance < min_stock. + */ + public function getLowStockCount(): int + { + $products = $this->getStockBalance(); + $count = 0; + foreach ($products as $product) { + $balance = (float) $product["total_in"] - (float) $product["total_out"]; + if ($balance < (float) $product["min_stock"]) { + $count++; + } + } + return $count; + } + + /** + * Return all SKUs at or below their reorder_point with full product and warehouse details. + * + * Includes a 'status' field: 'critical' if balance <= min_stock, 'warning' otherwise. + * Only products with reorder_point > 0 are included (to skip un-configured products). + * + * @return array Low/critical stock items with warehouse_name, product_name, balance, status. + */ public function getLowStockItems(): array { $sql = "SELECT @@ -200,55 +369,43 @@ class ReportManager return $items; } + /** + * Count SKUs with status = 'critical' (balance <= min_stock). + * Derived from getLowStockItems(). + * + * @return int Number of critical stock items. + */ public function getCriticalStockCount(): int { $items = $this->getLowStockItems(); return count(array_filter($items, fn($i) => $i['status'] === 'critical')); } + /** + * Count SKUs with status = 'warning' (reorder_point < balance <= min_stock boundary). + * Derived from getLowStockItems(). + * + * @return int Number of warning stock items. + */ public function getWarningStockCount(): int { $items = $this->getLowStockItems(); return count(array_filter($items, fn($i) => $i['status'] === 'warning')); } - // CONTACT SUMMARY - public function getTotalContactCategory(): int - { - $sql = "SELECT COUNT(*) - FROM md_contact_type - WHERE company_id = :company_id"; - return (int) $this->fetchScalar($sql); - } - - public function getActiveContactCategory(): int - { - $sql = "SELECT COUNT(*) - FROM md_contact_type - WHERE company_id = :company_id - AND `status` = 1"; - return (int) $this->fetchScalar($sql); - } - - public function getInactiveContactCategory(): int - { - $sql = "SELECT COUNT(*) - FROM md_contact_type - WHERE company_id = :company_id - AND `status` = 0"; - return (int) $this->fetchScalar($sql); - } - - public function getTotalContact(): int - { - $sql = "SELECT COUNT(*) - FROM md_contact - WHERE company_id = :company_id"; - return (int) $this->fetchScalar($sql); - } - - // DASHBOARD REPORTS (cross-warehouse, month-filtered via warehouse_balance) + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Dashboard reports + // ───────────────────────────────────────────────────────────── + /** + * Return aggregate stock movement stats for a specific month. + * + * Used by the main dashboard stats widget. + * $month format: 'YYYY-MM' (e.g. '2025-04'). + * + * @param string $month Month in YYYY-MM format. + * @return array Keys: total_in, total_out, active_products. + */ public function getDashboardStats(string $month): array { $sth = $this->pdo->prepare( @@ -266,6 +423,14 @@ class ReportManager ]; } + /** + * Count SKUs currently below their min_stock threshold — for the dashboard alert tile. + * + * Similar to getLowStockCount() but computed directly via SQL aggregate for efficiency, + * without the intermediate getStockBalance() call. + * + * @return int Number of SKUs with balance < min_stock. + */ public function getDashboardLowStockCount(): int { $sth = $this->pdo->prepare( @@ -288,6 +453,15 @@ class ReportManager return $count; } + /** + * Return monthly stock_in and stock_out totals for a rolling N-month window. + * + * Produces chart-ready arrays with labels (e.g. "Apr 2025"), stock_in, and stock_out. + * Months with no data return 0. Data is sourced from warehouse_balance (all warehouses). + * + * @param int $months Number of months to include (default 12). + * @return array Keys: labels (array), stock_in (array), stock_out (array). + */ public function getStockMovementChart(int $months = 12): array { $now = new DateTime(); @@ -328,9 +502,19 @@ class ReportManager return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out]; } + /** + * Return the top N most-moved products (by total_out) for a given month. + * + * Used by the dashboard "most moved" widget. + * $limit is cast to (int) before interpolation to prevent SQL injection. + * + * @param string $month Month in YYYY-MM format. + * @param int $limit Maximum number of products to return (default 10). + * @return array Rows with product_sku, total_in, total_out, product_name, category_name. + */ public function getMostMovedProducts(string $month, int $limit = 10): array { - $limit = (int) $limit; + $limit = (int) $limit; // cast before interpolation $sth = $this->pdo->prepare( "SELECT wb.product_sku, @@ -358,6 +542,13 @@ class ReportManager return $sth->fetchAll(PDO::FETCH_ASSOC); } + /** + * Return all months for which warehouse_balance data exists, newest first. + * + * Used to populate the month selector on the dashboard and reports pages. + * + * @return array Flat array of YYYY-MM strings. + */ public function getAvailableMonths(): array { $sth = $this->pdo->prepare( @@ -371,7 +562,17 @@ class ReportManager } /** - * Recent stock activity across all warehouses — last N transactions. + * Return the most recent N stock transactions across all active warehouses. + * + * 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. */ public function getRecentActivity(int $limit = 10): array { @@ -384,21 +585,27 @@ 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( - fn($wh) => "SELECT s.date, s.product_sku, s.type, + function ($wh) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); + $cid = (int) $this->companyId; + 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, - '{$wh['warehouse_name']}' AS warehouse_name - FROM `td_stock_{$wh['warehouse_name']}` s + '{$safe}' AS warehouse_name + FROM `td_stock_{$safe}` s LEFT JOIN md_product p ON p.company_id = s.company_id AND p.sku = s.product_sku - WHERE s.company_id = {$this->companyId}", + WHERE s.company_id = {$cid}"; + }, $warehouses )); - $limit = (int) $limit; + $limit = (int) $limit; // cast before interpolation $sth = $this->pdo->query( "SELECT * FROM ({$unions}) AS all_stock ORDER BY date DESC @@ -421,7 +628,16 @@ class ReportManager return $items; } - // WAREHOUSE OVERVIEW + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Warehouse detail reports + // ───────────────────────────────────────────────────────────── + + /** + * Total rack capacity for a specific warehouse (all racks regardless of occupancy). + * + * @param int $warehouse_id The warehouse to query. + * @return int Total rack count. + */ public function getWarehouseCapacity(int $warehouse_id): int { $sth = $this->pdo->prepare( @@ -432,6 +648,12 @@ class ReportManager return (int) $sth->fetchColumn(); } + /** + * Number of occupied racks (product_sku IS NOT NULL) for a specific warehouse. + * + * @param int $warehouse_id The warehouse to query. + * @return int Occupied rack count. + */ public function getWarehouseSpaceUsed(int $warehouse_id): int { $sth = $this->pdo->prepare( @@ -443,6 +665,12 @@ class ReportManager return (int) $sth->fetchColumn(); } + /** + * Total in and out balance for a specific warehouse from warehouse_balance. + * + * @param int $warehouse_id The warehouse to query. + * @return array Keys: total_in, total_out. + */ public function getWarehouseBalance(int $warehouse_id): array { $sth = $this->pdo->prepare( @@ -456,6 +684,14 @@ class ReportManager return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; } + /** + * Count SKUs below min_stock threshold for a specific warehouse. + * + * Scoped to warehouse_balance rows for this warehouse only. + * + * @param int $warehouse_id The warehouse to query. + * @return int Number of SKUs below min_stock. + */ public function getWarehouseLowStockCount(int $warehouse_id): int { $sth = $this->pdo->prepare( @@ -479,6 +715,15 @@ class ReportManager return $count; } + /** + * 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) + * for per-warehouse granularity. Produces chart-ready arrays with month labels. + * + * @param int $warehouse_id The warehouse to query. + * @return array Keys: labels, stock_in, stock_out (each a 12-element array). + */ public function getWarehouseStockMovement(int $warehouse_id): array { $table = $this->resolveWarehouseTable($warehouse_id); @@ -522,6 +767,16 @@ class ReportManager return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out]; } + /** + * Return a running cumulative balance trend for a warehouse over the last 12 months. + * + * Starts from the balance before the 12-month window and accumulates + * monthly in/out to produce a month-by-month balance curve. + * Used by the warehouse overview balance trend chart. + * + * @param int $warehouse_id The warehouse to query. + * @return array Keys: labels (12-element), balance (12-element cumulative floats). + */ public function getWarehouseStockTrend(int $warehouse_id): array { $table = $this->resolveWarehouseTable($warehouse_id); @@ -531,6 +786,7 @@ class ReportManager $start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00'); $end = $now->format('Y-m-d 23:59:59'); + // Opening balance: everything before the 12-month window $sth = $this->pdo->prepare( "SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance FROM `{$table}` @@ -574,6 +830,15 @@ class ReportManager return ['labels' => $labels, 'balance' => $balance]; } + /** + * Return the 10 most recent stock movements for a warehouse. + * + * Used by the warehouse overview recent activity list. + * Includes only rows with in > 0 or out > 0 (excludes zero-quantity rows). + * + * @param int $warehouse_id The warehouse to query. + * @return array Activity rows with product_name, sku, direction, qty, type, description, date. + */ public function getWarehouseActivity(int $warehouse_id): array { $table = $this->resolveWarehouseTable($warehouse_id); @@ -610,24 +875,59 @@ class ReportManager return $activities; } - // EXPIRED / NEAR EXPIRY STOCK + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Expiry reports + // ───────────────────────────────────────────────────────────── + + /** + * Return all stock rows with lot numbers that are expired or expiring within 30 days. + * + * Can be scoped to a single warehouse by passing warehouse_id > 0, + * or run across all warehouses with warehouse_id = 0. + * + * Status thresholds: + * days_remaining < 0 → 'expired' + * 0–7 → 'critical' + * 8–14 → 'warning' + * 15–30 → 'caution' + * > 30 → excluded + * + * 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. + */ public function getExpiredStock(int $warehouse_id = 0): array { - $where_wh = $warehouse_id > 0 ? "AND id = {$warehouse_id}" : ''; - $sth = $this->pdo->prepare( - "SELECT id, warehouse_name - FROM md_warehouse - WHERE company_id = :company_id - AND status = 1 - {$where_wh}" - ); - $sth->execute([':company_id' => $this->companyId]); + // Security: cast to int, use bound parameter in WHERE + $warehouse_id = (int) $warehouse_id; + + if ($warehouse_id > 0) { + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND status = 1 AND id = :warehouse_id" + ); + $sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]); + } else { + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $sth->execute([':company_id' => $this->companyId]); + } $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); if (empty($warehouses)) return []; + $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( - fn($wh) => "SELECT + function ($wh) use ($cid) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); + return "SELECT s.id, s.product_sku, s.lot_number, @@ -636,10 +936,11 @@ class ReportManager s.rack, ROUND(s.`in`, 2) AS quantity, {$wh['id']} AS warehouse_id - FROM `td_stock_{$wh['warehouse_name']}` s - WHERE s.company_id = {$this->companyId} + FROM `td_stock_{$safe}` s + WHERE s.company_id = {$cid} AND s.type = 'in' - AND s.lot_number IS NOT NULL", + AND s.lot_number IS NOT NULL"; + }, $warehouses )); @@ -658,17 +959,17 @@ class ReportManager mw.warehouse_name FROM ($unions) AS stock INNER JOIN md_lot l - ON l.company_id = {$this->companyId} + ON l.company_id = {$cid} AND l.product_sku = stock.product_sku AND l.lot_number = stock.lot_number INNER JOIN md_product p - ON p.company_id = {$this->companyId} + ON p.company_id = {$cid} AND p.sku = stock.product_sku LEFT JOIN md_product_category pc - ON pc.company_id = {$this->companyId} + ON pc.company_id = {$cid} AND pc.id = p.category INNER JOIN md_warehouse mw - ON mw.company_id = {$this->companyId} + ON mw.company_id = {$cid} AND mw.id = stock.warehouse_id WHERE l.expiry_date IS NOT NULL ORDER BY l.expiry_date ASC"; @@ -708,35 +1009,20 @@ class ReportManager return $items; } - // ───────────────────────────────────────────────────────────── - // Warehouse balance summary + // REPORT BASIS — Lot / Rack reports // ───────────────────────────────────────────────────────────── /** - * Aggregate total_in, total_out and warehouse count across all warehouses. - */ - public function getWarehouseBalanceSummary(): array - { - $sth = $this->pdo->prepare( - "SELECT - SUM(total_in) AS total_in, - SUM(total_out) AS total_out, - COUNT(DISTINCT warehouse_id) AS total_warehouse - FROM warehouse_balance - WHERE company_id = :company_id" - ); - $sth->execute([':company_id' => $this->companyId]); - return $sth->fetch(PDO::FETCH_ASSOC) ?: []; - } - - // ───────────────────────────────────────────────────────────── - // Dashboard report queries - // ───────────────────────────────────────────────────────────── - - /** - * All stock movements for a given product_sku + lot_number across all warehouses, - * sorted by date DESC. + * Return all stock movements for a specific product_sku + lot_number across all warehouses. + * + * Used by the lot stock log dashboard report page. + * Results are merged from all td_stock_* tables and sorted by date DESC. + * Returns empty array if either parameter is blank. + * + * @param string $product_sku SKU to filter by. + * @param string $lot_number Lot number to filter by. + * @return array Stock rows with date, type, zone/aisle/rack, in/out amounts, warehouse_name. */ public function getLotStockLog(string $product_sku, string $lot_number): array { @@ -767,7 +1053,7 @@ class ReportManager ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, s.serial_number, s.description, - '{$wh['warehouse_name']}' AS warehouse_name + '{$safe}' AS warehouse_name FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku @@ -787,8 +1073,19 @@ class ReportManager } /** - * All lots with running balance, expiry status, and days remaining. - * Returns rows plus summary counts (total, active, expired, near). + * Return all lots with running balance, expiry status, and summary counts. + * + * Used by the product lot dashboard report page. + * Each row gets a 'status' field ('expired' | 'critical' | 'near' | 'ok') + * based on days_remaining. + * + * Returns both the row data and a summary: + * total → total number of lot rows + * active → lots with balance > 0 + * expired → lots past expiry date + * near → lots expiring within 30 days + * + * @return array Keys: rows (array), total (int), active (int), expired (int), near (int). */ public function getProductLots(): array { @@ -845,13 +1142,25 @@ class ReportManager } /** - * Rack activity log for a given rack_id, with user name. + * Return the action log for a specific rack, with user name. + * + * Used by the rack log dashboard report page. + * Joins the wms.user table for the acting user's name. + * + * Security fix: the database name is now fetched once via a bound query + * and stored as $db_name (string), then used as a backtick-quoted identifier + * rather than being embedded in the JOIN without escaping. + * + * @param int $rack_id The md_rack.id to fetch logs for (0 returns empty array). + * @return array Log rows with dt, action, product_sku, td_stock_id, login, user_name. */ public function getRackLog(int $rack_id): array { if (!$rack_id) return []; - $db = $this->pdo->query("SELECT DATABASE()")->fetchColumn(); + // Fetch DB name safely — used as a backtick-quoted identifier, not user input + $db_name = $this->pdo->query("SELECT DATABASE()")->fetchColumn(); + $db_name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db_name); $sth = $this->pdo->prepare( "SELECT @@ -863,7 +1172,7 @@ class ReportManager rl.login, u.name AS user_name FROM md_rack_log rl - LEFT JOIN {$db}.user u + LEFT JOIN `{$db_name}`.user u ON u.user_id = rl.user_id WHERE rl.company_id = :company_id AND rl.md_rack_id = :rack_id @@ -876,9 +1185,16 @@ class ReportManager return $sth->fetchAll(PDO::FETCH_ASSOC); } - /** - * Full rack list with warehouse name, product info and occupancy status. + * Return all rack slots across all warehouses with occupancy status, + * warehouse name, and product name. + * + * Used by the rack occupancy dashboard report page to visualise which + * racks are empty vs occupied and which product is in each slot. + * Results are ordered by warehouse → zone → aisle → rack with + * numeric-first sorting via CAST. + * + * @return array All md_rack rows joined to warehouse and product with 'status' field. */ public function getRackOccupancy(): array { @@ -909,6 +1225,29 @@ class ReportManager $sth->execute([':company_id' => $this->companyId]); return $sth->fetchAll(PDO::FETCH_ASSOC); } -} -?> \ No newline at end of file + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Balance summary + // ───────────────────────────────────────────────────────────── + + /** + * Return aggregate total_in, total_out, and warehouse count across all warehouses. + * + * Used by the warehouse balance overview page to show company-wide totals. + * + * @return array Keys: total_in, total_out, total_warehouse. + */ + public function getWarehouseBalanceSummary(): array + { + $sth = $this->pdo->prepare( + "SELECT + SUM(total_in) AS total_in, + SUM(total_out) AS total_out, + COUNT(DISTINCT warehouse_id) AS total_warehouse + FROM warehouse_balance + WHERE company_id = :company_id" + ); + $sth->execute([':company_id' => $this->companyId]); + return $sth->fetch(PDO::FETCH_ASSOC) ?: []; + } +} \ No newline at end of file diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index d88c36e..962ba7a 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -3,12 +3,24 @@ /** * StockManager * - * Encapsulates read operations for ICS stock transactions: - * - Stock list by type (in / out / transfer) - * - Single record retrieval for stock_in, stock_out, transfer + * Handles read and write operations for ICS stock transactions + * (stock in, stock out, stock transfer). * - * Write operations (manage_stock_in, manage_stock_out, manage_stock_transfer) - * are handled in their engine files via WarehouseManager. + * Method order: + * Master file basis → (none — stock transactions are not master data) + * Transaction basis → getStockList, getStockInById, getStockOutById, + * getTransferById, saveStockIn, saveStockOut, saveStockTransfer + * Report basis → (none — reporting is handled by ReportManager) + * + * Write operations delegate rack and balance side-effects to WarehouseManager. + * Delete operations are handled directly in WarehouseManager (deleteStockIn, etc.). + * + * 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 derived from DB-sourced warehouse + * names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation. */ class StockManager { @@ -25,8 +37,17 @@ class StockManager { // ───────────────────────────────────────────────────────────── /** - * Resolve warehouse name by ID and return the td_stock_ table name. - * Throws if warehouse not found. + * 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 { @@ -46,19 +67,26 @@ class StockManager { } // ───────────────────────────────────────────────────────────── - // Stock list queries + // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── /** - * List all stock records of a given type for a warehouse. - * type: 'in' | 'out' | 'transfer' + * Return all stock records of a given movement type for a warehouse. + * + * Used to populate the stock in / stock out / transfer listing pages. + * The 'quantity' alias resolves to the correct column (in or out) depending + * on the type. For transfers, only the outbound row is listed (out > 0). + * + * @param int $warehouse_id The md_warehouse.id to query. + * @param string $type Movement type: 'in' | 'out' | 'transfer'. + * @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'. */ 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) + // 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( @@ -79,13 +107,16 @@ class StockManager { 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. + * Fetch a single stock_in record with related product, contact, and lot data. + * + * Used to pre-fill the manage stock in form in edit mode and for the + * 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. + * @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 { @@ -112,7 +143,15 @@ class StockManager { } /** - * Fetch a single stock_out record with product and contact name. + * Fetch a single stock_out record with related product and contact data. + * + * Used to pre-fill the manage stock out form in edit mode and for the + * 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. + * @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 { @@ -134,9 +173,18 @@ class StockManager { } /** - * 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. + * Fetch a transfer record pair — the outbound (from) row and its paired + * inbound (to) row — as a single structure. + * + * The two rows are linked by a shared UUID and ref_warehouse cross-reference. + * The inbound row is returned under the 'ref' key of the outbound row. + * This is used by the manage stock transfer form in edit mode and the + * 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. + * @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 { @@ -180,16 +228,29 @@ class StockManager { return $output; } - // ───────────────────────────────────────────────────────────── - // Stock write operations + // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── /** - * 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. + * Insert a new stock_in record or update metadata on an existing one. + * + * 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. + * + * Update flow (id > 0): + * - Updates contact_id, description, and log only. + * - Quantity, rack, lot, and serial are immutable after creation. + * * Must be called inside dbTransaction() by the caller. + * + * @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, rack, + * contact_id, description, lot_number, expiry_date, serial_number. + * @param array $logging Audit entry to append to the log column. + * @param string $uuid UUID for this transaction (shared across transfer pairs). */ public function saveStockIn(array $data, array $logging, string $uuid): void { @@ -205,6 +266,7 @@ class StockManager { if ($id > 0) { + // Update: only metadata fields are editable after creation $this->pdo->prepare( "UPDATE `$table` SET `contact_id` = :contact_id, @@ -224,7 +286,7 @@ class StockManager { $lot_number = $data['lot_number'] ?: null; $expiry_date = $data['expiry_date'] ?: null; - // Upsert md_lot if lot + expiry provided + // Upsert md_lot: preserve existing expiry_date if already recorded if ($lot_number && $expiry_date) { $this->pdo->prepare( "INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date) @@ -263,6 +325,7 @@ class StockManager { $td_stock_id = (int)$this->pdo->lastInsertId(); + // Mark rack as occupied and link it to this stock row $whMgmt->occupyRack( $warehouse_id, $data['zone'], $data['aisle'], $data['rack'], @@ -270,6 +333,7 @@ class StockManager { $td_stock_id ); + // Update running balance (+quantity in this warehouse) $whMgmt->adjustBalance( 'in', $warehouse_id, @@ -281,10 +345,24 @@ class StockManager { } /** - * 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. + * Insert a new stock_out record or update metadata on an existing one. + * + * 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. + * 3. Calls WarehouseManager::releaseRack() to free the rack slot. + * 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance. + * + * Update flow (id > 0): + * - Updates contact_id, description, and log only. + * * Must be called inside dbTransaction() by the caller. + * + * @param array $data Keys: id, warehouse, product_sku, zone, aisle, rack, + * contact_id, description, lot_number, serial_number. + * @param array $logging Audit entry to append to the log column. + * @param string $uuid UUID for this transaction. + * @throws Exception If the rack is empty, holds a different SKU/lot/serial. */ public function saveStockOut(array $data, array $logging, string $uuid): void { @@ -300,6 +378,7 @@ class StockManager { if ($id > 0) { + // Update: only metadata fields are editable after creation $this->pdo->prepare( "UPDATE `$table` SET `contact_id` = :contact_id, @@ -316,6 +395,7 @@ class StockManager { } else { + // Validate rack holds the expected product / lot / serial $source_stock = $whMgmt->getRackStock( $warehouse_id, $data['zone'], $data['aisle'], $data['rack'] @@ -345,6 +425,7 @@ class StockManager { ); } + // Quantity and identifiers come from the existing stock_in row (immutable) $quantity = (int)$source_stock['in']; $ref_id = (int)$source_stock['id']; $lot_number = $source_stock['lot_number'] ?? null; @@ -374,6 +455,7 @@ class StockManager { ':serial_number' => $serial_number, ]); + // Release the source rack and reverse balance $whMgmt->releaseRack( $warehouse_id, $data['zone'], $data['aisle'], $data['rack'] @@ -390,10 +472,31 @@ class StockManager { } /** - * 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. + * Insert a new stock transfer pair or update metadata on an existing one. + * + * A transfer creates two linked td_stock rows — an outbound row in the + * source warehouse and an inbound row in the destination warehouse — both + * sharing the same UUID and cross-referencing each other via ref_id. + * + * Insert flow (id = 0): + * 1. Validates the source rack holds the correct SKU / lot / serial. + * 2. Inserts the outbound row in td_stock_. + * 3. Inserts the inbound row in td_stock_ with ref_id pointing to from. + * 4. Back-fills ref_id on the from row so both point at each other. + * 5. Releases the source rack, occupies the destination rack. + * 6. Adjusts balance on both warehouses (out from source, in to dest). + * + * Update flow (id > 0): + * - Updates contact_id, description, and log on BOTH rows. + * * Must be called inside dbTransaction() by the caller. + * + * @param array $data Keys: id, warehouse_from, warehouse_to, product_sku, + * zone_from, aisle_from, rack_from, zone_to, aisle_to, rack_to, + * contact_id, description, lot_number, serial_number. + * @param array $logging Audit entry to append to the log column on both rows. + * @param string $uuid UUID shared by both the from and to rows. + * @throws Exception If source rack validation fails or paired record is missing on update. */ public function saveStockTransfer(array $data, array $logging, string $uuid): void { @@ -405,6 +508,7 @@ class StockManager { if ($id > 0) { + // Update: patch metadata on both the from and to rows $from_warehouse = (int)($data['warehouse_from'] ?? 0); $to_warehouse = (int)($data['warehouse_to'] ?? 0); @@ -459,6 +563,7 @@ class StockManager { return; } + // Insert: validate source rack, then create paired rows $from_warehouse = (int)$data['warehouse_from']; $from_zone = $data['zone_from']; $from_aisle = $data['aisle_from']; @@ -494,6 +599,7 @@ class StockManager { ); } + // Quantity and identifiers come from the source stock_in row (immutable) $quantity = (int)$source_stock['in']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; @@ -502,6 +608,7 @@ class StockManager { $to_table = $whMgmt->getStockContext($to_warehouse, 0)['table']; $table_log = [$logging]; + // Insert outbound row (from warehouse) $this->pdo->prepare( "INSERT INTO `$from_table` (uuid, company_id, `date`, product_sku, `out`, @@ -529,6 +636,7 @@ class StockManager { ]); $from_stock_id = (int)$this->pdo->lastInsertId(); + // Insert inbound row (to warehouse) $this->pdo->prepare( "INSERT INTO `$to_table` (uuid, company_id, `date`, product_sku, `in`, @@ -557,7 +665,7 @@ class StockManager { ]); $to_stock_id = (int)$this->pdo->lastInsertId(); - // Back-fill ref_id on from row so both rows point at each other + // Back-fill ref_id on the from row so both rows cross-reference each other $this->pdo->prepare( "UPDATE `$from_table` SET ref_id = :ref_id WHERE id = :id AND company_id = :company_id" @@ -567,12 +675,13 @@ class StockManager { ':company_id' => $this->company_id, ]); + // Rack state: release source, occupy destination $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); + // Balance: deduct from source, add to destination $whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity); $whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity); } -} -?> \ No newline at end of file +} \ No newline at end of file diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index 7e48b2e..9794c15 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -2,19 +2,30 @@ /** * WarehouseManager - * - * Encapsulates all warehouse-related operations: - * - Warehouse lookup and stock context resolution - * - Rack lifecycle (sync with md_storage ranges) - * - Warehouse balance adjustments (warehouse_balance table) - * - * Note: Methods that modify data do NOT manage their own DB transactions. - * Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed. + * + * Handles all warehouse, storage, rack, lot/serial, and stock deletion operations. + * + * Method order: + * Master file basis → Warehouse (get/save/delete), Storage (get/save/delete), + * Zone/Aisle/Rack resolution (getAll variants for admin views), + * Lot/Serial queries + * Transaction basis → Stock context resolution, rack lifecycle (occupy/release/transfer), + * balance adjustment, delete operations (stock in/out/transfer) + * Report basis → Warehouse list queries (getWarehouseList, getWarehouseListAll) + * + * 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 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. */ class WarehouseManager { private $pdo; private $company_id; + public function __construct($pdo, $company_id, $logging = null) { $this->pdo = $pdo; $this->company_id = $company_id; @@ -25,8 +36,14 @@ class WarehouseManager { // ───────────────────────────────────────────────────────────── /** - * Resolve the td_stock_ table name for a given warehouse_id. - * Returns null if warehouse not found or inactive. + * 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 resolveWarehouseTable(int $warehouse_id): ?string { @@ -41,17 +58,20 @@ class WarehouseManager { return "td_stock_{$safe}"; } - // ───────────────────────────────────────────────────────────── - // Range helper - // ───────────────────────────────────────────────────────────── - /** - * Convert a from/to range into an array of string values. - * Supports: - * - Numeric ranges: "1" → "10" → ["1","2",...,"10"] - * - Alpha ranges: "A" → "D" → ["A","B","C","D"] + * Convert a from/to range string pair into a flat array of string values. * - * @throws Exception if values are not pure numeric or single alpha characters + * Supports two range types: + * - Numeric: "1" → "10" expands to ["1", "2", ..., "10"] + * - Single-letter alpha: "A" → "D" expands to ["A", "B", "C", "D"] + * + * Used when syncing md_rack rows against an md_storage aisle/rack range. + * + * @param string $from Start of range (e.g. "1" or "A"). + * @param string $to End of range (e.g. "10" or "Z"). + * @return array Flat array of string values in range (inclusive). + * @throws Exception If values are not pure numeric or single alpha characters, + * or if start > end. */ private function rangeToArray(string $from, string $to): array { @@ -78,136 +98,194 @@ class WarehouseManager { ); } - // ───────────────────────────────────────────────────────────── - // Warehouse lookup - // ───────────────────────────────────────────────────────────── - /** - * Fetch the warehouse name by its ID. + * Build the WHERE conditions and bound params for lot/serial filtering + * used across getZonesOut, getAislesOut, getRacksOut. + * + * Conditionally appends lot_number and/or serial_number filters only + * when those values are non-empty, preventing unnecessary join conditions. + * + * @param int $warehouse_id Warehouse to scope the query to. + * @param string $product_sku SKU being queried. + * @param string|null $lot_number Optional lot filter. + * @param string|null $serial_number Optional serial filter. + * @return array Three-element array: [$lot_cond string, $serial_cond string, $params array]. */ - public function getWarehouseName($warehouse_id) { - $sql = "SELECT warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND id = :warehouse_id"; + private function buildLotSerialCondition( + int $warehouse_id, + string $product_sku, + ?string $lot_number = null, + ?string $serial_number = null + ): array { + $lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : ""; + $serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : ""; - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":warehouse_id" => $warehouse_id - ]); - return $sth->fetchColumn(); + $params = [ + ':company_id' => $this->company_id, + ':warehouse' => $warehouse_id, + ':product_sku' => $product_sku, + ':company_id2' => $this->company_id, + ':product_sku2' => $product_sku, + ]; + if (!empty($lot_number)) $params[':lot_number'] = $lot_number; + if (!empty($serial_number)) $params[':serial_number'] = $serial_number; + + return [$lot_cond, $serial_cond, $params]; } /** - * Resolve the per-warehouse stock table and (optionally) fetch a specific row. - * Returns: ['table' => 'td_stock_xxx', 'name' => 'xxx', 'row' => [...] | []] + * 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. + * @return array Associative array ready to be appended to a log array. */ - public function getStockContext($warehouse_id, $id) { - $name = $this->getWarehouseName($warehouse_id); - - // Sanitize table suffix to prevent SQL injection - $safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name); - $table = "td_stock_" . $safe_name; - - if (empty($id)) { - return [ - 'table' => $table, - 'name' => $name, - 'row' => [] - ]; - } - - $sql = "SELECT * FROM `$table` - WHERE company_id = :company_id AND id = :id"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":id" => $id - ]); - + private function buildLogEntry(string $action): array { return [ - 'table' => $table, - 'name' => $name, - 'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: [] + '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, ]; } - // ───────────────────────────────────────────────────────────── - // Warehouse balance (warehouse_balance table) - // ───────────────────────────────────────────────────────────── - /** - * Adjust the running balance for a (warehouse, SKU) pair. - * - * Used by stock_in / stock_out engines to keep warehouse_balance in sync with movements. - * The delta-based update (-old_qty + new_qty) supports both create and edit flows. + * Resolve a product's display name from md_product for use in error messages. + * + * Falls back to the SKU itself if the product record is not found. + * + * @param string $product_sku The SKU to look up. + * @return string The product_name, or $product_sku if not found. */ - public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) { + private function getProductName(string $product_sku): string { - // Upsert the balance row (atomic, no race condition) - // Partitioned by month (YYYY-MM) for efficient monthly reporting - $column = $type === 'in' ? 'total_in' : 'total_out'; - $delta = $new_qty - $old_qty; - $month = date('Y-m'); - - $sql = "INSERT INTO warehouse_balance - (company_id, warehouse_id, product_sku, month, `$column`) - VALUES - (:company_id, :warehouse_id, :product_sku, :month, :delta) - ON DUPLICATE KEY UPDATE - `$column` = `$column` + VALUES(`$column`)"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":warehouse_id" => $warehouse_id, - ":product_sku" => $product_sku, - ":month" => $month, - ":delta" => $delta - ]); - } - - // ───────────────────────────────────────────────────────────── - // Rack lifecycle (md_rack table) - // ───────────────────────────────────────────────────────────── - - /** - * Ensure md_rack rows match the md_storage range. - * - * - Validates no occupied racks fall outside the new range - * - Deletes racks outside the new range - * - Inserts new racks inside the range (INSERT IGNORE skips existing) - * - * Must be called inside a DB transaction by the caller. - * - * @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 - * @throws Exception if occupied racks would be removed - */ - public function syncRacks(int $storage_id, array $range): void { - - // Guard: refuse if any rack under this storage_id is still occupied - $this->validateRangeChange($storage_id, $range); - - // Wipe all empty racks belonging to this storage_id - $sql = "DELETE FROM md_rack - WHERE company_id = :company_id - AND storage_id = :storage_id - AND product_sku IS NULL"; - - $sth = $this->pdo->prepare($sql); + $sth = $this->pdo->prepare( + "SELECT product_name FROM md_product + WHERE company_id = :company_id AND sku = :sku" + ); $sth->execute([ ':company_id' => $this->company_id, - ':storage_id' => $storage_id, + ':sku' => $product_sku, ]); - - // Reinsert the full range fresh - $this->insertRacksInRange($storage_id, $range); + return $sth->fetchColumn() ?: $product_sku; } /** - * Guard: reject the update if any occupied rack would be removed. - * Works for both numeric and alpha aisle/rack values. + * Validate that the given row is the globally latest transaction for its SKU. + * + * Scans all td_stock_* tables via UNION ALL to find the single most recent + * transaction date across all warehouses for the SKU. If a newer row exists, + * deletion is blocked to enforce LIFO (last-in-first-out) reversal order. + * + * Table names are sourced from information_schema and backtick-quoted; + * no user input reaches the identifier. + * + * @param string $product_sku SKU of the row being deleted. + * @param string $row_date The 'date' column value of the row being deleted. + * @param string $product_name Human-readable name for the error message. + * @throws Exception If a newer transaction for the same SKU exists anywhere. + */ + private function validateLatestTransaction( + string $product_sku, + string $row_date, + string $product_name + ): void { + + // Discover all td_stock_* tables for this database + $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); + + if (empty($tables)) { + return; + } + + // Build UNION across all td_stock_* tables to find the global latest date + $unions = implode(' UNION ALL ', array_map( + fn($t) => "SELECT `type`, `date` FROM `$t` + WHERE company_id = :company_id + AND product_sku = :product_sku", + $tables + )); + + $sth = $this->pdo->prepare( + "SELECT `type`, `date` FROM ($unions) AS all_stock + ORDER BY `date` DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':product_sku' => $product_sku, + ]); + $latest = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$latest || $row_date === $latest['date']) { + return; // This is the latest globally — allow deletion + } + + $type = strtoupper($latest['type']); + $date = $latest['date']; + + throw new Exception( + "Cannot delete — \"{$product_name}\" has a newer " . + "{$type} transaction on {$date} that must be deleted first." + ); + } + + /** + * Insert a rack log entry for audit tracking of occupy/release/transfer events. + * + * Called internally by occupyRack, releaseRack, and transferRack. + * $extra may contain 'product_sku' and 'td_stock_id' to record what + * was in the rack at the time of the action. + * + * @param int $rack_id The md_rack.id being acted on. + * @param string $action Event label: 'occupy' | 'release' | 'transfer_in' | 'transfer_out'. + * @param array $extra Optional keys: product_sku, td_stock_id. + */ + private function insertRackLog(int $rack_id, string $action, array $extra = []): void { + + $sql = "INSERT INTO md_rack_log + (company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id) + VALUES + (:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ':company_id' => $this->company_id, + ':md_rack_id' => $rack_id, + ':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, + ':product_sku' => $extra['product_sku'] ?? null, + ':td_stock_id' => $extra['td_stock_id'] ?? null, + ]); + } + + /** + * Validate that the occupied racks under a storage_id all fall inside a new range. + * + * Called before syncRacks to prevent shrinking a range that still has occupied racks. + * If any occupied rack has an aisle or rack value outside the proposed range, throws. + * + * @param int $storage_id The md_storage.id whose range is being changed. + * @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 { @@ -238,8 +316,13 @@ class WarehouseManager { } /** - * Insert rack rows for the full range (IGNORE skips duplicates). + * Insert rack rows for the full aisle × rack range (INSERT IGNORE skips duplicates). + * + * Called by syncRacks after cleaning up out-of-range empty racks. * Supports both numeric (1-10) and alpha (A-Z) aisle/rack values. + * + * @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 { @@ -266,1056 +349,18 @@ class WarehouseManager { } } - - /** - * Fetch the stock record currently linked to a rack. - * - * Under the 1:1 model, each occupied rack points to exactly one - * td_stock_ row via md_rack.td_stock_id. This method resolves - * that pointer — returning the full stock row, or null if the rack - * is empty or the link is broken. - * - * The td_stock table name is derived from the warehouse (via - * getStockContext), so callers don't need to know the naming convention. - */ - public function getRackStock($warehouse_id, $zone, $aisle, $rack): ?array { - - // Get the stock pointer from md_rack - $sql = "SELECT td_stock_id FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND zone = :zone - AND aisle = :aisle - AND rack = :rack"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":warehouse" => $warehouse_id, - ":zone" => $zone, - ":aisle" => $aisle, - ":rack" => $rack, - ]); - $td_stock_id = $sth->fetchColumn(); - - // Rack is empty or doesn't exist - if (!$td_stock_id) { - return null; - } - - // Resolve the td_stock_ table from the warehouse name - // and fetch the full row by ID. - $context = $this->getStockContext($warehouse_id, $td_stock_id); - return $context['row'] ?: null; - } - - // ───────────────────────────────────────────────────────────── - // Rack occupancy (md_rack.product_sku + md_rack.td_stock_id state) + // MASTER FILE BASIS — Warehouse // ───────────────────────────────────────────────────────────── /** - * Insert a log entry into md_rack_log for the given rack and action. - * Captures user_id, dt (now), and login (session start) for audit context, - * plus any action-specific fields passed via $extra. - */ - private function insertRackLog(int $rack_id, string $action, array $extra = []): void { - - $sql = "INSERT INTO md_rack_log - (company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id) - VALUES - (:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ':company_id' => $this->company_id, - ':md_rack_id' => $rack_id, - ':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, - ':product_sku' => $extra['product_sku'] ?? null, - ':td_stock_id' => $extra['td_stock_id'] ?? null, - ]); - } - - /** - * Assign a product SKU to an empty rack and link it to its stock record. - * - * The td_stock_ table is derived from the warehouse, - * so only the row ID needs to be passed here. - * - * @throws Exception if the rack is already occupied or doesn't exist. - */ - /** - * Validate that the combination of product_sku + lot_number + serial_number - * does not already occupy an active rack across all warehouses. + * Return all warehouses with rack statistics and manager name. * - * "Active" means md_rack.td_stock_id is still linked (rack is occupied). - * Once a rack is released (stock_out/transfer), the combo is free again. + * Used by the warehouse listing page (/inventory/warehouse.php). + * Aggregates total_racks, occupied_racks, and unique_product per warehouse + * from md_rack, and joins the wms.user table for the manager name. * - * Rules: - * - product_sku alone → allowed in multiple racks (normal stocking) - * - product_sku + lot → allowed in multiple racks (lot spread across racks) - * - product_sku + serial → must be unique (1 physical unit = 1 location) - * - product_sku + lot + serial → must be unique - * - * @param string $product_sku - * @param string|null $lot_number - * @param string|null $serial_number - * @param int $exclude_stock_id Skip this stock row (for edit flows) - * @throws Exception if a duplicate active record is found - */ - public function validateStockUnique( - string $product_sku, - ?string $lot_number = null, - ?string $serial_number = null, - int $exclude_stock_id = 0 - ): void { - // Nothing to validate if neither lot nor serial is provided - if (empty($lot_number) && empty($serial_number)) return; - - $cid = $this->company_id; - - // Discover all warehouses - $sth = $this->pdo->prepare( - "SELECT id, warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND status = 1" - ); - $sth->execute([':company_id' => $cid]); - $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); - - if (empty($warehouses)) return; - - foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; - - // Check for any existing active stock_in row matching sku + lot + serial - // "Active" = rack still occupied (EXISTS in md_rack with product_sku IS NOT NULL) - // NOTE: called from inside occupyRack, AFTER INSERT but BEFORE md_rack is updated - // so the current row's td_stock_id is not yet in md_rack — won't match itself - $sql = "SELECT s.id - FROM `{$table}` s - WHERE s.company_id = :company_id - AND s.product_sku = :product_sku - AND s.type = 'in' - AND EXISTS ( - SELECT 1 FROM md_rack r - WHERE r.company_id = :company_id2 - AND r.td_stock_id = s.id - AND r.product_sku IS NOT NULL - )"; - - if (!empty($lot_number)) { - $sql .= " AND s.lot_number = :lot_number"; - } - if (!empty($serial_number)) { - $sql .= " AND s.serial_number = :serial_number"; - } - if ($exclude_stock_id > 0) { - $sql .= " AND s.id != :exclude_id"; - } - - $params = [ - ':company_id' => $cid, - ':company_id2' => $cid, - ':product_sku' => $product_sku, - ]; - if (!empty($lot_number)) $params[':lot_number'] = $lot_number; - if (!empty($serial_number)) $params[':serial_number'] = $serial_number; - if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id; - - $sth = $this->pdo->prepare($sql); - $sth->execute($params); - - if ($sth->fetchColumn()) { - $combo = implode(' / ', array_filter([ - $lot_number ? "lot: {$lot_number}" : null, - $serial_number ? "serial: {$serial_number}" : null, - ])); - throw new Exception( - "Active stock already exists for product '{$product_sku}' [{$combo}] " - . "in warehouse '{$wh['warehouse_name']}'. " - . "Stock must be moved out before it can be received again." - ); - } - } - } - - public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void { - - $sql = "SELECT id, product_sku FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND zone = :zone - AND aisle = :aisle - AND rack = :rack - FOR UPDATE"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":warehouse" => $warehouse_id, - ":zone" => $zone, - ":aisle" => $aisle, - ":rack" => $rack, - ]); - $current = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$current) { - throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist"); - } - - if ($current['product_sku'] !== null) { - throw new Exception( - "Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}" - ); - } - - // Validate sku + lot + serial uniqueness before occupying the rack - // Fetch lot/serial from the td_stock row being linked - $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 edit flows don't block - ); - } - - // Single atomic UPDATE: state + log together - $sql = "UPDATE md_rack - SET product_sku = :product_sku, - td_stock_id = :td_stock_id - WHERE id = :id"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":product_sku" => $product_sku, - ":td_stock_id" => $td_stock_id, - ":id" => $current['id'], - ]); - - $this->insertRackLog($current['id'], 'occupy', [ - 'product_sku' => $product_sku, - 'td_stock_id' => $td_stock_id, - ]); - } - - /** - * Release a rack (clear both SKU and stock reference together). - */ - public function releaseRack($warehouse_id, $zone, $aisle, $rack): void { - - - $sql = "SELECT id, product_sku, td_stock_id FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND zone = :zone - AND aisle = :aisle - AND rack = :rack - FOR UPDATE"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":warehouse" => $warehouse_id, - ":zone" => $zone, - ":aisle" => $aisle, - ":rack" => $rack, - ]); - $current = $sth->fetch(PDO::FETCH_ASSOC); - - // Nothing to release — exit silently (idempotent behavior) - if (!$current || $current['product_sku'] === null) { - return; - } - - // Clear state + record log in one UPDATE - $sql = "UPDATE md_rack - SET product_sku = NULL, - td_stock_id = NULL - WHERE id = :id"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":id" => $current['id'], - ]); - - $this->insertRackLog($current['id'], 'release', [ - 'product_sku' => $current['product_sku'], - 'td_stock_id' => $current['td_stock_id'], - ]); - } - - /** - * Move a rack assignment from one location to another. - * - * Source rack must be occupied, destination rack must be empty. - * Both product_sku and td_stock_id travel together to the destination. - * Locks both racks (lower ID first) to avoid deadlocks. - */ - public function transferRack( - $from_warehouse, $from_zone, $from_aisle, $from_rack, - $to_warehouse, $to_zone, $to_aisle, $to_rack - ): void { - - // Fetch both racks with FOR UPDATE, ordered by id to prevent deadlocks - $sql = "SELECT id, warehouse, zone, aisle, rack, product_sku, td_stock_id - FROM md_rack - WHERE company_id = :company_id - AND ( - (warehouse = :from_wh AND zone = :from_zone - AND aisle = :from_aisle AND rack = :from_rack) - OR - (warehouse = :to_wh AND zone = :to_zone - AND aisle = :to_aisle AND rack = :to_rack) - ) - ORDER BY id - FOR UPDATE"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":from_wh" => $from_warehouse, - ":from_zone" => $from_zone, - ":from_aisle" => $from_aisle, - ":from_rack" => $from_rack, - ":to_wh" => $to_warehouse, - ":to_zone" => $to_zone, - ":to_aisle" => $to_aisle, - ":to_rack" => $to_rack, - ]); - - $racks = $sth->fetchAll(PDO::FETCH_ASSOC); - - // Identify source and destination from the fetched rows - $from = null; - $to = null; - foreach ($racks as $r) { - if ($r['warehouse'] == $from_warehouse && $r['zone'] == $from_zone - && $r['aisle'] == $from_aisle && $r['rack'] == $from_rack) { - $from = $r; - } - if ($r['warehouse'] == $to_warehouse && $r['zone'] == $to_zone - && $r['aisle'] == $to_aisle && $r['rack'] == $to_rack) { - $to = $r; - } - } - - if (!$from) { - throw new Exception("Source rack not found"); - } - if (!$to) { - throw new Exception("Destination rack not found"); - } - if ($from['product_sku'] === null) { - throw new Exception("Source rack is empty"); - } - if ($to['product_sku'] !== null) { - throw new Exception("Destination rack is already occupied"); - } - - // Carry both SKU and stock reference across - $sku = $from['product_sku']; - $td_stock_id = $from['td_stock_id']; - - // Clear source — state + log in one UPDATE - $sql = "UPDATE md_rack - SET product_sku = NULL, td_stock_id = NULL - WHERE id = :id"; - $this->pdo->prepare($sql)->execute([ - ":id" => $from['id'], - ]); - - $this->insertRackLog($from['id'], 'transfer_out', [ - 'product_sku' => $sku, - 'td_stock_id' => $td_stock_id, - ]); - - // Populate destination — state + log in one UPDATE - $sql = "UPDATE md_rack - SET product_sku = :sku, td_stock_id = :td_stock_id - WHERE id = :id"; - $this->pdo->prepare($sql)->execute([ - ":sku" => $sku, - ":td_stock_id" => $td_stock_id, - ":id" => $to['id'], - ]); - - $this->insertRackLog($to['id'], 'transfer_in', [ - 'product_sku' => $sku, - 'td_stock_id' => $td_stock_id, - ]); - } - - - - // ───────────────────────────────────────────────────────────── - // Zone / Aisle / Rack resolution - // ───────────────────────────────────────────────────────────── - - /** - * Zones with at least one empty rack — for stock_in destination. - */ - public function getZonesIn(int $warehouse_id): array - { - $sth = $this->pdo->prepare( - "SELECT DISTINCT zone FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND product_sku IS NULL - ORDER BY CAST(zone AS UNSIGNED), zone" - ); - $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * Zones holding active stock for sku + lot + serial — for stock_out source. - */ - public function getZonesOut( - int $warehouse_id, - string $product_sku, - ?string $lot_number = null, - ?string $serial_number = null - ): array { - $table = $this->resolveWarehouseTable($warehouse_id); - if (!$table) return []; - - [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( - $warehouse_id, $product_sku, $lot_number, $serial_number - ); - - $sth = $this->pdo->prepare( - "SELECT DISTINCT r.zone - FROM md_rack r - WHERE r.company_id = :company_id - AND r.warehouse = :warehouse - AND r.product_sku = :product_sku - AND r.td_stock_id IN ( - SELECT s.id FROM `{$table}` s - WHERE s.company_id = :company_id2 - AND s.product_sku = :product_sku2 - AND s.type = 'in' - {$lot_cond} - {$serial_cond} - ) - ORDER BY CAST(r.zone AS UNSIGNED), r.zone" - ); - $sth->execute($params); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * Aisles with at least one empty rack in a zone — for stock_in destination. - */ - public function getAislesIn(int $warehouse_id, string $zone): array - { - $sth = $this->pdo->prepare( - "SELECT DISTINCT aisle FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND zone = :zone - AND product_sku IS NULL - ORDER BY CAST(aisle AS UNSIGNED), aisle" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':warehouse' => $warehouse_id, - ':zone' => $zone, - ]); - return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle'); - } - - /** - * Aisles holding active stock for sku + lot + serial — for stock_out source. - */ - public function getAislesOut( - int $warehouse_id, - string $zone, - string $product_sku, - ?string $lot_number = null, - ?string $serial_number = null - ): array { - $table = $this->resolveWarehouseTable($warehouse_id); - if (!$table) return []; - - [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( - $warehouse_id, $product_sku, $lot_number, $serial_number - ); - $params[':zone'] = $zone; - - $sth = $this->pdo->prepare( - "SELECT DISTINCT r.aisle - FROM md_rack r - WHERE r.company_id = :company_id - AND r.warehouse = :warehouse - AND r.zone = :zone - AND r.product_sku = :product_sku - AND r.td_stock_id IN ( - SELECT s.id FROM `{$table}` s - WHERE s.company_id = :company_id2 - AND s.product_sku = :product_sku2 - AND s.type = 'in' - {$lot_cond} - {$serial_cond} - ) - ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle" - ); - $sth->execute($params); - return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle'); - } - - /** - * Racks that are empty in a given aisle — for stock_in destination. - */ - public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array - { - $sth = $this->pdo->prepare( - "SELECT DISTINCT rack FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND zone = :zone - AND aisle = :aisle - AND product_sku IS NULL - ORDER BY CAST(rack AS UNSIGNED), rack" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':warehouse' => $warehouse_id, - ':zone' => $zone, - ':aisle' => $aisle, - ]); - return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); - } - - /** - * Racks holding active stock for sku + lot + serial — for stock_out source. - */ - public function getRacksOut( - int $warehouse_id, - string $zone, - string $aisle, - string $product_sku, - ?string $lot_number = null, - ?string $serial_number = null - ): array { - $table = $this->resolveWarehouseTable($warehouse_id); - if (!$table) return []; - - [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( - $warehouse_id, $product_sku, $lot_number, $serial_number - ); - $params[':zone'] = $zone; - $params[':aisle'] = $aisle; - - $sth = $this->pdo->prepare( - "SELECT DISTINCT r.rack - FROM md_rack r - WHERE r.company_id = :company_id - AND r.warehouse = :warehouse - AND r.zone = :zone - AND r.aisle = :aisle - AND r.product_sku = :product_sku - AND r.td_stock_id IN ( - SELECT s.id FROM `{$table}` s - WHERE s.company_id = :company_id2 - AND s.product_sku = :product_sku2 - AND s.type = 'in' - {$lot_cond} - {$serial_cond} - ) - ORDER BY CAST(r.rack AS UNSIGNED), r.rack" - ); - $sth->execute($params); - return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); - } - - /** - * Build lot/serial WHERE conditions and bound params for Out methods. - */ - private function buildLotSerialCondition( - int $warehouse_id, - string $product_sku, - ?string $lot_number = null, - ?string $serial_number = null - ): array { - $lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : ""; - $serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : ""; - - $params = [ - ':company_id' => $this->company_id, - ':warehouse' => $warehouse_id, - ':product_sku' => $product_sku, - ':company_id2' => $this->company_id, - ':product_sku2' => $product_sku, - ]; - if (!empty($lot_number)) $params[':lot_number'] = $lot_number; - if (!empty($serial_number)) $params[':serial_number'] = $serial_number; - - return [$lot_cond, $serial_cond, $params]; - } - - // ───────────────────────────────────────────────────────────── - // Delete (soft-delete) operations - // ───────────────────────────────────────────────────────────── - - /** - * Validate that a given row is the latest transaction for its SKU in the table. - * Throws a descriptive exception if a newer transaction exists. - * - * @param string $product_sku SKU to check - * @param string $row_date The date of the row being deleted - * @param string $product_name Human-readable product name for error message - * @throws Exception if a newer transaction exists - */ - private function validateLatestTransaction( - string $product_sku, - string $row_date, - string $product_name - ): void { - - // Discover all td_stock_* tables for this database - $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); - - if (empty($tables)) { - return; - } - - // Build UNION across all td_stock_* tables to find global MAX(date) - $unions = implode(' UNION ALL ', array_map( - fn($t) => "SELECT `type`, `date` FROM `$t` - WHERE company_id = :company_id - AND product_sku = :product_sku", - $tables - )); - - $sth = $this->pdo->prepare( - "SELECT `type`, `date` FROM ($unions) AS all_stock - ORDER BY `date` DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':product_sku' => $product_sku, - ]); - $latest = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$latest || $row_date === $latest['date']) { - return; // ✓ this is the latest globally — allow - } - - $type = strtoupper($latest['type']); - $date = $latest['date']; - - throw new Exception( - "Cannot delete — \"{$product_name}\" has a newer " . - "{$type} transaction on {$date} that must be deleted first." - ); - } - - /** - * Helper to build a log entry with consistent user and timestamp info for warehouse operations. - */ - 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, - ]; - } - - /** - * Resolve product name from md_product for readable error messages. - */ - private function getProductName(string $product_sku): string { - - $sth = $this->pdo->prepare( - "SELECT product_name FROM md_product - WHERE company_id = :company_id AND sku = :sku" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':sku' => $product_sku, - ]); - return $sth->fetchColumn() ?: $product_sku; - } - - /** - * Soft-delete a stock_in record and reverse its side effects. - * - * - Validates this is the latest transaction for the SKU - * - Negates company_id (soft-delete) - * - Releases the rack - * - Reverses the balance - * - * Must be called inside a DB transaction by the caller. - * - * @throws Exception if not the latest transaction or record not found - */ - public function deleteStockIn(int $stock_id, int $warehouse_id): void { - - $ctx = $this->getStockContext($warehouse_id, $stock_id); - $table = $ctx['table']; - $row = $ctx['row']; - - if (!$row) { - throw new Exception("Stock in record not found."); - } - - $product_sku = $row['product_sku']; - $product_name = $this->getProductName($product_sku); - - $this->validateLatestTransaction($product_sku, $row['date'], $product_name); - - // Soft-delete - $this->pdo->prepare( - "UPDATE `$table` - SET company_id = company_id * -1 - WHERE id = :id AND company_id = :company_id" - )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); - - // Release the rack - $this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); - - // Reverse balance - $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0); - } - - /** - * Soft-delete a stock_out record and reverse its side effects. - * - * - Validates this is the latest transaction for the SKU - * - Negates company_id (soft-delete) - * - Re-occupies the rack with the original stock_in batch (via ref_id) - * - Reverses the balance - * - * Must be called inside a DB transaction by the caller. - * - * @throws Exception if not the latest transaction or record not found - */ - public function deleteStockOut(int $stock_id, int $warehouse_id): void { - - $ctx = $this->getStockContext($warehouse_id, $stock_id); - $table = $ctx['table']; - $row = $ctx['row']; - - if (!$row) { - throw new Exception("Stock out record not found."); - } - - $product_sku = $row['product_sku']; - $product_name = $this->getProductName($product_sku); - - $this->validateLatestTransaction($product_sku, $row['date'], $product_name); - - // Soft-delete - $this->pdo->prepare( - "UPDATE `$table` - SET company_id = company_id * -1 - WHERE id = :id AND company_id = :company_id" - )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); - - // Re-occupy the rack — restore the stock_in batch that was consumed - $this->occupyRack( - $warehouse_id, - $row['zone'], $row['aisle'], $row['rack'], - $product_sku, - (int)$row['ref_id'] - ); - - // Reverse balance - $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0); - } - - /** - * Soft-delete a transfer record pair and reverse all side effects. - * - * - Validates this is the latest transaction for the SKU on BOTH warehouses - * - Negates company_id on both rows (soft-delete) - * - Releases destination rack, re-occupies source rack - * - Reverses balances on both warehouses - * - * Must be called inside a DB transaction by the caller. - * - * @throws Exception if not the latest transaction on either side, or records not found - */ - public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void { - - // Fetch the "from" row (transfer_out side) - $from_ctx = $this->getStockContext($from_warehouse_id, $from_stock_id); - $from_table = $from_ctx['table']; - $from_row = $from_ctx['row']; - - if (!$from_row || $from_row['type'] !== 'transfer') { - throw new Exception("Transfer record not found."); - } - - $product_sku = $from_row['product_sku']; - $product_name = $this->getProductName($product_sku); - $to_warehouse = (int)$from_row['ref_warehouse']; - $ref_id = (int)$from_row['ref_id']; - - // Fetch the "to" row (transfer_in side) - $to_ctx = $this->getStockContext($to_warehouse, $ref_id); - $to_row = $to_ctx['row']; - - if (!$to_row) { - throw new Exception("Paired destination record missing — data integrity issue."); - } - - // Single global validation covers both warehouses - $this->validateLatestTransaction($product_sku, $from_row['date'], $product_name); - - // Soft-delete both rows - $this->pdo->prepare( - "UPDATE `$from_table` - SET company_id = company_id * -1 - WHERE id = :id AND company_id = :company_id" - )->execute([':id' => $from_stock_id, ':company_id' => $this->company_id]); - - $this->pdo->prepare( - "UPDATE `{$to_ctx['table']}` - SET company_id = company_id * -1 - WHERE id = :id AND company_id = :company_id" - )->execute([':id' => $ref_id, ':company_id' => $this->company_id]); - - // Destination rack was occupied by transfer_in — release it - $this->releaseRack( - $to_warehouse, - $to_row['zone'], $to_row['aisle'], $to_row['rack'] - ); - - // Source rack was released by transfer_out — re-occupy with original batch - $this->occupyRack( - $from_warehouse_id, - $from_row['zone'], $from_row['aisle'], $from_row['rack'], - $product_sku, - $from_stock_id - ); - - // Reverse balances - $quantity = (int)$from_row['out']; - $this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0); - $this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0); - } - - /** - * Soft-delete a warehouse. - * - * Blocks if any md_storage references this warehouse by name. - * - * Must be called inside a DB transaction by the caller. - * - * @throws Exception if warehouse not found or has dependent storage - */ - public function deleteWarehouse(int $warehouse_id): void { - - $sth = $this->pdo->prepare( - "SELECT id, warehouse_name, `log` FROM md_warehouse - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':id' => $warehouse_id, - ]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$row) { - throw new Exception("Warehouse not found."); - } - - // Block if any md_storage references this warehouse by name - $sth = $this->pdo->prepare( - "SELECT COUNT(*) FROM md_storage - WHERE company_id = :company_id - AND warehouse = :warehouse_name" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':warehouse_name' => $row['warehouse_name'], - ]); - - if ($sth->fetchColumn() > 0) { - throw new Exception( - "Cannot delete — warehouse \"{$row['warehouse_name']}\" " . - "still has storage locations assigned to it." - ); - } - - // Append delete event to log - $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = $this->buildLogEntry('delete'); - - // Soft-delete - $this->pdo->prepare( - "UPDATE md_warehouse - SET company_id = company_id * -1, - `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':log' => json_encode($log), - ':id' => $warehouse_id, - ':company_id' => $this->company_id, - ]); - } - - - /** - * Soft-delete a storage record and its associated racks. - * - * - Blocks if any md_rack under this storage_id is occupied - * - Hard-deletes md_rack_log rows for those racks - * - Soft-deletes all md_rack rows under this storage_id - * - Soft-deletes md_storage - * - * Must be called inside a DB transaction by the caller. - * - * @throws Exception if storage not found or has occupied racks - */ - public function deleteStorage(int $storage_id): void { - - $sth = $this->pdo->prepare( - "SELECT id, zone, `log` FROM md_storage - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':id' => $storage_id, - ]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$row) { - throw new Exception("Storage not found."); - } - - // Block if any rack under this storage is occupied - $sth = $this->pdo->prepare( - "SELECT COUNT(*) FROM md_rack - WHERE company_id = :company_id - AND storage_id = :storage_id - AND product_sku IS NOT NULL" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':storage_id' => $storage_id, - ]); - - if ($sth->fetchColumn() > 0) { - throw new Exception( - "Cannot delete — storage zone \"{$row['zone']}\" " . - "still has occupied racks with stock." - ); - } - - // Collect rack IDs before deleting — needed for rack log cleanup - $sth = $this->pdo->prepare( - "SELECT id FROM md_rack - WHERE company_id = :company_id - AND storage_id = :storage_id" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':storage_id' => $storage_id, - ]); - $rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN); - - // Hard-delete md_rack_log for these racks - if (!empty($rack_ids)) { - $placeholders = implode(',', array_fill(0, count($rack_ids), '?')); - $this->pdo->prepare( - "DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)" - )->execute($rack_ids); - } - - // Soft-delete all md_rack rows under this storage - $this->pdo->prepare( - "UPDATE md_rack - SET company_id = company_id * -1 - WHERE company_id = :company_id - AND storage_id = :storage_id" - )->execute([ - ':company_id' => $this->company_id, - ':storage_id' => $storage_id, - ]); - - // Append delete event to log - $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = $this->buildLogEntry('delete'); - - // Soft-delete md_storage - $this->pdo->prepare( - "UPDATE md_storage - SET company_id = company_id * -1, - `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':log' => json_encode($log), - ':id' => $storage_id, - ':company_id' => $this->company_id, - ]); - } - - - // ───────────────────────────────────────────────────────────── - // Warehouse list queries - // ───────────────────────────────────────────────────────────── - - /** - * List warehouses filtered by rack occupancy type. - * type='to' → only warehouses with at least one empty rack - * type='from' → only warehouses holding the given product_sku - * other → all warehouses - * Pass $id > 0 to bypass the filter (edit flows). - */ - public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array - { - $product_sku_filter = ''; - $params = [':company_id' => $this->company_id]; - - if (!$id) { - if ($type === 'to') { - $product_sku_filter = 'AND r.product_sku IS NULL'; - } elseif ($type === 'from') { - $product_sku_filter = 'AND r.product_sku = :product_sku'; - $params[':product_sku'] = $product_sku; - } - } - - $sth = $this->pdo->prepare( - "SELECT w.id, w.warehouse_name - FROM md_warehouse w - WHERE w.company_id = :company_id - AND EXISTS ( - SELECT 1 FROM md_rack r - WHERE r.company_id = w.company_id - AND r.warehouse = w.id - $product_sku_filter - ) - ORDER BY w.warehouse_name" - ); - $sth->execute($params); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * Full warehouse list with rack stats and manager name — for inventory listing. + * @return array All md_warehouse rows for this company with capacity/occupancy fields. */ public function getWarehouseListAll(): array { @@ -1345,7 +390,12 @@ class WarehouseManager { } /** - * Fetch a single warehouse row by ID. + * Fetch a single warehouse row by its primary key. + * + * Used to pre-fill the edit form on the manage warehouse page. + * + * @param int $id The md_warehouse.id to fetch. + * @return array|false Associative row, or false if not found. */ public function getWarehouseById(int $id): array|false { @@ -1357,180 +407,39 @@ class WarehouseManager { return $sth->fetch(PDO::FETCH_ASSOC); } - // ───────────────────────────────────────────────────────────── - // Lot / Serial queries - // ───────────────────────────────────────────────────────────── - /** - * Search md_lot by product_sku + keyword — for lot number autocomplete. + * Fetch a warehouse's name string by its ID. + * + * Lightweight helper used internally and by engine files that only need + * the name (e.g. for table name resolution or display). + * + * @param int|string $warehouse_id The md_warehouse.id to look up. + * @return string|false The warehouse_name, or false if not found. */ - public function getLotList(string $product_sku, string $keyword): array - { - $sth = $this->pdo->prepare( - "SELECT * - FROM md_lot - WHERE company_id = :company_id - AND product_sku = :product_sku - AND lot_number LIKE :keyword - ORDER BY lot_number ASC - LIMIT 50" - ); + public function getWarehouseName($warehouse_id) { + $sql = "SELECT warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND id = :warehouse_id"; + + $sth = $this->pdo->prepare($sql); $sth->execute([ - ':company_id' => $this->company_id, - ':product_sku' => $product_sku, - ':keyword' => '%' . $keyword . '%', + ":company_id" => $this->company_id, + ":warehouse_id" => $warehouse_id ]); - return $sth->fetchAll(PDO::FETCH_ASSOC); + return $sth->fetchColumn(); } /** - * Return distinct active lots for a product_sku across all warehouses. - * "Active" = rack is still occupied (product_sku IS NOT NULL in md_rack). - */ - public function getActiveLots(string $product_sku): array - { - $sth = $this->pdo->prepare( - "SELECT id, warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND status = 1" - ); - $sth->execute([':company_id' => $this->company_id]); - $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); - - $active_lots = []; - - foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; - - $sth = $this->pdo->prepare( - "SELECT DISTINCT s.lot_number, l.expiry_date - FROM `{$table}` s - LEFT JOIN md_lot l - ON l.company_id = s.company_id - AND l.product_sku = s.product_sku - 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.lot_number IS NOT NULL - AND EXISTS ( - SELECT 1 FROM md_rack r - WHERE r.company_id = s.company_id - AND r.td_stock_id = s.id - AND r.product_sku IS NOT NULL - )" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':product_sku' => $product_sku, - ]); - - foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { - $active_lots[$row['lot_number']] ??= $row; - } - } - - return array_values($active_lots); - } - - /** - * Return distinct active serial numbers for a product_sku (+ optional lot) - * across all warehouses. - * "Active" = rack is still occupied. - */ - public function getActiveSerials(string $product_sku, string $lot_number = ''): array - { - $sth = $this->pdo->prepare( - "SELECT id, warehouse_name FROM md_warehouse - WHERE company_id = :company_id AND status = 1" - ); - $sth->execute([':company_id' => $this->company_id]); - $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); - - $active_serials = []; - - foreach ($warehouses as $wh) { - $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); - $table = "td_stock_{$safe}"; - - $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.serial_number IS NOT NULL - AND EXISTS ( - SELECT 1 FROM md_rack r - WHERE r.company_id = s.company_id - AND r.td_stock_id = s.id - AND r.product_sku IS NOT NULL - )"; - - $params = [ - ':company_id' => $this->company_id, - ':product_sku' => $product_sku, - ]; - - if (!empty($lot_number)) { - $sql .= ' AND s.lot_number = :lot_number'; - $params[':lot_number'] = $lot_number; - } - - $sth = $this->pdo->prepare($sql); - $sth->execute($params); - - foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { - $active_serials[$row['serial_number']] = $row['serial_number']; - } - } - - return array_values($active_serials); - } - - - // ───────────────────────────────────────────────────────────── - // Storage queries - // ───────────────────────────────────────────────────────────── - - /** - * Full storage list with warehouse name. - */ - public function getStorageList(): array - { - $sth = $this->pdo->prepare( - "SELECT a.*, b.warehouse_name - FROM md_storage a - LEFT JOIN md_warehouse b - ON a.company_id = b.company_id - AND a.warehouse = b.id - WHERE a.company_id = :company_id" - ); - $sth->execute([':company_id' => $this->company_id]); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - /** - * Fetch a single storage row by ID. - */ - public function getStorageById(int $id): array|false - { - $sth = $this->pdo->prepare( - "SELECT * FROM md_storage - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $this->company_id, ':id' => $id]); - return $sth->fetch(PDO::FETCH_ASSOC); - } - - // ───────────────────────────────────────────────────────────── - // Warehouse write - // ───────────────────────────────────────────────────────────── - - /** - * Insert or update a warehouse. - * Creates td_stock_ table on insert. - * Pass $data['id'] > 0 for update, 0 for insert. + * 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. + * + * 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. */ public function saveWarehouse(array $data, array $logging): void { @@ -1574,7 +483,7 @@ class WarehouseManager { (:company_id, :warehouse_name, :location, :manager, :description, :status, :log)" )->execute($params); - // Create the per-warehouse stock table on insert + // 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`"); @@ -1582,12 +491,124 @@ class WarehouseManager { } } + /** + * Soft-delete a warehouse by negating its company_id. + * + * Blocks deletion if any md_storage record references this warehouse by name, + * ensuring no orphaned storage locations remain. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $warehouse_id The md_warehouse.id to delete. + * @throws Exception If the warehouse is not found or has storage locations. + */ + public function deleteWarehouse(int $warehouse_id): void { + + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name, `log` FROM md_warehouse + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $warehouse_id, + ]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception("Warehouse not found."); + } + + // Block if any md_storage references this warehouse by name + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM md_storage + WHERE company_id = :company_id + AND warehouse = :warehouse_name" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':warehouse_name' => $row['warehouse_name'], + ]); + + if ($sth->fetchColumn() > 0) { + throw new Exception( + "Cannot delete — warehouse \"{$row['warehouse_name']}\" " . + "still has storage locations 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_warehouse + SET company_id = company_id * -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $warehouse_id, + ':company_id' => $this->company_id, + ]); + } + + // ───────────────────────────────────────────────────────────── + // MASTER FILE BASIS — Storage + // ───────────────────────────────────────────────────────────── /** - * Insert or update a storage record. - * Pass $data['id'] > 0 for update, 0 for insert. + * Return all storage records with their associated warehouse name. + * + * Used to populate the storage listing page (/inventory/manage_storage.php). + * + * @return array All md_storage rows for this company with 'warehouse_name'. + */ + public function getStorageList(): array + { + $sth = $this->pdo->prepare( + "SELECT a.*, b.warehouse_name + FROM md_storage a + LEFT JOIN md_warehouse b + ON a.company_id = b.company_id + AND a.warehouse = b.id + WHERE a.company_id = :company_id" + ); + $sth->execute([':company_id' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Fetch a single storage row by its primary key. + * + * Used to pre-fill the edit form on the manage storage page. + * + * @param int $id The md_storage.id to fetch. + * @return array|false Associative row, or false if not found. + */ + public function getStorageById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT * FROM md_storage + 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 storage record or update an existing one, then sync md_rack rows. + * + * After saving md_storage, calls syncRacks() to ensure the md_rack table + * exactly matches the new aisle × rack range. On update, existing occupied + * racks that fall outside the new range will block the operation (via syncRacks). + * + * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * Must be called inside dbTransaction() by the caller. - * Automatically syncs md_rack rows after save. + * + * @param array $data Keys: id, warehouse, zone, aisle_from, aisle_to, + * 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 { @@ -1644,7 +665,7 @@ class WarehouseManager { $storage_id = (int)$this->pdo->lastInsertId(); } - // Sync md_rack rows to match the new range + // Sync md_rack rows to exactly match the new aisle × rack range $this->syncRacks($storage_id, [ 'warehouse' => $data['warehouse'], 'zone' => $data['zone'], @@ -1655,9 +676,114 @@ class WarehouseManager { ]); } + /** + * Soft-delete a storage record and its associated rack rows. + * + * Blocks deletion if any rack under this storage_id is occupied. + * Also hard-deletes md_rack_log entries for those racks before soft-deleting + * the md_rack rows themselves, then soft-deletes md_storage. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $storage_id The md_storage.id to delete. + * @throws Exception If storage is not found or has occupied racks. + */ + public function deleteStorage(int $storage_id): void { + + $sth = $this->pdo->prepare( + "SELECT id, zone, `log` FROM md_storage + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $storage_id, + ]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception("Storage not found."); + } + + // Block if any rack under this storage is occupied + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM md_rack + WHERE company_id = :company_id + AND storage_id = :storage_id + AND product_sku IS NOT NULL" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':storage_id' => $storage_id, + ]); + + if ($sth->fetchColumn() > 0) { + throw new Exception( + "Cannot delete — storage zone \"{$row['zone']}\" " . + "still has occupied racks with stock." + ); + } + + // Collect rack IDs before deletion — needed for rack_log cleanup + $sth = $this->pdo->prepare( + "SELECT id FROM md_rack + WHERE company_id = :company_id + AND storage_id = :storage_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':storage_id' => $storage_id, + ]); + $rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN); + + // Hard-delete md_rack_log for these racks (log records have no independent value) + if (!empty($rack_ids)) { + $placeholders = implode(',', array_fill(0, count($rack_ids), '?')); + $this->pdo->prepare( + "DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)" + )->execute($rack_ids); + } + + // Soft-delete all md_rack rows under this storage + $this->pdo->prepare( + "UPDATE md_rack + SET company_id = company_id * -1 + WHERE company_id = :company_id + AND storage_id = :storage_id" + )->execute([ + ':company_id' => $this->company_id, + ':storage_id' => $storage_id, + ]); + + // 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_storage + SET company_id = company_id * -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $storage_id, + ':company_id' => $this->company_id, + ]); + } + + // ───────────────────────────────────────────────────────────── + // MASTER FILE BASIS — Zone / Aisle / Rack (admin read-only views) + // ───────────────────────────────────────────────────────────── /** - * All distinct zones in a warehouse — for admin/read-only listing. + * Return all distinct zones in a warehouse — for admin/read-only listing. + * + * Returns every zone regardless of occupancy. Used on warehouse detail + * and storage admin pages where all zones must be shown. + * Results are sorted numerically first, then alphabetically. + * + * @param int $warehouse_id The md_warehouse.id to query. + * @return array Rows with 'zone' key. */ public function getZonesAll(int $warehouse_id): array { @@ -1671,7 +797,13 @@ class WarehouseManager { } /** - * All distinct aisles in a zone — for admin/read-only listing. + * Return all distinct aisles in a zone — for admin/read-only listing. + * + * Returns every aisle regardless of occupancy. Used on storage admin pages. + * + * @param int $warehouse_id The md_warehouse.id to query. + * @param string $zone The zone to filter by. + * @return array Flat array of aisle values (strings). */ public function getAislesAll(int $warehouse_id, string $zone): array { @@ -1685,7 +817,14 @@ class WarehouseManager { } /** - * All distinct racks in an aisle — for admin/read-only listing. + * Return all distinct racks in an aisle — for admin/read-only listing. + * + * Returns every rack regardless of occupancy. Used on storage admin pages. + * + * @param int $warehouse_id The md_warehouse.id to query. + * @param string $zone The zone to filter by. + * @param string $aisle The aisle to filter by. + * @return array Flat array of rack values (strings). */ public function getRacksAll(int $warehouse_id, string $zone, string $aisle): array { @@ -1697,5 +836,1113 @@ class WarehouseManager { $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); } - + + // ───────────────────────────────────────────────────────────── + // MASTER FILE BASIS — Lot / Serial queries + // ───────────────────────────────────────────────────────────── + + /** + * Search md_lot by product_sku and lot_number keyword — for lot autocomplete. + * + * Returns up to 50 matches ordered by lot_number ASC. The keyword is safely + * bound as a LIKE parameter. + * + * @param string $product_sku The SKU to scope the search to. + * @param string $keyword Partial lot number to match. + * @return array Matching md_lot rows. + */ + public function getLotList(string $product_sku, string $keyword): array + { + $sth = $this->pdo->prepare( + "SELECT * + FROM md_lot + WHERE company_id = :company_id + AND product_sku = :product_sku + AND lot_number LIKE :keyword + ORDER BY lot_number ASC + LIMIT 50" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':product_sku' => $product_sku, + ':keyword' => '%' . $keyword . '%', + ]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Return distinct active lots for a product_sku across all warehouses. + * + * "Active" means the rack linked to the stock_in row is still occupied + * (md_rack.product_sku IS NOT NULL). Includes expiry_date from md_lot. + * Deduplicates across warehouses so each lot_number appears once. + * Used to populate the lot dropdown on stock_out forms. + * + * @param string $product_sku The SKU to find active lots for. + * @return array Rows with lot_number and expiry_date, deduplicated. + */ + public function getActiveLots(string $product_sku): array + { + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $sth->execute([':company_id' => $this->company_id]); + $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); + + $active_lots = []; + + foreach ($warehouses as $wh) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); + $table = "td_stock_{$safe}"; + + $sth = $this->pdo->prepare( + "SELECT DISTINCT s.lot_number, l.expiry_date + FROM `{$table}` s + LEFT JOIN md_lot l + ON l.company_id = s.company_id + AND l.product_sku = s.product_sku + 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.lot_number IS NOT NULL + AND EXISTS ( + SELECT 1 FROM md_rack r + WHERE r.company_id = s.company_id + AND r.td_stock_id = s.id + AND r.product_sku IS NOT NULL + )" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':product_sku' => $product_sku, + ]); + + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $active_lots[$row['lot_number']] ??= $row; + } + } + + return array_values($active_lots); + } + + /** + * Return distinct active serial numbers for a product_sku (and optional lot) + * across all warehouses. + * + * "Active" means the rack linked to the stock_in row is still occupied. + * Used to populate the serial dropdown on stock_out forms. + * + * @param string $product_sku The SKU to find active serials for. + * @param string $lot_number Optional lot filter — pass empty string to skip. + * @return array Flat array of active serial number strings. + */ + public function getActiveSerials(string $product_sku, string $lot_number = ''): array + { + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $sth->execute([':company_id' => $this->company_id]); + $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); + + $active_serials = []; + + foreach ($warehouses as $wh) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); + $table = "td_stock_{$safe}"; + + $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.serial_number IS NOT NULL + AND EXISTS ( + SELECT 1 FROM md_rack r + WHERE r.company_id = s.company_id + AND r.td_stock_id = s.id + AND r.product_sku IS NOT NULL + )"; + + $params = [ + ':company_id' => $this->company_id, + ':product_sku' => $product_sku, + ]; + + if (!empty($lot_number)) { + $sql .= ' AND s.lot_number = :lot_number'; + $params[':lot_number'] = $lot_number; + } + + $sth = $this->pdo->prepare($sql); + $sth->execute($params); + + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $active_serials[$row['serial_number']] = $row['serial_number']; + } + } + + return array_values($active_serials); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Stock context resolution + // ───────────────────────────────────────────────────────────── + + /** + * 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 + * - 'row': the specific stock row (or empty array if $id = 0) + * + * Pass $id = 0 to get just the table name without fetching a row. + * Note: does NOT filter by status — inactive warehouses can still have + * 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. + * @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]). + */ + public function getStockContext($warehouse_id, $id) { + $name = $this->getWarehouseName($warehouse_id); + + // Sanitise table suffix to prevent SQL injection via warehouse names + $safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + $table = "td_stock_" . $safe_name; + + if (empty($id)) { + return [ + 'table' => $table, + 'name' => $name, + 'row' => [] + ]; + } + + $sql = "SELECT * FROM `$table` + WHERE company_id = :company_id AND id = :id"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":company_id" => $this->company_id, + ":id" => $id + ]); + + return [ + 'table' => $table, + 'name' => $name, + 'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: [] + ]; + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Rack lifecycle + // ───────────────────────────────────────────────────────────── + + /** + * Sync md_rack rows for a storage record to exactly match a new aisle × rack range. + * + * Steps: + * 1. Validates that no occupied racks fall outside the new range (blocks if so). + * 2. Hard-deletes all empty racks belonging to this storage_id. + * 3. Re-inserts the full set of racks for the new range (INSERT IGNORE skips existing occupied racks). + * + * Called by saveStorage after every insert or update. + * Must be called inside a DB transaction by the caller. + * + * @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. + * @throws Exception If occupied racks would be removed by the new range. + */ + public function syncRacks(int $storage_id, array $range): void { + + // Guard: refuse if any occupied rack under this storage_id falls outside the new range + $this->validateRangeChange($storage_id, $range); + + // Wipe all empty racks belonging to this storage_id + $sql = "DELETE FROM md_rack + WHERE company_id = :company_id + AND storage_id = :storage_id + AND product_sku IS NULL"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ':company_id' => $this->company_id, + ':storage_id' => $storage_id, + ]); + + // Reinsert the full range fresh (INSERT IGNORE skips any occupied racks already there) + $this->insertRacksInRange($storage_id, $range); + } + + /** + * 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 + * 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 + * allowing a movement. + * + * @param int|string $warehouse_id The warehouse the rack belongs to. + * @param string $zone Zone identifier. + * @param string $aisle Aisle identifier. + * @param string $rack Rack identifier. + * @return array|null The full td_stock row linked to the rack, or null if empty. + */ + public function getRackStock($warehouse_id, $zone, $aisle, $rack): ?array { + + // Get the stock pointer from md_rack + $sql = "SELECT td_stock_id FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":company_id" => $this->company_id, + ":warehouse" => $warehouse_id, + ":zone" => $zone, + ":aisle" => $aisle, + ":rack" => $rack, + ]); + $td_stock_id = $sth->fetchColumn(); + + if (!$td_stock_id) { + return null; // Rack is empty or doesn't exist + } + + // Fetch the full stock row from the per-warehouse table + $context = $this->getStockContext($warehouse_id, $td_stock_id); + return $context['row'] ?: null; + } + + /** + * Validate that a sku + lot + serial combination is not already active in any rack. + * + * "Active" means the td_stock row is currently linked to an occupied md_rack slot. + * Enforces uniqueness rules: + * - SKU alone → allowed in multiple racks (normal stocking) + * - SKU + lot → allowed in multiple racks (lot spread across locations) + * - SKU + serial → must be unique (one physical unit = one location) + * - SKU + lot + serial → must be unique + * + * Called from occupyRack to prevent double-stocking the same serialised unit. + * The $exclude_stock_id parameter skips the row just inserted (prevents self-conflict + * in insert flows where the row exists but md_rack has not yet been updated). + * + * @param string $product_sku SKU to validate. + * @param string|null $lot_number Lot number to check (or null to skip). + * @param string|null $serial_number Serial number to check (or null to skip). + * @param int $exclude_stock_id Stock row ID to exclude from the check. + * @throws Exception If an active duplicate is found in any warehouse. + */ + public function validateStockUnique( + string $product_sku, + ?string $lot_number = null, + ?string $serial_number = null, + int $exclude_stock_id = 0 + ): void { + // Nothing to validate if neither lot nor serial was provided + if (empty($lot_number) && empty($serial_number)) return; + + $cid = $this->company_id; + + // Discover all active warehouses + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $sth->execute([':company_id' => $cid]); + $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); + + if (empty($warehouses)) return; + + foreach ($warehouses as $wh) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); + $table = "td_stock_{$safe}"; + + // 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. + $sql = "SELECT s.id + FROM `{$table}` s + WHERE s.company_id = :company_id + AND s.product_sku = :product_sku + AND s.type = 'in' + AND EXISTS ( + SELECT 1 FROM md_rack r + WHERE r.company_id = :company_id2 + AND r.td_stock_id = s.id + AND r.product_sku IS NOT NULL + )"; + + if (!empty($lot_number)) { + $sql .= " AND s.lot_number = :lot_number"; + } + if (!empty($serial_number)) { + $sql .= " AND s.serial_number = :serial_number"; + } + if ($exclude_stock_id > 0) { + $sql .= " AND s.id != :exclude_id"; + } + + $params = [ + ':company_id' => $cid, + ':company_id2' => $cid, + ':product_sku' => $product_sku, + ]; + if (!empty($lot_number)) $params[':lot_number'] = $lot_number; + if (!empty($serial_number)) $params[':serial_number'] = $serial_number; + if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id; + + $sth = $this->pdo->prepare($sql); + $sth->execute($params); + + if ($sth->fetchColumn()) { + $combo = implode(' / ', array_filter([ + $lot_number ? "lot: {$lot_number}" : null, + $serial_number ? "serial: {$serial_number}" : null, + ])); + throw new Exception( + "Active stock already exists for product '{$product_sku}' [{$combo}] " + . "in warehouse '{$wh['warehouse_name']}'. " + . "Stock must be moved out before it can be received again." + ); + } + } + } + + /** + * 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. + * Updates md_rack state (product_sku + td_stock_id) in a single UPDATE + * and appends a rack log entry. + * + * Must be called inside a DB transaction by the caller. + * + * @param int|string $warehouse_id Warehouse the rack belongs to. + * @param string $zone Zone identifier. + * @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. + * @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 { + + $sql = "SELECT id, product_sku FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + FOR UPDATE"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":company_id" => $this->company_id, + ":warehouse" => $warehouse_id, + ":zone" => $zone, + ":aisle" => $aisle, + ":rack" => $rack, + ]); + $current = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$current) { + throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist"); + } + + if ($current['product_sku'] !== null) { + 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 ($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 + $sql = "UPDATE md_rack + SET product_sku = :product_sku, + td_stock_id = :td_stock_id + WHERE id = :id"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":product_sku" => $product_sku, + ":td_stock_id" => $td_stock_id, + ":id" => $current['id'], + ]); + + $this->insertRackLog($current['id'], 'occupy', [ + 'product_sku' => $product_sku, + 'td_stock_id' => $td_stock_id, + ]); + } + + /** + * Release a rack — clear its product_sku and td_stock_id link. + * + * Uses FOR UPDATE to lock the rack row. If the rack is already empty, + * exits silently (idempotent: safe to call on an already-empty rack). + * Appends a rack log entry on successful release. + * + * Must be called inside a DB transaction by the caller. + * + * @param int|string $warehouse_id Warehouse the rack belongs to. + * @param string $zone Zone identifier. + * @param string $aisle Aisle identifier. + * @param string $rack Rack identifier. + */ + public function releaseRack($warehouse_id, $zone, $aisle, $rack): void { + + $sql = "SELECT id, product_sku, td_stock_id FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + FOR UPDATE"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":company_id" => $this->company_id, + ":warehouse" => $warehouse_id, + ":zone" => $zone, + ":aisle" => $aisle, + ":rack" => $rack, + ]); + $current = $sth->fetch(PDO::FETCH_ASSOC); + + // Nothing to release — exit silently (idempotent) + if (!$current || $current['product_sku'] === null) { + return; + } + + // Clear both state fields in a single UPDATE + $sql = "UPDATE md_rack + SET product_sku = NULL, + td_stock_id = NULL + WHERE id = :id"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([":id" => $current['id']]); + + $this->insertRackLog($current['id'], 'release', [ + 'product_sku' => $current['product_sku'], + 'td_stock_id' => $current['td_stock_id'], + ]); + } + + /** + * Move a rack assignment from one location to another. + * + * Transfers both product_sku and td_stock_id together from the source + * rack to the destination rack. The source must be occupied; the destination + * must be empty. Both racks are locked in id-ascending order to prevent + * deadlocks under concurrent transfers. + * + * Must be called inside a DB transaction by the caller. + * + * @param int|string $from_warehouse Source warehouse. + * @param string $from_zone Source zone. + * @param string $from_aisle Source aisle. + * @param string $from_rack Source rack. + * @param int|string $to_warehouse Destination warehouse. + * @param string $to_zone Destination zone. + * @param string $to_aisle Destination aisle. + * @param string $to_rack Destination rack. + * @throws Exception If source/destination racks are not found, source is empty, + * or destination is already occupied. + */ + public function transferRack( + $from_warehouse, $from_zone, $from_aisle, $from_rack, + $to_warehouse, $to_zone, $to_aisle, $to_rack + ): void { + + // Lock both racks ordered by id to prevent deadlocks on concurrent transfers + $sql = "SELECT id, warehouse, zone, aisle, rack, product_sku, td_stock_id + FROM md_rack + WHERE company_id = :company_id + AND ( + (warehouse = :from_wh AND zone = :from_zone + AND aisle = :from_aisle AND rack = :from_rack) + OR + (warehouse = :to_wh AND zone = :to_zone + AND aisle = :to_aisle AND rack = :to_rack) + ) + ORDER BY id + FOR UPDATE"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":company_id" => $this->company_id, + ":from_wh" => $from_warehouse, + ":from_zone" => $from_zone, + ":from_aisle" => $from_aisle, + ":from_rack" => $from_rack, + ":to_wh" => $to_warehouse, + ":to_zone" => $to_zone, + ":to_aisle" => $to_aisle, + ":to_rack" => $to_rack, + ]); + + $racks = $sth->fetchAll(PDO::FETCH_ASSOC); + + // Identify source and destination from the fetched rows + $from = null; + $to = null; + foreach ($racks as $r) { + if ($r['warehouse'] == $from_warehouse && $r['zone'] == $from_zone + && $r['aisle'] == $from_aisle && $r['rack'] == $from_rack) { + $from = $r; + } + if ($r['warehouse'] == $to_warehouse && $r['zone'] == $to_zone + && $r['aisle'] == $to_aisle && $r['rack'] == $to_rack) { + $to = $r; + } + } + + if (!$from) { + throw new Exception("Source rack not found"); + } + if (!$to) { + throw new Exception("Destination rack not found"); + } + if ($from['product_sku'] === null) { + throw new Exception("Source rack is empty"); + } + if ($to['product_sku'] !== null) { + throw new Exception("Destination rack is already occupied"); + } + + // Carry both SKU and stock reference from source to destination + $sku = $from['product_sku']; + $td_stock_id = $from['td_stock_id']; + + // Clear source + $this->pdo->prepare("UPDATE md_rack SET product_sku = NULL, td_stock_id = NULL WHERE id = :id") + ->execute([":id" => $from['id']]); + + $this->insertRackLog($from['id'], 'transfer_out', [ + 'product_sku' => $sku, + 'td_stock_id' => $td_stock_id, + ]); + + // Populate destination + $this->pdo->prepare("UPDATE md_rack SET product_sku = :sku, td_stock_id = :td_stock_id WHERE id = :id") + ->execute([":sku" => $sku, ":td_stock_id" => $td_stock_id, ":id" => $to['id']]); + + $this->insertRackLog($to['id'], 'transfer_in', [ + 'product_sku' => $sku, + 'td_stock_id' => $td_stock_id, + ]); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Warehouse balance + // ───────────────────────────────────────────────────────────── + + /** + * Adjust the running balance for a (warehouse, product_sku) pair in warehouse_balance. + * + * Uses an INSERT ... ON DUPLICATE KEY UPDATE to atomically upsert the balance row, + * partitioned by month (YYYY-MM) for efficient monthly reporting queries. + * + * The delta formula ($new_qty - $old_qty) supports both create and edit flows: + * - On insert: old_qty = 0, delta = new_qty (adds the full amount) + * - On delete: new_qty = 0, delta = -old_qty (reverses the full amount) + * - On edit: delta = new_qty - old_qty (adjusts for the difference) + * + * Called by StockManager and deleteStock* methods; never called directly by engine files. + * + * @param string $type 'in' or 'out' — which balance column to update. + * @param int|string $warehouse_id Warehouse to adjust balance for. + * @param string $product_sku SKU to adjust. + * @param int|float $old_qty Previous quantity (0 for new records). + * @param int|float $new_qty New quantity (0 to reverse/delete). + */ + public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) { + + $column = $type === 'in' ? 'total_in' : 'total_out'; + $delta = $new_qty - $old_qty; + $month = date('Y-m'); + + $sql = "INSERT INTO warehouse_balance + (company_id, warehouse_id, product_sku, month, `$column`) + VALUES + (:company_id, :warehouse_id, :product_sku, :month, :delta) + ON DUPLICATE KEY UPDATE + `$column` = `$column` + VALUES(`$column`)"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ":company_id" => $this->company_id, + ":warehouse_id" => $warehouse_id, + ":product_sku" => $product_sku, + ":month" => $month, + ":delta" => $delta + ]); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Zone / Aisle / Rack (stock movement selectors) + // ───────────────────────────────────────────────────────────── + + /** + * Return distinct zones with at least one empty rack — for stock_in destination selector. + * + * Only zones that have at least one available (empty) rack slot are returned, + * so the user cannot direct stock to a fully occupied zone. + * + * @param int $warehouse_id The warehouse to query. + * @return array Rows with 'zone' key. + */ + public function getZonesIn(int $warehouse_id): array + { + $sth = $this->pdo->prepare( + "SELECT DISTINCT zone FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND product_sku IS NULL + ORDER BY CAST(zone AS UNSIGNED), zone" + ); + $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Return distinct zones holding active stock for a sku/lot/serial — for stock_out source selector. + * + * Only zones that currently have at least one occupied rack with the matching + * product/lot/serial combination are returned. + * + * @param int $warehouse_id The warehouse to query. + * @param string $product_sku SKU to filter by. + * @param string|null $lot_number Optional lot filter. + * @param string|null $serial_number Optional serial filter. + * @return array Rows with 'zone' key. + */ + public function getZonesOut( + int $warehouse_id, + string $product_sku, + ?string $lot_number = null, + ?string $serial_number = null + ): array { + $table = $this->resolveWarehouseTable($warehouse_id); + if (!$table) return []; + + [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( + $warehouse_id, $product_sku, $lot_number, $serial_number + ); + + $sth = $this->pdo->prepare( + "SELECT DISTINCT r.zone + FROM md_rack r + WHERE r.company_id = :company_id + AND r.warehouse = :warehouse + AND r.product_sku = :product_sku + AND r.td_stock_id IN ( + SELECT s.id FROM `{$table}` s + WHERE s.company_id = :company_id2 + AND s.product_sku = :product_sku2 + AND s.type = 'in' + {$lot_cond} + {$serial_cond} + ) + ORDER BY CAST(r.zone AS UNSIGNED), r.zone" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Return distinct aisles with at least one empty rack in a zone — for stock_in destination. + * + * @param int $warehouse_id The warehouse to query. + * @param string $zone Zone to filter by. + * @return array Flat array of aisle values (strings). + */ + public function getAislesIn(int $warehouse_id, string $zone): array + { + $sth = $this->pdo->prepare( + "SELECT DISTINCT aisle FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND product_sku IS NULL + ORDER BY CAST(aisle AS UNSIGNED), aisle" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ]); + return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle'); + } + + /** + * Return distinct aisles holding active stock for a sku/lot/serial in a zone — for stock_out source. + * + * @param int $warehouse_id The warehouse to query. + * @param string $zone Zone to filter by. + * @param string $product_sku SKU to filter by. + * @param string|null $lot_number Optional lot filter. + * @param string|null $serial_number Optional serial filter. + * @return array Flat array of aisle values (strings). + */ + public function getAislesOut( + int $warehouse_id, + string $zone, + string $product_sku, + ?string $lot_number = null, + ?string $serial_number = null + ): array { + $table = $this->resolveWarehouseTable($warehouse_id); + if (!$table) return []; + + [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( + $warehouse_id, $product_sku, $lot_number, $serial_number + ); + $params[':zone'] = $zone; + + $sth = $this->pdo->prepare( + "SELECT DISTINCT r.aisle + FROM md_rack r + WHERE r.company_id = :company_id + AND r.warehouse = :warehouse + AND r.zone = :zone + AND r.product_sku = :product_sku + AND r.td_stock_id IN ( + SELECT s.id FROM `{$table}` s + WHERE s.company_id = :company_id2 + AND s.product_sku = :product_sku2 + AND s.type = 'in' + {$lot_cond} + {$serial_cond} + ) + ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle" + ); + $sth->execute($params); + return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle'); + } + + /** + * Return distinct empty racks in an aisle — for stock_in destination selector. + * + * @param int $warehouse_id The warehouse to query. + * @param string $zone Zone to filter by. + * @param string $aisle Aisle to filter by. + * @return array Flat array of rack values (strings). + */ + public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array + { + $sth = $this->pdo->prepare( + "SELECT DISTINCT rack FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND product_sku IS NULL + ORDER BY CAST(rack AS UNSIGNED), rack" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ]); + return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); + } + + /** + * Return distinct racks holding active stock for a sku/lot/serial in an aisle — for stock_out source. + * + * @param int $warehouse_id The warehouse to query. + * @param string $zone Zone to filter by. + * @param string $aisle Aisle to filter by. + * @param string $product_sku SKU to filter by. + * @param string|null $lot_number Optional lot filter. + * @param string|null $serial_number Optional serial filter. + * @return array Flat array of rack values (strings). + */ + public function getRacksOut( + int $warehouse_id, + string $zone, + string $aisle, + string $product_sku, + ?string $lot_number = null, + ?string $serial_number = null + ): array { + $table = $this->resolveWarehouseTable($warehouse_id); + if (!$table) return []; + + [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( + $warehouse_id, $product_sku, $lot_number, $serial_number + ); + $params[':zone'] = $zone; + $params[':aisle'] = $aisle; + + $sth = $this->pdo->prepare( + "SELECT DISTINCT r.rack + FROM md_rack r + WHERE r.company_id = :company_id + AND r.warehouse = :warehouse + AND r.zone = :zone + AND r.aisle = :aisle + AND r.product_sku = :product_sku + AND r.td_stock_id IN ( + SELECT s.id FROM `{$table}` s + WHERE s.company_id = :company_id2 + AND s.product_sku = :product_sku2 + AND s.type = 'in' + {$lot_cond} + {$serial_cond} + ) + ORDER BY CAST(r.rack AS UNSIGNED), r.rack" + ); + $sth->execute($params); + return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); + } + + // ───────────────────────────────────────────────────────────── + // TRANSACTION BASIS — Stock deletion (reversal) + // ───────────────────────────────────────────────────────────── + + /** + * Soft-delete a stock_in record and reverse all its side effects. + * + * 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). + * + * 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 $warehouse_id The warehouse the row belongs to. + * @throws Exception If not the latest transaction or record not found. + */ + public function deleteStockIn(int $stock_id, int $warehouse_id): void { + + $ctx = $this->getStockContext($warehouse_id, $stock_id); + $table = $ctx['table']; + $row = $ctx['row']; + + if (!$row) { + throw new Exception("Stock in record not found."); + } + + $product_sku = $row['product_sku']; + $product_name = $this->getProductName($product_sku); + + $this->validateLatestTransaction($product_sku, $row['date'], $product_name); + + // Soft-delete: negate company_id so row is hidden but recoverable + $this->pdo->prepare( + "UPDATE `$table` + SET company_id = company_id * -1 + WHERE id = :id AND company_id = :company_id" + )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); + + // Release the rack back to empty + $this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); + + // Reverse the balance (subtract the previously added quantity) + $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0); + } + + /** + * Soft-delete a stock_out record and reverse all its side effects. + * + * Steps: + * 1. Validates this is the globally latest transaction for the SKU. + * 2. Soft-deletes the td_stock row (negates company_id). + * 3. Re-occupies the rack with the original stock_in batch (via ref_id). + * 4. Reverses the balance (adjustBalance with new_qty = 0). + * + * 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 $warehouse_id The warehouse the row belongs to. + * @throws Exception If not the latest transaction or record not found. + */ + public function deleteStockOut(int $stock_id, int $warehouse_id): void { + + $ctx = $this->getStockContext($warehouse_id, $stock_id); + $table = $ctx['table']; + $row = $ctx['row']; + + if (!$row) { + throw new Exception("Stock out record not found."); + } + + $product_sku = $row['product_sku']; + $product_name = $this->getProductName($product_sku); + + $this->validateLatestTransaction($product_sku, $row['date'], $product_name); + + // Soft-delete: negate company_id so row is hidden but recoverable + $this->pdo->prepare( + "UPDATE `$table` + SET company_id = company_id * -1 + WHERE id = :id AND company_id = :company_id" + )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); + + // Re-occupy the rack with the original stock_in batch that was consumed + $this->occupyRack( + $warehouse_id, + $row['zone'], $row['aisle'], $row['rack'], + $product_sku, + (int)$row['ref_id'] + ); + + // Reverse the balance (subtract the previously deducted quantity) + $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0); + } + + /** + * Soft-delete a transfer record pair and reverse all side effects on both warehouses. + * + * Steps: + * 1. Validates this is the globally latest transaction for the SKU. + * 2. Soft-deletes both the from and to rows (negates company_id on each). + * 3. Releases the destination rack (which was occupied by the transfer_in). + * 4. Re-occupies the source rack with the original from_stock_id batch. + * 5. Reverses balances on both warehouses. + * + * Must be called inside a DB transaction by the caller. + * + * @param int $from_stock_id The td_stock_.id of the outbound transfer row. + * @param int $from_warehouse_id The source warehouse the outbound row belongs to. + * @throws Exception If not the latest transaction, or if either row is missing. + */ + public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void { + + // Fetch the outbound (from) row + $from_ctx = $this->getStockContext($from_warehouse_id, $from_stock_id); + $from_table = $from_ctx['table']; + $from_row = $from_ctx['row']; + + if (!$from_row || $from_row['type'] !== 'transfer') { + throw new Exception("Transfer record not found."); + } + + $product_sku = $from_row['product_sku']; + $product_name = $this->getProductName($product_sku); + $to_warehouse = (int)$from_row['ref_warehouse']; + $ref_id = (int)$from_row['ref_id']; + + // Fetch the inbound (to) row + $to_ctx = $this->getStockContext($to_warehouse, $ref_id); + $to_row = $to_ctx['row']; + + if (!$to_row) { + throw new Exception("Paired destination record missing — data integrity issue."); + } + + // Single global validation covers both warehouses (same SKU, same date) + $this->validateLatestTransaction($product_sku, $from_row['date'], $product_name); + + // Soft-delete both rows + $this->pdo->prepare( + "UPDATE `$from_table` + SET company_id = company_id * -1 + WHERE id = :id AND company_id = :company_id" + )->execute([':id' => $from_stock_id, ':company_id' => $this->company_id]); + + $this->pdo->prepare( + "UPDATE `{$to_ctx['table']}` + SET company_id = company_id * -1 + WHERE id = :id AND company_id = :company_id" + )->execute([':id' => $ref_id, ':company_id' => $this->company_id]); + + // Destination rack was occupied by transfer_in — release it + $this->releaseRack( + $to_warehouse, + $to_row['zone'], $to_row['aisle'], $to_row['rack'] + ); + + // Source rack was released by transfer_out — re-occupy with original batch + $this->occupyRack( + $from_warehouse_id, + $from_row['zone'], $from_row['aisle'], $from_row['rack'], + $product_sku, + $from_stock_id + ); + + // Reverse balances on both sides + $quantity = (int)$from_row['out']; + $this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0); + $this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0); + } + + // ───────────────────────────────────────────────────────────── + // REPORT BASIS — Warehouse list queries + // ───────────────────────────────────────────────────────────── + + /** + * Return warehouses filtered by rack availability — for stock movement warehouse selectors. + * + * Used on stock_in / stock_out / transfer forms to present only valid warehouse options: + * type = 'to' → warehouses with at least one empty rack (valid destination) + * type = 'from' → warehouses holding the given product_sku (valid source) + * other → all warehouses with any racks + * + * Passing $id > 0 bypasses the occupancy filter (edit mode — warehouse is already set). + * + * @param string $type 'to', 'from', or any other string for unfiltered. + * @param string $product_sku SKU filter used when type = 'from'. + * @param int $id If > 0, skips the filter (edit mode). + * @return array md_warehouse rows (id, warehouse_name) ordered by name. + */ + public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array + { + $product_sku_filter = ''; + $params = [':company_id' => $this->company_id]; + + if (!$id) { + if ($type === 'to') { + $product_sku_filter = 'AND r.product_sku IS NULL'; + } elseif ($type === 'from') { + $product_sku_filter = 'AND r.product_sku = :product_sku'; + $params[':product_sku'] = $product_sku; + } + } + + $sth = $this->pdo->prepare( + "SELECT w.id, w.warehouse_name + FROM md_warehouse w + WHERE w.company_id = :company_id + AND EXISTS ( + SELECT 1 FROM md_rack r + WHERE r.company_id = w.company_id + AND r.warehouse = w.id + $product_sku_filter + ) + ORDER BY w.warehouse_name" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } } \ No newline at end of file diff --git a/app/login/api/engine/back.php b/app/login/api/engine/back.php index 5216049..a40e82b 100644 --- a/app/login/api/engine/back.php +++ b/app/login/api/engine/back.php @@ -1,13 +1,33 @@ - \ No newline at end of file +$answer["success"] = 1; +exit(json_encode($answer)); \ No newline at end of file diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index 25e81a9..dd9b8b9 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -1,68 +1,128 @@ -prepare("select * from user where user_id = :user_id limit 1;"); - $sth->execute([ - ":user_id" => $user_id - ]); - $temp = $sth->fetch(PDO::FETCH_ASSOC); +// ── Step 1: Load session state written by login_otp.php ─────────────────────── +$data["username"] = $_SESSION["login_data"]['username']; +$data["password"] = $_SESSION["login_data"]['password']; +$user_id = $_SESSION["login_user_id"]; - /** - * Validate OTP - */ - function generateOTP($sercet_key, $time_step = 180, $length = 6){ - $counter = floor($_SESSION["otpTime"] / $time_step); - $data = pack("NN", 0, $counter); - $hash = hash_hmac('sha1', $data, $sercet_key, true); - $offset = ord(substr($hash, -1)) & 0x0F; - $value = unpack("N", substr($hash, $offset, 4)); - $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); +// ── Step 2: Fetch user record — need password hash to re-derive the OTP ─────── +$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;"); +$sth->execute([":user_id" => $user_id]); +$temp = $sth->fetch(PDO::FETCH_ASSOC); - return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); - } +// ── Step 3: Re-derive expected OTP ──────────────────────────────────────────── +// Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP +// counter base. This is the same algorithm used in login_otp.php and +// request_new_otp.php — any change to one must be reflected in all three. +function generateOTP($sercet_key, $time_step = 180, $length = 6) { + $counter = floor($_SESSION["otpTime"] / $time_step); + $data = pack("NN", 0, $counter); + $hash = hash_hmac('sha1', $data, $sercet_key, true); + $offset = ord(substr($hash, -1)) & 0x0F; + $value = unpack("N", substr($hash, $offset, 4)); + $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); - $otp = generateOTP($temp["password"]); + return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); +} - // time diff between $_SESSION["otpTime"] and now() in minutes - $otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0; - $now = time(); - $otp_diff_seconds = max(0, $now - $otp_time); - $otp_diff_minutes = $otp_diff_seconds / 60.0; +$otp = generateOTP($temp["password"]); - // print time - $_SESSION["now"] = $now; - $_SESSION["diff"] = $otp_diff_minutes; +// ── Step 3b: Calculate elapsed time since OTP was issued ────────────────────── +// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent. +// The diff is computed in minutes for the 5-minute validity window check. +$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0; +$now = time(); +$otp_diff_seconds = max(0, $now - $otp_time); +$otp_diff_minutes = $otp_diff_seconds / 60.0; - /** - * Validate OTP - */ - if( $data["otp"]!=$otp || $otp_diff_minutes > 5 ){ - $answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)"; - exit(json_encode($answer)); - } +// Store for debug convenience — visible in $_SESSION on the session inspect page +$_SESSION["now"] = $now; +$_SESSION["diff"] = $otp_diff_minutes; - session_regenerate_id(true); // ← fixes session fixation - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // ← CSRF token +// ── Step 4: Validate OTP value and expiry ───────────────────────────────────── +// Fails if either the code doesn't match OR more than 5 minutes have elapsed +// since the OTP was issued. The two conditions are intentionally combined in one +// error message to avoid leaking whether the code was correct but expired. +if ($data["otp"] != $otp || $otp_diff_minutes > 5) { + $answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)"; + exit(json_encode($answer)); +} - /** - * Create login session - */ - $_SESSION["login_status"] = 1; - $_SESSION["login_username"] = $temp["username"]; - $_SESSION["login_name"] = $temp["name"]; - $_SESSION["login_surname"] = $temp["surname"]; - $_SESSION["login_company_id"] = $temp["default_company"]; +// ── Step 5a: Regenerate session ID ──────────────────────────────────────────── +// session_regenerate_id(true) issues a brand-new session ID and deletes the old +// session file, preventing session fixation attacks where an attacker pre-sets +// a session ID before the user logs in. +session_regenerate_id(true); - $answer["success"] = 1; - $answer["message"] = "Login Complete!"; - exit(json_encode($answer)); +// ── Step 5b: Issue CSRF token ───────────────────────────────────────────────── +// A fresh 256-bit token is generated here and stored in session. All subsequent +// POST requests from the authenticated app must include this token in the +// X-CSRF-Token header (validated by individual engine endpoints). +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); -?> \ No newline at end of file +// ── Step 5c: Write authenticated login session ──────────────────────────────── +// These keys are read by db_auth.php on every subsequent request to gate access. +// login_company_id is the user's default_company — used to scope all DB queries. +$_SESSION["login_status"] = 1; +$_SESSION["login_username"] = $temp["username"]; +$_SESSION["login_name"] = $temp["name"]; +$_SESSION["login_surname"] = $temp["surname"]; +$_SESSION["login_company_id"] = $temp["default_company"]; + +// ── Step 6: Respond ─────────────────────────────────────────────────────────── +$answer["success"] = 1; +$answer["message"] = "Login Complete!"; +exit(json_encode($answer)); \ No newline at end of file diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php index e529081..060f044 100644 --- a/app/login/api/engine/login_otp.php +++ b/app/login/api/engine/login_otp.php @@ -1,253 +1,338 @@ - $expire + 1 day → return "expire". + * 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window). + * 7. Generate a 6-letter human-readable reference number from the TOTP. + * 8. If the user's default_company has a company_smtp row → send OTP email. + * If no SMTP configured → skip email, set skip_otp flag in response. + * 9. Clear session and repopulate with OTP state: + * login_data, otp, otpTime, reference, user_email, login_user_id, no_smtp. + * 10. Return { success: 1, skip_otp: bool, message: "Login Complete!" }. + * When skip_otp=true the login page skips the OTP step and calls + * login_confirm.php directly. + * + * Session keys written: + * login_data — original { username, password } for request_new_otp.php + * otp — the generated TOTP value + * otpTime — Unix timestamp the OTP was generated (used for expiry check) + * reference — 6-letter reference code shown on the OTP screen + * user_email — masked in UI; full value stored for display + * login_user_id — resolved user_id (used by login_confirm.php) + * no_smtp — true if no company SMTP exists (OTP step is skipped) + * + * Response JSON: + * On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" } + * On failure: { "message": "" } + * Special: { "message": "wait" } — device pending whitelist approval + * { "message": "block" } — device is blacklisted + * { "expire": "expire" } — licence has expired + */ - // get user_id by username or password - $sth = $pdo1->prepare("select user_id from user where ? in (username,email) "); - $sth->execute(array(strtolower($data["username"]))); - $user_id = $sth->fetchColumn(); +require '../../../session.php'; +require '../../../config.php'; +require '../../../preset.php'; +require '../../../assets/utils/db_auth.php'; - $username = strtolower($data["username"]); - // get password - $sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;"); - $sth->execute(array($username,$username)); - $temp = $sth->fetch(PDO::FETCH_ASSOC); +// ── Step 1: Resolve user_id from username or email (case-insensitive) ──────── +$sth = $pdo1->prepare("select user_id from user where ? in (username,email) "); +$sth->execute(array(strtolower($data["username"]))); +$user_id = $sth->fetchColumn(); - /** - * validate password - */ - if(password_verify(trim($data["password"]), $temp["password"])) { +$username = strtolower($data["username"]); - // create user session - if( strtolower($data["username"]) == "support" ){ - $s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;"); - $r = $s->fetch(PDO::FETCH_ASSOC); - }else{ - $s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;"); - $s->execute(array($username,$username,$user_id)); - $r = $s->fetch(PDO::FETCH_ASSOC); - } +// ── Step 2: Fetch the user's hashed password ────────────────────────────────── +$sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;"); +$sth->execute(array($username, $username)); +$temp = $sth->fetch(PDO::FETCH_ASSOC); - // user email - $user_email = $r["email"]; +// ── Step 3–4: Verify password — exit with error on mismatch ────────────────── +if (password_verify(trim($data["password"]), $temp["password"])) { - if(strpos($user_email,"@")===false){ - $answer["message"] = "".$user_email." is not eligible email, please contact your administrator to change your email."; - exit(json_encode($answer)); - } + // ── Step 5a: Fetch full user record ────────────────────────────────────── + // 'support' user gets a hardcoded email so it can always log in even without + // a registered email address in the DB. + if (strtolower($data["username"]) == "support") { + $s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;"); + $r = $s->fetch(PDO::FETCH_ASSOC); + } else { + $s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;"); + $s->execute(array($username, $username, $user_id)); + $r = $s->fetch(PDO::FETCH_ASSOC); + } - // ── Block unverified accounts — resend verification email ─── - if ($r["status"] === "pending") { + $user_email = $r["email"]; - // generate fresh token - $token = bin2hex(random_bytes(32)); - $expires_at = date('Y-m-d H:i:s', strtotime('+30 days')); + // ── Step 5b: Email format guard ─────────────────────────────────────────── + // Blocks accounts with a malformed email (e.g. set by admin without @) so + // the OTP email delivery step further down doesn't silently fail. + if (strpos($user_email, "@") === false) { + $answer["message"] = "" . $user_email . " is not eligible email, please contact your administrator to change your email."; + exit(json_encode($answer)); + } - $sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id"); - $sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]); + // ── Step 5c: Unverified account (status = 'pending') ───────────────────── + // Generate a fresh verification token and resend the email. + // Errors from the mailer are caught silently so the user still gets the + // "check your inbox" message without exposing internal error details. + if ($r["status"] === "pending") { - // build verify URL - $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') - . '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/'); - $verify_url = $base_url . '/login/verify.php?token=' . $token; + $token = bin2hex(random_bytes(32)); + $expires_at = date('Y-m-d H:i:s', strtotime('+30 days')); - // send email — silently ignore if it fails, don't expose error to user - try { - require_once $include_url . 'assets/utils/module/mailer.php'; - $mailer = new mailer(['pdo1' => $pdo1]); - $mailer->send_email([ - 'company_id' => 0, - 'smtp' => $SMTP, - 'to' => $r['email'], - 'subject' => 'Verify your email — WMS', - 'message' => implode(" -", [ - "Hi {$r['name']},", - "", - "You attempted to login but your email is not yet verified.", - "Please verify your email address by clicking the button below:", - "", - "Verify Email Address", - "", - "Or copy and paste this link into your browser:", - "{$verify_url}", - "", - "This link will expire in 30 days.", - ]), - 'channel_name' => 'WMS', - 'key' => $pinkey, - ]); - } catch (Exception $e) { - error_log('[resend_verify] ' . $e->getMessage()); - } + $sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id"); + $sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]); - $answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email."; - exit(json_encode($answer)); - } + // Build absolute verify URL from current server context + $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/'); + $verify_url = $base_url . '/login/verify.php?token=' . $token; - if ($r["status"] === "not activated") { - $answer["message"] = "Your account has been deactivated. Please contact your administrator."; - exit(json_encode($answer)); - } + try { + require_once $include_url . 'assets/utils/module/mailer.php'; + $mailer = new mailer(['pdo1' => $pdo1]); + $mailer->send_email([ + 'company_id' => 0, + 'smtp' => $SMTP, + 'to' => $r['email'], + 'subject' => 'Verify your email — WMS', + 'message' => implode("\n", [ + "Hi {$r['name']},", + "", + "You attempted to login but your email is not yet verified.", + "Please verify your email address by clicking the button below:", + "", + "Verify Email Address", + "", + "Or copy and paste this link into your browser:", + "{$verify_url}", + "", + "This link will expire in 30 days.", + ]), + 'channel_name' => 'WMS', + 'key' => $pinkey, + ]); + } catch (Exception $e) { + // Log silently — do not expose mailer errors to the end user + error_log('[resend_verify] ' . $e->getMessage()); + } - //~ access control - if( isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord"){ - - $sth = $pdo1->prepare("select * from whitelist where cookie = :cookie"); - $sth->execute(array(":cookie"=>$data["cookie"])); - if($sth->rowCount()==0){ - - $s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);"); - $s->execute(array(":cookie"=>$data["cookie"],":ip"=>$_SERVER["REMOTE_ADDR"])); - - session_destroy(); - $answer["message"] = "wait"; - setcookie("u", "", time()-1, "/"); - setcookie("h1", "", time()-1, "/"); - setcookie("h2", "", time()-1, "/"); - echo json_encode($answer); - - }else{ - - $coo = $sth->fetch(PDO::FETCH_ASSOC); - if( $coo["status"] == "0" ){ - session_destroy(); - $answer["message"] = "block"; - setcookie("u", "", time()-1, "/"); - setcookie("h1", "", time()-1, "/"); - setcookie("h2", "", time()-1, "/"); - echo json_encode($answer); + $answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email."; + exit(json_encode($answer)); + } - $deviceDecision = [ - 'type' => 'BLOCKED', - 'status' => 0 - ]; + // ── Step 5d: Deactivated account ───────────────────────────────────────── + if ($r["status"] === "not activated") { + $answer["message"] = "Your account has been deactivated. Please contact your administrator."; + exit(json_encode($answer)); + } - }else if( $coo["status"] == "1" ){ - session_destroy(); - $answer["message"] = "wait"; - setcookie("u", "", time()-1, "/"); - setcookie("h1", "", time()-1, "/"); - setcookie("h2", "", time()-1, "/"); - echo json_encode($answer); + // ── Step 5e: Secure-login device whitelist check ────────────────────────── + // Only enforced when secure_login is "on" in $pinform and the licence + // is not "lord". The user's browser sends a device cookie ($data["cookie"]). + // - Unknown cookie → INSERT into whitelist with status=1 (pending approval), + // destroy session, return "wait". + // - status=0 (blocked) → destroy session, return "block". + // - status=1 (pending) → destroy session, return "wait", + // fire new_device_login_alert notification. + // - status=2 (approved) → fall through and continue login. + if (isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord") { - $deviceDecision = [ - 'type' => 'WAIT_APPROVAL', - 'status' => 1 - ]; - include __DIR__ . "/api/engine-notification/new_device_login_alert.php"; - exit; - }else if( $coo["status"] == "2" ){ - //~ you can go - } - } - } - //~ end access control - - if( strtotime("now") > strtotime($expire." + 1 day") ){ - session_destroy(); - $answer["expire"] = "expire"; - exit(json_encode($answer)); - setcookie("u", "", time()-1, "/"); - setcookie("h1", "", time()-1, "/"); - setcookie("h2", "", time()-1, "/"); - } + $sth = $pdo1->prepare("select * from whitelist where cookie = :cookie"); + $sth->execute(array(":cookie" => $data["cookie"])); + if ($sth->rowCount() == 0) { + // Register unknown device as pending approval + $s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);"); + $s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"])); - /** - * Generate OTP - */ - function generateOTP($sercet_key, $time_step = 180, $length = 6){ + session_destroy(); + $answer["message"] = "wait"; + setcookie("u", "", time() - 1, "/"); + setcookie("h1", "", time() - 1, "/"); + setcookie("h2", "", time() - 1, "/"); + echo json_encode($answer); - global $otpTime; + } else { - $otpTime = time(); + $coo = $sth->fetch(PDO::FETCH_ASSOC); - $counter = floor($otpTime / $time_step); - $data = pack("NN", 0, $counter); - $hash = hash_hmac('sha1', $data, $sercet_key, true); - $offset = ord(substr($hash, -1)) & 0x0F; - $value = unpack("N", substr($hash, $offset, 4)); - $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); + if ($coo["status"] == "0") { - return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); - } + // Device explicitly blocked by admin + session_destroy(); + $answer["message"] = "block"; + setcookie("u", "", time() - 1, "/"); + setcookie("h1", "", time() - 1, "/"); + setcookie("h2", "", time() - 1, "/"); + echo json_encode($answer); + $deviceDecision = ['type' => 'BLOCKED', 'status' => 0]; - function numberToLetters($num) { - $result = ''; - while ($num > 0) { - $mod = ($num - 1) % 26; - $result = chr(65 + $mod) . $result; - $num = intval(($num - $mod) / 26); - } - return str_pad($result, 6, 'A', STR_PAD_LEFT); - } + } else if ($coo["status"] == "1") { - $otp = generateOTP($temp["password"]); + // Device registered but not yet approved — notify admin + session_destroy(); + $answer["message"] = "wait"; + setcookie("u", "", time() - 1, "/"); + setcookie("h1", "", time() - 1, "/"); + setcookie("h2", "", time() - 1, "/"); + echo json_encode($answer); - $reference_number = numberToLetters(generateOTP($otp)); + $deviceDecision = ['type' => 'WAIT_APPROVAL', 'status' => 1]; + include __DIR__ . "/api/engine-notification/new_device_login_alert.php"; + exit; - // ── Look up company SMTP using user's default_company ─────── - $smtp_config = null; - $default_company = (int)($r["default_company"] ?? 0); + } else if ($coo["status"] == "2") { + // Device approved — continue to OTP step + } + } + } + // ── End secure-login device whitelist check ─────────────────────────────── - if($default_company > 0) { - $sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1"); - $sth->execute([":cid" => $default_company]); - $smtp_row = $sth->fetch(PDO::FETCH_ASSOC); - if(!empty($smtp_row)) { - $smtp_config = $smtp_row; - } - } + // ── Step 5f: Licence expiry check ──────────────────────────────────────── + // $expire is loaded from db_auth.php via session/preset bootstrap. + // If the licence expired more than 1 day ago, reject the login. + // Note: the cookie-clearing lines after exit() are unreachable — left as-is + // to preserve original logic without business-logic changes. + if (strtotime("now") > strtotime($expire . " + 1 day")) { + session_destroy(); + $answer["expire"] = "expire"; + exit(json_encode($answer)); + setcookie("u", "", time() - 1, "/"); // unreachable — preserved from original + setcookie("h1", "", time() - 1, "/"); + setcookie("h2", "", time() - 1, "/"); + } - // ── SMTP found → send OTP email ────────────────────────────── - if(!empty($smtp_config)) { + // ── Step 6: Generate 6-digit TOTP ──────────────────────────────────────── + // The secret key is the user's current password hash, so the OTP is unique + // per user and automatically invalidated if the password changes. + // time_step=180 means the OTP window is 3 minutes (same counter for 3 min). + function generateOTP($sercet_key, $time_step = 180, $length = 6) { - require "../../../assets/utils/module/mailer.php"; + global $otpTime; - $mailer = new mailer(["pdo1"=>$pdo1,"pdo2"=>$pdo2]); + $otpTime = time(); // captured globally so it can be stored in session - $mailer->send_email([ - "company_id" => $default_company, - "smtp" => $smtp_config, - "subject" => "One Time Password (OTP) For reference number ".$reference_number, - "message" => "Your OTP is ".$otp." for reference number ".$reference_number, - "channel_name" => "WMS LOGIN OTP", - "to" => $user_email, - "key" => $pinkey, - ]); + $counter = floor($otpTime / $time_step); + $data = pack("NN", 0, $counter); + $hash = hash_hmac('sha1', $data, $sercet_key, true); + $offset = ord(substr($hash, -1)) & 0x0F; + $value = unpack("N", substr($hash, $offset, 4)); + $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); - } + return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); + } - $_SESSION = []; + // ── Step 7: Generate 6-letter reference number ─────────────────────────── + // Converts a second TOTP (derived from the first OTP as key) to a base-26 + // uppercase letter string. Shown on the OTP screen so the user can confirm + // they received the correct email. + function numberToLetters($num) { + $result = ''; + while ($num > 0) { + $mod = ($num - 1) % 26; + $result = chr(65 + $mod) . $result; + $num = intval(($num - $mod) / 26); + } + return str_pad($result, 6, 'A', STR_PAD_LEFT); + } - $_SESSION["login_data"] = $data; - $_SESSION["otp"] = $otp; - $_SESSION["otpTime"] = $otpTime; - $_SESSION["reference"] = $reference_number; - $_SESSION["user_email"] = $user_email; - $_SESSION["login_user_id"] = $user_id; - $_SESSION["no_smtp"] = empty($smtp_config); // flag for login_confirm + $otp = generateOTP($temp["password"]); + $reference_number = numberToLetters(generateOTP($otp)); - $answer["success"] = 1; - $answer["skip_otp"] = empty($smtp_config); - $answer["message"] = "Login Complete!"; - exit(json_encode($answer)); - } - else - { - $answer["message"] = "Incorrect Password"; - setcookie("u", "", time()-1, "/"); - setcookie("h1", "", time()-1, "/"); - setcookie("h2", "", time()-1, "/"); - exit(json_encode($answer)); - } + // ── Step 8: Look up company SMTP and send OTP email ────────────────────── + // Uses the SMTP settings saved for the user's default_company. + // If no SMTP row exists, the email step is skipped and skip_otp=true is + // returned so the login page can proceed directly to login_confirm.php + // without waiting for an OTP the user will never receive. + $smtp_config = null; + $default_company = (int)($r["default_company"] ?? 0); - $answer["success"] = 1; - exit(json_encode($answer)); + if ($default_company > 0) { + $sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1"); + $sth->execute([":cid" => $default_company]); + $smtp_row = $sth->fetch(PDO::FETCH_ASSOC); + if (!empty($smtp_row)) { + $smtp_config = $smtp_row; + } + } -?> \ No newline at end of file + if (!empty($smtp_config)) { + + require "../../../assets/utils/module/mailer.php"; + + $mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]); + + $mailer->send_email([ + "company_id" => $default_company, + "smtp" => $smtp_config, + "subject" => "One Time Password (OTP) For reference number " . $reference_number, + "message" => "Your OTP is " . $otp . " for reference number " . $reference_number, + "channel_name" => "WMS LOGIN OTP", + "to" => $user_email, + "key" => $pinkey, + ]); + } + + // ── Step 9: Reset session and write OTP state ───────────────────────────── + // The full session is cleared first to prevent session fixation — any data + // from a previous partial login attempt is discarded before writing new state. + $_SESSION = []; + + $_SESSION["login_data"] = $data; // preserved for request_new_otp.php resend flow + $_SESSION["otp"] = $otp; // expected value for login_confirm.php to verify + $_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window + $_SESSION["reference"] = $reference_number; // shown on OTP input screen + $_SESSION["user_email"] = $user_email; // shown masked on OTP screen + $_SESSION["login_user_id"] = $user_id; // used by login_confirm.php to build the login session + $_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page + + // ── Step 10: Respond ────────────────────────────────────────────────────── + $answer["success"] = 1; + $answer["skip_otp"] = empty($smtp_config); // login page skips OTP screen when true + $answer["message"] = "Login Complete!"; + exit(json_encode($answer)); + +} else { + + // ── Password mismatch ───────────────────────────────────────────────────── + // Clear identifying cookies on failure to prevent cookie-based session reuse. + $answer["message"] = "Incorrect Password"; + setcookie("u", "", time() - 1, "/"); + setcookie("h1", "", time() - 1, "/"); + setcookie("h2", "", time() - 1, "/"); + exit(json_encode($answer)); +} + +$answer["success"] = 1; +exit(json_encode($answer)); \ No newline at end of file diff --git a/app/login/api/engine/onboarding.php b/app/login/api/engine/onboarding.php index fa054ee..92809a9 100644 --- a/app/login/api/engine/onboarding.php +++ b/app/login/api/engine/onboarding.php @@ -1,174 +1,251 @@ " } + */ - header('Content-Type: application/json; charset=utf-8'); +require '../../../session.php'; +require '../../../config.php'; +require '../../../dbconn.php'; +require '../../../assets/utils/db_helpers.php'; - $answer = ['success' => 0, 'message' => '']; +header('Content-Type: application/json; charset=utf-8'); - // ─── Must come from onboarding session ─────────────────────── - if (empty($_SESSION['onboarding_user_id'])) { - $answer['message'] = 'Invalid session. Please verify your email first.'; +$answer = ['success' => 0, 'message' => '']; + +// ── Step 1: Session guard ───────────────────────────────────────────────────── +// 'onboarding_user_id' is only written by verify.php after successful email +// verification. If it's missing, this request is out-of-sequence — reject. +if (empty($_SESSION['onboarding_user_id'])) { + $answer['message'] = 'Invalid session. Please verify your email first.'; + http_response_code(403); + exit(json_encode($answer)); +} + +$user_id = (int)$_SESSION['onboarding_user_id']; + +// ── Step 2: CSRF check ──────────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) { http_response_code(403); + exit(json_encode(['message' => 'Invalid request.'])); + } +} + +$data = json_decode($_POST['json'] ?? '{}', true) ?: []; + +try { + + // ── Step 3: Sanitise input ──────────────────────────────────────────────── + $company_name = trim($data['company_name'] ?? ''); + $company_name2 = trim($data['company_name2'] ?? ''); + + // channel_name is the URL slug / identifier — strip everything except + // lowercase letters, digits, hyphens, and underscores. + $channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? '')); + + $branch = trim($data['branch'] ?? 'สำนักงานใหญ่'); + $branch_no = trim($data['branch_no'] ?? '00000'); + $email = trim($data['email'] ?? ''); + $phone = trim($data['phone'] ?? ''); + + // ── Step 4: Required field validation ──────────────────────────────────── + if (!$company_name || !$channel_name) { + $answer['message'] = 'Company name and channel name are required.'; + http_response_code(422); exit(json_encode($answer)); } - $user_id = (int)$_SESSION['onboarding_user_id']; + // ── Step 5: SMTP field validation ──────────────────────────────────────── + // SMTP is mandatory because the company needs to send OTP emails to users. + // An account without working SMTP would be unable to complete 2FA login. + $smtp_host = trim($data['smtp_host'] ?? ''); + $smtp_username = trim($data['smtp_username'] ?? ''); + $smtp_password = $data['smtp_password'] ?? ''; - // ─── CSRF ───────────────────────────────────────────────────── - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; - if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) { - http_response_code(403); - exit(json_encode(['message' => 'Invalid request.'])); - } + if (!$smtp_host || !$smtp_username || !$smtp_password) { + $answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.'; + http_response_code(422); + exit(json_encode($answer)); } - $data = json_decode($_POST['json'] ?? '{}', true) ?: []; + // ── Step 6: Normalise SMTP port and encryption ──────────────────────────── + // Clamp to known-good values to prevent storing unsupported configuration. + $smtp_port = trim($data['smtp_port'] ?? '587'); + $smtp_encryption = trim($data['smtp_encryption'] ?? 'tls'); - try { + if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587'; + if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls'; - $company_name = trim($data['company_name'] ?? ''); - $company_name2 = trim($data['company_name2'] ?? ''); - $channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? '')); - $branch = trim($data['branch'] ?? 'สำนักงานใหญ่'); - $branch_no = trim($data['branch_no'] ?? '00000'); - $email = trim($data['email'] ?? ''); - $phone = trim($data['phone'] ?? ''); + // ── Step 7: Encrypt SMTP password ──────────────────────────────────────── + // Uses the same OpenSSL method/iv/key as the rest of the app (from config.php) + // so the stored password can be decrypted by the mailer module. + $encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv); - if (!$company_name || !$channel_name) { - $answer['message'] = 'Company name and channel name are required.'; - http_response_code(422); - exit(json_encode($answer)); - } + // Assemble a temporary SMTP config for the test send (step 8) + $smtp_config = [ + 'server' => $smtp_host, + 'port' => $smtp_port, + 'username' => $smtp_username, + 'password' => $encrypted_pass, + 'from_name' => $company_name ?: $smtp_username, + 'from_email' => $email ?: $smtp_username, + 'encryption' => $smtp_encryption, + ]; - // ── SMTP fields required ────────────────────────────────── - $smtp_host = trim($data['smtp_host'] ?? ''); - $smtp_username = trim($data['smtp_username'] ?? ''); - $smtp_password = $data['smtp_password'] ?? ''; + // ── Step 8: Silent SMTP test — before any DB writes ────────────────────── + // Sends a test email to the onboarding user's registered address. + // If the mailer throws or exits, no DB records have been created yet, + // so the user can correct their SMTP settings and retry cleanly. + require_once $include_url . 'assets/utils/module/mailer.php'; - if (!$smtp_host || !$smtp_username || !$smtp_password) { - $answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.'; - http_response_code(422); - exit(json_encode($answer)); - } + $mailer = new mailer(['pdo1' => $pdo1]); + $mailer->send_email([ + 'company_id' => 0, + 'smtp' => $smtp_config, + 'to' => $_SESSION['onboarding_email'] ?? $smtp_username, + 'subject' => 'WMS — SMTP Verification', + 'message' => "Your SMTP is working correctly.\n\nSetup is now complete.", + 'channel_name' => $company_name ?: 'WMS', + 'key' => $pinkey, + ]); + // If mailer fails, it calls exit() internally — nothing below this line runs. - $smtp_port = trim($data['smtp_port'] ?? '587'); - - - $smtp_encryption = trim($data['smtp_encryption'] ?? 'tls'); - - if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587'; - if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls'; - - // ── Silent SMTP test — before touching the DB ───────────── - // Build a temporary config using the encrypted password - $encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv); - - $smtp_config = [ - 'server' => $smtp_host, - 'port' => $smtp_port, - 'username' => $smtp_username, - 'password' => $encrypted_pass, - 'from_name' => $company_name ?: $smtp_username, - 'from_email' => $email ?: $smtp_username, - 'encryption' => $smtp_encryption, - ]; - - require_once $include_url . 'assets/utils/module/mailer.php'; - - $mailer = new mailer(['pdo1' => $pdo1]); - $mailer->send_email([ - 'company_id' => 0, - 'smtp' => $smtp_config, - 'to' => $_SESSION['onboarding_email'] ?? $smtp_username, - 'subject' => 'WMS — SMTP Verification', - 'message' => "Your SMTP is working correctly.\n\nSetup is now complete.", - 'channel_name' => $company_name ?: 'WMS', - 'key' => $pinkey, - ]); - // if mailer fails it exits with its own error JSON — nothing below runs - - // ── Duplicate channel name ──────────────────────────────── - $sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1'); - $sth->execute([':c' => $channel_name]); - db_check($sth, $answer); - if ($sth->fetchColumn()) { - $answer['message'] = 'Channel name is already taken. Please choose another.'; - http_response_code(409); - exit(json_encode($answer)); - } - - // ── Insert company ──────────────────────────────────────── - $sth = $pdo1->prepare(" - INSERT INTO company_list - (channel_name, company_name, company_name2, branch, branch_no, email, phone, fx) - VALUES - (:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb') - "); - $sth->execute([ - ':channel_name' => $channel_name, - ':company_name' => $company_name, - ':company_name2' => $company_name2, - ':branch' => $branch, - ':branch_no' => $branch_no, - ':email' => $email, - ':phone' => $phone, - ]); - db_check($sth, $answer); - $company_id = (int)$pdo1->lastInsertId(); - - // ── Map user as owner ───────────────────────────────────── - $sth = $pdo1->prepare(" - INSERT INTO company_map_user (company_id, user_id, role, created_at) - VALUES (:company_id, :user_id, 'owner', NOW()) - "); - $sth->execute([':company_id' => $company_id, ':user_id' => $user_id]); - db_check($sth, $answer); - - // ── Set as default company for this user ────────────────── - $sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u"); - $sth->execute([':c' => $company_id, ':u' => $user_id]); - db_check($sth, $answer); - - // ── Save SMTP ───────────────────────────────────────────── - $sth = $pdo1->prepare(" - INSERT INTO company_smtp - (company_id, server, port, username, password, - from_name, from_email, encryption, updated_at) - VALUES - (:company_id, :server, :port, :username, :password, - :from_name, :from_email, :encryption, NOW()) - "); - $sth->execute([ - ':company_id' => $company_id, - ':server' => $smtp_host, - ':port' => $smtp_port, - ':username' => $smtp_username, - ':password' => $encrypted_pass, - ':from_name' => $company_name, - ':from_email' => $email ?: $smtp_username, - ':encryption' => $smtp_encryption, - ]); - db_check($sth, $answer); - - // ── Clear onboarding session ────────────────────────────── - unset( - $_SESSION['onboarding_user_id'], - $_SESSION['onboarding_name'], - $_SESSION['onboarding_email'] - ); - - $answer['success'] = 1; - $answer['message'] = 'Setup complete.'; - - } catch (Exception $e) { - error_log('[onboarding] ' . $e->getMessage()); - $answer['message'] = 'Setup failed. Please try again.'; - http_response_code(500); + // ── Step 9: Duplicate channel_name check ───────────────────────────────── + // channel_name is the unique identifier used in URLs and API calls — must be globally unique. + $sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1'); + $sth->execute([':c' => $channel_name]); + db_check($sth, $answer); + if ($sth->fetchColumn()) { + $answer['message'] = 'Channel name is already taken. Please choose another.'; + http_response_code(409); + exit(json_encode($answer)); } - exit(json_encode($answer)); -?> \ No newline at end of file + // ── Step 10: Create company record ─────────────────────────────────────── + // fx (currency) defaults to 'thb' — can be changed later in company settings. + $sth = $pdo1->prepare(" + INSERT INTO company_list + (channel_name, company_name, company_name2, branch, branch_no, email, phone, fx) + VALUES + (:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb') + "); + $sth->execute([ + ':channel_name' => $channel_name, + ':company_name' => $company_name, + ':company_name2' => $company_name2, + ':branch' => $branch, + ':branch_no' => $branch_no, + ':email' => $email, + ':phone' => $phone, + ]); + db_check($sth, $answer); + $company_id = (int)$pdo1->lastInsertId(); + + // ── Step 11: Map user as company owner ─────────────────────────────────── + // company_map_user is the many-to-many table between users and companies. + // 'owner' role grants full admin access within the company. + $sth = $pdo1->prepare(" + INSERT INTO company_map_user (company_id, user_id, role, created_at) + VALUES (:company_id, :user_id, 'owner', NOW()) + "); + $sth->execute([':company_id' => $company_id, ':user_id' => $user_id]); + db_check($sth, $answer); + + // ── Step 12: Activate user account and set default company ─────────────── + // Changing status from 'pending' to 'active' lets login_otp.php proceed + // past the unverified-account check. default_company scopes all DB queries + // after login to this company. + $sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u"); + $sth->execute([':c' => $company_id, ':u' => $user_id]); + db_check($sth, $answer); + + // ── Step 13: Save company SMTP settings ────────────────────────────────── + // Stored with the encrypted password so the mailer module can decrypt and + // use it for all outgoing email from this company (OTP, notifications, etc.). + $sth = $pdo1->prepare(" + INSERT INTO company_smtp + (company_id, server, port, username, password, + from_name, from_email, encryption, updated_at) + VALUES + (:company_id, :server, :port, :username, :password, + :from_name, :from_email, :encryption, NOW()) + "); + $sth->execute([ + ':company_id' => $company_id, + ':server' => $smtp_host, + ':port' => $smtp_port, + ':username' => $smtp_username, + ':password' => $encrypted_pass, + ':from_name' => $company_name, + ':from_email' => $email ?: $smtp_username, + ':encryption' => $smtp_encryption, + ]); + db_check($sth, $answer); + + // ── Step 14: Clear onboarding session keys ─────────────────────────────── + // These keys are no longer needed and should not persist into the + // authenticated session. The user will be redirected to the login page. + unset( + $_SESSION['onboarding_user_id'], + $_SESSION['onboarding_name'], + $_SESSION['onboarding_email'] + ); + + // ── Step 15: Respond ────────────────────────────────────────────────────── + $answer['success'] = 1; + $answer['message'] = 'Setup complete.'; + +} catch (Exception $e) { + // Unexpected error — log details server-side, return generic message to client + error_log('[onboarding] ' . $e->getMessage()); + $answer['message'] = 'Setup failed. Please try again.'; + http_response_code(500); +} + +exit(json_encode($answer)); \ No newline at end of file diff --git a/app/login/api/engine/register.php b/app/login/api/engine/register.php index b036c9d..133a91e 100644 --- a/app/login/api/engine/register.php +++ b/app/login/api/engine/register.php @@ -1,156 +1,217 @@ /login/verify.php?token= + * 14. Send verification email via system SMTP ($SMTP from config.php). + * If mailer fails, it exits internally with its own error JSON. + * 15. Return { success: 1, message: "Account created! Please check your email..." } + * + * HTTP status codes used: + * 200 — success + * 403 — CSRF failure + * 409 — duplicate username or email + * 422 — validation failure (missing fields, bad format, weak password) + * 500 — unexpected exception (logged server-side, generic message to client) + * + * Response JSON: + * On success: { "success": 1, "message": "Account created! Please check your email to verify your account." } + * On failure: { "success": 0, "message": "" } + */ - header('Content-Type: application/json; charset=utf-8'); +require '../../../session.php'; +require '../../../config.php'; +require '../../../dbconn.php'; +require '../../../assets/utils/db_helpers.php'; +require '../../../assets/utils/classes/PasswordManager.php'; - $answer = ['success' => 0, 'message' => '']; +header('Content-Type: application/json; charset=utf-8'); - // ─── CSRF ───────────────────────────────────────────────────────────────── - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; - if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) { - http_response_code(403); - exit(json_encode(['message' => 'Invalid request.'])); - } +$answer = ['success' => 0, 'message' => '']; + +// ── Step 1: CSRF check ──────────────────────────────────────────────────────── +// All POST requests must include a valid X-CSRF-Token header matching the token +// stored in session. This prevents cross-site request forgery on the register form. +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) { + http_response_code(403); + exit(json_encode(['message' => 'Invalid request.'])); + } +} + +$data = json_decode($_POST['json'] ?? '{}', true) ?: []; + +try { + + // ── Step 2: Sanitise input ──────────────────────────────────────────────── + $name = trim($data['name'] ?? ''); + $surname = trim($data['surname'] ?? ''); + $username = strtolower(trim($data['username'] ?? '')); + $email = strtolower(trim($data['email'] ?? '')); + $password = $data['password'] ?? ''; + $confirm = $data['confirm_password'] ?? ''; + + // ── Step 3: Required field validation ──────────────────────────────────── + if (!$name || !$surname || !$username || !$email || !$password || !$confirm) { + $answer['message'] = 'All fields are required.'; + http_response_code(422); + exit(json_encode($answer)); } - $data = json_decode($_POST['json'] ?? '{}', true) ?: []; - - try { - - $name = trim($data['name'] ?? ''); - $surname = trim($data['surname'] ?? ''); - $username = strtolower(trim($data['username'] ?? '')); - $email = strtolower(trim($data['email'] ?? '')); - $password = $data['password'] ?? ''; - $confirm = $data['confirm_password'] ?? ''; - - // ── Required fields ─────────────────────────────────────── - if (!$name || !$surname || !$username || !$email || !$password || !$confirm) { - $answer['message'] = 'All fields are required.'; - http_response_code(422); - exit(json_encode($answer)); - } - - // ── Username format ─────────────────────────────────────── - if (!preg_match('/^[a-z0-9_]+$/', $username)) { - $answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.'; - http_response_code(422); - exit(json_encode($answer)); - } - - // ── Email format ────────────────────────────────────────── - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - $answer['message'] = 'Invalid email address.'; - http_response_code(422); - exit(json_encode($answer)); - } - - // ── Password match ──────────────────────────────────────── - if ($password !== $confirm) { - $answer['message'] = 'Passwords do not match.'; - http_response_code(422); - exit(json_encode($answer)); - } - - // ── Duplicate username ──────────────────────────────────── - $sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1'); - $sth->execute([':u' => $username]); - db_check($sth, $answer); - if ($sth->fetchColumn()) { - $answer['message'] = 'Username is already taken.'; - http_response_code(409); - exit(json_encode($answer)); - } - - // ── Duplicate email ─────────────────────────────────────── - $sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1'); - $sth->execute([':e' => $email]); - db_check($sth, $answer); - if ($sth->fetchColumn()) { - $answer['message'] = 'An account with that email already exists.'; - http_response_code(409); - exit(json_encode($answer)); - } - - // ── Password strength ───────────────────────────────────── - $pm = new PasswordManager($pdo1, $include_url); - $result = $pm->checkStrength($password, [$name, $surname, $username, $email]); - if ($result['score'] < PasswordManager::MIN_SCORE) { - $msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.'); - $answer['message'] = 'Password is too weak. ' . $msg; - http_response_code(422); - exit(json_encode($answer)); - } - - // ── Insert user with status=pending ─────────────────────── - $hashed = password_hash($password, PASSWORD_BCRYPT); - $token = bin2hex(random_bytes(32)); - - $expires_at = date('Y-m-d H:i:s', strtotime('+30 days')); - - $sth = $pdo1->prepare(" - INSERT INTO user - (username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at) - VALUES - (:username, :name, :surname, :email, :password, 'pending', '', :token, :expires) - "); - $sth->execute([ - ':username' => $username, - ':name' => $name, - ':surname' => $surname, - ':email' => $email, - ':password' => $hashed, - ':token' => $token, - ':expires' => $expires_at, - ]); - db_check($sth, $answer); - - // ── Send verification email via default SMTP ────────────── - // Build absolute URL - $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') - . '://' . $_SERVER['HTTP_HOST'] - . rtrim($server_url, '/'); - $verify_url = $base_url . '/login/verify.php?token=' . $token; - - require_once $include_url . 'assets/utils/module/mailer.php'; - - $mailer = new mailer(['pdo1' => $pdo1]); - $mailer->send_email([ - 'company_id' => 0, - 'smtp' => $SMTP, - 'to' => $email, - 'subject' => 'Verify your email — WMS', - 'message' => implode("\n", [ - "Hi {$name},", - "", - "Thanks for registering. Please verify your email address by clicking the button below:", - "", - "Verify Email Address", - "", - "Or copy and paste this link into your browser:", - "{$verify_url}", - "", - "This link will expire in 30 days.", - "", - "If you did not create an account, you can ignore this email.", - ]), - 'channel_name' => 'WMS', - 'key' => $pinkey, - ]); - - $answer['success'] = 1; - $answer['message'] = 'Account created! Please check your email to verify your account.'; - - } catch (Exception $e) { - error_log('[register] ' . $e->getMessage()); - $answer['message'] = 'Registration failed. Please try again.'; - http_response_code(500); + // ── Step 4: Username format validation ─────────────────────────────────── + // Restricts usernames to URL-safe characters — prevents injection via + // username in any context where it appears in a URL or query. + if (!preg_match('/^[a-z0-9_]+$/', $username)) { + $answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.'; + http_response_code(422); + exit(json_encode($answer)); } - exit(json_encode($answer)); -?> \ No newline at end of file + // ── Step 5: Email format validation ────────────────────────────────────── + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $answer['message'] = 'Invalid email address.'; + http_response_code(422); + exit(json_encode($answer)); + } + + // ── Step 6: Password match check ───────────────────────────────────────── + if ($password !== $confirm) { + $answer['message'] = 'Passwords do not match.'; + http_response_code(422); + exit(json_encode($answer)); + } + + // ── Step 7: Duplicate username check ───────────────────────────────────── + $sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1'); + $sth->execute([':u' => $username]); + db_check($sth, $answer); + if ($sth->fetchColumn()) { + $answer['message'] = 'Username is already taken.'; + http_response_code(409); + exit(json_encode($answer)); + } + + // ── Step 8: Duplicate email check ──────────────────────────────────────── + $sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1'); + $sth->execute([':e' => $email]); + db_check($sth, $answer); + if ($sth->fetchColumn()) { + $answer['message'] = 'An account with that email already exists.'; + http_response_code(409); + exit(json_encode($answer)); + } + + // ── Step 9: Password strength check via PasswordManager ────────────────── + // Passes user's own personal data as penalty inputs so zxcvbn penalises + // passwords that contain the user's name, username, or email. + $pm = new PasswordManager($pdo1, $include_url); + $result = $pm->checkStrength($password, [$name, $surname, $username, $email]); + if ($result['score'] < PasswordManager::MIN_SCORE) { + $msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.'); + $answer['message'] = 'Password is too weak. ' . $msg; + http_response_code(422); + exit(json_encode($answer)); + } + + // ── Step 10–11: Hash password and generate verification token ───────────── + $hashed = password_hash($password, PASSWORD_BCRYPT); + $token = bin2hex(random_bytes(32)); // 64-char hex token + $expires_at = date('Y-m-d H:i:s', strtotime('+30 days')); + + // ── Step 12: Insert user with status='pending' ──────────────────────────── + // status='pending' means the account exists but cannot log in until the + // email is verified. login_otp.php checks this and re-sends the verify email + // if the user tries to log in before verifying. + $sth = $pdo1->prepare(" + INSERT INTO user + (username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at) + VALUES + (:username, :name, :surname, :email, :password, 'pending', '', :token, :expires) + "); + $sth->execute([ + ':username' => $username, + ':name' => $name, + ':surname' => $surname, + ':email' => $email, + ':password' => $hashed, + ':token' => $token, + ':expires' => $expires_at, + ]); + db_check($sth, $answer); + + // ── Step 13: Build absolute verify URL ─────────────────────────────────── + // $server_url is the app's root path from config.php (e.g. '/wms'). + // The full URL is constructed from the current request's server context + // so it works correctly across dev / staging / production environments. + $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST'] + . rtrim($server_url, '/'); + $verify_url = $base_url . '/login/verify.php?token=' . $token; + + // ── Step 14: Send verification email via system SMTP ───────────────────── + // Uses $SMTP from config.php (system-level, not company SMTP) because the + // user does not have a company yet at registration time. + // If the mailer fails it exits internally with its own error JSON response. + require_once $include_url . 'assets/utils/module/mailer.php'; + + $mailer = new mailer(['pdo1' => $pdo1]); + $mailer->send_email([ + 'company_id' => 0, + 'smtp' => $SMTP, + 'to' => $email, + 'subject' => 'Verify your email — WMS', + 'message' => implode("\n", [ + "Hi {$name},", + "", + "Thanks for registering. Please verify your email address by clicking the button below:", + "", + "Verify Email Address", + "", + "Or copy and paste this link into your browser:", + "{$verify_url}", + "", + "This link will expire in 30 days.", + "", + "If you did not create an account, you can ignore this email.", + ]), + 'channel_name' => 'WMS', + 'key' => $pinkey, + ]); + + // ── Step 15: Respond ────────────────────────────────────────────────────── + $answer['success'] = 1; + $answer['message'] = 'Account created! Please check your email to verify your account.'; + +} catch (Exception $e) { + // Unexpected error — log details server-side, return generic message to client + error_log('[register] ' . $e->getMessage()); + $answer['message'] = 'Registration failed. Please try again.'; + http_response_code(500); +} + +exit(json_encode($answer)); \ No newline at end of file diff --git a/app/login/api/engine/request_new_otp.php b/app/login/api/engine/request_new_otp.php index b9223ec..ef5298d 100644 --- a/app/login/api/engine/request_new_otp.php +++ b/app/login/api/engine/request_new_otp.php @@ -1,114 +1,152 @@ -prepare("select * from user where user_id = :user_id limit 1;"); - $sth->execute([ - ":user_id" => $user_id - ]); - $temp = $sth->fetch(PDO::FETCH_ASSOC); +// ── Step 1: Reload credentials from session ─────────────────────────────────── +// These were stored by login_otp.php so the user doesn't have to retype them. +$data["username"] = $_SESSION["login_data"]['username']; +$data["password"] = $_SESSION["login_data"]['password']; +$user_id = (int)$_SESSION["login_user_id"]; - // user email - $user_email = $temp["email"]; +// ── Step 2: Fetch user record ───────────────────────────────────────────────── +$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;"); +$sth->execute([":user_id" => $user_id]); +$temp = $sth->fetch(PDO::FETCH_ASSOC); - /** - * validate password - */ - if(password_verify(trim($data["password"]), $temp["password"])) { +$user_email = $temp["email"]; - /** - * Generate OTP - */ - function generateOTP($sercet_key, $time_step = 180, $length = 6){ +// ── Step 3–4: Re-verify password ───────────────────────────────────────────── +// Safety check — ensures the session hasn't been tampered with between +// login_otp.php and this resend call. +if (password_verify(trim($data["password"]), $temp["password"])) { - global $otpTime; + // ── Step 5a: Generate fresh 6-digit TOTP ────────────────────────────────── + // Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php. + // A new $otpTime is captured so the OTP window resets from this moment. + function generateOTP($sercet_key, $time_step = 180, $length = 6) { - $otpTime = time(); + global $otpTime; - $counter = floor($otpTime / $time_step); - $data = pack("NN", 0, $counter); - $hash = hash_hmac('sha1', $data, $sercet_key, true); - $offset = ord(substr($hash, -1)) & 0x0F; - $value = unpack("N", substr($hash, $offset, 4)); - $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); + $otpTime = time(); // new timestamp — extends the 5-minute validity window - return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); - } + $counter = floor($otpTime / $time_step); + $data = pack("NN", 0, $counter); + $hash = hash_hmac('sha1', $data, $sercet_key, true); + $offset = ord(substr($hash, -1)) & 0x0F; + $value = unpack("N", substr($hash, $offset, 4)); + $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length); + return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); + } - function numberToLetters($num) { - $result = ''; - while ($num > 0) { - $mod = ($num - 1) % 26; - $result = chr(65 + $mod) . $result; - $num = intval(($num - $mod) / 26); - } - return str_pad($result, 6, 'A', STR_PAD_LEFT); - } + // ── Step 5b: Generate 6-letter reference number ─────────────────────────── + // Converts a second TOTP (derived from the first OTP as the key) to a + // base-26 uppercase letter string shown on the OTP input screen. + function numberToLetters($num) { + $result = ''; + while ($num > 0) { + $mod = ($num - 1) % 26; + $result = chr(65 + $mod) . $result; + $num = intval(($num - $mod) / 26); + } + return str_pad($result, 6, 'A', STR_PAD_LEFT); + } - $otp = generateOTP($temp["password"]); + $otp = generateOTP($temp["password"]); + $reference_number = numberToLetters(generateOTP($otp)); - $reference_number = numberToLetters(generateOTP($otp)); + // ── Step 5c: Send OTP email ─────────────────────────────────────────────── + // Uses the system-level $SMTP config from config.php. + // The if(true) wrapper is a no-op placeholder from the original code — + // the email block always executes. + require "../../../assets/utils/module/mailer.php"; - /** - * Sent Email With OTP - */ - require "../../../assets/utils/module/mailer.php"; + if (true) { - // send email - if(true){ + $mailer = new mailer(["pdo1" => $pdo1]); - $mailer = new mailer(["pdo1"=>$pdo1]); + $mailer->send_email([ + "company_id" => 0, + "smtp" => $SMTP, + "subject" => "One Time Password (OTP) For reference number " . $reference_number, + "message" => "Your OTP is " . $otp . " for reference number " . $reference_number, + "channel_name" => "WMS LOGIN OTP ", + "to" => $user_email, + "key" => $pinkey, + ]); + } - $mailer->send_email([ - "company_id" => 0, - "smtp" => $SMTP, - "subject" => "One Time Password (OTP) For reference number ".$reference_number, - "message" => "Your OTP is ".$otp." for reference number ".$reference_number, - "channel_name" => "WMS LOGIN OTP ", - "to" => $user_email, - "key" => $pinkey, - ]); + // ── Step 5d: Reset session with new OTP state ───────────────────────────── + // Full session is cleared before repopulating to avoid stale state + // from the previous OTP attempt leaking into this one. + $_SESSION = []; - } + $_SESSION["login_data"] = $data; + $_SESSION["otp"] = $otp; + $_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this + $_SESSION["reference"] = $reference_number; + $_SESSION["user_email"] = $user_email; + $_SESSION["login_user_id"] = $user_id; + // ── Step 6: Respond ─────────────────────────────────────────────────────── + $answer["success"] = 1; + $answer["message"] = "Login Complete!"; + exit(json_encode($answer)); - $_SESSION = []; +} else { - $_SESSION["login_data"] = $data; // store variables + // ── Password mismatch — clear cookies and reject ────────────────────────── + $answer["message"] = "Incorrect Password"; + setcookie("u", "", time() - 1, "/"); + setcookie("h1", "", time() - 1, "/"); + setcookie("h2", "", time() - 1, "/"); + exit(json_encode($answer)); +} - $_SESSION["otp"] = $otp; - - $_SESSION["otpTime"] = $otpTime; - - $_SESSION["reference"] = $reference_number; - - $_SESSION["user_email"] = $user_email; - - $_SESSION["login_user_id"] = $user_id; - - - $answer["success"] = 1; - $answer["message"] = "Login Complete!"; - exit(json_encode($answer)); - } - else - { - $answer["message"] = "Incorrect Password"; - setcookie("u", "", time()-1, "/"); - setcookie("h1", "", time()-1, "/"); - setcookie("h2", "", time()-1, "/"); - exit(json_encode($answer)); - } - - $answer["success"] = 1; - exit(json_encode($answer)); - -?> \ No newline at end of file +$answer["success"] = 1; +exit(json_encode($answer)); \ No newline at end of file diff --git a/app/login/index.php b/app/login/index.php index a16825b..5421534 100644 --- a/app/login/index.php +++ b/app/login/index.php @@ -48,10 +48,10 @@
-
- - -
+