Lines
88.05%
140 / 159
Functions and Methods
55.55%
10 / 18
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| AppointmentProcessLimitsValidationService | 88.05% | 140 / 159 | 55.55% | 10 / 18 | 78.36 | 0.00% | 0 / 1 | |||
| 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 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace BO\Zmscitizenapi\Services\Core; | |
| 6 | ||
| 7 | use BO\Zmscitizenapi\Models\ThinnedProcess; | |
| 8 | use BO\Zmscitizenapi\Utils\ErrorMessages; | |
| 9 | use BO\Zmsentities\Process; | |
| 10 | ||
| 11 | class 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 | } |