modify classed and comments

This commit is contained in:
Thanakorn S
2026-04-29 14:21:09 +07:00
parent 2061624641
commit f6dc9a3278
15 changed files with 4122 additions and 2638 deletions
+199 -132
View File
@@ -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,
]);
}
}
+147 -38
View File
@@ -1,38 +1,84 @@
<?php
/**
* FileUploader
*
* Handles secure file upload, validation, cleanup, and rollback for
* the WMS application. Designed to be used with any form that accepts
* image or PDF attachments (product images, contact photos, etc.).
*
* Typical usage flow:
* 1. Instantiate with the absolute server path to the upload directory.
* 2. Call cleanup() to delete files the user removed in the UI.
* 3. Call upload() to validate and move new files from $_FILES.
* 4. If DB write succeeds, call buildFileString() to persist the final CSV.
* 5. If DB write fails, call rollbackUploads() to delete the newly uploaded files.
*
* Example:
* $uploader = new FileUploader('/var/www/app/uploads/products/');
* $uploader->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})";
}
}
?>
}
+152 -53
View File
@@ -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);
+151 -56
View File
@@ -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'],
+263 -185
View File
@@ -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);
}
}
+450 -111
View File
@@ -1,5 +1,33 @@
<?php
/**
* ReportManager
*
* Provides all read-only reporting and dashboard data aggregation.
* Contains no write operations — purely for data retrieval and summarisation.
*
* Method order (all report basis):
* Master data summaries → category, product, warehouse, contact counts
* Stock summaries → balance, low stock, critical/warning counts
* Dashboard reports → stats, movement charts, most moved, recent activity
* Warehouse detail → capacity, space used, balance, movement, trend, activity
* Expiry reports → expired / near-expiry stock
* Lot / Rack reports → lot stock log, product lots, rack log, rack occupancy
* Balance summary → getWarehouseBalanceSummary
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) 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_<wh> table name for a given warehouse_id.
* Returns null if warehouse not found.
* Resolve the td_stock_<wh> 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_<wh> 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);
}
}
?>
// ─────────────────────────────────────────────────────────────
// 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) ?: [];
}
}
+145 -36
View File
@@ -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_<warehouse>) 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_<wh> table name.
* Throws if warehouse not found.
* Resolve the dynamic td_stock_<warehouse> 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_<wh>.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_<wh>.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_<wh>.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_<wh> 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_<wh> 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_<from>.
* 3. Inserts the inbound row in td_stock_<to> 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);
}
}
?>
}
File diff suppressed because it is too large Load Diff