query( 'SELECT p.*,COALESCE(SUM(b.quantity),0) stock,(SELECT MIN(expires_on) FROM inventory_batches x WHERE x.product_id=p.id AND x.quantity>0 AND x.expires_on IS NOT NULL) next_expiry FROM inventory_products p LEFT JOIN inventory_batches b ON b.product_id=p.id GROUP BY p.id ORDER BY p.category,p.name COLLATE NOCASE', ) ->fetchAll(PDO::FETCH_ASSOC); } public static function batches(): array { return DB::pdo() ->query( 'SELECT b.*,p.name product_name,p.unit,p.category,dc.name supplier_name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id LEFT JOIN directory_contacts dc ON dc.id=b.supplier_contact_id ORDER BY b.quantity>0 DESC,date(b.expires_on),p.name COLLATE NOCASE', ) ->fetchAll(PDO::FETCH_ASSOC); } public static function movements(int $limit = 150): array { $limit = max(1, min(500, $limit)); return DB::pdo() ->query( "SELECT m.*,p.name product_name,p.unit,b.batch_number,a.name animal_name,COALESCE(u.display_name,u.username) actor FROM inventory_movements m JOIN inventory_products p ON p.id=m.product_id LEFT JOIN inventory_batches b ON b.id=m.batch_id LEFT JOIN animals a ON a.id=m.animal_id LEFT JOIN users u ON u.id=m.created_by ORDER BY datetime(m.occurred_at) DESC,m.id DESC LIMIT $limit", ) ->fetchAll(PDO::FETCH_ASSOC); } public static function saveProduct(array $data): int { $category = (string) ($data['category'] ?? ''); $name = trim((string) ($data['name'] ?? '')); $unit = trim((string) ($data['unit'] ?? '')); if (!in_array($category, ['medication', 'dewormer', 'vaccine'], true) || $name === '' || $unit === '') { throw new RuntimeException(t('inventory.invalid_product')); } $reference = (int) ($data['reference_id'] ?? 0) ?: null; $db = DB::pdo(); if (!$reference) { $table = match ($category) { 'medication' => 'ref_medications', 'dewormer' => 'ref_dewormers', 'vaccine' => 'ref_vaccines', }; $lookup = $db->prepare("SELECT id FROM $table WHERE name=? COLLATE NOCASE LIMIT 1"); $lookup->execute([$name]); $reference = (int) $lookup->fetchColumn() ?: null; } $minimum = max(0, (float) str_replace(',', '.', (string) ($data['minimum_quantity'] ?? 0))); $s = $db->prepare( 'INSERT INTO inventory_products(category,reference_id,name,unit,minimum_quantity) VALUES(?,?,?,?,?)', ); $s->execute([$category, $reference, mb_substr($name, 0, 150), mb_substr($unit, 0, 40), $minimum]); return (int) $db->lastInsertId(); } public static function receive(array $data): int { $product = (int) ($data['product_id'] ?? 0); $quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0)); if (!$product || $quantity <= 0) { throw new RuntimeException(t('inventory.invalid_quantity')); } $expiry = trim((string) ($data['expires_on'] ?? '')); if ($expiry !== '' && !self::date($expiry)) { throw new RuntimeException(t('inventory.invalid_date')); } $cost = trim((string) ($data['unit_cost'] ?? '')); $cents = $cost === '' ? null : (int) round((float) str_replace(',', '.', $cost) * 100); $db = DB::pdo(); $db->beginTransaction(); try { $s = $db->prepare( 'INSERT INTO inventory_batches(product_id,batch_number,expires_on,supplier_contact_id,unit_cost_cents,quantity,notes) VALUES(?,?,?,?,?,?,?)', ); $s->execute([ $product, trim((string) ($data['batch_number'] ?? '')) ?: null, $expiry ?: null, (int) ($data['supplier_contact_id'] ?? 0) ?: null, $cents, $quantity, trim((string) ($data['notes'] ?? '')) ?: null, ]); $batch = (int) $db->lastInsertId(); $db->prepare( "INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,reason,created_by) VALUES(?,?,'entry',?,?,?)", )->execute([$product, $batch, $quantity, t('inventory.receipt'), Auth::id()]); $db->commit(); return $batch; } catch (Throwable $e) { $db->rollBack(); throw $e; } } public static function move(array $data): void { $batch = (int) ($data['batch_id'] ?? 0); $type = (string) ($data['movement_type'] ?? ''); $quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0)); if (!$batch || !in_array($type, ['administration', 'loss', 'correction'], true) || $quantity <= 0) { throw new RuntimeException(t('inventory.invalid_movement')); } $db = DB::pdo(); $s = $db->prepare( 'SELECT b.*,p.id product_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?', ); $s->execute([$batch]); $row = $s->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new RuntimeException(t('inventory.batch_not_found')); } $delta = $type === 'correction' ? (float) str_replace(',', '.', (string) ($data['signed_quantity'] ?? 0)) : -$quantity; if ($delta == 0 || (float) $row['quantity'] + $delta < 0) { throw new RuntimeException(t('inventory.insufficient_stock')); } $db->beginTransaction(); try { $db->prepare('UPDATE inventory_batches SET quantity=quantity+? WHERE id=?')->execute([$delta, $batch]); $db->prepare( 'INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,?,?,?,?,?)', )->execute([ (int) $row['product_id'], $batch, $type, $delta, (int) ($data['animal_id'] ?? 0) ?: null, trim((string) ($data['reason'] ?? '')) ?: null, Auth::id(), ]); $db->commit(); } catch (Throwable $e) { $db->rollBack(); throw $e; } } public static function alerts(): array { $low = []; $expired = []; foreach (self::products() as $p) { if ((float) $p['stock'] <= (float) $p['minimum_quantity']) { $low[] = $p; } } foreach (self::batches() as $b) { if ( (float) $b['quantity'] > 0 && $b['expires_on'] && $b['expires_on'] <= date('Y-m-d', strtotime('+30 days')) ) { $expired[] = $b; } } return compact('low', 'expired'); } public static function consumeBatch(int $batchId, float $quantity, ?int $animalId, string $reason): void { if ($batchId <= 0 || $quantity <= 0) { return; } $db = DB::pdo(); $s = $db->prepare( 'SELECT b.product_id,b.quantity,b.unit_cost_cents,b.supplier_contact_id,p.name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?', ); $s->execute([$batchId]); $batch = $s->fetch(PDO::FETCH_ASSOC); if (!$batch) { throw new RuntimeException(t('inventory.batch_not_found')); } if ((float) $batch['quantity'] < $quantity) { throw new RuntimeException(t('inventory.insufficient_stock')); } $u = $db->prepare('UPDATE inventory_batches SET quantity=quantity-? WHERE id=? AND quantity>=?'); $u->execute([$quantity, $batchId, $quantity]); if ($u->rowCount() !== 1) { throw new RuntimeException(t('inventory.insufficient_stock')); } $movement = $db->prepare( "INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,'administration',?,?,?,?)", ); $movement->execute([(int) $batch['product_id'], $batchId, -$quantity, $animalId, $reason, Auth::id()]); $movementId = (int) $db->lastInsertId(); if ($animalId && !is_null($batch['unit_cost_cents'])) { $unit = (int) $batch['unit_cost_cents']; $db->prepare( "INSERT INTO animal_expenses(animal_id,clinic_contact_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?, 'inventory',?,?,date('now'),?,?,0,?,?,?)", )->execute([ $animalId, (int) $batch['supplier_contact_id'] ?: null, $movementId, (string) $batch['name'], $quantity, $unit, (int) round($unit * $quantity), $reason, Auth::id(), ]); } } private static function date(string $v): bool { $d = DateTimeImmutable::createFromFormat('!Y-m-d', $v); return $d && $d->format('Y-m-d') === $v; } }