diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 3bbcf03..75fceb3 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -698,6 +698,21 @@ function format_date(iso_string) { return (datePart) ? `${datePart[2]}/${datePart[1]}/${datePart[0]} ${split[1]}` : iso_string; } + +function to_iso_date(dateStr) { + if (!dateStr) return null; + + const parts = dateStr.split('/'); + if (parts.length !== 3) return null; + + let [day, month, year] = parts; + day = day.padStart(2, '0'); + month = month.padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + + function format_number(value, decimal) { var n = Number(value); if (isNaN(n)) return '--'; diff --git a/app/assets/utils/classes/CompanySettingManager.php b/app/assets/utils/classes/CompanySettingManager.php index de6c7ea..9e214fc 100644 --- a/app/assets/utils/classes/CompanySettingManager.php +++ b/app/assets/utils/classes/CompanySettingManager.php @@ -5,29 +5,38 @@ * Table: company_setting (company_id, setting_key, value, log, updated_at) * * Current keys: - * default_stock_status int 0 = draft (manual approve required) - * 1 = auto-approve on save - * auto_invoice_and_credit_note int 0 = manual - * 1 = auto-generate invoice on confirm order - * + auto-generate credit note on confirm return + * default_stock_status int 0 = draft, 1 = auto-approve on save + * auto_invoice_and_credit_note int 0 = manual, 1 = auto-generate + * auto_complete_on_ship int 0 = manual, 1 = auto-complete on shipped + * advanced_location int 0 = simple (single location), 1 = zone/aisle/rack + * location_label_rack string label for rack / single location field + * location_label_zone string label for zone (advanced only) + * location_label_aisle string label for aisle (advanced only) * * Designed to accept new keys without schema changes. */ class CompanySettingManager { private PDO $pdo; + private ?PDO $transactionPdo; private int $companyId; // ── Known keys with their defaults ─────────────────────────────────────── private const DEFAULTS = [ - 'default_stock_status' => 1, // auto-approve by default - 'auto_invoice_and_credit_note' => 0, // manual by default + 'default_stock_status' => 1, // auto-approve by default + 'auto_invoice_and_credit_note' => 0, // manual by default + 'auto_complete_on_ship' => 1, // auto-complete by default + 'advanced_location' => 0, // simple mode by default + 'location_label_rack' => 'Location', // single label default + 'location_label_zone' => 'Zone', // advanced level 1 default + 'location_label_aisle' => 'Aisle', // advanced level 2 default ]; - public function __construct(PDO $pdo, int $companyId) + public function __construct(PDO $pdo, int $companyId, ?PDO $transactionPdo = null) { - $this->pdo = $pdo; - $this->companyId = $companyId; + $this->pdo = $pdo; + $this->transactionPdo = $transactionPdo; + $this->companyId = $companyId; } // ───────────────────────────────────────────────────────────── @@ -80,6 +89,32 @@ class CompanySettingManager return $result; } + /** + * Return settings that are locked by existing transaction data. + * + * @return array Locked key => reason pairs. + */ + public function getTransactionLocks(): array + { + $locked = []; + + if (!$this->transactionPdo || $this->isLocalhostRequest()) { + return $locked; + } + + if ($this->hasStockTransactions()) { + $locked['default_stock_status'] = 'stock transactions already exist'; + $locked['advanced_location'] = 'stock transactions already exist'; + } + + if ($this->hasOrderTransactions()) { + $locked['auto_complete_on_ship'] = 'order, invoice, or return transactions already exist'; + $locked['auto_invoice_and_credit_note'] = 'order, invoice, or return transactions already exist'; + } + + return $locked; + } + /** * Upsert a single setting value. * @@ -114,6 +149,144 @@ class CompanySettingManager ]); } + /** + * Return keys whose value cannot be changed because related transactions exist. + * + * @param array $incoming Requested key => value pairs. + * @return array Blocked key => reason pairs. + */ + private function blockedChanges(array $incoming): array + { + if (!$this->transactionPdo || $this->isLocalhostRequest()) { + return []; + } + + $current = $this->getAll(); + $blocked = []; + + $stock_keys = [ + 'default_stock_status', + 'advanced_location', + ]; + + $order_keys = [ + 'auto_complete_on_ship', + 'auto_invoice_and_credit_note', + ]; + + $needs_stock_check = false; + foreach ($stock_keys as $key) { + if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) { + $needs_stock_check = true; + break; + } + } + + $needs_order_check = false; + foreach ($order_keys as $key) { + if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) { + $needs_order_check = true; + break; + } + } + + $has_stock_transactions = $needs_stock_check ? $this->hasStockTransactions() : false; + $has_order_transactions = $needs_order_check ? $this->hasOrderTransactions() : false; + + if ($has_stock_transactions) { + foreach ($stock_keys as $key) { + if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) { + $blocked[$key] = 'stock transactions already exist'; + } + } + } + + if ($has_order_transactions) { + foreach ($order_keys as $key) { + if (array_key_exists($key, $incoming) && (string)$current[$key] !== (string)$incoming[$key]) { + $blocked[$key] = 'order, invoice, or return transactions already exist'; + } + } + } + + return $blocked; + } + + private function hasOrderTransactions(): bool + { + foreach (['td_order', 'td_invoice', 'td_return'] as $table) { + if ($this->tableHasCompanyRows($table)) { + return true; + } + } + + return false; + } + + private function hasStockTransactions(): bool + { + if ($this->tableHasCompanyRows('warehouse_balance')) { + return true; + } + + if ($this->tableHasCompanyRows('md_lot')) { + return true; + } + + $sth = $this->transactionPdo->prepare( + "SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id" + ); + $sth->execute([':company_id' => $this->companyId]); + $warehouses = $sth->fetchAll(PDO::FETCH_COLUMN); + + foreach ($warehouses as $warehouse_name) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$warehouse_name); + if ($safe !== '' && $this->tableHasCompanyRows('td_stock_' . $safe)) { + return true; + } + } + + return false; + } + + private function tableHasCompanyRows(string $table): bool + { + if (!$this->tableExists($table)) { + return false; + } + + $safe_table = str_replace('`', '``', $table); + $sth = $this->transactionPdo->prepare( + "SELECT COUNT(*) FROM `{$safe_table}` WHERE company_id = :company_id LIMIT 1" + ); + $sth->execute([':company_id' => $this->companyId]); + + return (int)$sth->fetchColumn() > 0; + } + + private function tableExists(string $table): bool + { + $sth = $this->transactionPdo->prepare( + "SELECT COUNT(*) + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name" + ); + $sth->execute([':table_name' => $table]); + + return (int)$sth->fetchColumn() > 0; + } + + private function isLocalhostRequest(): bool + { + $host = strtolower($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''); + $host = preg_replace('/:\d+$/', '', $host); + + return in_array($host, ['localhost', '127.0.0.1', '::1'], true) + || ($_SERVER['REMOTE_ADDR'] ?? '') === '127.0.0.1' + || ($_SERVER['REMOTE_ADDR'] ?? '') === '::1'; + } + /** * HTTP handler — read or update settings via AJAX. * @@ -127,14 +300,48 @@ class CompanySettingManager $action = $data['action'] ?? ''; if ($action === 'read') { - echo json_encode(['success' => 1, 'output' => $this->getAll()]); + echo json_encode([ + 'success' => 1, + 'output' => $this->getAll(), + 'locked' => $this->getTransactionLocks(), + ]); exit; } if ($action === 'update') { $allowed = array_keys(self::DEFAULTS); + $incoming = []; foreach ($allowed as $key) { - $this->set($key, $data[$key] ?? self::DEFAULTS[$key]); + $incoming[$key] = $data[$key] ?? self::DEFAULTS[$key]; + } + + $blocked = $this->blockedChanges($incoming); + if (!empty($blocked)) { + $labels = [ + 'default_stock_status' => 'Default Stock Status', + 'advanced_location' => 'Advanced Location', + 'auto_invoice_and_credit_note' => 'Invoice & Credit Note Generation', + 'auto_complete_on_ship' => 'Auto-Complete on Shipped', + ]; + + $names = array_map( + fn($key) => $labels[$key] ?? $key, + array_keys($blocked) + ); + + http_response_code(409); + echo json_encode([ + 'success' => 0, + 'message' => 'Cannot change ' . implode(', ', $names) . ' because relevant transactions already exist.', + 'blocked' => $blocked, + ]); + exit; + } + + foreach ($incoming as $key => $value) { + if ((string)$this->get($key) !== (string)$value) { + $this->set($key, $value); + } } echo json_encode(['success' => 1, 'message' => 'Settings saved.']); exit; @@ -144,4 +351,4 @@ class CompanySettingManager echo json_encode(['success' => 0, 'message' => 'Invalid action.']); exit; } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php index 2c78a76..bccb065 100644 --- a/app/assets/utils/classes/OrderManager.php +++ b/app/assets/utils/classes/OrderManager.php @@ -8,7 +8,8 @@ * * Method order: * Transaction basis → getOrderList, getOrderById, generateOrderNumber, - * saveOrder, confirmOrder, cancelOrder + * saveOrder, confirmOrder, cancelOrder, + * updateFulfillmentStatus * * Key design decisions: * - Order items are stored as a JSON array in td_order.items. @@ -19,10 +20,10 @@ * - confirmOrder() creates stock-out rows with status=0 (draft). * Warehouse staff approve them via the existing approve_stock.php * engine, which handles rack release and balance adjustment. - * - cancelOrder() sets td_order.status = -1 and flips all linked - * draft stock-out rows (status=0) to status=-1 (cancelled). - * Approved stock-out rows block cancellation — caller must handle - * these manually before cancelling. + * - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked + * stock-out rows (status → -1) across all td_stock_* tables. + * Cancellation is blocked if any active invoice (status != -1) or + * active return (status != -1) is linked to the order. * - Cancel logic is intentionally self-contained here. status=-1 is * an order-domain concept with no rack/balance side effects, so * WarehouseManager and StockManager are not involved. @@ -240,7 +241,7 @@ class OrderManager { * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, contact_id, order_date, items (array), - * discount, tax, shipping_fee, notes. + * discount, tax, shipping_fee, shipping_tracking_number, notes. * @param array $logging Audit entry to append to log column. * @return int New td_order.id on insert, 0 on update. * @throws Exception If updating a non-draft order. @@ -257,6 +258,7 @@ class OrderManager { $discount = (float)($data['discount'] ?? 0); $tax = (float)($data['tax'] ?? 0); $shipping_fee = (float)($data['shipping_fee'] ?? 0); + $tracking_no = trim((string)($data['shipping_tracking_number'] ?? '')); $grand_total = $subtotal - $discount + $tax + $shipping_fee; if ($id > 0) { @@ -288,6 +290,7 @@ class OrderManager { discount = :discount, tax = :tax, shipping_fee = :shipping_fee, + shipping_tracking_number = :shipping_tracking_number, grand_total = :grand_total, notes = :notes, `log` = :log @@ -300,6 +303,7 @@ class OrderManager { ':discount' => $discount, ':tax' => $tax, ':shipping_fee' => $shipping_fee, + ':shipping_tracking_number' => $tracking_no, ':grand_total' => $grand_total, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), @@ -317,11 +321,11 @@ class OrderManager { "INSERT INTO td_order (company_id, uuid, order_number, contact_id, order_date, status, payment_status, subtotal, discount, tax, - shipping_fee, grand_total, items, notes, `log`, created_at) + shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at) VALUES (:company_id, :uuid, :order_number, :contact_id, :order_date, 0, 0, :subtotal, :discount, :tax, - :shipping_fee, :grand_total, :items, :notes, :log, :created_at)" + :shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), @@ -332,6 +336,7 @@ class OrderManager { ':discount' => $discount, ':tax' => $tax, ':shipping_fee' => $shipping_fee, + ':shipping_tracking_number' => $tracking_no, ':grand_total' => $grand_total, ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), ':notes' => $data['notes'] ?? '', @@ -484,28 +489,27 @@ class OrderManager { } /** - * Cancel an order and flip all linked draft stock-out rows to status=-1. + * Cancel an order and soft-delete all linked stock-out rows (status → -1). * - * Flow: - * 1. Load the order — must be status=0 (draft) or status=1 (confirmed). - * Orders at status=2 (processing) or status=3 (completed) cannot be - * cancelled here; they require manual stock reversal first. - * 2. Check that no linked stock-out rows are already approved (status=1). - * If any are approved, throw — those must be deleted via the existing - * deleteStockOut() flow before cancellation can proceed. - * 3. UPDATE all td_stock_ rows where - * source='order' AND source_id=order_id AND status=0 → status=-1. - * 4. UPDATE td_order.status = -1. + * Business rule: + * An order can be cancelled (Draft or Confirmed) as long as no active + * downstream documents exist. Active means status != -1 (not voided / + * not cancelled). * - * Cancel logic is intentionally self-contained — status=-1 rows have no - * rack/balance side effects so WarehouseManager is not involved. + * Downstream documents that block cancellation: + * - td_invoice where order_id = $order_id AND status != -1 + * - td_return where order_id = $order_id AND status != -1 + * + * Stock-out rows are children of the order — they follow the parent + * and are never independently approved. On cancel, ALL stock-out rows + * linked to this order (any status except already -1) are soft-deleted + * (status → -1) across all td_stock_* tables. * * Must be called inside dbTransaction() by the caller. * * @param int $order_id td_order.id to cancel. * @param array $logging Audit entry appended to td_order.log. - * @throws Exception If order not found, already cancelled, in a non-cancellable - * status, or has approved stock-out rows. + * @throws Exception */ public function cancelOrder(int $order_id, array $logging): void { @@ -528,14 +532,41 @@ class OrderManager { } if ($status >= 2) { throw new Exception( - "Cannot cancel an order that is processing or completed. " . - "Please reverse stock movements manually first." + "Cannot cancel an order that is processing or completed." ); } - // ── Guard: no approved stock-out rows ───────────────────────────── - // Discover all td_stock_* tables and check for approved rows - // linked to this order before making any changes. + // ── Guard: no active invoice ────────────────────────────────────── + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND order_id = :order_id + AND status != -1" + ); + $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception( + "Cannot cancel — this order has an active invoice. " . + "Please void the invoice first." + ); + } + + // ── Guard: no active return ─────────────────────────────────────── + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_return + WHERE company_id = :company_id + AND order_id = :order_id + AND status != -1" + ); + $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception( + "Cannot cancel — this order has an active return. " . + "Please cancel the return first." + ); + } + + // ── Soft-delete all linked stock-out rows ───────────────────────── $sth = $this->pdo->prepare( "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() @@ -545,33 +576,13 @@ class OrderManager { $tables = $sth->fetchAll(PDO::FETCH_COLUMN); foreach ($tables as $table) { - $sth = $this->pdo->prepare( - "SELECT COUNT(*) FROM `{$table}` - WHERE company_id = :company_id - AND source = 'order' - AND source_id = :order_id - AND status = 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':order_id' => $order_id, - ]); - if ((int)$sth->fetchColumn() > 0) { - throw new Exception( - "Cannot cancel — some stock-out rows for this order are already " . - "approved. Please delete them from the Stock Out page first." - ); - } - } - - // ── Flip draft stock-out rows to cancelled ──────────────────────── - foreach ($tables as $table) { + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table); $this->pdo->prepare( - "UPDATE `{$table}` SET status = -1 + "UPDATE `{$safe}` SET status = -1 WHERE company_id = :company_id AND source = 'order' AND source_id = :order_id - AND status = 0" + AND status != -1" )->execute([ ':company_id' => $this->company_id, ':order_id' => $order_id, @@ -593,4 +604,109 @@ class OrderManager { ':company_id' => $this->company_id, ]); } -} \ No newline at end of file + + /** + * Update the fulfillment sub-status of a confirmed order. + * + * Fulfillment status values: + * 1 = Picking — warehouse staff are picking items + * 2 = Packed — items are packed, ready to ship + * 3 = Shipped — goods have left the warehouse + * + * Rules: + * - Only allowed on orders with status >= 1 (confirmed) and not cancelled. + * - Fulfillment must move forward only (no going backwards). + * - If auto_complete_on_ship = true AND fulfillment_status = 3 (Shipped), + * order status is automatically advanced to 3 (Completed). + * - If auto_complete_on_ship = false, order status is set to 2 (Processing) + * on first fulfillment update, and stays there until manually completed. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $order_id td_order.id + * @param int $fulfillment_status 1=Picking, 2=Packed, 3=Shipped + * @param string $tracking_number Shipping carrier tracking number. + * @param array $logging Audit entry. + * @param bool $auto_complete Whether Shipped auto-drives order to Completed. + * @throws Exception + */ + public function updateFulfillmentStatus( + int $order_id, + int $fulfillment_status, + string $tracking_number, + array $logging, + bool $auto_complete = true + ): void { + // ── Load order ──────────────────────────────────────────────────── + $sth = $this->pdo->prepare( + "SELECT * FROM td_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); + $order = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$order) { + throw new Exception("Order not found."); + } + + $status = (int)$order['status']; + $current_fulfillment = (int)($order['fulfillment_status'] ?? 0); + $current_tracking = trim((string)($order['shipping_tracking_number'] ?? '')); + $tracking_number = trim($tracking_number); + + if ($status === -1) { + throw new Exception("Cannot update a cancelled order."); + } + if ($status < 1) { + throw new Exception("Confirm the order before updating fulfillment."); + } + if ($status === 3) { + throw new Exception("Order is already completed."); + } + if (!in_array($fulfillment_status, [1, 2, 3], true)) { + throw new Exception("Invalid fulfillment status."); + } + if ($fulfillment_status < $current_fulfillment) { + throw new Exception("Fulfillment status can only move forward."); + } + if ($fulfillment_status === $current_fulfillment && $tracking_number === $current_tracking) { + throw new Exception("No fulfillment or tracking changes to save."); + } + + // ── Determine new order status ──────────────────────────────────── + $new_order_status = $status; + + if ($fulfillment_status > $current_fulfillment && $fulfillment_status === 3 && $auto_complete) { + // Shipped + auto-complete → Completed + $new_order_status = 3; + } elseif ($fulfillment_status > $current_fulfillment && $status === 1) { + // First fulfillment update → Processing + $new_order_status = 2; + } + + // ── Persist ─────────────────────────────────────────────────────── + $log = json_decode($order['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, [ + 'action' => 'update_fulfillment', + 'fulfillment_status' => $fulfillment_status, + 'shipping_tracking_number' => $tracking_number, + 'order_status' => $new_order_status, + ]); + + $this->pdo->prepare( + "UPDATE td_order SET + fulfillment_status = :fulfillment_status, + shipping_tracking_number = :shipping_tracking_number, + status = :status, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':fulfillment_status' => $fulfillment_status, + ':shipping_tracking_number' => $tracking_number, + ':status' => $new_order_status, + ':log' => json_encode($log), + ':id' => $order_id, + ':company_id' => $this->company_id, + ]); + } +} diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php index 6285754..db1f89c 100644 --- a/app/assets/utils/classes/ReturnManager.php +++ b/app/assets/utils/classes/ReturnManager.php @@ -7,7 +7,7 @@ * * Method order: * Transaction basis → getReturnList, getReturnById, generateReturnNumber, - * saveReturn, confirmReturn + * saveReturn, confirmReturn, cancelReturn * * Key design decisions: * - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order) @@ -386,4 +386,144 @@ class ReturnManager { ); } } + + /** + * Cancel a return (Draft or Confirmed) and soft-delete linked stock-in rows. + * + * Business rule: + * A return can be cancelled as long as no active downstream document exists. + * Active means status != -1 (not voided). + * + * Downstream document that blocks cancellation: + * - td_invoice (credit note) where ref_invoice_id links back via + * order_id = return.order_id AND doc_type = 'credit_note' AND status != -1. + * Credit notes store order_id (not return_id), so we match via order_id + * and doc_type. + * + * If the return was confirmed, stock-in rows (source='return', source_id=$return_id) + * were created with status=1 and rack/balance were already adjusted. + * On cancel we soft-delete those rows (status → -1) and reverse the + * rack occupation and balance via WarehouseManager. + * + * If the return was still draft, no stock rows exist — simple status flip. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $return_id td_return.id to cancel. + * @param array $logging Audit entry appended to td_return.log. + * @param object $whMgmt WarehouseManager instance (needed for confirmed reversal). + * @throws Exception + */ + public function cancelReturn(int $return_id, array $logging, $whMgmt): void + { + // ── Load return ─────────────────────────────────────────────────── + $sth = $this->pdo->prepare( + "SELECT * FROM td_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); + $return = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$return) { + throw new Exception("Return not found."); + } + + $status = (int)$return['status']; + + if ($status === -1) { + throw new Exception("Return is already cancelled."); + } + + // ── Guard: no active credit note ────────────────────────────────── + $order_id = (int)$return['order_id']; + + if ($order_id > 0) { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND order_id = :order_id + AND doc_type = 'credit_note' + AND status != -1" + ); + $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception( + "Cannot cancel — this return has an active credit note. " . + "Please void the credit note first." + ); + } + } + + // ── If confirmed: reverse stock-in rows + rack/balance ──────────── + if ($status === 1) { + $items = json_decode($return['items'] ?? '[]', true) ?: []; + + foreach ($items as $item) { + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $product_sku = $item['product_sku'] ?? ''; + $quantity = (float)($item['quantity'] ?? 0); + + if (!$warehouse_id || !$product_sku) continue; + + $table = $this->resolveStockTable($warehouse_id); + $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + + // Find the stock-in row created by confirmReturn() + $sth = $this->pdo->prepare( + "SELECT id, zone, aisle, rack FROM `{$safe}` + WHERE company_id = :company_id + AND source = 'return' + AND source_id = :return_id + AND product_sku = :sku + AND status = 1 + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':return_id' => $return_id, + ':sku' => $product_sku, + ]); + $stock_row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$stock_row) continue; + + // Soft-delete the stock-in row + $this->pdo->prepare( + "UPDATE `{$safe}` SET status = -1 + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':id' => $stock_row['id'], + ':company_id' => $this->company_id, + ]); + + // Release rack occupation + $whMgmt->releaseRack( + $warehouse_id, + $stock_row['zone'], + $stock_row['aisle'], + $stock_row['rack'], + $product_sku, + $stock_row['id'] + ); + + // Reverse balance + $whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity); + } + } + + // ── Cancel the return ───────────────────────────────────────────── + $log = json_decode($return['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'cancel']); + + $this->pdo->prepare( + "UPDATE td_return SET + status = -1, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $return_id, + ':company_id' => $this->company_id, + ]); + } } \ No newline at end of file diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index 73bf1bb..552aee0 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -78,7 +78,12 @@ class WarehouseManager { $from = trim($from); $to = trim($to); - // Numeric range + // Same value — single location, any format allowed (e.g. "Shelf-A", "Bin-3") + if ($from === $to) { + return [$from]; + } + + // Numeric range e.g. 1 → 10 if (is_numeric($from) && is_numeric($to)) { $f = (int) $from; $t = (int) $to; @@ -86,15 +91,62 @@ class WarehouseManager { return array_map('strval', range($f, $t)); } - // Single-letter alpha range + // Single-letter alpha range e.g. A → Z if (ctype_alpha($from) && ctype_alpha($to) && strlen($from) === 1 && strlen($to) === 1) { - if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)"); - return range($from, $to); // PHP range() handles 'A' → 'Z' natively + if (ord(strtoupper($from)) > ord(strtoupper($to))) { + throw new Exception("Range start ($from) must be <= end ($to)"); + } + return range(strtoupper($from), strtoupper($to)); + } + + // Prefix + suffix range e.g. Shelf-A → Shelf-E or Bin-1 → Bin-5 + // Split on the last sequence of digits or single letter at the end + $pattern = '/^(.*?)([A-Za-z]|[0-9]+)$/'; + if (preg_match($pattern, $from, $mf) && preg_match($pattern, $to, $mt)) { + $prefix_f = $mf[1]; + $prefix_t = $mt[1]; + $suffix_f = $mf[2]; + $suffix_t = $mt[2]; + + // Prefixes must match + if ($prefix_f !== $prefix_t) { + throw new Exception( + "Location prefix mismatch: '$prefix_f' vs '$prefix_t'. " . + "From and To must share the same prefix (e.g. Shelf-A → Shelf-E)." + ); + } + + $prefix = $prefix_f; + + // Suffix: numeric range + if (is_numeric($suffix_f) && is_numeric($suffix_t)) { + $f = (int) $suffix_f; + $t = (int) $suffix_t; + if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)"); + return array_map(fn($n) => $prefix . $n, range($f, $t)); + } + + // Suffix: single-letter range + if (ctype_alpha($suffix_f) && ctype_alpha($suffix_t) + && strlen($suffix_f) === 1 && strlen($suffix_t) === 1) { + $sf = strtoupper($suffix_f); + $st = strtoupper($suffix_t); + if (ord($sf) > ord($st)) { + throw new Exception("Range start ($from) must be <= end ($to)"); + } + return array_map(fn($c) => $prefix . $c, range($sf, $st)); + } + + throw new Exception( + "Location suffix must be a number (e.g. 1–99) or single letter (e.g. A–Z). " . + "Got: '$suffix_f' → '$suffix_t'" + ); } throw new Exception( - "Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'" + "Invalid location range: '$from' → '$to'. " . + "Use formats like: 1–10, A–Z, Shelf-1–Shelf-10, or Shelf-A–Shelf-Z." ); } @@ -848,6 +900,16 @@ class WarehouseManager { */ public function getRacksAll(int $warehouse_id, string $zone, string $aisle): array { + if ($zone === '' || $aisle === '') { + $sth = $this->pdo->prepare( + "SELECT DISTINCT rack FROM md_rack + WHERE company_id = :company_id AND warehouse = :warehouse + ORDER BY CAST(rack AS UNSIGNED), rack" + ); + $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]); + return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); + } + $sth = $this->pdo->prepare( "SELECT DISTINCT rack FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle @@ -1703,6 +1765,21 @@ class WarehouseManager { */ public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array { + if ($zone === '' || $aisle === '') { + $sth = $this->pdo->prepare( + "SELECT DISTINCT rack FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND product_sku IS NULL + ORDER BY CAST(rack AS UNSIGNED), rack" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':warehouse' => $warehouse_id, + ]); + return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); + } + $sth = $this->pdo->prepare( "SELECT DISTINCT rack FROM md_rack WHERE company_id = :company_id @@ -1746,17 +1823,20 @@ class WarehouseManager { [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( $warehouse_id, $product_sku, $lot_number, $serial_number ); - $params[':zone'] = $zone; - $params[':aisle'] = $aisle; + $location_cond = ''; + if ($zone !== '' && $aisle !== '') { + $location_cond = "AND r.zone = :zone AND r.aisle = :aisle"; + $params[':zone'] = $zone; + $params[':aisle'] = $aisle; + } $sth = $this->pdo->prepare( "SELECT DISTINCT r.rack FROM md_rack r WHERE r.company_id = :company_id AND r.warehouse = :warehouse - AND r.zone = :zone - AND r.aisle = :aisle AND r.product_sku = :product_sku + {$location_cond} AND r.td_stock_id IN ( SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 @@ -2055,4 +2135,4 @@ class WarehouseManager { $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } -} \ No newline at end of file +} diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index 95a9644..9eeb468 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -68,22 +68,22 @@ -
- -
-
- -
-
- +
+ @@ -118,24 +118,72 @@ - \ No newline at end of file diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php index c130110..c23d370 100644 --- a/app/ics/stock_in.php +++ b/app/ics/stock_in.php @@ -51,9 +51,9 @@ Lot Number Serial Number Quantity - Zone - Aisle - Rack + Zone + Aisle + Rack Status Action @@ -78,6 +78,31 @@ diff --git a/app/inventory/warehouse.php b/app/inventory/warehouse.php index 8eca547..4a06817 100644 --- a/app/inventory/warehouse.php +++ b/app/inventory/warehouse.php @@ -202,7 +202,7 @@

Storage Locations

-

Zones, aisles, and rack assignments

+

Zones, aisles, and rack assignments

@@ -228,9 +228,9 @@ Warehouse - Zone - Aisle Range - Rack Range + Zone + Aisle Range + Rack Range Items Stored Action @@ -251,9 +251,57 @@ - \ No newline at end of file + diff --git a/app/order/manage_return.php b/app/order/manage_return.php index 6bea1ae..cad2bce 100644 --- a/app/order/manage_return.php +++ b/app/order/manage_return.php @@ -376,8 +376,8 @@ status === -1 ? 'Return is cancelled' : 'Return has already been confirmed'); set_btn_state('#btn_confirm', is_draft, status === -1 ? 'Return is cancelled' : 'Return is already confirmed'); - set_btn_state('#btn_cancel', is_draft, - status === -1 ? 'Return is already cancelled' : 'Only draft returns can be cancelled'); + set_btn_state('#btn_cancel', is_draft || status === 1, + status === -1 ? 'Return is already cancelled' : 'Return is completed and cannot be cancelled'); } diff --git a/app/order/order.php b/app/order/order.php index ad9c0ce..1723007 100644 --- a/app/order/order.php +++ b/app/order/order.php @@ -95,7 +95,7 @@
+ placeholder="Search order number, contact, or tracking..."> @@ -144,6 +144,8 @@ Items Grand Total Status + Fulfillment + Tracking # Payment Action @@ -188,6 +190,15 @@ return map[status] ?? '—'; } + function fulfillment_status_badge(status) { + const map = { + '1': 'Picking', + '2': 'Packed', + '3': 'Shipped', + }; + return map[String(status)] ?? '—'; + } + // ── Retrieve orders ─────────────────────────────────────────────────────── function retrieve_orders() { @@ -208,7 +219,8 @@ var data = all.filter(function(o) { var match_kw = !keyword || (o.order_number || '').toLowerCase().includes(keyword) || - (o.contact_name || '').toLowerCase().includes(keyword); + (o.contact_name || '').toLowerCase().includes(keyword) || + (o.shipping_tracking_number || '').toLowerCase().includes(keyword); var match_st = fil_status === '' || String(o.status) === fil_status; var match_pm = fil_payment === '' || String(o.payment_status) === fil_payment; return match_kw && match_st && match_pm; @@ -254,6 +266,8 @@ ${item_count} item${item_count !== 1 ? 's' : ''} ${format_number(o.grand_total, 2)} ${order_status_badge(o.status)} + ${fulfillment_status_badge(o.fulfillment_status)} + ${o.shipping_tracking_number || '—'} ${payment_status_badge(o.payment_status)} @@ -309,4 +323,4 @@ - \ No newline at end of file + diff --git a/app/order/return.php b/app/order/return.php index d21cf73..e15fc9c 100644 --- a/app/order/return.php +++ b/app/order/return.php @@ -146,7 +146,7 @@ $.each(page_data, function(i, r) { var items = JSON.parse(r.items || '[]'); - var can_cancel = parseInt(r.status) === 0; + var can_cancel = parseInt(r.status) >= 0 && parseInt(r.status) <= 1; body += ` ${r.return_number} diff --git a/app/reports/expired_stock.php b/app/reports/expired_stock.php index bb639f3..ad2f7a9 100644 --- a/app/reports/expired_stock.php +++ b/app/reports/expired_stock.php @@ -138,7 +138,7 @@ Product SKU Lot Number - Location + Location Expiry Date Days Overdue Quantity @@ -167,7 +167,7 @@ Product SKU Lot Number - Location + Location Expiry Date Days Remaining Quantity @@ -190,9 +190,44 @@ @@ -486,4 +528,4 @@ - \ No newline at end of file + diff --git a/app/setting/api/engine/company_setting.php b/app/setting/api/engine/company_setting.php index 726fda8..5fc0ea0 100644 --- a/app/setting/api/engine/company_setting.php +++ b/app/setting/api/engine/company_setting.php @@ -3,5 +3,5 @@ session_start(); require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/classes/CompanySettingManager.php'; -$csm = new CompanySettingManager($pdo1, $company_id); -$csm->handle($data); \ No newline at end of file +$csm = new CompanySettingManager($pdo1, $company_id, $pdo2); +$csm->handle($data); diff --git a/app/setting/system_config.php b/app/setting/system_config.php index 513fdd7..30c77f6 100644 --- a/app/setting/system_config.php +++ b/app/setting/system_config.php @@ -23,6 +23,96 @@
+ +
+
+
+
+
Warehouse Location Settings
+
+
+ +
+ +

+ By default, each storage location uses a single Location field — + ideal for small warehouses. Enable Advanced to use a three-level + hierarchy (e.g. Zone → Aisle → Rack) for larger operations. + You can customise the label for each level to match your team's terminology. +

+
+ + +
+ + +
+
+
+ +
+
+ +
+
+ default: "Location" +
+
+
+ + +
+
+
+ +
+
+ +
+
+ default: "Zone" +
+
+
+
+ +
+
+ +
+
+ default: "Aisle" +
+
+
+
+ +
+
+ +
+
+ default: "Rack" +
+
+
+
+ + + +
+
+
+
+
@@ -67,6 +157,48 @@
+ +
+
+
Order Fulfillment Settings
+
+
+ +
+ +

+ Controls whether an order is automatically marked as Completed + when its fulfillment status is set to Shipped. + When Manual, the order stays in Processing and staff + must mark it Completed themselves. +

+
+
+ + +
+
+ + +
+
+
+ + + +
+
+
@@ -127,32 +259,123 @@ action: 'read', onSuccess: function(res) { var s = res.output; + + // Location settings + var advanced = (s.advanced_location ?? 0) == 1; + $('#advanced_location_toggle').prop('checked', advanced); + toggle_location_mode(advanced); + $('#location_label_rack_simple').val(s.location_label_rack || ''); + $('#location_label_zone').val(s.location_label_zone || ''); + $('#location_label_aisle').val(s.location_label_aisle || ''); + $('#location_label_rack_advanced').val(s.location_label_rack || ''); + + // Other settings $('input[name="default_stock_status"][value="' + (s.default_stock_status ?? 1) + '"]').prop('checked', true); + $('input[name="auto_complete_on_ship"][value="' + (s.auto_complete_on_ship ?? 1) + '"]').prop('checked', true); $('input[name="auto_invoice_and_credit_note"][value="' + (s.auto_invoice_and_credit_note ?? 0) + '"]').prop('checked', true); + + apply_setting_locks(res.locked || {}); } }); + + // Toggle advanced/simple panels on switch change + $('#advanced_location_toggle').on('change', function() { + toggle_location_mode($(this).is(':checked')); + }); }); + function toggle_location_mode(advanced) { + if (advanced) { + $('#location_simple').addClass('d-none'); + $('#location_advanced').removeClass('d-none'); + } else { + $('#location_simple').removeClass('d-none'); + $('#location_advanced').addClass('d-none'); + } + } + + function apply_setting_locks(locked) { + set_setting_lock( + 'advanced_location', + '#advanced_location_toggle', + locked.advanced_location, + 'Advanced Location cannot be changed because stock transactions already exist. Location labels can still be renamed.' + ); + set_setting_lock( + 'default_stock_status', + 'input[name="default_stock_status"]', + locked.default_stock_status, + 'Default Stock Status cannot be changed because stock transactions already exist.' + ); + set_setting_lock( + 'auto_complete_on_ship', + 'input[name="auto_complete_on_ship"]', + locked.auto_complete_on_ship, + 'Auto-Complete on Shipped cannot be changed because order, invoice, or return transactions already exist.' + ); + set_setting_lock( + 'auto_invoice_and_credit_note', + 'input[name="auto_invoice_and_credit_note"]', + locked.auto_invoice_and_credit_note, + 'Invoice & Credit Note Generation cannot be changed because order, invoice, or return transactions already exist.' + ); + } + + function set_setting_lock(key, selector, is_locked, message) { + var $inputs = $(selector); + $inputs.prop('disabled', !!is_locked); + + var $container = $inputs.first().closest('.mb-4'); + $container.find('.setting-lock-note[data-lock-key="' + key + '"]').remove(); + + if (is_locked) { + $container.append( + '
' + + '' + message + + '
' + ); + } + } + // ── Save ────────────────────────────────────────────────────────────────── function save_config() { - var stock_status = $('input[name="default_stock_status"]:checked').val(); - var auto_invoice_and_cn = $('input[name="auto_invoice_and_credit_note"]:checked').val(); + var advanced = $('#advanced_location_toggle').is(':checked') ? 1 : 0; + var stock_status = $('input[name="default_stock_status"]:checked').val(); + var auto_invoice_and_cn = $('input[name="auto_invoice_and_credit_note"]:checked').val(); + var auto_complete_on_ship = $('input[name="auto_complete_on_ship"]:checked').val(); - if (stock_status === undefined || auto_invoice_and_cn === undefined) { + if (stock_status === undefined || auto_invoice_and_cn === undefined || auto_complete_on_ship === undefined) { bootbox.alert('Please select all settings before saving.'); return; } + // Resolve label values — fall back to defaults if blank + var label_rack, label_zone, label_aisle; + if (advanced) { + label_zone = $('#location_label_zone').val().trim() || 'Zone'; + label_aisle = $('#location_label_aisle').val().trim() || 'Aisle'; + label_rack = $('#location_label_rack_advanced').val().trim() || 'Rack'; + } else { + label_rack = $('#location_label_rack_simple').val().trim() || 'Location'; + label_zone = label_rack; + label_aisle = label_rack; + } + ajax_request({ url: server_url + 'setting/api/engine/company_setting.php', autoPrepare: true, checkRequired: 0, action: 'update', data: { + advanced_location: advanced, + location_label_zone: label_zone, + location_label_aisle: label_aisle, + location_label_rack: label_rack, default_stock_status: stock_status, auto_invoice_and_credit_note: auto_invoice_and_cn, + auto_complete_on_ship: auto_complete_on_ship, }, - onSuccess: function(res) { + onSuccess: function() { bootbox.alert('Settings saved.'); } }); @@ -161,4 +384,4 @@ - \ No newline at end of file +