351 lines
18 KiB
PHP
351 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
final class AgendaService
|
|
{
|
|
public const TYPES = ['appointment', 'veterinary', 'transport', 'pre_adoption', 'task', 'other'];
|
|
public static function save(array $data, ?int $id = null): int
|
|
{
|
|
$type = (string) ($data['item_type'] ?? 'task');
|
|
$title = trim((string) ($data['title'] ?? ''));
|
|
$date = trim((string) ($data['date'] ?? ''));
|
|
$time = trim((string) ($data['time'] ?? ''));
|
|
$allDay = isset($data['all_day']);
|
|
if (!in_array($type, self::TYPES, true) || $title === '' || !self::date($date)) {
|
|
throw new RuntimeException(t('agenda.invalid'));
|
|
}
|
|
if (!$allDay && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
|
throw new RuntimeException(t('agenda.invalid_time'));
|
|
}
|
|
$start = $date . ' ' . ($allDay ? '00:00:00' : $time . ':00');
|
|
$endDate = trim((string) ($data['end_date'] ?? ''));
|
|
$endTime = trim((string) ($data['end_time'] ?? ''));
|
|
$end = null;
|
|
if ($endDate !== '' || $endTime !== '') {
|
|
if (!self::date($endDate) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $endTime)) {
|
|
throw new RuntimeException(t('agenda.invalid_end'));
|
|
}
|
|
$end = $endDate . ' ' . $endTime . ':00';
|
|
if ($end < $start) {
|
|
throw new RuntimeException(t('agenda.end_before_start'));
|
|
}
|
|
}
|
|
$repeat = (string) ($data['repeat_rule'] ?? 'none');
|
|
if (!in_array($repeat, ['none', 'daily', 'weekly', 'monthly'], true)) {
|
|
$repeat = 'none';
|
|
}
|
|
$until = trim((string) ($data['repeat_until'] ?? ''));
|
|
if ($until !== '' && !self::date($until)) {
|
|
throw new RuntimeException(t('agenda.invalid_repeat'));
|
|
}
|
|
$values = [
|
|
':type' => $type,
|
|
':title' => mb_substr($title, 0, 180),
|
|
':description' => trim((string) ($data['description'] ?? '')) ?: null,
|
|
':start' => $start,
|
|
':end' => $end,
|
|
':all_day' => $allDay ? 1 : 0,
|
|
':location' => trim((string) ($data['location'] ?? '')) ?: null,
|
|
':priority' => in_array(
|
|
$p = (string) ($data['priority'] ?? 'normal'),
|
|
['low', 'normal', 'high', 'urgent'],
|
|
true,
|
|
)
|
|
? $p
|
|
: 'normal',
|
|
':assigned' => (int) ($data['assigned_user_id'] ?? 0) ?: null,
|
|
':animal' => (int) ($data['animal_id'] ?? 0) ?: null,
|
|
':contact' => (int) ($data['contact_id'] ?? 0) ?: null,
|
|
':reminder' => ($data['reminder_minutes'] ?? '') === '' ? null : max(0, (int) $data['reminder_minutes']),
|
|
':repeat' => $repeat,
|
|
':until' => $until ?: null,
|
|
':user' => Auth::id(),
|
|
];
|
|
$db = DB::pdo();
|
|
$db->beginTransaction();
|
|
try {
|
|
if ($id) {
|
|
$values[':id'] = $id;
|
|
$db->prepare(
|
|
"UPDATE agenda_items SET item_type=:type,title=:title,description=:description,starts_at=:start,ends_at=:end,all_day=:all_day,location=:location,priority=:priority,assigned_user_id=:assigned,animal_id=:animal,contact_id=:contact,reminder_minutes=:reminder,repeat_rule=:repeat,repeat_until=:until,updated_at=datetime('now') WHERE id=:id",
|
|
)->execute($values);
|
|
} else {
|
|
$db->prepare(
|
|
'INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,all_day,location,priority,assigned_user_id,animal_id,contact_id,reminder_minutes,repeat_rule,repeat_until,created_by) VALUES(:type,:title,:description,:start,:end,:all_day,:location,:priority,:assigned,:animal,:contact,:reminder,:repeat,:until,:user)',
|
|
)->execute($values);
|
|
$id = (int) $db->lastInsertId();
|
|
}
|
|
self::saveVolunteers($id, $data['volunteer_contact_ids'] ?? []);
|
|
$db->commit();
|
|
return $id;
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) {
|
|
$db->rollBack();
|
|
}
|
|
throw $e;
|
|
}
|
|
}
|
|
public static function find(int $id): ?array
|
|
{
|
|
$s = DB::pdo()->prepare(
|
|
"SELECT ai.*,(SELECT group_concat(contact_id,',') FROM agenda_volunteers WHERE agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai WHERE ai.id=?",
|
|
);
|
|
$s->execute([$id]);
|
|
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
|
|
}
|
|
public static function setStatus(int $id, string $status): void
|
|
{
|
|
if (!in_array($status, ['planned', 'completed', 'cancelled'], true)) {
|
|
throw new RuntimeException(t('agenda.invalid'));
|
|
}
|
|
DB::pdo()
|
|
->prepare(
|
|
"UPDATE agenda_items SET status=?,completed_at=CASE WHEN ?='completed' THEN datetime('now') ELSE NULL END,completed_by=CASE WHEN ?='completed' THEN ? ELSE NULL END,updated_at=datetime('now') WHERE id=?",
|
|
)
|
|
->execute([$status, $status, $status, Auth::id(), $id]);
|
|
}
|
|
public static function conflicts(int $id): array
|
|
{
|
|
$db = DB::pdo();
|
|
$s = $db->prepare(
|
|
"SELECT DISTINCT other.id,other.title,other.starts_at FROM agenda_items current JOIN agenda_items other ON other.id<>current.id AND other.status='planned' AND datetime(other.starts_at)<datetime(COALESCE(current.ends_at,CASE WHEN current.all_day=1 THEN datetime(current.starts_at,'+1 day') ELSE datetime(current.starts_at,'+1 hour') END)) AND datetime(COALESCE(other.ends_at,CASE WHEN other.all_day=1 THEN datetime(other.starts_at,'+1 day') ELSE datetime(other.starts_at,'+1 hour') END))>datetime(current.starts_at) WHERE current.id=:id AND current.status='planned' AND ((current.assigned_user_id IS NOT NULL AND other.assigned_user_id=current.assigned_user_id) OR EXISTS(SELECT 1 FROM agenda_volunteers mine JOIN agenda_volunteers theirs ON theirs.contact_id=mine.contact_id AND theirs.agenda_item_id=other.id WHERE mine.agenda_item_id=current.id)) ORDER BY datetime(other.starts_at)",
|
|
);
|
|
$s->execute([':id' => $id]);
|
|
return $s->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
public static function month(DateTimeImmutable $month): array
|
|
{
|
|
return self::between(
|
|
$month->modify('first day of this month')->setTime(0, 0),
|
|
$month->modify('last day of this month')->setTime(23, 59, 59),
|
|
);
|
|
}
|
|
public static function week(DateTimeImmutable $date): array
|
|
{
|
|
$start = $date->modify('monday this week')->setTime(0, 0);
|
|
return self::between($start, $start->modify('+6 days')->setTime(23, 59, 59));
|
|
}
|
|
public static function range(DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
{
|
|
return self::between($from, $to);
|
|
}
|
|
public static function upcoming(int $days = 30): array
|
|
{
|
|
return self::between(new DateTimeImmutable('today'), new DateTimeImmutable('+' . $days . ' days 23:59:59'));
|
|
}
|
|
public static function due(): array
|
|
{
|
|
$db = DB::pdo();
|
|
$overdue = $db
|
|
->query(
|
|
"SELECT ai.*,ai.starts_at instance_starts_at,a.name animal_name,COALESCE(u.display_name,u.username) assigned_name FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE ai.status='planned' AND ai.repeat_rule='none' AND datetime(ai.starts_at)<datetime('now') ORDER BY datetime(ai.starts_at) LIMIT 25",
|
|
)
|
|
->fetchAll(PDO::FETCH_ASSOC);
|
|
$soon = array_values(
|
|
array_filter(
|
|
self::upcoming(2),
|
|
static fn($row) => $row['status'] === 'planned' &&
|
|
strtotime($row['instance_starts_at']) <= strtotime('+1 day'),
|
|
),
|
|
);
|
|
$seen = [];
|
|
$out = [];
|
|
foreach ([...$overdue, ...$soon] as $row) {
|
|
$key = (int) $row['id'] . '|' . ($row['instance_starts_at'] ?? $row['starts_at']);
|
|
if (isset($seen[$key])) {
|
|
continue;
|
|
}
|
|
$seen[$key] = true;
|
|
$out[] = $row;
|
|
}
|
|
usort(
|
|
$out,
|
|
static fn($a, $b) => strcmp(
|
|
(string) ($a['instance_starts_at'] ?? $a['starts_at']),
|
|
(string) ($b['instance_starts_at'] ?? $b['starts_at']),
|
|
),
|
|
);
|
|
return array_slice($out, 0, 25);
|
|
}
|
|
private static function between(DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
{
|
|
$db = DB::pdo();
|
|
$s = $db->prepare(
|
|
"SELECT ai.*,a.name animal_name,dc.name contact_name,COALESCE(u.display_name,u.username) assigned_name,(SELECT group_concat(vdc.name,', ') FROM agenda_volunteers av JOIN directory_contacts vdc ON vdc.id=av.contact_id WHERE av.agenda_item_id=ai.id) volunteer_names,(SELECT group_concat(av.contact_id,',') FROM agenda_volunteers av WHERE av.agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN directory_contacts dc ON dc.id=ai.contact_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE date(ai.starts_at)<=:to AND (ai.repeat_rule<>'none' OR datetime(ai.starts_at)>=:from) AND ai.status<>'cancelled' ORDER BY datetime(ai.starts_at),ai.priority DESC",
|
|
);
|
|
$s->execute([':from' => $from->format('Y-m-d H:i:s'), ':to' => $to->format('Y-m-d')]);
|
|
$out = [];
|
|
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
$start = new DateTimeImmutable($row['starts_at']);
|
|
$end = $row['ends_at'] ? new DateTimeImmutable($row['ends_at']) : null;
|
|
$step = match ($row['repeat_rule']) {
|
|
'daily' => '+1 day',
|
|
'weekly' => '+1 week',
|
|
'monthly' => '+1 month',
|
|
default => null,
|
|
};
|
|
while ($start < $from && $step) {
|
|
$start = $start->modify($step);
|
|
}
|
|
do {
|
|
if ($start > $to) {
|
|
break;
|
|
}
|
|
if (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']) {
|
|
$instance = $row;
|
|
$instance['instance_starts_at'] = $start->format('Y-m-d H:i:s');
|
|
$instance['instance_ends_at'] = $end
|
|
? $end
|
|
->modify(
|
|
$start->getTimestamp() -
|
|
new DateTimeImmutable($row['starts_at'])->getTimestamp() .
|
|
' seconds',
|
|
)
|
|
->format('Y-m-d H:i:s')
|
|
: null;
|
|
$instance['source_type'] = 'agenda';
|
|
if (!empty($instance['volunteer_names'])) {
|
|
$instance['title'] .= ' · 👥 ' . $instance['volunteer_names'];
|
|
}
|
|
$out[] = $instance;
|
|
}
|
|
if (!$step) {
|
|
break;
|
|
}
|
|
$start = $start->modify($step);
|
|
} while (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']);
|
|
}
|
|
$vaccines = $db->prepare(
|
|
"SELECT v.id,v.due_date,rv.name vaccine_name,a.id animal_id,a.name animal_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id=v.vaccine_id JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM vaccinations newer WHERE newer.animal_id=v.animal_id AND newer.vaccine_id=v.vaccine_id AND date(newer.done_date)>date(v.done_date)) ORDER BY date(v.due_date),a.name COLLATE NOCASE",
|
|
);
|
|
$vaccines->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
|
|
foreach ($vaccines->fetchAll(PDO::FETCH_ASSOC) as $v) {
|
|
$out[] = self::reminder(
|
|
-(100000000 + (int) $v['id']),
|
|
(int) $v['id'],
|
|
'vaccine',
|
|
'vaccine',
|
|
t('agenda.vaccine_reminder', ['vaccine' => $v['vaccine_name']]),
|
|
$v['due_date'],
|
|
(int) $v['animal_id'],
|
|
$v['animal_name'],
|
|
'high',
|
|
);
|
|
}
|
|
|
|
$dewormings = $db->prepare(
|
|
"SELECT d.id,d.next_due_date,COALESCE(rd.name,'') dewormer_name,a.id animal_id,a.name animal_name FROM dewormings d LEFT JOIN ref_dewormers rd ON rd.id=d.dewormer_id JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND (date(newer.administered_on)>date(d.administered_on) OR (date(newer.administered_on)=date(d.administered_on) AND newer.id>d.id))) ORDER BY date(d.next_due_date),a.name COLLATE NOCASE",
|
|
);
|
|
$dewormings->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
|
|
foreach ($dewormings->fetchAll(PDO::FETCH_ASSOC) as $d) {
|
|
$out[] = self::reminder(
|
|
-(200000000 + (int) $d['id']),
|
|
(int) $d['id'],
|
|
'deworming',
|
|
'deworming',
|
|
t('agenda.deworming_reminder', ['product' => $d['dewormer_name'] ?: t('agenda.deworming')]),
|
|
$d['next_due_date'],
|
|
(int) $d['animal_id'],
|
|
$d['animal_name'],
|
|
'high',
|
|
);
|
|
}
|
|
|
|
$treatments = $db->prepare(
|
|
"SELECT t.id,t.start_date,t.end_date,m.name medication_name,a.id animal_id,a.name animal_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND (date(t.start_date) BETWEEN :from AND :to OR (t.end_date IS NOT NULL AND date(t.end_date) BETWEEN :from AND :to)) AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') ORDER BY date(t.start_date),a.name COLLATE NOCASE",
|
|
);
|
|
$treatments->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
|
|
foreach ($treatments->fetchAll(PDO::FETCH_ASSOC) as $t) {
|
|
if ($t['start_date'] >= $from->format('Y-m-d') && $t['start_date'] <= $to->format('Y-m-d')) {
|
|
$out[] = self::reminder(
|
|
-(300000000 + (int) $t['id']),
|
|
(int) $t['id'],
|
|
'treatment_start',
|
|
'treatment_start',
|
|
t('agenda.treatment_start', ['medication' => $t['medication_name']]),
|
|
$t['start_date'],
|
|
(int) $t['animal_id'],
|
|
$t['animal_name'],
|
|
'high',
|
|
);
|
|
}
|
|
if ($t['end_date'] && $t['end_date'] >= $from->format('Y-m-d') && $t['end_date'] <= $to->format('Y-m-d')) {
|
|
$out[] = self::reminder(
|
|
-(400000000 + (int) $t['id']),
|
|
(int) $t['id'],
|
|
'treatment_end',
|
|
'treatment_end',
|
|
t('agenda.treatment_end', ['medication' => $t['medication_name']]),
|
|
$t['end_date'],
|
|
(int) $t['animal_id'],
|
|
$t['animal_name'],
|
|
'high',
|
|
);
|
|
}
|
|
}
|
|
usort($out, static fn($a, $b) => strcmp($a['instance_starts_at'], $b['instance_starts_at']));
|
|
return $out;
|
|
}
|
|
private static function reminder(
|
|
int $id,
|
|
int $sourceId,
|
|
string $sourceType,
|
|
string $itemType,
|
|
string $title,
|
|
string $date,
|
|
int $animalId,
|
|
string $animalName,
|
|
string $priority,
|
|
): array {
|
|
return [
|
|
'id' => $id,
|
|
'source_id' => $sourceId,
|
|
'source_type' => $sourceType,
|
|
'item_type' => $itemType,
|
|
'title' => $title,
|
|
'description' => null,
|
|
'starts_at' => $date . ' 00:00:00',
|
|
'instance_starts_at' => $date . ' 00:00:00',
|
|
'instance_ends_at' => null,
|
|
'all_day' => 1,
|
|
'location' => null,
|
|
'status' => 'planned',
|
|
'priority' => $priority,
|
|
'assigned_user_id' => null,
|
|
'assigned_name' => null,
|
|
'volunteer_names' => null,
|
|
'volunteer_ids' => null,
|
|
'animal_id' => $animalId,
|
|
'animal_name' => $animalName,
|
|
'contact_id' => null,
|
|
'contact_name' => null,
|
|
'reminder_minutes' => null,
|
|
'repeat_rule' => 'none',
|
|
'repeat_until' => null,
|
|
];
|
|
}
|
|
private static function saveVolunteers(int $itemId, mixed $input): void
|
|
{
|
|
$ids = is_array($input) ? array_values(array_unique(array_filter(array_map('intval', $input)))) : [];
|
|
$db = DB::pdo();
|
|
$db->prepare('DELETE FROM agenda_volunteers WHERE agenda_item_id=?')->execute([$itemId]);
|
|
if (!$ids) {
|
|
return;
|
|
}
|
|
$check = $db->prepare(
|
|
"SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.id=? AND dc.deleted_at IS NULL",
|
|
);
|
|
$insert = $db->prepare('INSERT INTO agenda_volunteers(agenda_item_id,contact_id) VALUES(?,?)');
|
|
foreach ($ids as $id) {
|
|
$check->execute([$id]);
|
|
if ($check->fetchColumn()) {
|
|
$insert->execute([$itemId, $id]);
|
|
}
|
|
}
|
|
}
|
|
private static function date(string $v): bool
|
|
{
|
|
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
|
|
return $d && $d->format('Y-m-d') === $v;
|
|
}
|
|
}
|