tables. * * Method order: * Transaction basis → getOrderList, getOrderById, generateOrderNumber, * saveOrder, confirmOrder, cancelOrder, * updateShippingTracking * * 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 bins via FIFO — oldest approved stock-in row * still bin-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 bin 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. If linked * stock-out rows were already approved, their bin and balance effects are * reversed before the rows are cancelled. * Cancellation is blocked if any active invoice (status != 4 / void) or * active return (status != -1 / cancelled) is linked to the order. * - Cancel logic stays in the order domain, but uses WarehouseManager for * the same bin and balance reversal rules as manual stock-out deletion. * * 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 stock table names are derived only from md_warehouse.id. */ class OrderManager { private PDO $pdo; private int $company_id; public function __construct(PDO $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, ]; } private function stockTableNameFromWarehouseId(int $warehouse_id): string { if ($warehouse_id <= 0) { throw new Exception("Invalid warehouse id."); } $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); if (!$sth->fetchColumn()) { throw new Exception("Warehouse ID {$warehouse_id} not found."); } return 'td_stock_' . $warehouse_id; } /** * FIFO bin pick — find the oldest approved stock-in row for a SKU * in a given warehouse that is still bin-occupied. * * "Oldest" = smallest date value among status=1 stock-in rows * that have an md_bin 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/bin from md_bin, * or null if no available stock in this warehouse. */ private function pickFifoRack(int $warehouse_id, string $product_sku): ?array { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $sth = $this->pdo->prepare( "SELECT s.*, r.zone, r.aisle, r.bin, l.expiry_date, (s.`in` - COALESCE(( SELECT SUM(o.`out`) FROM `{$table}` o WHERE o.company_id = s.company_id AND o.ref_id = s.id AND o.`out` > 0 AND o.status != -1 ), 0)) AS available_qty FROM `{$table}` s INNER JOIN md_bin 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 LEFT JOIN md_lot l ON l.company_id = s.company_id AND l.product_sku = s.product_sku AND l.lot_number = s.lot_number AND s.lot_number != '' WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.`in` > 0 AND s.status = 1 HAVING available_qty > 0 ORDER BY s.date ASC, s.id 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(array $data = []): string { return (new DocumentNumberManager($this->pdo, $this->company_id)) ->resolveNumber($data, 'sales_order', 'td_order', 'order_number'); } /** * Derive fulfillment from linked stock-out rows and tracking. * * 0 = no fulfillment yet * 1 = picking (at least one linked stock-out row is draft) * 2 = packed (all linked stock-out rows are approved, no tracking) * 3 = shipped (all linked stock-out rows are approved, tracking exists) */ private function deriveFulfillmentStatus(array $order): int { $status = (int)($order['status'] ?? 0); if ($status < 1) return 0; $order_id = (int)($order['id'] ?? 0); $sth = $this->pdo->prepare( "SELECT warehouse_id, stock_out_id FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id" ); $sth->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); $items = $sth->fetchAll(PDO::FETCH_ASSOC); $linked_count = 0; $draft_count = 0; foreach ($items as $item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); $stock_out_id = (int)($item['stock_out_id'] ?? 0); if ($warehouse_id <= 0 || $stock_out_id <= 0) continue; $table = 'td_stock_' . $warehouse_id; $sth = $this->pdo->prepare( "SELECT status FROM `{$table}` WHERE company_id = :company_id AND id = :id AND type = 'out' LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $stock_out_id, ]); $stock_status = $sth->fetchColumn(); if ($stock_status === false) continue; $linked_count++; if ((int)$stock_status === 0) { $draft_count++; } } if ($linked_count === 0) return 0; if ($draft_count > 0) return 1; return trim((string)($order['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2; } private function syncOrderItems(int $order_id, array $items): void { $this->pdo->prepare( "DELETE FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id" )->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); $sth = $this->pdo->prepare( "INSERT INTO td_order_item (company_id, order_id, item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, lot_number, invoiced_qty) VALUES (:company_id, :order_id, :item_id, :product_sku, :product_name, :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :lot_number, 0)" ); foreach ($items as $pos => $item) { $sth->execute([ ':company_id' => $this->company_id, ':order_id' => $order_id, ':item_id' => $pos + 1, ':product_sku' => $item['product_sku'] ?? '', ':product_name' => $item['product_name'] ?? '', ':quantity' => (float)($item['quantity'] ?? 0), ':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0), ':total_price' => (float)($item['total_price'] ?? 0), ':tax_amount' => (float)($item['tax_amount'] ?? 0), ':tax_rate' => (float)($item['tax_rate'] ?? 0), ':warehouse_id' => (int)($item['warehouse_id'] ?? 0), ':stock_out_id' => (int)($item['stock_out_id'] ?? 0), ':lot_number' => $item['lot_number'] ?? '', ]); } } // ───────────────────────────────────────────────────────────── // 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, (SELECT COUNT(*) FROM td_order_item i WHERE i.company_id = o.company_id AND i.order_id = o.id) AS item_count 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]); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$row) { $row['fulfillment_status'] = $this->deriveFulfillmentStatus($row); } unset($row); return $rows; } /** * 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; $sth2 = $this->pdo->prepare( "SELECT * FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]); $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); $row['fulfillment_status'] = $this->deriveFulfillmentStatus($row); return $row; } public function getActiveInvoiceId(int $order_id): int { $sth = $this->pdo->prepare( "SELECT id FROM td_invoice WHERE company_id = :company_id AND order_id = :order_id AND doc_type = 'invoice' AND status != 4 ORDER BY id DESC LIMIT 1" ); $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); return (int)$sth->fetchColumn(); } public function getReturnableItems(array $order): array { $order_id = (int)($order['id'] ?? 0); $order_items = $order['items'] ?? []; $sth = $this->pdo->prepare( "SELECT ri.item_id, ri.quantity FROM td_return_item ri JOIN td_return r ON r.id = ri.return_id AND r.company_id = ri.company_id WHERE ri.company_id = :company_id AND r.order_id = :order_id AND r.status = 1" ); $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); $returned_qty = []; foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $item) { $item_id = (int)($item['item_id'] ?? -1); if ($item_id < 0) continue; $returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity']; } $returnable = []; foreach ($order_items as $i => $item) { $already_returned = $returned_qty[$i] ?? 0; $remaining = (float)$item['quantity'] - $already_returned; if ($remaining <= 0) { continue; } $stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0); $stock_out_id = (int)($item['stock_out_id'] ?? 0); if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) { $table = 'td_stock_' . $stock_out_warehouse_id; $sth = $this->pdo->prepare( "SELECT id, status, zone, aisle, bin, lot_number, serial_number FROM `{$table}` WHERE company_id = :company_id AND id = :id AND type = 'out' LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $stock_out_id, ]); $stock_out = $sth->fetch(PDO::FETCH_ASSOC); if ($stock_out) { $item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? ''); $item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? ''); $item['bin'] = $stock_out['bin'] ?? ($item['bin'] ?? ''); $item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''); $item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''); $item['stock_out_status'] = (int)$stock_out['status']; } } $returnable[] = array_merge($item, [ 'item_id' => $i, 'original_qty' => (float)$item['quantity'], 'returned_qty' => $already_returned, 'returnable_qty' => $remaining, 'stock_out_warehouse_id' => $stock_out_warehouse_id, 'stock_out_status' => (int)($item['stock_out_status'] ?? 0), ]); } return $returnable; } /** * Mark an accepted quotation as converted once an order has been created * from it. * * The link itself lives on the order (td_order.source = 'quotation', * source_id = quotation id), which saveOrder() has already written and * QuotationManager::getById() joins on. This used to also write * td_quotation.order_id — a column that has never existed — so saving an * order with source=quotation failed with "Unknown column" and rolled the * new order back with it. */ public function linkQuotationToOrder(int $quotation_id, int $order_id): void { $this->pdo->prepare( "UPDATE td_quotation SET status = 5 WHERE id = :id AND company_id = :cid AND status = 2" )->execute([':id' => $quotation_id, ':cid' => $this->company_id]); } public function assertRevenueOrderEditable(int $order_id): void { $sth = $this->pdo->prepare( "SELECT source, status FROM td_order WHERE id = :id AND company_id = :cid LIMIT 1" ); $sth->execute([':id' => $order_id, ':cid' => $this->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row || !in_array($row['source'], ['revenue', 'quotation'], true)) { throw new Exception('Revenue sales order not found.'); } if ((int)$row['status'] !== -2) { throw new Exception('Only pending sales orders can be edited.'); } } public function assertRevenueOrder(int $order_id): void { $sth = $this->pdo->prepare( "SELECT source FROM td_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); $source = (string)$sth->fetchColumn(); if (!in_array($source, ['revenue', 'quotation'], true)) { throw new Exception("This is not a Revenue sales order."); } } // ───────────────────────────────────────────────────────────── // 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_adjustment = (float)($data['tax_adjustment'] ?? 0); if (abs($tax_adjustment) > 0.30) { throw new Exception("Tax adjustment cannot exceed ±0.30."); } $tax = round(array_reduce($items, fn($carry, $item) => $carry + (float)($item['tax_amount'] ?? 0), 0.0 ), 2) + $tax_adjustment; $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, department_id, `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."); } // Pages without a department field (Revenue SO) must not wipe the stored one $department_id = array_key_exists('department_id', $data) ? (int)$data['department_id'] : (int)$row['department_id']; $cur_status = (int)$row['status']; if ($cur_status !== 0 && $cur_status !== -2) { throw new Exception("Only draft or pending orders can be edited."); } $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $logging; // Auto-promote Pending → Draft when all items have a warehouse assigned $new_status = $cur_status; if ($cur_status === -2) { $all_have_warehouse = !empty($items) && array_reduce( $items, fn($ok, $item) => $ok && (int)($item['warehouse_id'] ?? 0) > 0, true ); if ($all_have_warehouse) { $new_status = 0; } } $this->pdo->prepare( "UPDATE td_order SET contact_id = :contact_id, department_id = :department_id, order_date = :order_date, subtotal = :subtotal, discount = :discount, tax = :tax, tax_adjustment = :tax_adjustment, shipping_fee = :shipping_fee, shipping_tracking_number = :shipping_tracking_number, grand_total = :grand_total, notes = :notes, status = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => $department_id, ':order_date' => $data['order_date'] ?? date('Y-m-d'), ':subtotal' => $subtotal, ':discount' => $discount, ':tax' => $tax, ':tax_adjustment' => $tax_adjustment, ':shipping_fee' => $shipping_fee, ':shipping_tracking_number' => $tracking_no, ':grand_total' => $grand_total, ':notes' => $data['notes'] ?? '', ':status' => $new_status, ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); $this->syncOrderItems($id, $items); return 0; } else { $log = [$logging]; $source = trim((string)($data['source'] ?? '')); $source_id = (int)($data['source_id'] ?? 0); // Direct WMS orders start as Draft; sourced orders wait for WMS assignment. $init_status = $source !== '' ? -2 : 0; $this->pdo->prepare( "INSERT INTO td_order (company_id, uuid, source_id, `source`, order_number, contact_id, department_id, order_date, status, subtotal, discount, tax, tax_adjustment, shipping_fee, shipping_tracking_number, grand_total, notes, `log`, created_at) VALUES (:company_id, :uuid, :source_id, :source, :order_number, :contact_id, :department_id, :order_date, :status, :subtotal, :discount, :tax, :tax_adjustment, :shipping_fee, :shipping_tracking_number, :grand_total, :notes, :log, :created_at)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, ':source' => $source, ':order_number' => $this->generateOrderNumber($data), ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':order_date' => $data['order_date'] ?? date('Y-m-d'), ':status' => $init_status, ':subtotal' => $subtotal, ':discount' => $discount, ':tax' => $tax, ':tax_adjustment' => $tax_adjustment, ':shipping_fee' => $shipping_fee, ':shipping_tracking_number' => $tracking_no, ':grand_total' => $grand_total, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), ':created_at' => date('Y-m-d H:i:s'), ]); $new_id = (int)$this->pdo->lastInsertId(); $this->syncOrderItems($new_id, $items); return $new_id; } } /** * Confirm a draft order — create stock-out rows (status=0) for each item * using FIFO bin selection, then advance the order to status=1. * * Flow per item: * 1. pickFifoRack() — find oldest bin-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). * * Bin 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 bin 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."); } $sth2 = $this->pdo->prepare( "SELECT * FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); $items = $sth2->fetchAll(PDO::FETCH_ASSOC); if (empty($items)) { throw new Exception("Cannot confirm an order with no items."); } // ── Pre-flight: validate ALL items before creating any rows ─────── $preflight_errors = []; foreach ($items as $i => $item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); $product_sku = $item['product_sku'] ?? ''; $quantity = (float)($item['quantity'] ?? 0); $name = $item['product_name'] ?? $product_sku; if (!$warehouse_id || !$product_sku) { $preflight_errors[] = "Item #{$i}: missing warehouse or SKU."; continue; } if ($quantity <= 0) { $preflight_errors[] = "Item #{$i}: quantity must be greater than zero."; continue; } $bin_stock = $this->pickFifoRack($warehouse_id, $product_sku); if (!$bin_stock) { $preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse."; continue; } $available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0); if ($quantity - $available_qty > 0.000001) { $preflight_errors[] = "Insufficient stock for \"{$name}\": " . "{$available_qty} available, {$quantity} requested."; } $expiry = $bin_stock['expiry_date'] ?? null; if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) { $lot = $bin_stock['lot_number'] ?? ''; $preflight_errors[] = "Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked."; } } if (!empty($preflight_errors)) { throw new Exception(implode("\n", $preflight_errors)); } // Validate all warehouse IDs in one query before entering the item loop. $itemWarehouseIds = array_values(array_unique(array_filter( array_map(fn($item) => (int)($item['warehouse_id'] ?? 0), $items), fn($id) => $id > 0 ))); if (!empty($itemWarehouseIds)) { $placeholders = implode(',', array_fill(0, count($itemWarehouseIds), '?')); $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = ? AND id IN ($placeholders)" ); $sth->execute([$this->company_id, ...$itemWarehouseIds]); $validWarehouseIds = array_flip($sth->fetchAll(PDO::FETCH_COLUMN)); } else { $validWarehouseIds = []; } foreach ($items as $i => &$item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); $product_sku = $item['product_sku'] ?? ''; $quantity = (float)($item['quantity'] ?? 0); $bin_stock = $this->pickFifoRack($warehouse_id, $product_sku); $available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0); if (!isset($validWarehouseIds[$warehouse_id])) { throw new Exception("Warehouse ID {$warehouse_id} not found."); } $table = 'td_stock_' . $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, bin, 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, :bin, :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' => $quantity, ':zone' => $bin_stock['zone'], ':aisle' => $bin_stock['aisle'], ':bin' => $bin_stock['bin'], ':contact_id' => (int)$order['contact_id'], ':description' => $order['order_number'], ':log' => json_encode($item_log), ':ref_id' => (int)$bin_stock['id'], ':lot_number' => $bin_stock['lot_number'] ?? '', ':serial_number' => $bin_stock['serial_number'] ?? '', ':source_id' => $order_id, ':price' => (float)($item['unit_price'] ?? 0), ]); // Write bin context + stock_out_id back into the item object $item['zone'] = $bin_stock['zone']; $item['aisle'] = $bin_stock['aisle']; $item['bin'] = $bin_stock['bin']; $item['lot_number'] = $bin_stock['lot_number'] ?? ''; $item['serial_number'] = $bin_stock['serial_number'] ?? ''; $item['stock_out_id'] = (int)$this->pdo->lastInsertId(); // Auto-approve: immediately release bin + 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: status=1 ─────────────────────────────────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'confirm']); $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, ]); $upd = $this->pdo->prepare( "UPDATE td_order_item SET stock_out_id = :stock_out_id, lot_number = :lot_number, warehouse_id = :warehouse_id WHERE order_id = :order_id AND item_id = :item_id AND company_id = :company_id" ); foreach ($items as $item) { $upd->execute([ ':stock_out_id' => (int)($item['stock_out_id'] ?? 0), ':lot_number' => $item['lot_number'] ?? '', ':warehouse_id' => (int)($item['warehouse_id'] ?? 0), ':order_id' => $order_id, ':item_id' => (int)($item['item_id'] ?? 0), ':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. For invoices, active means not voided * (status != 4). For returns, active means not cancelled (status != -1). * * Downstream documents that block cancellation: * - td_invoice where order_id = $order_id AND status != 4 * - td_return where order_id = $order_id AND status != -1 * * Stock-out rows are children of the order — they follow the parent. * On cancel, approved rows first re-occupy the original source bin and * reverse the stock-out balance. Then 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."); } // Pending orders (-2) have no stock rows — skip the stock reversal loop below // ── 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 != 4" ); $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." ); } // ── Reverse approved stock-out side effects, then soft-delete rows ─ $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $tables = $whMgmt->getStockTables(); foreach ($tables as $table) { if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) { continue; } $warehouse_id = (int)$matches[1]; $row_sth = $this->pdo->prepare( "SELECT id, product_sku, `out`, zone, aisle, bin, ref_id, status, `date` FROM `{$table}` WHERE company_id = :company_id AND source = 'order' AND source_id = :order_id AND type = 'out' AND status != -1" ); $row_sth->execute([ ':company_id' => $this->company_id, ':order_id' => $order_id, ]); $rows = $row_sth->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $row) { if ((int)$row['status'] !== 1) { continue; } $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation'); $whMgmt->occupyBin( $warehouse_id, (string)$row['zone'], (string)$row['aisle'], (string)$row['bin'], (string)$row['product_sku'], (int)$row['ref_id'] ); $whMgmt->adjustBalance( 'out', $warehouse_id, (string)$row['product_sku'], (float)$row['out'], 0, (int)$row['id'], 'order', $order_id ); } $this->pdo->prepare( "UPDATE `{$table}` 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, ]); } // ── Release converted_qty on source quotation ───────────────────────── $source = (string)($order['source'] ?? ''); $source_id = (int)($order['source_id'] ?? 0); if ($source === 'quotation' && $source_id > 0) { $sth2 = $this->pdo->prepare( "SELECT item_id, quantity FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); $items = $sth2->fetchAll(PDO::FETCH_ASSOC); $dec = $this->pdo->prepare( "UPDATE td_quotation_item SET converted_qty = GREATEST(0, converted_qty - :qty) WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid" ); foreach ($items as $item) { $dec->execute([ ':qty' => (float)($item['quantity'] ?? 0), ':qid' => $source_id, ':item_id' => (int)($item['item_id'] ?? 0), ':cid' => $this->company_id, ]); } // Revert quotation from Converted back to Accepted if it was fully done $this->pdo->prepare( "UPDATE td_quotation SET status = 2 WHERE id = :id AND company_id = :cid AND status = 5" )->execute([':id' => $source_id, ':cid' => $this->company_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, ]); } /** * Soft-delete an order by negating company_id on the header, items, and all * linked stock-out rows. Approved stock-out side effects are reversed first. * Blocked if any invoice or return (not yet soft-deleted) exists for this order. */ public function softDelete(int $id): void { $sth = $this->pdo->prepare( "SELECT * FROM td_order WHERE company_id = :cid AND id = :id" ); $sth->execute([':cid' => $this->company_id, ':id' => $id]); $order = $sth->fetch(PDO::FETCH_ASSOC); if (!$order) throw new Exception('Sales order not found.'); global $pdo1; if (isset($pdo1) && $pdo1 instanceof PDO) { $guard = new PostingWindowGuard($pdo1, $this->company_id); $guard->assertOpenDate($order['order_date'] ?: date('Y-m-d'), 'Order deletion'); } $sth2 = $this->pdo->prepare( "SELECT COUNT(*) FROM td_invoice WHERE company_id = :cid AND order_id = :id" ); $sth2->execute([':cid' => $this->company_id, ':id' => $id]); if ((int)$sth2->fetchColumn() > 0) { throw new Exception('Cannot delete — this order has linked invoices. Delete the invoices first.'); } $sth3 = $this->pdo->prepare( "SELECT COUNT(*) FROM td_return WHERE company_id = :cid AND order_id = :id" ); $sth3->execute([':cid' => $this->company_id, ':id' => $id]); if ((int)$sth3->fetchColumn() > 0) { throw new Exception('Cannot delete — this order has linked returns. Delete the returns first.'); } // Block if any approved stock-out rows exist; user must reverse via ICS first $tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables(); foreach ($tables as $table) { $chk = $this->pdo->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status = 1" ); $chk->execute([':cid' => $this->company_id, ':id' => $id]); if ((int)$chk->fetchColumn() > 0) { throw new Exception('Cannot delete — stock-out for this order has been approved. Reverse via ICS first.'); } } // Negate draft/pending stock-out rows (status != 1) foreach ($tables as $table) { $this->pdo->prepare( "UPDATE `{$table}` SET company_id = company_id * -1 WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status != 1" )->execute([':cid' => $this->company_id, ':id' => $id]); } // Release converted_qty on source quotation $source = (string)($order['source'] ?? ''); $source_id = (int)($order['source_id'] ?? 0); if ($source === 'quotation' && $source_id > 0) { $sth5 = $this->pdo->prepare( "SELECT item_id, quantity FROM td_order_item WHERE order_id = :id AND company_id = :cid ORDER BY item_id" ); $sth5->execute([':id' => $id, ':cid' => $this->company_id]); $items = $sth5->fetchAll(PDO::FETCH_ASSOC); $dec = $this->pdo->prepare( "UPDATE td_quotation_item SET converted_qty = GREATEST(0, converted_qty - :qty) WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid" ); foreach ($items as $item) { $dec->execute([ ':qty' => (float)($item['quantity'] ?? 0), ':qid' => $source_id, ':item_id' => (int)($item['item_id'] ?? 0), ':cid' => $this->company_id, ]); } $this->pdo->prepare( "UPDATE td_quotation SET status = 2 WHERE id = :id AND company_id = :cid AND status = 5" )->execute([':id' => $source_id, ':cid' => $this->company_id]); } $this->pdo->prepare( "UPDATE td_order_item SET company_id = company_id * -1 WHERE order_id = :id AND company_id = :cid" )->execute([':id' => $id, ':cid' => $this->company_id]); $this->pdo->prepare( "UPDATE td_order SET company_id = company_id * -1 WHERE id = :id AND company_id = :cid" )->execute([':id' => $id, ':cid' => $this->company_id]); } /** * Update shipping tracking number. Fulfillment itself is derived from * linked stock-out rows and tracking, not selected manually. */ public function updateShippingTracking( int $order_id, 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_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 adding a tracking number."); } if ($tracking_number === $current_tracking) { throw new Exception("No tracking changes to save."); } // ── Persist ─────────────────────────────────────────────────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, [ 'action' => 'update_tracking', 'shipping_tracking_number' => $tracking_number, ]); $this->pdo->prepare( "UPDATE td_order SET shipping_tracking_number = :shipping_tracking_number, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':shipping_tracking_number' => $tracking_number, ':log' => json_encode($log), ':id' => $order_id, ':company_id' => $this->company_id, ]); } }