'Designação vazia', 'incomplete_code_path' => 'Percurso de códigos incompleto', 'duplicate_code_path' => 'Código repetido', 'missing_parent' => 'Nível superior ausente', 'ambiguous_parent' => 'Nível superior ambíguo', 'ancestor_requires_review' => 'Ascendente por rever', ]; /** @var array|null */ private ?array $data = null; /** @return array */ public function catalogue(): array { if ($this->data === null) { $content = file_get_contents(resource_path('data/geography/cartografia-moz.json')); abort_unless(is_string($content) && hash_equals((string) config('geography.catalogue_sha256'), hash('sha256', $content)), 503, 'A integridade do catálogo precisa de verificação.'); $data = json_decode($content, true, 32, JSON_THROW_ON_ERROR); abort_unless(is_array($data) && ($data['manifest']['source_sha256'] ?? null) === config('geography.source_sha256'), 503, 'Catálogo geográfico indisponível.'); $this->data = $data; } return $this->data; } public function enabled(): bool { return config('geography.enabled') === true && Schema::hasColumn('suppliers', 'geography_reference') && Schema::hasColumn('tenants', 'geography_reference'); } public function availableFor(string $table): bool { return in_array($table, ['tenants', 'suppliers', 'warehouses', 'equipment'], true) && (! in_array($table, ['warehouses', 'equipment'], true) || config('geography.operational_enabled') === true) && $this->enabled() && Schema::hasColumn($table, 'geography_reference'); } /** @return array */ public function referenceRules(string $table): array { return ['geography_reference' => $this->availableFor($table) ? 'sometimes|nullable|array:country,catalogue,record_id' : 'prohibited', 'geography_reference.catalogue' => 'required_with:geography_reference|string|size:64', 'geography_reference.record_id' => 'required_with:geography_reference|string|regex:/^[0-9]+$/D|max:20']; } /** @param array|null $reference * @return array|null */ public function canonicalFor(string $table, ?array $reference): ?array { abort_unless($this->availableFor($table), 503, 'A localização deste cadastro ainda não está instalada.'); return $this->canonical($reference); } /** @return array */ public function record(string $id): array { foreach ($this->catalogue()['records'] as $record) { if ($record['id'] === $id) { return $record; } } throw ValidationException::withMessages(['geography_reference' => 'Localização não encontrada no catálogo.']); } /** @param array|null $reference * @return array|null */ public function canonical(?array $reference): ?array { abort_unless($this->enabled(), 503, 'A localização administrativa ainda não está disponível.'); if ($reference === null) { return null; } if (($reference['catalogue'] ?? null) !== $this->catalogue()['manifest']['source_sha256']) { throw ValidationException::withMessages(['geography_reference' => 'O catálogo foi alterado. Reabra a ficha antes de guardar.']); } $record = $this->record((string) ($reference['record_id'] ?? '')); if (! $record['selectable']) { throw ValidationException::withMessages(['geography_reference' => 'Este registo ou a sua hierarquia precisa de revisão. Escolha um nível superior confirmado.']); } return ['country' => 'MZ', 'catalogue' => $reference['catalogue'], 'record_id' => $record['id']]; } /** @param array|null $reference * @return array|null */ public function describe(?array $reference): ?array { if ($reference === null) { return null; } if (($reference['catalogue'] ?? null) !== $this->catalogue()['manifest']['source_sha256']) { return ['reference' => $reference, 'path' => [], 'requires_review' => true]; } $record = $this->record((string) $reference['record_id']); $path = [$record]; while ($record['parent_id'] !== null) { $record = $this->record($record['parent_id']); array_unshift($path, $record); } return ['reference' => $reference, 'path' => array_map(fn ($node) => $this->summary($node), $path), 'requires_review' => ! $path[count($path) - 1]['selectable']]; } /** @param array $record * @return array */ private function summary(array $record): array { return array_intersect_key($record, array_flip(['id', 'name', 'level', 'parent_id'])); } /** @return array */ public function children(?string $parent, int $page = 1): array { if ($parent !== null && ! $this->record($parent)['selectable']) { throw ValidationException::withMessages(['parent' => 'Hierarquia pendente de revisão.']); } $records = array_values(array_filter($this->catalogue()['records'], fn ($record) => $record['selectable'] && $record['parent_id'] === $parent)); usort($records, fn ($a, $b) => strcmp($a['name'], $b['name']) ?: strcmp($a['id'], $b['id'])); return ['data' => array_map(fn ($record) => $this->summary($record), array_slice($records, ($page - 1) * 50, 50)), 'page' => $page, 'has_more' => count($records) > $page * 50, 'catalogue' => $this->catalogue()['manifest']['source_sha256']]; } /** @return list */ public function descendants(string $id): array { $root = $this->record($id); if (! $root['selectable']) { throw ValidationException::withMessages(['location' => 'Localização pendente de revisão.']); } $prefix = array_slice($root['codes'], 0, $root['level']); return array_values(array_map(fn ($record) => $record['id'], array_filter($this->catalogue()['records'], fn ($record) => $record['selectable'] && array_slice($record['codes'], 0, count($prefix)) === $prefix))); } /** @param array $filters * @return list> */ public function inconsistencies(array $filters): array { $search = trim($filters['search'] ?? ''); $issue = $filters['issue'] ?? ''; return array_values(array_filter($this->catalogue()['records'], fn ($record) => ! $record['selectable'] && ($issue === '' || in_array($issue, $record['issues'], true)) && ($search === '' || $record['id'] === $search || mb_stripos($record['name'], $search) !== false))); } }