Permet de modifier et supprimer les tarifs

Refs #24
This commit is contained in:
Alexandre NOEL 2026-09-08 00:57:43 +02:00
commit b906cf7b88
11 changed files with 96 additions and 13 deletions

View file

@ -2,6 +2,13 @@
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
## [1.0.0-rc.13] — 2026-09-08
### Gestion du catalogue vétérinaire
- ajout d’une commande de modification pour les tarifs vétérinaires déjà enregistrés (`#24`) ;
- ajout d’une suppression confirmée qui conserve les montants, libellés et remises des dépenses historiques.
## [1.0.0-rc.12] — 2026-09-08 ## [1.0.0-rc.12] — 2026-09-08
### Tarifs d’adoption et environnement local ### Tarifs d’adoption et environnement local

View file

@ -57,3 +57,7 @@ Ajout de critères d’accueil par salle et de quatre créneaux de tournée conf
## `1.0.0-rc.12` — Tarifs par espèce ## `1.0.0-rc.12` — Tarifs par espèce
Remplacement des deux montants globaux par une grille d’adoption propre à chaque espèce, avec tarif fixe ou libre. Remplacement des deux montants globaux par une grille d’adoption propre à chaque espèce, avec tarif fixe ou libre.
## `1.0.0-rc.13` — Catalogue vétérinaire
Ajout de la modification et de la suppression sécurisée des tarifs, sans altérer les dépenses historiques.

View file

@ -1 +1 @@
1.0.0-rc.12 1.0.0-rc.13

View file

@ -613,6 +613,27 @@ final class SettingsController
} }
exit(); exit();
} }
public static function deleteTariff(): void
{
self::admin();
self::postOnly();
$id = (int) ($_POST['id'] ?? 0);
$label = PricingService::deleteTariff($id);
if ($label === null) {
http_response_code(404);
echo h(t('error.tariff_not_found'));
return;
}
AuditService::log(
'tariff_deleted',
'/settings/pricing/tariff/delete',
'Tarif supprimé : ' . $label,
'clinic_tariff',
$id,
);
header('Location: /settings/pricing?saved=1');
exit();
}
public static function saveAdoptionFees(): void public static function saveAdoptionFees(): void
{ {
self::admin(); self::admin();

View file

@ -329,6 +329,9 @@ switch ($path) {
case '/settings/pricing/tariff': case '/settings/pricing/tariff':
SettingsController::saveTariff(); SettingsController::saveTariff();
break; break;
case '/settings/pricing/tariff/delete':
SettingsController::deleteTariff();
break;
case '/settings/pricing/discount': case '/settings/pricing/discount':
SettingsController::saveDiscount(); SettingsController::saveDiscount();
break; break;

View file

@ -54,6 +54,19 @@ final class PricingService
} }
return (int) $rule[$sterilized ? 'fee_after_sterilization' : 'fee_before_sterilization']; return (int) $rule[$sterilized ? 'fee_after_sterilization' : 'fee_before_sterilization'];
} }
public static function deleteTariff(int $id): ?string
{
$db = DB::pdo();
$stmt = $db->prepare('SELECT label FROM clinic_tariffs WHERE id=:id');
$stmt->execute([':id' => $id]);
$label = $stmt->fetchColumn();
if ($label === false) {
return null;
}
$db->prepare('DELETE FROM clinic_tariffs WHERE id=:id')->execute([':id' => $id]);
return (string) $label;
}
public static function tariffs(bool $active = false): array public static function tariffs(bool $active = false): array
{ {
$w = $active ? 'WHERE ct.active=1 AND dc.deleted_at IS NULL' : ''; $w = $active ? 'WHERE ct.active=1 AND dc.deleted_at IS NULL' : '';

View file

@ -31,9 +31,9 @@
<div class="col-lg-6"> <div class="col-lg-6">
<section class="card h-100"> <section class="card h-100">
<div class="card-body"> <div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.12</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.13</span>
<h3>Tarifs d’adoption par espèce</h3> <h3>Catalogue vétérinaire modifiable</h3>
<p class="text-muted">Montants avant ou après stérilisation, avec possibilité de laisser le tarif libre.</p> <p class="text-muted">Tarifs modifiables ou supprimables tout en préservant les dépenses historiques.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -98,36 +98,37 @@
<div class="text-muted small"><?= h(t('pricing.add_label_help')) ?></div> <div class="text-muted small"><?= h(t('pricing.add_label_help')) ?></div>
</div> </div>
</div> </div>
<form method="post" action="/settings/pricing/tariff"> <form method="post" action="/settings/pricing/tariff" id="tariffForm">
<input type="hidden" name="id" id="tariffId" value="0">
<div class="card-body row g-3"> <div class="card-body row g-3">
<div class="col-md-4"><label class="form-label required"><?= h(t('pricing.structure')) ?></label><select <div class="col-md-4"><label class="form-label required"><?= h(t('pricing.structure')) ?></label><select
class="form-select" name="clinic_contact_id" required> class="form-select" name="clinic_contact_id" id="tariffClinic" required>
<option value="">—</option><?php foreach ($clinics as $c): ?><option value="<?= (int)$c['id'] ?>"> <option value="">—</option><?php foreach ($clinics as $c): ?><option value="<?= (int)$c['id'] ?>">
<?= h($c['name']) ?></option><?php endforeach; ?> <?= h($c['name']) ?></option><?php endforeach; ?>
</select></div> </select></div>
<div class="col-md-5"><label class="form-label required"><?= h(t('pricing.label')) ?></label><input <div class="col-md-5"><label class="form-label required"><?= h(t('pricing.label')) ?></label><input
class="form-control" name="label" required placeholder="<?= h(t('pricing.label_example')) ?>"></div> class="form-control" name="label" id="tariffLabel" required placeholder="<?= h(t('pricing.label_example')) ?>"></div>
<div class="col-md-3"><label class="form-label"><?= h(t('pricing.catalog_price')) ?></label> <div class="col-md-3"><label class="form-label"><?= h(t('pricing.catalog_price')) ?></label>
<div class="input-group"><input class="form-control" name="amount" inputmode="decimal" required><span <div class="input-group"><input class="form-control" name="amount" id="tariffAmount" inputmode="decimal" required><span
class="input-group-text">€</span></div> class="input-group-text">€</span></div>
</div> </div>
<div class="col-md-3"><label class="form-label"><?= h(t('settings.category')) ?></label><select <div class="col-md-3"><label class="form-label"><?= h(t('settings.category')) ?></label><select
class="form-select" name="category"><?php foreach ($categories as $k => $v): ?><option class="form-select" name="category" id="tariffCategory"><?php foreach ($categories as $k => $v): ?><option
value="<?= h($k) ?>"><?= h(PricingService::categoryLabel($k)) ?></option> value="<?= h($k) ?>"><?= h(PricingService::categoryLabel($k)) ?></option>
<?php endforeach; ?></select></div> <?php endforeach; ?></select></div>
<div class="col-md-3"><label class="form-label"><?= h(t('pricing.discount_group')) ?></label><select <div class="col-md-3"><label class="form-label"><?= h(t('pricing.discount_group')) ?></label><select
class="form-select" name="discount_group"><?php foreach ($groups as $k => $v): ?><option class="form-select" name="discount_group" id="tariffGroup"><?php foreach ($groups as $k => $v): ?><option
value="<?= h($k) ?>"><?= h(PricingService::groupLabel($k)) ?></option> value="<?= h($k) ?>"><?= h(PricingService::groupLabel($k)) ?></option>
<?php endforeach; ?></select></div> <?php endforeach; ?></select></div>
<div class="col-md-2"><label class="form-label"><?= h(t('pricing.unit')) ?></label><input <div class="col-md-2"><label class="form-label"><?= h(t('pricing.unit')) ?></label><input
class="form-control" name="unit_label" value="acte"></div> class="form-control" name="unit_label" id="tariffUnit" value="acte"></div>
<div class="col-md-4"><label class="form-label"><?= h(t('pricing.linked_medication')) ?> <span <div class="col-md-4"><label class="form-label"><?= h(t('pricing.linked_medication')) ?> <span
class="text-muted">(<?= h(t('common.optional')) ?>)</span></label><select class="form-select" class="text-muted">(<?= h(t('common.optional')) ?>)</span></label><select class="form-select"
name="medication_id"> name="medication_id" id="tariffMedication">
<option value="">—</option><?php foreach ($medications as $m): ?><option <option value="">—</option><?php foreach ($medications as $m): ?><option
value="<?= (int)$m['id'] ?>"><?= h($m['name']) ?></option><?php endforeach; ?> value="<?= (int)$m['id'] ?>"><?= h($m['name']) ?></option><?php endforeach; ?>
</select></div> </select></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" name="active" <div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" name="active" id="tariffActive"
checked><span class="form-check-label"><?= h(t('pricing.active_tariff')) ?></span></label></div> checked><span class="form-check-label"><?= h(t('pricing.active_tariff')) ?></span></label></div>
</div> </div>
<div class="card-footer text-end"><button class="btn btn-primary"><?= h(t('pricing.add_tariff')) ?></button> <div class="card-footer text-end"><button class="btn btn-primary"><?= h(t('pricing.add_tariff')) ?></button>
@ -149,6 +150,7 @@
<th class="text-end"><?= h(t('pricing.catalog')) ?></th> <th class="text-end"><?= h(t('pricing.catalog')) ?></th>
<th class="text-end"><?= h(t('pricing.estimated_cost')) ?></th> <th class="text-end"><?= h(t('pricing.estimated_cost')) ?></th>
<th><?= h(t('pricing.state')) ?></th> <th><?= h(t('pricing.state')) ?></th>
<th class="text-end"><?= h(t('common.actions')) ?></th>
</tr> </tr>
</thead> </thead>
<tbody><?php foreach ($tariffs as $t): ?><tr> <tbody><?php foreach ($tariffs as $t): ?><tr>
@ -164,7 +166,27 @@
<td><span <td><span
class="badge <?= $t['active'] ? 'bg-green-lt' : 'bg-secondary-lt' ?>"><?= $t['active'] ? t('pricing.active') : t('pricing.inactive') ?></span> class="badge <?= $t['active'] ? 'bg-green-lt' : 'bg-secondary-lt' ?>"><?= $t['active'] ? t('pricing.active') : t('pricing.inactive') ?></span>
</td> </td>
<td class="text-end"><div class="btn-list justify-content-end">
<button class="btn btn-sm btn-outline-primary tariff-edit" type="button"
data-id="<?= (int)$t['id'] ?>" data-clinic="<?= (int)$t['clinic_contact_id'] ?>"
data-label="<?= h($t['label']) ?>" data-amount="<?= h(number_format($t['amount_cents'] / 100, 2, '.', '')) ?>"
data-category="<?= h($t['category']) ?>" data-group="<?= h($t['discount_group']) ?>"
data-unit="<?= h($t['unit_label']) ?>" data-medication="<?= (int)($t['medication_id'] ?? 0) ?>"
data-active="<?= (int)$t['active'] ?>"><?= h(t('common.edit')) ?></button>
<form method="post" action="/settings/pricing/tariff/delete" onsubmit="return confirm(<?= h(json_encode(t('pricing.delete_confirm'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?>)">
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>"><button class="btn btn-sm btn-outline-danger" type="submit"><?= h(t('common.delete')) ?></button>
</form>
</div></td>
</tr><?php endforeach; ?></tbody> </tr><?php endforeach; ?></tbody>
</table> </table>
</div> </div>
</div> </div>
<script>
document.querySelectorAll('.tariff-edit').forEach((button) => button.addEventListener('click', () => {
const fields = {tariffId:'id',tariffClinic:'clinic',tariffLabel:'label',tariffAmount:'amount',tariffCategory:'category',tariffGroup:'group',tariffUnit:'unit',tariffMedication:'medication'};
Object.entries(fields).forEach(([id, key]) => { document.getElementById(id).value = button.dataset[key] || ''; });
document.getElementById('tariffActive').checked = button.dataset.active === '1';
document.getElementById('tariffForm').scrollIntoView({behavior:'smooth', block:'start'});
document.getElementById('tariffLabel').focus({preventScroll:true});
}));
</script>

View file

@ -126,4 +126,5 @@ return [
'pricing.adoption_fees_help'=>'Indicative amounts shared by all animals, according to their sterilisation status.','rooms.inactive_help'=>'An unchecked room becomes inactive, while its previous assignments remain in the history. Its internal code never changes.', 'pricing.adoption_fees_help'=>'Indicative amounts shared by all animals, according to their sterilisation status.','rooms.inactive_help'=>'An unchecked room becomes inactive, while its previous assignments remain in the history. Its internal code never changes.',
'care.noon'=>'noon','care.afternoon'=>'afternoon','rooms.admission_rules'=>'Optional admission criteria','rooms.care_periods'=>'Rounds scheduled for this room','rooms.period_required'=>'Select at least one care-round period per room.','rooms.animal_not_allowed'=>'This animal does not match the admission criteria of the selected room.', 'care.noon'=>'noon','care.afternoon'=>'afternoon','rooms.admission_rules'=>'Optional admission criteria','rooms.care_periods'=>'Rounds scheduled for this room','rooms.period_required'=>'Select at least one care-round period per room.','rooms.animal_not_allowed'=>'This animal does not match the admission criteria of the selected room.',
'pricing.before_sterilization'=>'Before sterilisation','pricing.after_sterilization'=>'After sterilisation','pricing.free_fee'=>'Open amount','pricing.free'=>'Open','pricing.adoption_fees_help'=>'Indicative amounts by species. Enable “Open amount” when no fixed fee is required.','animal.sterilization_auto_free'=>'The adoption fee for this species remains open after sterilisation.', 'pricing.before_sterilization'=>'Before sterilisation','pricing.after_sterilization'=>'After sterilisation','pricing.free_fee'=>'Open amount','pricing.free'=>'Open','pricing.adoption_fees_help'=>'Indicative amounts by species. Enable “Open amount” when no fixed fee is required.','animal.sterilization_auto_free'=>'The adoption fee for this species remains open after sterilisation.',
'pricing.delete_confirm'=>'Delete this pricing item? Previously recorded expenses will retain their label and amount.','pricing.edit_tariff'=>'Edit pricing item',
]; ];

View file

@ -126,4 +126,5 @@ return [
'pricing.adoption_fees_help'=>'Montants indicatifs communs à tous les animaux, selon leur état de stérilisation.','rooms.inactive_help'=>'Une salle décochée devient inactive, mais ses anciennes affectations restent dans l’historique. Son code interne ne change jamais.', 'pricing.adoption_fees_help'=>'Montants indicatifs communs à tous les animaux, selon leur état de stérilisation.','rooms.inactive_help'=>'Une salle décochée devient inactive, mais ses anciennes affectations restent dans l’historique. Son code interne ne change jamais.',
'care.noon'=>'midi','care.afternoon'=>'après-midi','rooms.admission_rules'=>'Critères d’accueil facultatifs','rooms.care_periods'=>'Tournées prévues dans cette salle','rooms.period_required'=>'Sélectionnez au moins un créneau de tournée par salle.','rooms.animal_not_allowed'=>'Cet animal ne correspond pas aux critères d’accueil de la salle choisie.', 'care.noon'=>'midi','care.afternoon'=>'après-midi','rooms.admission_rules'=>'Critères d’accueil facultatifs','rooms.care_periods'=>'Tournées prévues dans cette salle','rooms.period_required'=>'Sélectionnez au moins un créneau de tournée par salle.','rooms.animal_not_allowed'=>'Cet animal ne correspond pas aux critères d’accueil de la salle choisie.',
'pricing.before_sterilization'=>'Avant stérilisation','pricing.after_sterilization'=>'Après stérilisation','pricing.free_fee'=>'Tarif libre','pricing.free'=>'Libre','pricing.adoption_fees_help'=>'Montants indicatifs par espèce. Activez « Tarif libre » lorsqu’aucun montant fixe n’est imposé.','animal.sterilization_auto_free'=>'Le tarif d’adoption de cette espèce reste libre après la stérilisation.', 'pricing.before_sterilization'=>'Avant stérilisation','pricing.after_sterilization'=>'Après stérilisation','pricing.free_fee'=>'Tarif libre','pricing.free'=>'Libre','pricing.adoption_fees_help'=>'Montants indicatifs par espèce. Activez « Tarif libre » lorsqu’aucun montant fixe n’est imposé.','animal.sterilization_auto_free'=>'Le tarif d’adoption de cette espèce reste libre après la stérilisation.',
'pricing.delete_confirm'=>'Supprimer ce tarif ? Les dépenses déjà enregistrées conserveront leur libellé et leur montant.','pricing.edit_tariff'=>'Modifier le tarif',
]; ];

View file

@ -143,6 +143,17 @@ try {
$db->exec("UPDATE adoption_fee_rules SET is_free=1 WHERE species_code='chat'"); $db->exec("UPDATE adoption_fee_rules SET is_free=1 WHERE species_code='chat'");
$assert(PricingService::adoptionFee('chat', true) === null, 'Le tarif libre n’est pas appliqué'); $assert(PricingService::adoptionFee('chat', true) === null, 'Le tarif libre n’est pas appliqué');
}); });
$test('Un tarif vétérinaire peut être supprimé sans effacer la dépense', function () use ($db, $assert): void {
$db->exec("INSERT INTO animals(internal_code,name,species,status) VALUES('TEST-TARIF','Tarif Test','chat','refuge')");
$animalId = (int) $db->lastInsertId();
$db->exec("INSERT INTO clinic_tariffs(clinic_contact_id,label,amount_cents) VALUES(1,'Tarif temporaire',1234)");
$tariffId = (int) $db->lastInsertId();
$db->prepare("INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,label,occurred_on,catalog_unit_cents,total_cents) VALUES(?,1,?,'Tarif temporaire','2099-01-01',1234,1234)")->execute([$animalId, $tariffId]);
$expenseId = (int) $db->lastInsertId();
$assert(PricingService::deleteTariff($tariffId) === 'Tarif temporaire');
$expense = $db->query("SELECT tariff_id,label,total_cents FROM animal_expenses WHERE id=$expenseId")->fetch(PDO::FETCH_ASSOC);
$assert($expense && $expense['tariff_id'] === null && $expense['label'] === 'Tarif temporaire' && (int) $expense['total_cents'] === 1234);
});
$test('Les identifiants RNA et SIRET sont contrôlés', function () use ($assert): void { $test('Les identifiants RNA et SIRET sont contrôlés', function () use ($assert): void {
$assert(Ids::validRna('W123456789')); $assert(Ids::validRna('W123456789'));
$assert(!Ids::validRna('W1234')); $assert(!Ids::validRna('W1234'));