Lines 88.05% 140 / 159
Methods 55.55% 10 / 18
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 getError 100.00% 1 / 1 100.00% 1 / 1 1
 validateProcessLimits 100.00% 10 / 10 100.00% 1 / 1 3
 validateThinnedProcessLimits 100.00% 4 / 4 100.00% 1 / 1 1
 validateReserveRequestLimits 90.00% 9 / 10 0.00% 0 / 1 2.00
 filterOfficeIdsWithinRequestLimits 95.65% 22 / 23 0.00% 0 / 1 5
 validateReserveLimits 100.00% 11 / 11 100.00% 1 / 1 5
 computeRequiredSlotCount 85.71% 12 / 14 0.00% 0 / 1 7.14
 readSlotsPerAppointmentFromProcess 80.00% 4 / 5 0.00% 0 / 1 4.13
 validateRequestLimitsWithMaps 76.92% 10 / 13 0.00% 0 / 1 3.11
 readSlotsPerAppointmentByOffice 100.00% 1 / 1 100.00% 1 / 1 1
 indexSlotsPerAppointmentByOffice 69.56% 16 / 23 0.00% 0 / 1 12.82
 buildServiceCountMap 100.00% 5 / 5 100.00% 1 / 1 2
 validateSlotLimit 100.00% 6 / 6 100.00% 1 / 1 4
 validateServiceQuantityLimits 85.71% 6 / 7 0.00% 0 / 1 6.10
 resolveMaxSlots 100.00% 6 / 6 100.00% 1 / 1 4
 readMaxQuantityByOffice 100.00% 1 / 1 100.00% 1 / 1 1
 relationMapsForOffice 100.00% 2 / 2 100.00% 1 / 1 1
 indexRelationMapsByOffice 82.35% 14 / 17 0.00% 0 / 1 10.55
11class AppointmentProcessLimitsValidationService
12{
13    /** Must match Slot::MAX_SLOTS / citizenview MAX_SLOTS */
14    private const int MAX_SLOTS_FALLBACK = 25;
15
16    private static function getError(string $key): array
17    {
18        return ErrorMessages::get($key);
19    }
20
21    public static function validateProcessLimits(
22        ?int $slotCount,
23        mixed $slotsPerAppointment,
24        array $serviceIdToCount,
25        int $officeId
26    ): array {
27        $errors = self::validateSlotLimit($slotCount, $slotsPerAppointment)['errors'];
28        if ($officeId > 0 && $serviceIdToCount !== []) {
29            $errors = array_merge(
30                $errors,
31                self::validateServiceQuantityLimits(
32                    $serviceIdToCount,
33                    self::readMaxQuantityByOffice($officeId)
34                )['errors']
35            );
36        }
37
38        return ['errors' => $errors];
39    }
40
41    public static function validateThinnedProcessLimits(ThinnedProcess $process): array
42    {
43        return self::validateSlotLimit(
44            $process->slotCount,
45            $process->scope?->getSlotsPerAppointment()
46        );
47    }
48
49    /**
50     * Request-side check before free-slot lookup. Uses relation slots × counts so over-spa
51     * rejects with tooManySlotsPerAppointment even when no free hole of that size exists.
52     */
53    public static function validateReserveRequestLimits(
54        array $serviceIds,
55        array $serviceCounts,
56        int $officeId
57    ): array {
58        $serviceIdToCount = self::buildServiceCountMap($serviceIds, $serviceCounts);
59        if ($officeId <= 0) {
60            return ['errors' => []];
61        }
62
63        $relationMaps = self::relationMapsForOffice($officeId);
64
65        return self::validateRequestLimitsWithMaps(
66            $serviceIdToCount,
67            self::computeRequiredSlotCount($serviceIdToCount, $relationMaps['slots']),
68            self::readSlotsPerAppointmentByOffice($officeId),
69            $relationMaps['maxQuantity']
70        );
71    }
72
73    /**
74     * Keep offices that pass the same request-side slot/quantity limits as reserve.
75     * Loads scopes and relations once for the whole office list (calendar multi-office path).
76     *
77     * @param list<string|int> $officeIds
78     * @param list<string|int> $serviceIds
79     * @param list<string|int> $serviceCounts
80     * @return array{officeIds?: list<string>, errors?: list<array>}
81     */
82    public static function filterOfficeIdsWithinRequestLimits(
83        array $officeIds,
84        array $serviceIds,
85        array $serviceCounts
86    ): array {
87        $serviceIdToCount = self::buildServiceCountMap($serviceIds, $serviceCounts);
88        $slotsPerAppointmentByOffice = self::indexSlotsPerAppointmentByOffice();
89        $relationMapsByOffice = self::indexRelationMapsByOffice();
90
91        $eligible = [];
92        $lastErrors = ['errors' => []];
93
94        foreach ($officeIds as $officeIdRaw) {
95            $officeId = (int) $officeIdRaw;
96            if ($officeId <= 0) {
97                continue;
98            }
99
100            $relationMaps = $relationMapsByOffice[$officeId] ?? ['slots' => [], 'maxQuantity' => []];
101            $limitErrors = self::validateRequestLimitsWithMaps(
102                $serviceIdToCount,
103                self::computeRequiredSlotCount($serviceIdToCount, $relationMaps['slots']),
104                $slotsPerAppointmentByOffice[$officeId] ?? null,
105                $relationMaps['maxQuantity']
106            );
107            if (!empty($limitErrors['errors'])) {
108                $lastErrors = $limitErrors;
109                continue;
110            }
111
112            $eligible[] = (string) $officeIdRaw;
113        }
114
115        if ($eligible === []) {
116            return $lastErrors;
117        }
118
119        return ['officeIds' => $eligible];
120    }
121
122    public static function validateReserveLimits(
123        Process $process,
124        array $serviceIds,
125        array $serviceCounts,
126        int $officeId
127    ): array {
128        $appointment = $process->getFirstAppointment();
129        $slotCount = $appointment ? (int) $appointment->getSlotCount() : null;
130        $slotsPerAppointment = self::readSlotsPerAppointmentFromProcess($process);
131        // Free-process payloads often omit client.slotsPerAppointment; fall back to the office scope.
132        if (($slotsPerAppointment === null || $slotsPerAppointment === '') && $officeId > 0) {
133            $slotsPerAppointment = self::readSlotsPerAppointmentByOffice($officeId);
134        }
135
136        return self::validateProcessLimits(
137            $slotCount,
138            $slotsPerAppointment,
139            self::buildServiceCountMap($serviceIds, $serviceCounts),
140            $officeId
141        );
142    }
143
144    public static function computeRequiredSlotCount(array $serviceIdToCount, array $slotsByServiceId): ?int
145    {
146        if ($serviceIdToCount === []) {
147            return null;
148        }
149
150        $total = 0;
151        foreach ($serviceIdToCount as $serviceId => $count) {
152            if ($count <= 0) {
153                continue;
154            }
155            $sid = (int) $serviceId;
156            if (!array_key_exists($sid, $slotsByServiceId)) {
157                return null;
158            }
159            $slots = (int) $slotsByServiceId[$sid];
160            if ($slots < 1) {
161                return null;
162            }
163            $total += $slots * (int) $count;
164        }
165
166        return $total > 0 ? $total : null;
167    }
168
169    private static function readSlotsPerAppointmentFromProcess(Process $process): mixed
170    {
171        if (
172            !isset($process->scope)
173            || !is_object($process->scope)
174            || !method_exists($process->scope, 'getSlotsPerAppointment')
175        ) {
176            return null;
177        }
178
179        return $process->scope->getSlotsPerAppointment();
180    }
181
182    /**
183     * Shared request-side checks for reserve and calendar filtering (indexed maps already loaded).
184     * Slot and quantity errors are both returned when both limits fail.
185     *
186     * @param array<int, int> $serviceIdToCount
187     * @param array<int, int|null> $maxQuantityByServiceId
188     * @return array{errors: list<array>}
189     */
190    private static function validateRequestLimitsWithMaps(
191        array $serviceIdToCount,
192        ?int $requiredSlotCount,
193        mixed $slotsPerAppointment,
194        array $maxQuantityByServiceId
195    ): array {
196        if ($requiredSlotCount === null) {
197            // Missing relation or slots < 1: do not fall through to quantity-only checks.
198            // Backend may otherwise normalize an unresolved requirement to a one-slot hold.
199            if ($serviceIdToCount !== []) {
200                return ['errors' => [self::getError('invalidLocationAndServiceCombination')]];
201            }
202
203            return ['errors' => []];
204        }
205
206        return [
207            'errors' => array_merge(
208                self::validateSlotLimit($requiredSlotCount, $slotsPerAppointment)['errors'],
209                self::validateServiceQuantityLimits(
210                    $serviceIdToCount,
211                    $maxQuantityByServiceId
212                )['errors']
213            ),
214        ];
215    }
216
217    private static function readSlotsPerAppointmentByOffice(int $officeId): mixed
218    {
219        return self::indexSlotsPerAppointmentByOffice()[$officeId] ?? null;
220    }
221
222    /**
223     * @return array<int, int> officeId => minimum slotsPerAppointment across scopes
224     */
225    private static function indexSlotsPerAppointmentByOffice(): array
226    {
227        $scopeList = ZmsApiClientService::getScopes();
228        if (!$scopeList) {
229            return [];
230        }
231
232        $byOffice = [];
233        foreach ($scopeList as $scope) {
234            try {
235                $provider = $scope->getProvider();
236            } catch (\Throwable) {
237                continue;
238            }
239            if (!$provider) {
240                continue;
241            }
242            $officeId = (int) $provider->id;
243            if ($officeId <= 0) {
244                continue;
245            }
246            $slotsPerAppointment = $scope->getSlotsPerAppointment();
247            if ($slotsPerAppointment === null || $slotsPerAppointment === '') {
248                continue;
249            }
250            $parsed = (int) $slotsPerAppointment;
251            if ($parsed < 1) {
252                continue;
253            }
254            $byOffice[$officeId] = isset($byOffice[$officeId])
255                ? min($byOffice[$officeId], $parsed)
256                : $parsed;
257        }
258
259        return $byOffice;
260    }
261
262    public static function buildServiceCountMap(array $serviceIds, array $serviceCounts): array
263    {
264        $map = [];
265        foreach ($serviceIds as $index => $serviceId) {
266            $id = (int) $serviceId;
267            $map[$id] = ($map[$id] ?? 0) + (int) ($serviceCounts[$index] ?? 0);
268        }
269
270        return $map;
271    }
272
273    public static function validateSlotLimit(?int $slotCount, mixed $slotsPerAppointment): array
274    {
275        if ($slotCount === null || $slotCount <= 0) {
276            return ['errors' => [self::getError('processInvalid')]];
277        }
278
279        $maxSlots = self::resolveMaxSlots($slotsPerAppointment);
280        if ($slotCount > $maxSlots) {
281            return ['errors' => [self::getError('tooManySlotsPerAppointment')]];
282        }
283
284        return ['errors' => []];
285    }
286
287    public static function validateServiceQuantityLimits(array $serviceIdToCount, array $maxQuantityByServiceId): array
288    {
289        foreach ($serviceIdToCount as $serviceId => $count) {
290            if ($count <= 0) {
291                continue;
292            }
293            $maxQuantity = $maxQuantityByServiceId[(int) $serviceId] ?? null;
294            if ($maxQuantity !== null && $maxQuantity > 0 && $count > $maxQuantity) {
295                return ['errors' => [self::getError('tooManyServicesPerAppointment')]];
296            }
297        }
298
299        return ['errors' => []];
300    }
301
302    private static function resolveMaxSlots(mixed $slotsPerAppointment): int
303    {
304        if ($slotsPerAppointment === null || $slotsPerAppointment === '') {
305            return self::MAX_SLOTS_FALLBACK;
306        }
307        $parsed = (int) $slotsPerAppointment;
308        if ($parsed < 1) {
309            return self::MAX_SLOTS_FALLBACK;
310        }
311
312        return $parsed;
313    }
314
315    private static function readMaxQuantityByOffice(int $officeId): array
316    {
317        return self::relationMapsForOffice($officeId)['maxQuantity'];
318    }
319
320    /**
321     * @return array{slots: array<int, int>, maxQuantity: array<int, int|null>}
322     */
323    private static function relationMapsForOffice(int $officeId): array
324    {
325        return self::indexRelationMapsByOffice()[$officeId]
326            ?? ['slots' => [], 'maxQuantity' => []];
327    }
328
329    /**
330     * @return array<int, array{slots: array<int, int>, maxQuantity: array<int, int|null>}>
331     */
332    private static function indexRelationMapsByOffice(): array
333    {
334        $relationList = ZmsApiClientService::getRequestRelationList();
335        if (!$relationList) {
336            return [];
337        }
338
339        $byOffice = [];
340        foreach ($relationList as $relation) {
341            $officeId = isset($relation->provider->id) ? (int) $relation->provider->id : 0;
342            $serviceId = isset($relation->request->id) ? (int) $relation->request->id : 0;
343            if ($officeId <= 0 || $serviceId <= 0) {
344                continue;
345            }
346            if (!isset($byOffice[$officeId])) {
347                $byOffice[$officeId] = ['slots' => [], 'maxQuantity' => []];
348            }
349            $byOffice[$officeId]['slots'][$serviceId] = (int) $relation->slots;
350            $maxQuantity = $relation->getMaxQuantity();
351            $byOffice[$officeId]['maxQuantity'][$serviceId] = $maxQuantity === null || $maxQuantity === ''
352                ? null
353                : (int) $maxQuantity;
354        }
355
356        return $byOffice;
357    }
358}