Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.09% covered (warning)
76.09%
70 / 92
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
AvailabilityHistory
76.09% covered (warning)
76.09%
70 / 92
50.00% covered (danger)
50.00%
5 / 10
49.81
0.00% covered (danger)
0.00%
0 / 1
 writeCreated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeUpdated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeDeleted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeDldbSlotUpdate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 readListByScopeId
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 deleteOlderThanDays
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 buildSnapshot
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
9.07
 formatTimeValue
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
7.33
 write
50.00% covered (danger)
50.00%
11 / 22
0.00% covered (danger)
0.00%
0 / 1
6.00
 resolveChangedBy
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmsbackend\Availability\Service;
6
7use BO\Zmsbackend\Availability\Repository\AvailabilityHistory as AvailabilityHistoryQuery;
8use BO\Zmsbackend\Helper\User;
9use BO\Zmsentities\Availability;
10use BO\Zmsentities\AvailabilityHistory as Entity;
11use BO\Zmsentities\Collection\AvailabilityHistoryList as Collection;
12use App;
13
14class AvailabilityHistory extends \BO\Zmsbackend\Base
15{
16    public const string ACTION_CREATED = Entity::ACTION_CREATED;
17    public const string ACTION_UPDATED = Entity::ACTION_UPDATED;
18    public const string ACTION_DELETED = Entity::ACTION_DELETED;
19    public const string ACTION_DLDB_SLOT_UPDATE = Entity::ACTION_DLDB_SLOT_UPDATE;
20
21    public const int DEFAULT_RETENTION_DAYS = 180;
22    public const int MAX_ROWS = 500;
23
24    private const int COMMENT_MAX_LENGTH = 200;
25
26    public function writeCreated(Availability $availability, ?string $changedBy = null): void
27    {
28        $this->write(self::ACTION_CREATED, $availability, $changedBy);
29    }
30
31    public function writeUpdated(Availability $availability, ?string $changedBy = null): void
32    {
33        $this->write(self::ACTION_UPDATED, $availability, $changedBy);
34    }
35
36    public function writeDeleted(Availability $availability, ?string $changedBy = null): void
37    {
38        $this->write(self::ACTION_DELETED, $availability, $changedBy);
39    }
40
41    public function writeDldbSlotUpdate(Availability $availability): void
42    {
43        $this->write(self::ACTION_DLDB_SLOT_UPDATE, $availability, 'dldb');
44    }
45
46    public function readListByScopeId(
47        int $scopeId,
48        \DateTimeInterface $from,
49        \DateTimeInterface $to,
50        ?int $availabilityId = null,
51        ?string $action = null
52    ): Collection {
53        $query = new AvailabilityHistoryQuery(\BO\Zmsbackend\Query\Base::SELECT);
54        $query->addEntityMapping()
55            ->addConditionScopeId($scopeId)
56            ->addConditionChangedAtRange($from, $to)
57            ->addLimit(self::MAX_ROWS);
58
59        if ($availabilityId !== null) {
60            $query->addConditionAvailabilityId($availabilityId);
61        }
62        if ($action !== null) {
63            $query->addConditionAction($action);
64        }
65
66        $collection = new Collection();
67        foreach ($this->fetchList($query, new Entity()) as $entity) {
68            if ($entity instanceof Entity) {
69                $collection->addEntity($entity);
70            }
71        }
72
73        return $collection;
74    }
75
76    public function deleteOlderThanDays(int $days): int
77    {
78        $days = max(1, $days);
79        $now = \DateTimeImmutable::createFromInterface(App::$now ?? new \DateTimeImmutable('now'));
80        $cutoff = $now->modify('-' . $days . ' days')->format('Y-m-d H:i:s');
81
82        return (int) $this->fetchAffected(AvailabilityHistoryQuery::QUERY_DELETE_OLDER_THAN, [
83            'cutoff' => $cutoff,
84        ]);
85    }
86
87    public function buildSnapshot(Availability $availability): array
88    {
89        $comment = $availability['description'] ?? null;
90        if (is_string($comment) && mb_strlen($comment) > self::COMMENT_MAX_LENGTH) {
91            $comment = mb_substr($comment, 0, self::COMMENT_MAX_LENGTH - 3) . '...';
92        }
93
94        $intern = (int) ($availability['workstationCount']['intern'] ?? 0);
95        $public = (int) ($availability['workstationCount']['public'] ?? 0);
96        $slotMinutes = (int) ($availability['slotTimeInMinutes'] ?? $availability->getSlotTimeInMinutes());
97        $isOpeningHours = $availability['type'] === 'openinghours';
98
99        return [
100            'start_date' => $availability->getStartDateTime()->format('Y-m-d'),
101            'end_date' => $availability->getEndDateTime()->format('Y-m-d'),
102            'every_x_weeks' => (int) ($availability['repeat']['afterWeeks'] ?? 0),
103            'every_other_week' => (int) ($availability['repeat']['weekOfMonth'] ?? 0),
104            'weekday' => Entity::encodeWeekdayMask($availability),
105            'start_time' => $isOpeningHours ? $this->formatTimeValue($availability['startTime']) : '00:00:00',
106            'end_time' => $isOpeningHours ? $this->formatTimeValue($availability['endTime']) : '00:00:00',
107            'appointment_start_time' => $isOpeningHours
108                ? '00:00:00'
109                : $this->formatTimeValue($availability['startTime']),
110            'appointment_end_time' => $isOpeningHours
111                ? '00:00:00'
112                : $this->formatTimeValue($availability['endTime']),
113            'time_slot' => gmdate('H:i:s', max(0, $slotMinutes) * 60),
114            'workstation_count' => 0,
115            'appointment_workstation_count' => $intern,
116            'comment' => $comment,
117            'internet_reduction' => $intern - $public,
118            'multiple_slots_allowed' => !empty($availability['multipleSlotsAllowed']) ? 1 : 0,
119            'open_from_days' => (int) ($availability['bookable']['startInDays'] ?? 0),
120            'open_until_days' => (int) ($availability['bookable']['endInDays'] ?? 0),
121            'version' => $availability['version'] !== null ? (int) $availability['version'] : 1,
122        ];
123    }
124
125    protected function formatTimeValue(mixed $value): string
126    {
127        if ($value === null || $value === '' || $value === 0 || $value === '0') {
128            return '00:00:00';
129        }
130
131        $value = trim((string) $value);
132        if (preg_match('/^\d{1,2}:\d{2}$/', $value) === 1) {
133            return $value . ':00';
134        }
135
136        return $value;
137    }
138
139    protected function write(string $action, Availability $availability, ?string $changedBy): void
140    {
141        try {
142            $scopeId = (int) ($availability->scope['id'] ?? 0);
143            if ($scopeId < 1) {
144                App::$log->warning('availability_history skipped: missing scope_id', [
145                    'action' => $action,
146                    'availability_id' => $availability->id ?? null,
147                ]);
148                return;
149            }
150
151            $snapshot = $this->buildSnapshot($availability);
152            $query = new AvailabilityHistoryQuery(\BO\Zmsbackend\Query\Base::INSERT);
153            $query->addValues($query->reverseEntityMapping(array_merge($snapshot, [
154                'scope_id' => $scopeId,
155                'availability_id' => $availability->hasId() ? (int) $availability->getId() : null,
156                'action' => $action,
157                'changed_by' => $changedBy ?? $this->resolveChangedBy(),
158            ])));
159
160            $this->writeItem($query);
161        } catch (\Throwable $exception) {
162            App::$log->error('availability_history write failed', [
163                'action' => $action,
164                'availability_id' => $availability->id ?? null,
165                'exception' => $exception->getMessage(),
166            ]);
167        }
168    }
169
170    protected function resolveChangedBy(): string
171    {
172        try {
173            if (User::hasLogin()) {
174                $userId = User::readWorkstation()->getUseraccount()->id ?? null;
175                if ($userId !== null && $userId !== '') {
176                    return (string) $userId;
177                }
178            }
179        } catch (\Throwable $exception) {
180            App::$log->warning('availability_history actor resolution failed', [
181                'exception' => $exception->getMessage(),
182            ]);
183        }
184
185        return 'system';
186    }
187}