Files
Investidor-V2/app/Services/TerritorialCsvAudit.php
T
2026-09-29 08:41:57 +02:00

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);
}
}
}