Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
final class InventoryService
{
public static function products(): array
{
return DB::pdo()
->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;
}
}