134 lines
6.6 KiB
PHP
134 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use RuntimeException;
|
|
|
|
/** Offline structural validation. This never approves a source or writes territories. */
|
|
final class TerritorialCsvAudit
|
|
{
|
|
public const HEADERS = ['province_code', 'province_name', 'district_code', 'district_name', 'admin_post_code', 'admin_post_name', 'locality_code', 'locality_name', 'locality_type', 'latitude', 'longitude'];
|
|
|
|
/** @param list<string> $provinceCodes
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function inspect(string $path, array $provinceCodes): array
|
|
{
|
|
if (! is_file($path) || ! is_readable($path) || filesize($path) > 20 * 1024 * 1024) {
|
|
throw new RuntimeException('CSV inacessível ou superior a 20 MiB.');
|
|
}
|
|
$bytes = file_get_contents($path);
|
|
if ($bytes === false || ! mb_check_encoding($bytes, 'UTF-8') || str_contains($bytes, "\0")) {
|
|
throw new RuntimeException('O CSV deve ser UTF-8 sem bytes nulos.');
|
|
}
|
|
// Parse the exact bytes hashed, avoiding a second read of a changing input.
|
|
$stream = fopen('php://temp', 'w+');
|
|
if ($stream === false) {
|
|
throw new RuntimeException('Não foi possível preparar a leitura.');
|
|
}
|
|
fwrite($stream, preg_replace('/^\xEF\xBB\xBF/', '', $bytes) ?? $bytes);
|
|
rewind($stream);
|
|
try {
|
|
if (fgetcsv($stream, null, ';', '"', '') !== self::HEADERS) {
|
|
throw new RuntimeException('Cabeçalho inválido: usar o contrato INE com separador ponto e vírgula.');
|
|
}
|
|
$errors = $seen = $names = $totals = [];
|
|
$duplicates = $count = 0;
|
|
while (($values = fgetcsv($stream, null, ';', '"', '')) !== false) {
|
|
$count++;
|
|
if ($count > 50000) {
|
|
throw new RuntimeException('O CSV excede 50.000 registos.');
|
|
}
|
|
$issues = [];
|
|
if (count($values) !== count(self::HEADERS)) {
|
|
$errors[] = ['record' => $count + 1, 'issues' => ['column_count']];
|
|
|
|
continue;
|
|
}
|
|
$row = array_combine(self::HEADERS, array_map(fn ($v) => trim((string) $v), $values));
|
|
foreach ($row as $field => $value) {
|
|
if (mb_strlen($value) > 255 || preg_match('/[\x00-\x1F\x7F]/', $value)) {
|
|
$issues[] = 'invalid_field:'.$field;
|
|
}
|
|
}
|
|
if (! preg_match('/^[0-9]{2}$/D', $row['province_code']) || ! in_array($row['province_code'], $provinceCodes, true)) {
|
|
$issues[] = 'unknown_province';
|
|
}
|
|
foreach (['province', 'district', 'admin_post', 'locality'] as $kind) {
|
|
$code = $row[$kind.'_code'];
|
|
$name = $row[$kind.'_name'];
|
|
if (($kind === 'province' || $kind === 'district' || $code !== '') && $name === '') {
|
|
$issues[] = 'missing_name:'.$kind;
|
|
}
|
|
if (($kind === 'province' || $kind === 'district' || $name !== '') && $code === '') {
|
|
$issues[] = 'missing_code:'.$kind;
|
|
}
|
|
if ($code === '') {
|
|
continue;
|
|
}
|
|
if (! preg_match('/^[0-9]+$/D', $code)) {
|
|
$issues[] = 'invalid_code:'.$kind;
|
|
}
|
|
$parent = match ($kind) {
|
|
'province' => 'MZ', 'district' => $row['province_code'],
|
|
'admin_post' => $row['district_code'], default => $row['admin_post_code'],
|
|
};
|
|
$identity = $kind.':'.$code;
|
|
$definition = [$parent, $name];
|
|
if ($kind === 'locality') {
|
|
$definition = [...$definition, $row['locality_type'], $row['latitude'], $row['longitude']];
|
|
}
|
|
if (isset($seen[$identity]) && $seen[$identity] !== $definition) {
|
|
$issues[] = 'conflicting_code:'.$kind;
|
|
}
|
|
$seen[$identity] ??= $definition;
|
|
$nameKey = $kind.':'.$parent.':'.mb_strtolower($name);
|
|
if (isset($names[$nameKey]) && $names[$nameKey] !== $code) {
|
|
$issues[] = 'ambiguous_name:'.$kind;
|
|
}
|
|
$names[$nameKey] ??= $code;
|
|
$totals[$row['province_code']][$kind][$code] = true;
|
|
}
|
|
if ($row['locality_code'] !== '' && $row['admin_post_code'] === '') {
|
|
$issues[] = 'locality_requires_reviewed_parent_exception';
|
|
}
|
|
if (($row['latitude'] === '') !== ($row['longitude'] === '')) {
|
|
$issues[] = 'incomplete_coordinates';
|
|
}
|
|
foreach (['latitude' => 90, 'longitude' => 180] as $field => $bound) {
|
|
if ($row[$field] !== '' && (! preg_match('/^-?[0-9]+(?:\.[0-9]+)?$/D', $row[$field]) || abs((float) $row[$field]) > $bound)) {
|
|
$issues[] = 'invalid_coordinate:'.$field;
|
|
}
|
|
}
|
|
if ($row['locality_code'] === '' && ($row['latitude'] !== '' || $row['longitude'] !== '' || $row['locality_type'] !== '')) {
|
|
$issues[] = 'locality_metadata_without_locality';
|
|
}
|
|
$fingerprint = hash('sha256', json_encode($row, JSON_THROW_ON_ERROR));
|
|
if (isset($seen['row:'.$fingerprint])) {
|
|
$duplicates++;
|
|
}
|
|
$seen['row:'.$fingerprint] = [];
|
|
if ($issues !== []) {
|
|
$errors[] = ['record' => $count + 1, 'issues' => array_values(array_unique($issues))];
|
|
}
|
|
}
|
|
if ($count === 0) {
|
|
$errors[] = ['record' => 1, 'issues' => ['empty_dataset']];
|
|
}
|
|
ksort($totals);
|
|
$counts = [];
|
|
foreach ($totals as $province => $kinds) {
|
|
$counts[$province] = array_map('count', $kinds);
|
|
}
|
|
|
|
return ['schema_version' => 1, 'sha256' => hash('sha256', $bytes), 'encoding' => 'UTF-8',
|
|
'delimiter' => ';', 'coordinate_crs' => 'EPSG:4326', 'records' => $count,
|
|
'duplicate_records' => $duplicates, 'rejected_records' => count($errors),
|
|
'status' => $errors === [] ? 'structurally_valid_requires_review' : 'rejected',
|
|
'candidate_counts_by_province' => $counts, 'errors' => $errors];
|
|
} finally {
|
|
fclose($stream);
|
|
}
|
|
}
|
|
}
|