pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── 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; } /** * Round a quantity to the stored scale and reject values that cannot be * represented. * * A positive input that rounds to zero is a mistake worth naming — the * caller asked to move some stock and would otherwise get a zero-quantity * movement that looks successful and reports as "0.00" everywhere. * * @param mixed $value Raw client input. * @param string $label Field name used in the error message. * @return float Quantity rounded to QTY_SCALE. * @throws Exception When the value is not a usable quantity. */ public static function normaliseQuantity($value, string $label = 'Quantity'): float { $raw = (float)$value; if ($raw <= 0) { throw new Exception("{$label} must be greater than zero."); } $rounded = round($raw, self::QTY_SCALE); if ($rounded < self::QTY_MIN) { throw new Exception( "{$label} of {$raw} is smaller than the minimum the system records (" . rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")." ); } return $rounded; } private function stockReferenceSql(): string { return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))"; } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── /** * Return all stock records of a given movement type for a warehouse. * * Used to populate the stock in / stock out / transfer listing pages. * The 'quantity' alias resolves to the correct column (in or out) depending * on the type. For transfers, only the outbound row is listed (out > 0). * * @param int $warehouse_id The md_warehouse.id to query, or 0 for every * warehouse of this company. * @param string $type Movement type: 'in' | 'out' | 'transfer'. * @return array Stock rows ordered by date DESC, each with 'quantity', * 'product_name', 'warehouse_id' and 'warehouse_name'. */ public function getStockList(int $warehouse_id, string $type): array { // warehouse_id 0 = every warehouse. Stock lives in one table per // warehouse, so a single-warehouse list hides the rest of a receipt // that was split across warehouses — a 4-line PO received into two of // them looked like only 3 lines had been received. $warehouses = $warehouse_id > 0 ? [$warehouse_id] : $this->warehouseIdsWithStockTable(); // The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0). // Quantity is NOT rounded for display here: rounding to 2 dp reports a // small-but-real quantity as "0.00", which reads as missing data. $column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in'; $stock_ref = $this->stockReferenceSql(); // Transfer list: show only the outbound side (out > 0) to avoid duplicate display $extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : ''; $rows = []; foreach ($warehouses as $wh_id) { $table = $this->stockTableNameFromWarehouseId($wh_id); $sth = $this->pdo->prepare( "SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom, w.warehouse_name FROM `{$table}` a LEFT JOIN md_product b ON a.company_id = b.company_id AND a.product_sku = b.sku LEFT JOIN md_warehouse w ON w.company_id = a.company_id AND w.id = :warehouse_id WHERE a.company_id = :company_id AND a.type = :type {$extra_cond} ORDER BY a.date DESC" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse_id' => $wh_id, ':type' => $type, ]); foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { // The row's own warehouse, so the list can link each Action // back to the right td_stock_ table when showing them all. $row['warehouse_id'] = $wh_id; $rows[] = $row; } } // Re-sort across warehouses — each table was only ordered internally. usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? ''))); return $rows; } /** * Warehouse ids of this company that actually have a stock table. * * td_stock_ tables are created lazily on first use, so a warehouse with * no movements yet has none and must be skipped rather than queried. * * @return int[] */ private function warehouseIdsWithStockTable(): array { $sth = $this->pdo->prepare( "SELECT w.id FROM md_warehouse w JOIN information_schema.tables t ON t.table_schema = DATABASE() AND t.table_name = CONCAT('td_stock_', w.id) WHERE w.company_id = :company_id ORDER BY w.id" ); $sth->execute([':company_id' => $this->company_id]); return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN)); } /** * Fetch a single stock_in record with related product, contact, and lot data. * * Used to pre-fill the manage stock in form in edit mode and for the * stock in detail view. Joins md_lot to include lot expiry_date when available. * * @param int $warehouse_id The warehouse the stock_in belongs to. * @param int $id The td_stock_.id of the stock_in row. * @return array|false Full row with 'quantity', 'contact_name', 'product_name', * 'expiry_date', or false if not found. */ public function getStockInById(int $warehouse_id, int $id): array|false { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $stock_ref = $this->stockReferenceSql(); $sth = $this->pdo->prepare( "SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity, b.contact_name, c.product_name, c.uom, d.expiry_date FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = c.company_id AND a.product_sku = c.sku LEFT JOIN md_lot d ON a.company_id = d.company_id AND a.product_sku = d.product_sku AND a.lot_number = d.lot_number WHERE a.company_id = :company_id AND a.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Fetch a single stock_out record with related product and contact data. * * Used to pre-fill the manage stock out form in edit mode and for the * stock out detail view. * * @param int $warehouse_id The warehouse the stock_out belongs to. * @param int $id The td_stock_.id of the stock_out row. * @return array|false Full row with 'quantity', 'contact_name', 'product_name', * or false if not found. */ public function getStockOutById(int $warehouse_id, int $id): array|false { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $stock_ref = $this->stockReferenceSql(); $sth = $this->pdo->prepare( "SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity, b.contact_name, c.product_name, c.uom FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = c.company_id AND a.product_sku = c.sku WHERE a.company_id = :company_id AND a.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Fetch a transfer record pair — the outbound (from) row and its paired * inbound (to) row — as a single structure. * * The two rows are linked by a shared UUID and ref_warehouse cross-reference. * The inbound row is returned under the 'ref' key of the outbound row. * This is used by the manage stock transfer form in edit mode and the * transfer detail view. * * @param int $warehouse_id The warehouse holding the outbound (from) row. * @param int $id The td_stock_.id of the outbound transfer row. * @return array|false Outbound row with 'quantity', 'contact_name', 'product_name', * and a 'ref' key containing the inbound row, or false if not found. */ public function getTransferById(int $warehouse_id, int $id): array|false { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $stock_ref = $this->stockReferenceSql(); // Fetch the outbound (from) row $sth = $this->pdo->prepare( "SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity, b.contact_name, c.product_name, c.uom FROM `{$table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = c.company_id AND a.product_sku = c.sku WHERE a.company_id = :company_id AND a.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $output = $sth->fetch(PDO::FETCH_ASSOC); if (!$output) return false; // Resolve the inbound (to) row via ref_warehouse + uuid $to_warehouse_id = (int)$output['ref_warehouse']; $to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id); $sth = $this->pdo->prepare( "SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity, b.contact_name, c.product_name, c.uom FROM `{$to_table}` a LEFT JOIN md_contact b ON a.company_id = b.company_id AND a.contact_id = b.id LEFT JOIN md_product c ON a.company_id = b.company_id AND a.product_sku = c.sku WHERE a.company_id = :company_id AND a.uuid = :uuid" ); $sth->execute([':company_id' => $this->company_id, ':uuid' => $output['uuid']]); $output['ref'] = $sth->fetch(PDO::FETCH_ASSOC); return $output; } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── /** * Insert a new stock_in record or update metadata on an existing one. * * Insert flow (id = 0): * 1. Upserts md_lot if lot_number + expiry_date are provided. * 2. Inserts the td_stock_ row. * 3. Calls WarehouseManager::occupyBin() to reserve the bin. * etl_stock_summary is updated later by approveStock(). * * Update flow (id > 0): * - Updates contact_id, description, and log only. * - Quantity, bin, lot, and serial are immutable after creation. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, bin, * contact_id, description, lot_number, expiry_date, serial_number. * @param array $logging Audit entry to append to the log column. * @param string $uuid UUID for this transaction (shared across transfer pairs). */ public function saveStockIn(array $data, array $logging, string $uuid): int { $id = (int)($data['id'] ?? 0); $warehouse_id = (int)($data["warehouse"] ?? 0); $quantity = (float)($data['quantity'] ?? 0); if ($id === 0) { $quantity = self::normaliseQuantity($quantity); } $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); $row = $ctx['row'] ?? []; $table = $ctx['table']; $raw_log = $row['log'] ?? []; $table_log = is_array($raw_log) ? $raw_log : (json_decode($raw_log, true) ?: []); $table_log[] = $logging; if ($id > 0) { if ((int)($row['status'] ?? 0) === 1) { $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-in edit'); } // Update: only metadata fields are editable after creation $this->pdo->prepare( "UPDATE `$table` SET `contact_id` = :contact_id, `description` = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':id' => $id, ':company_id' => $this->company_id, ':contact_id' => (int)($data['contact_id'] ?? 0), ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ]); return 0; // update — no new row } else { $lot_number = trim((string)($data["lot_number"] ?? "")); $expiry_date = trim((string)($data["expiry_date"] ?? "")); // Upsert md_lot: preserve existing expiry_date if already recorded if ($lot_number && $expiry_date) { $this->pdo->prepare( "INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date) VALUES (:company_id, :product_sku, :lot_number, :expiry_date) ON DUPLICATE KEY UPDATE expiry_date = expiry_date" )->execute([ ':company_id' => $this->company_id, ':product_sku' => $data['product_sku'], ':lot_number' => $lot_number, ':expiry_date' => $expiry_date, ]); } $this->pdo->prepare( "INSERT INTO `$table` (uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, bin, contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :bin, :contact_id, :description, :log, 'in', :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':price' => (float)($data['price'] ?? 0), ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':bin' => $data['bin'], ':contact_id' => (int)($data['contact_id'] ?? 0), ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ':lot_number' => $lot_number, ':serial_number' => trim((string)($data["serial_number"] ?? "")), ]); $td_stock_id = (int)$this->pdo->lastInsertId(); // Reserve the location immediately so draft stock-in rows cannot // leave the same bin available for another receipt. Balance still // changes only when approveStock() runs. $whMgmt->occupyBin( $warehouse_id, $data['zone'], $data['aisle'], $data['bin'], $data['product_sku'], $td_stock_id ); return $td_stock_id; } } /** * Insert a new stock_out record or update metadata on an existing one. * * Insert flow (id = 0): * 1. Validates the bin is occupied with the correct SKU / lot / serial. * 2. Inserts the td_stock_ row, copying quantity and lot info from the bin. * 3. Calls WarehouseManager::releaseBin() to free the bin slot. * 4. Calls WarehouseManager::adjustBalance() to update etl_stock_summary. * * Update flow (id > 0): * - Updates contact_id, description, and log only. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, warehouse, product_sku, zone, aisle, bin, * contact_id, description, lot_number, serial_number. * @param array $logging Audit entry to append to the log column. * @param string $uuid UUID for this transaction. * @throws Exception If the bin is empty, holds a different SKU/lot/serial. */ public function saveStockOut(array $data, array $logging, string $uuid): int { $id = (int)($data['id'] ?? 0); $warehouse_id = (int)($data['warehouse'] ?? 0); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); $row = $ctx['row'] ?? []; $table = $ctx['table']; $raw_log = $row['log'] ?? []; $table_log = is_array($raw_log) ? $raw_log : (json_decode($raw_log, true) ?: []); $table_log[] = $logging; if ($id > 0) { if ((int)($row['status'] ?? 0) === 1) { $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-out edit'); } // Update: only metadata fields are editable after creation $this->pdo->prepare( "UPDATE `$table` SET `contact_id` = :contact_id, `description` = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':id' => $id, ':company_id' => $this->company_id, ':contact_id' => (int)($data['contact_id'] ?? 0), ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ]); return 0; // update — no new row } else { // Validate bin holds the expected product / lot / serial $source_stock = $whMgmt->getBinStock( $warehouse_id, $data['zone'], $data['aisle'], $data['bin'] ); if (!$source_stock) { throw new Exception( "Bin {$data['zone']}-{$data['aisle']}-{$data['bin']} is empty — nothing to take out." ); } if ($source_stock['product_sku'] !== $data['product_sku']) { throw new Exception( "Bin holds {$source_stock['product_sku']}, not {$data['product_sku']}." ); } if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) { throw new Exception( "Bin holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'." ); } if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) { throw new Exception( "Bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'." ); } // Quantity and identifiers come from the existing stock_in row (immutable). // `in` is decimal(18,4) — an int cast would drop fractional quantities. $quantity = (float)$source_stock['in']; $ref_id = (int)$source_stock['id']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; $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, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :bin, :contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':bin' => $data['bin'], ':contact_id' => (int)($data['contact_id'] ?? 0), ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ':ref_id' => $ref_id, ':lot_number' => $lot_number, ':serial_number' => $serial_number, ]); $out_stock_id = (int)$this->pdo->lastInsertId(); // releaseBin and adjustBalance deferred — called from approveStock() only. return $out_stock_id; } } /** * Insert a new stock transfer pair or update metadata on an existing one. * * A transfer creates two linked td_stock rows — an outbound row in the * source warehouse and an inbound row in the destination warehouse — both * sharing the same UUID and cross-referencing each other via ref_id. * * Insert flow (id = 0): * 1. Validates the source bin holds the correct SKU / lot / serial. * 2. Inserts the outbound row in td_stock_. * 3. Inserts the inbound row in td_stock_ with ref_id pointing to from. * 4. Back-fills ref_id on the from row so both point at each other. * 5. Releases the source bin, occupies the destination bin. * 6. Adjusts balance on both warehouses (out from source, in to dest). * * Update flow (id > 0): * - Updates contact_id, description, and log on BOTH rows. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, warehouse_from, warehouse_to, product_sku, * zone_from, aisle_from, bin_from, zone_to, aisle_to, bin_to, * contact_id, description, lot_number, serial_number. * @param array $logging Audit entry to append to the log column on both rows. * @param string $uuid UUID shared by both the from and to rows. * @throws Exception If source bin validation fails or paired record is missing on update. */ public function saveStockTransfer(array $data, array $logging, string $uuid): int { $id = (int)($data['id'] ?? 0); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $contact_id = (int)($data['contact_id'] ?? 0); $description = ($data['description'] ?? '' ?? ''); if ($id > 0) { // Update: patch metadata on both the from and to rows $from_warehouse = (int)($data['warehouse_from'] ?? 0); $to_warehouse = (int)($data['warehouse_to'] ?? 0); $from_ctx = $whMgmt->getStockContext($from_warehouse, $id); $from_row = $from_ctx['row']; if (!$from_row) { throw new Exception("Transfer record not found."); } $to_id = (int)$from_row['ref_id']; $to_ctx = $whMgmt->getStockContext($to_warehouse, $to_id); $to_row = $to_ctx['row']; if (!$to_row) { throw new Exception("Paired destination record missing — data integrity issue."); } if ((int)($from_row['status'] ?? 0) === 1 || (int)($to_row['status'] ?? 0) === 1) { $whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer edit'); $whMgmt->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer edit'); } $raw_from = $from_row['log'] ?? []; $from_log = is_array($raw_from) ? $raw_from : (json_decode($raw_from, true) ?: []); $from_log[] = $logging; $raw_to = $to_row['log'] ?? []; $to_log = is_array($raw_to) ? $raw_to : (json_decode($raw_to, true) ?: []); $to_log[] = $logging; $this->pdo->prepare( "UPDATE `{$from_ctx['table']}` SET contact_id = :contact_id, description = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($from_log), ':id' => $id, ':company_id' => $this->company_id, ]); $this->pdo->prepare( "UPDATE `{$to_ctx['table']}` SET contact_id = :contact_id, description = :description, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($to_log), ':id' => $to_id, ':company_id' => $this->company_id, ]); return 0; // update — no new row } // Insert: validate source bin, then create paired rows $from_warehouse = (int)$data['warehouse_from']; $from_zone = $data['zone_from']; $from_aisle = $data['aisle_from']; $from_bin = $data['bin_from']; $to_warehouse = (int)$data['warehouse_to']; $to_zone = $data['zone_to']; $to_aisle = $data['aisle_to']; $to_bin = $data['bin_to']; $source_stock = $whMgmt->getBinStock( $from_warehouse, $from_zone, $from_aisle, $from_bin ); if (!$source_stock) { throw new Exception("Source bin {$from_zone}-{$from_aisle}-{$from_bin} is empty."); } if ($source_stock['product_sku'] !== $data['product_sku']) { throw new Exception( "Source bin holds {$source_stock['product_sku']}, not {$data['product_sku']}." ); } if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) { throw new Exception( "Source bin holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'." ); } if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) { throw new Exception( "Source bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'." ); } // Quantity and identifiers come from the source stock_in row (immutable). // `in` is decimal(18,4) — an int cast would drop fractional quantities. $quantity = (float)$source_stock['in']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; $from_table = $whMgmt->getStockContext($from_warehouse, 0)['table']; $to_table = $whMgmt->getStockContext($to_warehouse, 0)['table']; $table_log = [$logging]; // Insert outbound row (from warehouse) $this->pdo->prepare( "INSERT INTO `$from_table` (uuid, company_id, `date`, product_sku, `out`, ref_warehouse, zone, aisle, bin, contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :ref_warehouse, :zone, :aisle, :bin, :contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':ref_warehouse' => $to_warehouse, ':zone' => $from_zone, ':aisle' => $from_aisle, ':bin' => $from_bin, ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($table_log), ':lot_number' => $lot_number, ':serial_number' => $serial_number, ]); $from_stock_id = (int)$this->pdo->lastInsertId(); // Insert inbound row (to warehouse) $this->pdo->prepare( "INSERT INTO `$to_table` (uuid, company_id, `date`, product_sku, `in`, ref_warehouse, ref_id, zone, aisle, bin, contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at) VALUES (:uuid, :company_id, :date, :product_sku, :quantity, :ref_warehouse, :ref_id, :zone, :aisle, :bin, :contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())" )->execute([ ':uuid' => $uuid, ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], ':quantity' => $quantity, ':ref_warehouse' => $from_warehouse, ':ref_id' => $from_stock_id, ':zone' => $to_zone, ':aisle' => $to_aisle, ':bin' => $to_bin, ':contact_id' => $contact_id, ':description' => $description, ':log' => json_encode($table_log), ':lot_number' => $lot_number, ':serial_number' => $serial_number, ]); $to_stock_id = (int)$this->pdo->lastInsertId(); // Back-fill ref_id on the from row so both rows cross-reference each other $this->pdo->prepare( "UPDATE `$from_table` SET ref_id = :ref_id WHERE id = :id AND company_id = :company_id" )->execute([ ':ref_id' => $to_stock_id, ':id' => $from_stock_id, ':company_id' => $this->company_id, ]); // releaseBin, occupyBin and adjustBalance deferred — called from approveStock() only. return $from_stock_id; } // ───────────────────────────────────────────────────────────── // Approve // ───────────────────────────────────────────────────────────── /** * Approve a draft td_stock row (status 0 → 1) and update etl_stock_summary. * * This is the single entry point for balance updates — both auto-approve * (called immediately after save) and manual approve go through here. * adjustBalance() is never called from anywhere else. * * For transfer rows, both the outbound (from) and inbound (to) rows share * the same uuid. We approve both in one call so the pair is always consistent. * * @param int $id The td_stock row id. * @param int $warehouse_id The warehouse the row belongs to. * @param string $type 'in' | 'out' | 'transfer' * @param WarehouseManager $whMgmt Injected to keep balance logic centralised. * @throws Exception If the row is not found, already approved, or wrong company. */ public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void { $table = $this->stockTableNameFromWarehouseId($warehouse_id); // Load the row and validate ownership / status $sth = $this->pdo->prepare( "SELECT * FROM `{$table}` WHERE id = :id AND company_id = :company_id LIMIT 1" ); $sth->execute([':id' => $id, ':company_id' => $this->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception('Stock record not found.'); } if ((int)$row['status'] === 1) { throw new Exception('Already approved.'); } $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock approval'); // ── Approve this row ────────────────────────────────────────────── $this->pdo->prepare( "UPDATE `{$table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id" )->execute([':id' => $id, ':company_id' => $this->company_id]); // ── Bin state + balance ────────────────────────────────────────── if ($type === 'in') { // Occupy bin now that stock is approved $whMgmt->occupyBin( $warehouse_id, $row['zone'], $row['aisle'], $row['bin'], $row['product_sku'], $id ); $whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'], $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? ''); } elseif ($type === 'out') { $remaining_qty = 0.0; if ((int)($row['ref_id'] ?? 0) > 0) { $remaining_sth = $this->pdo->prepare( "SELECT src.`in` - COALESCE(SUM(out_rows.`out`), 0) AS remaining_qty FROM `{$table}` src LEFT JOIN `{$table}` out_rows ON out_rows.company_id = src.company_id AND out_rows.ref_id = src.id AND out_rows.`out` > 0 AND out_rows.status = 1 WHERE src.id = :ref_id AND src.company_id = :company_id GROUP BY src.id, src.`in`" ); $remaining_sth->execute([ ':ref_id' => (int)$row['ref_id'], ':company_id' => $this->company_id, ]); $remaining_qty = (float)$remaining_sth->fetchColumn(); } // Release the bin only when the source batch is fully consumed. if ($remaining_qty <= 0.000001) { $whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']); } $whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'], $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? ''); } elseif ($type === 'transfer') { // Transfer always has two rows in two different tables: // outbound row → td_stock_ (out > 0, ref_warehouse = to_id) // inbound row → td_stock_ (in > 0, ref_warehouse = from_id) // Both rows share the same uuid. We always approve both atomically. // Identify which side we were given and derive the other. $is_outbound = (float)$row['out'] > 0; // The outbound row lives in the from-warehouse table (already loaded as $row/$table). // The inbound row lives in td_stock_. $from_row = $is_outbound ? $row : null; $from_table = $is_outbound ? $table : null; $from_wh_id = $is_outbound ? $warehouse_id : null; // Resolve the paired table from ref_warehouse id $paired_wh_id = (int)$row['ref_warehouse']; $paired_table = $this->stockTableNameFromWarehouseId($paired_wh_id); // If we received the inbound side, swap so $from_* is always outbound if (!$is_outbound) { $from_table = $paired_table; $from_wh_id = $paired_wh_id; $paired_table = $table; $paired_wh_id = $warehouse_id; } // Load the inbound row from the paired table using uuid $sth = $this->pdo->prepare( "SELECT * FROM `{$paired_table}` WHERE uuid = :uuid AND company_id = :company_id AND `in` > 0 LIMIT 1" ); $sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]); $inbound_row = $sth->fetch(PDO::FETCH_ASSOC); // Load outbound row if we were given the inbound side if (!$is_outbound) { $sth = $this->pdo->prepare( "SELECT * FROM `{$from_table}` WHERE uuid = :uuid AND company_id = :company_id AND `out` > 0 LIMIT 1" ); $sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]); $from_row = $sth->fetch(PDO::FETCH_ASSOC); } else { $from_row = $row; } if ($from_row) { $whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer approval'); } if ($inbound_row) { $whMgmt->assertStockMovementWindow($inbound_row['date'] ?? null, 'Stock transfer approval'); } // Approve outbound row if ($from_row && (int)$from_row['status'] === 0) { $this->pdo->prepare( "UPDATE `{$from_table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id" )->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]); } // Approve inbound row if ($inbound_row && (int)$inbound_row['status'] === 0) { $this->pdo->prepare( "UPDATE `{$paired_table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id" )->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]); } // Bin state: release source, occupy destination if ($from_row && $from_wh_id) { $whMgmt->releaseBin( $from_wh_id, $from_row['zone'], $from_row['aisle'], $from_row['bin'] ); $whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'], (int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0), $from_row['date'] ?? ''); } if ($inbound_row && $paired_wh_id) { $whMgmt->occupyBin( $paired_wh_id, $inbound_row['zone'], $inbound_row['aisle'], $inbound_row['bin'], $inbound_row['product_sku'], $inbound_row['id'] ); $whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'], (int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0), $inbound_row['date'] ?? ''); } } $notify_company_id = $this->company_id; db_after_commit(function() use ($warehouse_id, $type, $notify_company_id) { notify_node("stock_updated", ["warehouse_id" => $warehouse_id, "type" => $type], $notify_company_id); }); } }