Publier Globinours 1.0.0-rc.3
This commit is contained in:
parent
ea8c24d622
commit
9a2b4068da
325 changed files with 38230 additions and 20 deletions
211
app/Services/InventoryService.php
Normal file
211
app/Services/InventoryService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue