Configure les tarifs d'adoption par espèce

Refs #14 #18
This commit is contained in:
Alexandre NOEL 2026-09-08 00:44:40 +02:00
commit 1378aefa9c
14 changed files with 93 additions and 35 deletions

View file

@ -2,6 +2,15 @@
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.12] — 2026-09-08
### Tarifs d’adoption et environnement local
- grille des frais d’adoption par espèce, avant et après stérilisation, avec possibilité de choisir un tarif libre (`#14`) ;
- reprise automatique des deux anciens montants pour toutes les espèces existantes ;
- affichage du tarif correspondant sur chaque fiche animale, quelle que soit l’espèce ;
- explication des tentatives SSL provoquées par le mode HTTPS uniquement d’un navigateur avec le serveur PHP local (`#18`).
## [1.0.0-rc.11] — 2026-09-08 ## [1.0.0-rc.11] — 2026-09-08
### Configuration des salles et des tournées ### Configuration des salles et des tournées

View file

@ -53,3 +53,7 @@ Correction du champ de statut du formulaire animal, stabilisation des codes inte
## `1.0.0-rc.11` — Salles et tournées ## `1.0.0-rc.11` — Salles et tournées
Ajout de critères d’accueil par salle et de quatre créneaux de tournée configurables sans modifier les réglages existants. Ajout de critères d’accueil par salle et de quatre créneaux de tournée configurables sans modifier les réglages existants.
## `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.

View file

@ -98,6 +98,8 @@ Le dernier argument, `public/router.php`, est indispensable : sans lui, les rout
Ouvrez ensuite `http://127.0.0.1:8000/setup`. L’assistant vérifie l’environnement, recueille l’identité de l’association et crée le premier compte administrateur. Ouvrez ensuite `http://127.0.0.1:8000/setup`. L’assistant vérifie l’environnement, recueille l’identité de l’association et crée le premier compte administrateur.
Le serveur PHP intégré ne prend pas en charge HTTPS. Si le navigateur utilise un mode « HTTPS uniquement », autorisez HTTP pour l’adresse locale ; une tentative en `https://127.0.0.1:8000` apparaît dans le terminal comme une requête SSL non prise en charge.
Les migrations sont appliquées automatiquement. Elles peuvent aussi être exécutées explicitement : Les migrations sont appliquées automatiquement. Elles peuvent aussi être exécutées explicitement :
```bash ```bash

View file

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

View file

@ -495,6 +495,7 @@ final class SettingsController
->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE') ->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC), ->fetchAll(PDO::FETCH_ASSOC),
'settings' => AppSettings::all(), 'settings' => AppSettings::all(),
'adoptionFees' => PricingService::adoptionFees(),
'saved' => isset($_GET['saved']), 'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''),
]); ]);
@ -616,19 +617,23 @@ final class SettingsController
{ {
self::admin(); self::admin();
self::postOnly(); self::postOnly();
AppSettings::save( $submitted = (array) ($_POST['fees'] ?? []);
[ $allowed = array_keys(PricingService::adoptionFees());
'adoption_fee_sterilized' => (string) max( $stmt = DB::pdo()->prepare(
0, "INSERT INTO adoption_fee_rules(species_code,fee_before_sterilization,fee_after_sterilization,is_free,updated_at) VALUES(?,?,?,?,datetime('now')) ON CONFLICT(species_code) DO UPDATE SET fee_before_sterilization=excluded.fee_before_sterilization,fee_after_sterilization=excluded.fee_after_sterilization,is_free=excluded.is_free,updated_at=datetime('now')",
min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)),
),
'adoption_fee_unsterilized' => (string) max(
0,
min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)),
),
],
Auth::id(),
); );
foreach ($submitted as $species => $values) {
$species = SpeciesService::normalize((string) $species);
if (!in_array($species, $allowed, true) || !is_array($values)) {
continue;
}
$stmt->execute([
$species,
max(0, min(2000, (int) ($values['before'] ?? 0))),
max(0, min(2000, (int) ($values['after'] ?? 0))),
isset($values['free']) ? 1 : 0,
]);
}
AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés'); AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés');
header('Location: /settings/pricing?saved=1'); header('Location: /settings/pricing?saved=1');
exit(); exit();

View file

@ -37,6 +37,23 @@ final class PricingService
{ {
return max(0, (int) round((float) str_replace(',', '.', trim($v)) * 100)); return max(0, (int) round((float) str_replace(',', '.', trim($v)) * 100));
} }
public static function adoptionFees(): array
{
$rows = DB::pdo()->query(
"SELECT s.code,s.name,s.icon,COALESCE(r.fee_before_sterilization,200) fee_before_sterilization,COALESCE(r.fee_after_sterilization,220) fee_after_sterilization,COALESCE(r.is_free,0) is_free FROM ref_species s LEFT JOIN adoption_fee_rules r ON r.species_code=s.code WHERE s.active=1 ORDER BY s.sort_order,s.name COLLATE NOCASE",
)->fetchAll(PDO::FETCH_ASSOC);
return array_column($rows, null, 'code');
}
public static function adoptionFee(string $species, bool $sterilized): ?int
{
$fees = self::adoptionFees();
$rule = $fees[SpeciesService::normalize($species)] ?? null;
if (!$rule || (int) $rule['is_free'] === 1) {
return null;
}
return (int) $rule[$sterilized ? 'fee_after_sterilization' : 'fee_before_sterilization'];
}
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

@ -641,7 +641,7 @@ $today = date('Y-m-d');
<span class="form-check-label"> <span class="form-check-label">
<span class="fw-bold d-block"><?= h(t('animal.sterilization_act')) ?></span> <span class="fw-bold d-block"><?= h(t('animal.sterilization_act')) ?></span>
<span <span
class="form-hint mt-0"><?= h(!empty($animal['sterilized']) ? t('animal.already_sterilized') : t('animal.sterilization_auto', ['price' => AppSettings::int('adoption_fee_sterilized')])) ?></span> class="form-hint mt-0"><?php $feeAfterSterilization = PricingService::adoptionFee((string)$animal['species'], true); ?><?= h(!empty($animal['sterilized']) ? t('animal.already_sterilized') : ($feeAfterSterilization === null ? t('animal.sterilization_auto_free') : t('animal.sterilization_auto', ['price' => $feeAfterSterilization]))) ?></span>
</span> </span>
</label> </label>
</div> </div>

View file

@ -285,9 +285,9 @@ if ($felvPos) {
<span class="badge <?= h($sterilizationClass) ?>"> <span class="badge <?= h($sterilizationClass) ?>">
<?= h($sterilizationLabel) ?> <?= h($sterilizationLabel) ?>
</span> </span>
<?php if ($isCat): ?><span class="text-muted small"><?= h(t('animal.adoption_fee_hint')) ?> <?php $adoptionFee = PricingService::adoptionFee((string)$animal['species'], $sterilizationState === 'yes'); ?>
<strong <span class="text-muted small"><?= h(t('animal.adoption_fee_hint')) ?>
class="text-body"><?= $sterilizationState === 'yes' ? AppSettings::int('adoption_fee_sterilized') . ' €' : AppSettings::int('adoption_fee_unsterilized') . ' €' ?></strong></span><?php endif; ?> <strong class="text-body"><?= $adoptionFee === null ? h(t('pricing.free_fee')) : $adoptionFee . ' €' ?></strong></span>
<?php if ($sterilizationState === 'yes' && (!empty($animal['sterilization_date']) || !empty($sterilizationContacts))): ?><span <?php if ($sterilizationState === 'yes' && (!empty($animal['sterilization_date']) || !empty($sterilizationContacts))): ?><span
class="text-muted small"><?= !empty($animal['sterilization_date']) ? h(date('d/m/Y', strtotime((string)$animal['sterilization_date']))) : '' ?><?= !empty($sterilizationContacts['vet']['name']) ? ' · ' . h($sterilizationContacts['vet']['name']) : '' ?><?= !empty($sterilizationContacts['clinic']['name']) ? ' · ' . h($sterilizationContacts['clinic']['name']) : '' ?></span><?php endif; ?> class="text-muted small"><?= !empty($animal['sterilization_date']) ? h(date('d/m/Y', strtotime((string)$animal['sterilization_date']))) : '' ?><?= !empty($sterilizationContacts['vet']['name']) ? ' · ' . h($sterilizationContacts['vet']['name']) : '' ?><?= !empty($sterilizationContacts['clinic']['name']) ? ' · ' . h($sterilizationContacts['clinic']['name']) : '' ?></span><?php endif; ?>
</div> </div>

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.11</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.12</span>
<h3>Salles et tournées configurables</h3> <h3>Tarifs d’adoption par espèce</h3>
<p class="text-muted">Critères d’accueil par salle et créneaux de soins adaptés à l’organisation du refuge.</p> <p class="text-muted">Montants avant ou après stérilisation, avec possibilité de laisser le tarif libre.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -6,7 +6,7 @@
<?php if ($saved): ?><div class="alert alert-success">✅ <?= h(t('pricing.saved')) ?></div> <?php if ($saved): ?><div class="alert alert-success">✅ <?= h(t('pricing.saved')) ?></div>
<?php endif; ?><?php if ($error !== ''): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?> <?php endif; ?><?php if ($error !== ''): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<div class="row row-cards mb-4"> <div class="row row-cards mb-4">
<div class="col-lg-5"> <div class="col-lg-7">
<form class="card h-100" method="post" action="/settings/pricing/adoption"> <form class="card h-100" method="post" action="/settings/pricing/adoption">
<div class="card-status-top bg-green"></div> <div class="card-status-top bg-green"></div>
<div class="card-header"> <div class="card-header">
@ -15,24 +15,19 @@
<div class="text-muted small"><?= h(t('pricing.adoption_fees_help')) ?></div> <div class="text-muted small"><?= h(t('pricing.adoption_fees_help')) ?></div>
</div> </div>
</div> </div>
<div class="card-body row g-3"> <div class="table-responsive">
<div class="col-6"><label class="form-label"><?= h(t('pricing.sterilized')) ?></label> <table class="table table-vcenter mb-0"><thead><tr><th><?= h(t('animal.species')) ?></th><th><?= h(t('pricing.before_sterilization')) ?></th><th><?= h(t('pricing.after_sterilization')) ?></th><th><?= h(t('pricing.free_fee')) ?></th></tr></thead><tbody>
<div class="input-group"><input class="form-control" type="number" min="0" <?php foreach ($adoptionFees as $species => $fee): ?><tr><td class="fw-semibold"><?= h(($fee['icon'] ?? '') . ' ' . $fee['name']) ?></td>
name="adoption_fee_sterilized" <td><div class="input-group"><input class="form-control" type="number" min="0" max="2000" name="fees[<?= h($species) ?>][before]" value="<?= (int)$fee['fee_before_sterilization'] ?>"><span class="input-group-text">€</span></div></td>
value="<?= (int)$settings['adoption_fee_sterilized'] ?>"><span <td><div class="input-group"><input class="form-control" type="number" min="0" max="2000" name="fees[<?= h($species) ?>][after]" value="<?= (int)$fee['fee_after_sterilization'] ?>"><span class="input-group-text">€</span></div></td>
class="input-group-text">€</span></div> <td><label class="form-check form-switch"><input class="form-check-input adoption-fee-free" type="checkbox" name="fees[<?= h($species) ?>][free]" <?= (int)$fee['is_free'] === 1 ? 'checked' : '' ?>><span class="form-check-label"><?= h(t('pricing.free')) ?></span></label></td>
</div> </tr><?php endforeach; ?>
<div class="col-6"><label class="form-label"><?= h(t('pricing.unsterilized')) ?></label> </tbody></table>
<div class="input-group"><input class="form-control" type="number" min="0"
name="adoption_fee_unsterilized"
value="<?= (int)$settings['adoption_fee_unsterilized'] ?>"><span
class="input-group-text">€</span></div>
</div>
</div> </div>
<div class="card-footer text-end"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div> <div class="card-footer text-end"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
</form> </form>
</div> </div>
<div class="col-lg-7"> <div class="col-lg-5">
<div class="card h-100"> <div class="card h-100">
<div class="card-status-top bg-orange"></div> <div class="card-status-top bg-orange"></div>
<div class="card-header"> <div class="card-header">

View file

@ -0,0 +1,14 @@
CREATE TABLE adoption_fee_rules (
species_code TEXT PRIMARY KEY,
fee_before_sterilization INTEGER NOT NULL DEFAULT 200,
fee_after_sterilization INTEGER NOT NULL DEFAULT 220,
is_free INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(species_code) REFERENCES ref_species(code) ON DELETE CASCADE
);
INSERT OR IGNORE INTO adoption_fee_rules(species_code,fee_before_sterilization,fee_after_sterilization)
SELECT s.code,
COALESCE((SELECT CAST(setting_value AS INTEGER) FROM app_settings WHERE setting_key='adoption_fee_unsterilized'),200),
COALESCE((SELECT CAST(setting_value AS INTEGER) FROM app_settings WHERE setting_key='adoption_fee_sterilized'),220)
FROM ref_species s;

View file

@ -125,4 +125,5 @@ return [
'settings.invalid_rna'=>'The RNA number must start with W followed by 9 digits.','settings.invalid_siret'=>'The SIRET number must contain 14 digits and be valid.','rooms.name_too_long'=>'A room name cannot exceed 100 characters.','rooms.box_name_too_long'=>'A box name cannot exceed 100 characters.','animal.name_too_long'=>'An animal name cannot exceed 150 characters.', 'settings.invalid_rna'=>'The RNA number must start with W followed by 9 digits.','settings.invalid_siret'=>'The SIRET number must contain 14 digits and be valid.','rooms.name_too_long'=>'A room name cannot exceed 100 characters.','rooms.box_name_too_long'=>'A box name cannot exceed 100 characters.','animal.name_too_long'=>'An animal name cannot exceed 150 characters.',
'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.',
]; ];

View file

@ -125,4 +125,5 @@ return [
'settings.invalid_rna'=>'Le numéro RNA doit commencer par W et être suivi de 9 chiffres.','settings.invalid_siret'=>'Le numéro SIRET doit contenir 14 chiffres et être valide.','rooms.name_too_long'=>'Le nom d’une salle ne peut pas dépasser 100 caractères.','rooms.box_name_too_long'=>'Le nom d’un box ne peut pas dépasser 100 caractères.','animal.name_too_long'=>'Le nom d’un animal ne peut pas dépasser 150 caractères.', 'settings.invalid_rna'=>'Le numéro RNA doit commencer par W et être suivi de 9 chiffres.','settings.invalid_siret'=>'Le numéro SIRET doit contenir 14 chiffres et être valide.','rooms.name_too_long'=>'Le nom d’une salle ne peut pas dépasser 100 caractères.','rooms.box_name_too_long'=>'Le nom d’un box ne peut pas dépasser 100 caractères.','animal.name_too_long'=>'Le nom d’un animal ne peut pas dépasser 150 caractères.',
'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.',
]; ];

View file

@ -27,6 +27,7 @@ foreach (
'NotificationService', 'NotificationService',
'PrivacyService', 'PrivacyService',
'StatisticsService', 'StatisticsService',
'PricingService',
'SpeciesService', 'SpeciesService',
'LabReferenceService', 'LabReferenceService',
'PublicContentFormatter', 'PublicContentFormatter',
@ -133,6 +134,15 @@ try {
$assert(AppSettings::get('app_language') === 'en'); $assert(AppSettings::get('app_language') === 'en');
I18n::setLocale('fr'); I18n::setLocale('fr');
}); });
$test('Les frais d’adoption sont configurables par espèce', function () use ($db, $assert): void {
$fees = PricingService::adoptionFees();
$assert(isset($fees['chat']), 'Tarif du chat absent');
$db->exec("UPDATE adoption_fee_rules SET fee_before_sterilization=175,fee_after_sterilization=225,is_free=0 WHERE species_code='chat'");
$assert(PricingService::adoptionFee('chat', false) === 175);
$assert(PricingService::adoptionFee('chat', true) === 225);
$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é');
});
$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'));