Files
compras/app/Services/AdministrativeGeography.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

166 lines
7.0 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
class AdministrativeGeography
{
public const ISSUES = [
'blank_designation' => '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<string,mixed>|null */
private ?array $data = null;
/** @return array<string,mixed> */
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<string,string> */
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<string,mixed>|null $reference
* @return array<string,mixed>|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<string,mixed> */
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<string,mixed>|null $reference
* @return array<string,mixed>|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<string,mixed>|null $reference
* @return array<string,mixed>|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<string,mixed> $record
* @return array<string,mixed>
*/
private function summary(array $record): array
{
return array_intersect_key($record, array_flip(['id', 'name', 'level', 'parent_id']));
}
/** @return array<string,mixed> */
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<string> */
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<string,mixed> $filters
* @return list<array<string,mixed>>
*/
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)));
}
}