tables. * * Method order: * Transaction basis → getOrderList, getOrderById, generateOrderNumber, * saveOrder, confirmOrder, cancelOrder, * updateFulfillmentStatus * * Key design decisions: * - Order items are stored as a JSON array in td_order.items. * No separate child table exists. SKU-level reporting is served by * td_stock_* rows (source='order') rather than querying items JSON. * - confirmOrder() picks racks via FIFO — oldest approved stock-in row * still rack-occupied, ordered by td_stock_.date ASC. * - 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 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. * * Note: Write methods do NOT manage their own DB transactions. * Callers must wrap multi-step operations inside dbTransaction(). * * Security: All SQL uses PDO prepared statements with bound parameters. * Dynamic table names (td_stock_) are sanitised with * preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation — * no user input ever reaches a table identifier directly. */ class OrderManager { private $pdo; private $company_id; public function __construct($pdo, int $company_id) { $this->pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Build a standard audit log entry array for append-to-JSON log columns. * * @param string $action Short label e.g. 'create', 'update', 'confirm', 'cancel'. * @return array */ private function buildLogEntry(string $action): array { return [ 'user_id' => $_SESSION['login_user_id'] ?? null, 'dt' => date('Y-m-d H:i:s'), 'login' => isset($_SESSION['otpTime']) ? date('Y-m-d H:i:s', $_SESSION['otpTime']) : null, 'action' => $action, ]; } /** * Resolve the td_stock_ table name for a warehouse_id. * * Does not filter by status — allows reading inactive warehouses * for historical order lookups. * * @param int $warehouse_id * @return string Sanitised table name e.g. "td_stock_Main". * @throws Exception If warehouse not found. */ private function resolveStockTable(int $warehouse_id): string { $sth = $this->pdo->prepare( "SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); $name = $sth->fetchColumn(); if (!$name) { throw new Exception("Warehouse ID {$warehouse_id} not found."); } $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); return "td_stock_{$safe}"; } /** * FIFO rack pick — find the oldest approved stock-in row for a SKU * in a given warehouse that is still rack-occupied. * * "Oldest" = smallest date value among status=1 stock-in rows * that have an md_rack row pointing back to them (td_stock_id IS set). * * @param int $warehouse_id * @param string $product_sku * @return array|null Full td_stock row + zone/aisle/rack from md_rack, * or null if no available stock in this warehouse. */ private function pickFifoRack(int $warehouse_id, string $product_sku): ?array { $table = $this->resolveStockTable($warehouse_id); $sth = $this->pdo->prepare( "SELECT s.*, r.zone, r.aisle, r.rack FROM `{$table}` s INNER JOIN md_rack r ON r.company_id = s.company_id AND r.warehouse = :warehouse_id AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.type = 'in' AND s.status = 1 ORDER BY s.date ASC LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id, ':product_sku' => $product_sku, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); return $row ?: null; } /** * Generate the next sequential order number in ORD-YYYYMMDD-XXXX format. * * Finds the highest sequence number already used today and increments by 1. * Thread-safe enough for single-company use; wrap in transaction if needed. * * @return string e.g. "ORD-20260502-0001" */ private function generateOrderNumber(): string { $prefix = 'ORD-' . date('Ymd') . '-'; $sth = $this->pdo->prepare( "SELECT order_number FROM td_order WHERE company_id = :company_id AND order_number LIKE :prefix ORDER BY order_number DESC LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':prefix' => $prefix . '%', ]); $last = $sth->fetchColumn(); $seq = $last ? ((int)substr($last, -4) + 1) : 1; return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── /** * Return all orders for the company, ordered by created_at DESC. * * Joins md_contact for contact_name display. * * @return array */ public function getOrderList(): array { $sth = $this->pdo->prepare( "SELECT o.*, COALESCE(c.contact_name, '') AS contact_name FROM td_order o LEFT JOIN md_contact c ON c.company_id = o.company_id AND c.id = o.contact_id WHERE o.company_id = :company_id ORDER BY o.created_at DESC" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single order row by its primary key. * * Returns the row with items decoded as a PHP array. * * @param int $id td_order.id * @return array|false */ public function getOrderById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT o.*, COALESCE(c.contact_name, '') AS contact_name FROM td_order o LEFT JOIN md_contact c ON c.company_id = o.company_id AND c.id = o.contact_id WHERE o.company_id = :company_id AND o.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) return false; $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; return $row; } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── /** * Insert a new order (draft) or update metadata on an existing one. * * Insert flow (id = 0): * - Generates order_number. * - Stores items as JSON array. * - Calculates grand_total from items + adjustments. * - Sets status = 0 (draft), created_at = now(). * * Update flow (id > 0): * - Only allowed while status = 0 (draft). * - Updates contact, date, items, totals, notes. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, contact_id, order_date, items (array), * 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. */ public function saveOrder(array $data, array $logging): int { $id = (int)($data['id'] ?? 0); $items = $data['items'] ?? []; // Calculate totals from items $subtotal = array_reduce($items, fn($carry, $item) => $carry + (float)($item['total_price'] ?? 0), 0.0 ); $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) { // Fetch existing row to check status and load log $sth = $this->pdo->prepare( "SELECT status, `log` FROM td_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Order not found."); } if ((int)$row['status'] !== 0) { throw new Exception("Only draft orders can be edited."); } $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $logging; $this->pdo->prepare( "UPDATE td_order SET contact_id = :contact_id, order_date = :order_date, items = :items, subtotal = :subtotal, discount = :discount, tax = :tax, shipping_fee = :shipping_fee, shipping_tracking_number = :shipping_tracking_number, grand_total = :grand_total, notes = :notes, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => (int)($data['contact_id'] ?? 0), ':order_date' => $data['order_date'] ?? date('Y-m-d'), ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), ':subtotal' => $subtotal, ':discount' => $discount, ':tax' => $tax, ':shipping_fee' => $shipping_fee, ':shipping_tracking_number' => $tracking_no, ':grand_total' => $grand_total, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); return 0; } else { $log = [$logging]; $this->pdo->prepare( "INSERT INTO td_order (company_id, uuid, order_number, contact_id, order_date, status, payment_status, subtotal, discount, tax, 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, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':order_number' => $this->generateOrderNumber(), ':contact_id' => (int)($data['contact_id'] ?? 0), ':order_date' => $data['order_date'] ?? date('Y-m-d'), ':subtotal' => $subtotal, ':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'] ?? '', ':log' => json_encode($log), ':created_at' => date('Y-m-d H:i:s'), ]); return (int)$this->pdo->lastInsertId(); } } /** * Confirm a draft order — create stock-out rows (status=0) for each item * using FIFO rack selection, then advance the order to status=1. * * Flow per item: * 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU. * 2. INSERT into td_stock_ with type='out', status=0, * source='order', source_id=order_id. * 3. Write back stock_out_id into the item object in td_order.items. * * After all items are processed: * 4. UPDATE td_order.items with stock_out_id values written back. * 5. UPDATE td_order.status = 1 (confirmed). * * Rack release and balance adjustment are intentionally deferred — * they happen when warehouse staff approve the stock-out rows via * the existing approve_stock.php engine (StockManager::approveStock). * * Must be called inside dbTransaction() by the caller. * * @param int $order_id td_order.id to confirm. * @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}. * @param array $logging Audit entry appended to td_order.log. * @throws Exception If order not found, not in draft status, or any item * has no available FIFO rack in its assigned warehouse. */ public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void { $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."); } if ((int)$order['status'] !== 0) { throw new Exception("Only draft orders can be confirmed."); } $items = json_decode($order['items'] ?? '[]', true) ?: []; if (empty($items)) { throw new Exception("Cannot confirm an order with no items."); } // ── Per-item: FIFO pick + insert stock-out row ──────────────────── foreach ($items as $i => &$item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); $product_sku = $item['product_sku'] ?? ''; if (!$warehouse_id || !$product_sku) { throw new Exception( "Item #{$i}: missing warehouse_id or product_sku." ); } $rack_stock = $this->pickFifoRack($warehouse_id, $product_sku); if (!$rack_stock) { $name = $item['product_name'] ?? $product_sku; throw new Exception( "No available stock for \"{$name}\" in the selected warehouse." ); } $table = $this->resolveStockTable($warehouse_id); $item_uuid = $uuid . '_' . $i; $item_log = [array_merge($logging, ['action' => 'confirm_item'])]; $this->pdo->prepare( "INSERT INTO `{$table}` (uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack, contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, source, source_id, price, status) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack, :contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 'order', :source_id, :price, 0)" )->execute([ ':uuid' => $item_uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $product_sku, ':quantity' => (float)$item['quantity'], ':zone' => $rack_stock['zone'], ':aisle' => $rack_stock['aisle'], ':rack' => $rack_stock['rack'], ':contact_id' => (int)$order['contact_id'], ':description' => $order['order_number'], ':log' => json_encode($item_log), ':ref_id' => (int)$rack_stock['id'], ':lot_number' => $rack_stock['lot_number'] ?? '', ':serial_number' => $rack_stock['serial_number'] ?? '', ':source_id' => $order_id, ':price' => (float)($item['price'] ?? 0), ]); // Write rack context + stock_out_id back into the item object $item['zone'] = $rack_stock['zone']; $item['aisle'] = $rack_stock['aisle']; $item['rack'] = $rack_stock['rack']; $item['lot_number'] = $rack_stock['lot_number'] ?? ''; $item['serial_number'] = $rack_stock['serial_number'] ?? ''; $item['stock_out_id'] = (int)$this->pdo->lastInsertId(); // Auto-approve: immediately release rack + adjust balance if ($auto_approve) { $stock = new StockManager($this->pdo, $this->company_id); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $stock->approveStock($item['stock_out_id'], $warehouse_id, 'out', $whMgmt); } } unset($item); // break reference // ── Update order: items (with stock_out_id) + status=1 ─────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'confirm']); $this->pdo->prepare( "UPDATE td_order SET status = 1, items = :items, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), ':log' => json_encode($log), ':id' => $order_id, ':company_id' => $this->company_id, ]); } /** * Cancel an order and soft-delete all linked stock-out rows (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). * * 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 */ public function cancelOrder(int $order_id, array $logging): 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']; if ($status === -1) { throw new Exception("Order is already cancelled."); } if ($status >= 2) { throw new Exception( "Cannot cancel an order that is processing or completed." ); } // ── 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() AND table_name LIKE 'td_stock_%'" ); $sth->execute(); $tables = $sth->fetchAll(PDO::FETCH_COLUMN); foreach ($tables as $table) { $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table); $this->pdo->prepare( "UPDATE `{$safe}` SET status = -1 WHERE company_id = :company_id AND source = 'order' AND source_id = :order_id AND status != -1" )->execute([ ':company_id' => $this->company_id, ':order_id' => $order_id, ]); } // ── Cancel the order ────────────────────────────────────────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'cancel']); $this->pdo->prepare( "UPDATE td_order SET status = -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $order_id, ':company_id' => $this->company_id, ]); } /** * 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, ]); } }