Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\DemographicEvidence;
|
||||
use App\Services\DemographicImport;
|
||||
use App\Services\DemographicInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class DemographyController extends Controller
|
||||
{
|
||||
public function index(Request $request, DemographicEvidence $service): Response|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
|
||||
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
|
||||
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
|
||||
$start = (int) ($data['start_year'] ?? date('Y'));
|
||||
$end = (int) ($data['end_year'] ?? $start);
|
||||
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
|
||||
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($evidence);
|
||||
}
|
||||
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
|
||||
|
||||
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
|
||||
}
|
||||
|
||||
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
|
||||
{
|
||||
if (! Schema::hasTable('demographic_imports')) {
|
||||
return redirect('/management/demography/install');
|
||||
}
|
||||
$service->authorize($request->user());
|
||||
$data = $request->validate(['batch' => 'nullable|uuid']);
|
||||
|
||||
return Inertia::render('management/demography', ['qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
|
||||
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
|
||||
}
|
||||
|
||||
public function installation(Request $request, DemographicInstallation $service): Response
|
||||
{
|
||||
return Inertia::render('management/demography-install', ['plan' => $service->preview($request->user())]);
|
||||
}
|
||||
|
||||
public function install(Request $request, DemographicInstallation $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->apply($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function prepare(Request $request, DemographicImport $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file|mimes:xlsx|max:19531', 'publication_version' => 'required|string|max:255', 'baseline_year' => 'nullable|integer|between:1900,2100']);
|
||||
$file = $request->file('file');
|
||||
|
||||
return response()->json($service->prepare($request->user(), $file->getRealPath(), $file->getClientOriginalName(), $data['publication_version'], isset($data['baseline_year']) ? (int) $data['baseline_year'] : null));
|
||||
}
|
||||
|
||||
public function confirm(Request $request, string $id, DemographicImport $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['sha256' => 'required|string|size:64', 'mapping' => 'present|array', 'mapping.*' => 'required|uuid', 'reason' => 'required|string|min:10|max:2000']);
|
||||
|
||||
return response()->json($service->confirm($request->user(), $id, $data['sha256'], $data['mapping'], $data['reason']));
|
||||
}
|
||||
|
||||
public function transition(Request $request, string $id, DemographicImport $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['action' => 'required|in:approved,disabled', 'reason' => 'required|string|min:10|max:2000']);
|
||||
$service->transition($request->user(), $id, $data['action'], $data['reason']);
|
||||
|
||||
return response()->json(['id' => $id, 'status' => $data['action']]);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ use Inertia\Response;
|
||||
|
||||
class InvestorPathController extends Controller
|
||||
{
|
||||
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(app(InvestorPath::class)->available(), 404);
|
||||
$request->validate(['opportunity' => 'nullable|string|max:150']);
|
||||
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
|
||||
|
||||
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
|
||||
->header('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
public function index(Request $request, InvestorPath $path): JsonResponse|Response
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class DemographicEvidence
|
||||
{
|
||||
public function territories(): Builder
|
||||
{
|
||||
$public = app(InvestmentGeography::class)->publicReferences();
|
||||
|
||||
return DB::table('geo_locations')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district'])
|
||||
->where(function ($q) use ($public) {
|
||||
$q->whereIn('id', (clone $public)->select('id'));
|
||||
if ($this->installed() && str_ends_with(DB::connection()->getDatabaseName(), '_test')) {
|
||||
// Macate/Vanduzi: expressly authorised QA references, never active registry changes.
|
||||
$q->orWhere(fn ($qa) => $qa->where('source_system', 'ine-demography-qa')->where('data_classification', 'public')->whereIn('parent_id', (clone $public)->select('id')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return (bool) config('demography.enabled', app()->environment('local', 'testing'))
|
||||
&& Schema::hasTable('demographic_imports') && Schema::hasTable('demographic_observations');
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public static function sectors(): array
|
||||
{
|
||||
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
|
||||
}
|
||||
|
||||
/** @param list<string> $territories
|
||||
* @return array<string,mixed> */
|
||||
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
|
||||
{
|
||||
$result = ['state' => 'unavailable', 'start_year' => $start, 'end_year' => $end, 'sector' => $sector, 'territories' => [], 'differences' => [], 'comparisons' => [],
|
||||
'limitations' => ['População residente não mede procura efectiva ou poder de compra.', 'Idade activa não demonstra disponibilidade ou qualificação de mão-de-obra.',
|
||||
'Sem extrapolação: anos sem série oficial permanecem sem dados.', 'A demografia não altera impostos, incentivos ou probabilidades de sucesso.']];
|
||||
if (! $this->installed() || $territories === []) {
|
||||
return $result;
|
||||
}
|
||||
$locations = $this->territories()->whereIn('id', $territories)->get(['id', 'name', 'location_type']);
|
||||
$records = DB::table('demographic_observations as o')->join('demographic_imports as b', 'b.id', '=', 'o.import_id')
|
||||
->where('b.status', 'approved')->whereIn('o.geo_location_id', $locations->pluck('id'))->whereBetween('o.year', [$start, $end])
|
||||
->where('o.sex', 'all')->when($edition, fn ($q) => $q->where('b.id', $edition))
|
||||
->orderBy('o.year')->orderBy('o.import_id')->orderBy('o.id')
|
||||
->get(['o.*', 'b.source', 'b.publication_version', 'b.sha256', 'b.file_name']);
|
||||
$issuesByImport = DB::table('demographic_imports')->whereIn('id', $records->pluck('import_id')->unique()->all())
|
||||
->get(['id', DB::raw("report->'issue_counts' AS issue_counts")])->keyBy('id');
|
||||
foreach ($locations as $location) {
|
||||
$series = [];
|
||||
foreach ($records->where('geo_location_id', $location->id)->groupBy('import_id') as $importId => $rows) {
|
||||
$first = $rows->first();
|
||||
$issueCounts = json_decode($issuesByImport->get($importId)->issue_counts ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$values = $rows->filter(function ($r) use ($sector) {
|
||||
if ($r->indicator !== 'population') {
|
||||
return in_array($sector, ['health', 'general'], true);
|
||||
}
|
||||
|
||||
return $r->age_group === 'all' || in_array($sector, ['education', 'health', 'industry', 'agriculture', 'general'], true);
|
||||
})->map(fn ($r) => ['year' => $r->year, 'indicator' => $r->indicator, 'age_group' => $r->age_group, 'residence' => $r->residence,
|
||||
'value' => $r->numeric_value, 'original_value' => $r->original_value, 'unit' => $r->unit, 'data_kind' => $r->data_kind,
|
||||
'sheet' => $r->sheet, 'cell' => $r->cell, 'validation_status' => $r->validation_status])->values()->all();
|
||||
$years = array_values(array_unique(array_column($values, 'year')));
|
||||
$series[] = ['import_id' => $importId, 'source' => $first->source, 'version' => $first->publication_version, 'sha256' => $first->sha256, 'file_name' => $first->file_name,
|
||||
'values' => $values, 'missing_years' => array_values(array_diff(range($start, $end), $years)), 'issue_counts' => $issueCounts];
|
||||
}
|
||||
$result['territories'][] = ['id' => $location->id, 'name' => $location->name, 'level' => $location->location_type, 'series' => $series,
|
||||
'factors_to_assess' => match ($sector) {
|
||||
'industry' => ['Estrutura etária e evolução da população; verificar qualificações, mobilidade e emprego.'],
|
||||
'agriculture' => ['População rural e mercados locais; relacionar com os clusters documentados.'],
|
||||
'education' => ['Grupos etários da população-alvo; verificar cobertura e capacidade dos serviços.'],
|
||||
'health' => ['Estrutura etária e estatísticas vitais com unidades conhecidas; verificar cobertura dos serviços.'],
|
||||
default => ['Dimensão e evolução da população; confrontar com rendimento, acesso e condições sectoriais.'],
|
||||
}];
|
||||
}
|
||||
// Identify concurrent values without summing editions or choosing a supposedly newer one.
|
||||
foreach ($result['territories'] as $territory) {
|
||||
$byDimension = [];
|
||||
foreach ($territory['series'] as $series) {
|
||||
foreach ($series['values'] as $row) {
|
||||
$key = implode('|', [$row['year'], $row['indicator'], $row['age_group'], $row['residence'], $row['unit']]);
|
||||
$byDimension[$key][] = ['import_id' => $series['import_id'], 'value' => $row['value'], 'data_kind' => $row['data_kind']];
|
||||
}
|
||||
}
|
||||
foreach ($byDimension as $key => $values) {
|
||||
if (count($values) > 1) {
|
||||
$result['differences'][] = ['territory_id' => $territory['id'], 'dimension' => $key, 'state' => count(array_unique(array_column($values, 'value'))) === 1 ? 'matching_values' : 'divergent_values', 'values' => $values];
|
||||
}
|
||||
}
|
||||
}
|
||||
$result['state'] = $records->isEmpty() ? 'no_data' : 'available';
|
||||
foreach ($result['territories'] as $i => $left) {
|
||||
foreach (array_slice($result['territories'], $i + 1) as $right) {
|
||||
foreach ($left['series'] as $a) {
|
||||
foreach ($right['series'] as $b) {
|
||||
foreach (array_unique([$start, $end]) as $year) {
|
||||
$isTotal = fn ($r) => $r['year'] === $year && $r['indicator'] === 'population' && $r['age_group'] === 'all' && $r['residence'] === 'all';
|
||||
$x = array_values(array_filter($a['values'], $isTotal))[0] ?? null;
|
||||
$y = array_values(array_filter($b['values'], $isTotal))[0] ?? null;
|
||||
if (! $x || ! $y) {
|
||||
continue;
|
||||
}
|
||||
$comparable = $left['level'] === $right['level'] && $x['unit'] === $y['unit'] && $x['data_kind'] === $y['data_kind'] && $a['version'] === $b['version'];
|
||||
$delta = $comparable ? DB::selectOne('SELECT (?::numeric - ?::numeric)::text AS value', [$x['value'], $y['value']])->value : null;
|
||||
$result['comparisons'][] = ['year' => $year, 'left' => $left['name'], 'right' => $right['name'], 'left_value' => $x['value'], 'right_value' => $y['value'],
|
||||
'left_import' => $a['import_id'], 'right_import' => $b['import_id'], 'difference' => $delta,
|
||||
'explanation' => $comparable ? 'Diferença dos totais de população no mesmo ano, nível, tipo de dado, unidade e identificação de edição. Não mede procura efectiva.' : 'Âmbitos ou metadados distintos; não se calculou uma diferença como se fossem equivalentes.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context
|
||||
* @return array<string,mixed> */
|
||||
public function forContext(array $context): array
|
||||
{
|
||||
$fields = $context['fields'] ?? [];
|
||||
$id = ($fields['district_id'] ?? null) ?: ($fields['province_id'] ?? null);
|
||||
|
||||
// No inference from an influence area to a project's implantation territory.
|
||||
return $this->analyse($id ? [$id] : [], (int) date('Y'), (int) date('Y'), 'general');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Support\CheckedFile;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final class DemographicImport
|
||||
{
|
||||
/**
|
||||
* CLI preparation is read-only input, never an import or approval.
|
||||
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
|
||||
*/
|
||||
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
|
||||
{
|
||||
$root = config('demography.prepared_directory');
|
||||
if (! is_string($root) || $root === '') { return null; }
|
||||
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
|
||||
$directory = $root.'/'.$sha;
|
||||
if (! is_file($directory.'/preparation.json')) { return null; }
|
||||
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
|
||||
try { $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR); }
|
||||
catch (\JsonException) { abort(409, 'Recibo CLI inválido.'); }
|
||||
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
|
||||
$preview = $directory.'/preview.json';
|
||||
$records = $directory.'/records.ndjson';
|
||||
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
|
||||
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
|
||||
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
|
||||
try { $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR); }
|
||||
catch (\JsonException) { abort(409, 'Prévia CLI inválida.'); }
|
||||
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
|
||||
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
|
||||
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
|
||||
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
|
||||
|
||||
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,mixed>> $files */
|
||||
public function preparedSourcesReady(array $files): bool
|
||||
{
|
||||
try {
|
||||
foreach ($files as $name => $expected) {
|
||||
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
|
||||
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) { return false; }
|
||||
}
|
||||
return $files !== [];
|
||||
} catch (\Throwable) { return false; }
|
||||
}
|
||||
|
||||
public function authorize(User $actor): void
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless(($actor->can('manage-portal') || $access->superuser($actor)) && ! $access->has($actor, 'auditor'), 403);
|
||||
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme MFA para gerir dados demográficos.');
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
abort_unless(app(DemographicInstallation::class)->activeTarget() && app(DemographicEvidence::class)->installed(), 403, 'Destino demográfico não instalado ou não autorizado.');
|
||||
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Conclua MFA nesta sessão para gerir dados demográficos.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function prepare(User $actor, string $file, string $name, string $version, ?int $baselineYear): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
abort_unless(is_file($file) && filesize($file) <= 20_000_000 && strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'xlsx', 422);
|
||||
$sha = CheckedFile::sha256($file);
|
||||
$existing = DB::table('demographic_imports')->where('sha256', $sha)->first();
|
||||
if ($existing) {
|
||||
return $this->preview($actor, $existing->id);
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
$directory = 'demography/'.$id;
|
||||
$disk = Storage::disk('local');
|
||||
$disk->makeDirectory($directory);
|
||||
$path = $disk->path($directory.'/original.xlsx');
|
||||
abort_unless(copy($file, $path) && CheckedFile::matches($path, $sha), 409);
|
||||
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
|
||||
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
|
||||
$output = $disk->path($directory.'/preview.json');
|
||||
$prepared = $this->preparedSource($sha, $baselineYear);
|
||||
if ($prepared !== null) {
|
||||
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
|
||||
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
|
||||
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
|
||||
} else {
|
||||
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
|
||||
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
|
||||
if ($baselineYear !== null) {
|
||||
array_push($command, '--baseline-year', (string) $baselineYear);
|
||||
}
|
||||
$process = new Process($command, base_path(), timeout: 120);
|
||||
$process->run();
|
||||
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
|
||||
}
|
||||
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
|
||||
$summary = array_diff_key($report, ['records' => true]);
|
||||
$summary['scan'] = ['result' => $scan['result'], 'engine' => $scan['engine'], 'engine_version' => $scan['engine_version'], 'sha256' => $sha];
|
||||
// The supplied publication label is provenance metadata, never an invented edition/date.
|
||||
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'file_name' => basename($name),
|
||||
'source' => $report['source'], 'publication_version' => $version, 'status' => 'prepared',
|
||||
'report' => json_encode($summary, JSON_THROW_ON_ERROR), 'preview_sha256' => CheckedFile::sha256($output),
|
||||
'prepared_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->event($id, $actor, 'prepared', 'Pré-visualização do original; sem aprovação de utilização.', ['sha256' => $sha]);
|
||||
|
||||
return $this->preview($actor, $id);
|
||||
}
|
||||
|
||||
public static function key(string $province, string $territory, string $level): string
|
||||
{
|
||||
return $province.'|'.$territory.'|'.$level;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor, string $id): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
$batch = DB::table('demographic_imports')->where('id', $id)->first();
|
||||
abort_unless($batch !== null, 404);
|
||||
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
|
||||
$locations = DB::table('geo_locations')->whereIn('location_type', ['province', 'district'])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'location_type']);
|
||||
$matches = [];
|
||||
foreach ($report['territories'] as [$province, $territory, $level]) {
|
||||
$provinces = $locations->filter(fn ($g) => $g->location_type === 'province' && $this->normalize($g->name) === $this->normalize($province));
|
||||
$candidates = $level === 'province' ? $provinces : $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->normalize($g->name) === $this->normalize($territory));
|
||||
$key = self::key($province, $territory, $level);
|
||||
if ($level === 'district') {
|
||||
$headers = $report['territory_headers'][$key] ?? [];
|
||||
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Distrito de /i', $h) === 1)) {
|
||||
$candidates = $candidates->filter(fn ($g) => ! preg_match('/^CIDADE /i', $g->name));
|
||||
}
|
||||
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Cidade (de|da) /i', $h) === 1)) {
|
||||
$candidates = $candidates->filter(fn ($g) => preg_match('/^CIDADE /i', $g->name) === 1);
|
||||
}
|
||||
$manifest = DemographicInstallation::remainingManifest();
|
||||
$hashes = array_column($manifest['files'], 'sha256');
|
||||
$alias = in_array($batch->sha256, $hashes, true) ? ($manifest['aliases'][$key] ?? null) : null;
|
||||
if ($alias !== null) {
|
||||
$candidates = $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->literal($g->name) === $this->literal($alias));
|
||||
}
|
||||
}
|
||||
$matches[] = ['key' => self::key($province, $territory, $level), 'province' => $province, 'territory' => $territory, 'level' => $level,
|
||||
'geo_location_id' => $candidates->count() === 1 ? $candidates->first()->id : null, 'state' => $candidates->count() === 1 ? 'matched' : ($candidates->isEmpty() ? 'missing' : 'ambiguous')];
|
||||
}
|
||||
|
||||
return ['id' => $id, 'file_name' => $batch->file_name, 'sha256' => $batch->sha256, 'publication_version' => $batch->publication_version,
|
||||
'status' => $batch->status, 'report' => $report, 'matches' => $matches,
|
||||
'territory_options' => $locations->values()->all(), 'events' => DB::table('demographic_events')->where('import_id', $id)->orderBy('id')->get()];
|
||||
}
|
||||
|
||||
private function normalize(string $name): string
|
||||
{
|
||||
return preg_replace('/^(cidade|distrito) (de|da) /', '', $this->literal($name));
|
||||
}
|
||||
|
||||
private function literal(string $name): string
|
||||
{
|
||||
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
|
||||
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
|
||||
}
|
||||
|
||||
/** @param array<string,string> $mapping
|
||||
* @return array<string,mixed> */
|
||||
public function confirm(User $actor, string $id, string $sha, array $mapping, string $reason): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $sha, $mapping, $reason) {
|
||||
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($batch && hash_equals($batch->sha256, $sha), 409);
|
||||
if ($batch->status !== 'prepared') {
|
||||
return ['id' => $id, 'status' => $batch->status, 'records' => DB::table('demographic_observations')->where('import_id', $id)->count(), 'repeated' => true];
|
||||
}
|
||||
$disk = Storage::disk('local');
|
||||
$file = $disk->path('demography/'.$id.'/preview.json');
|
||||
abort_unless(is_file($file) && CheckedFile::matches($file, $batch->preview_sha256) && CheckedFile::matches($disk->path('demography/'.$id.'/original.xlsx'), $sha), 409, 'Prévia ou original alterados.');
|
||||
$report = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
$blocking = array_intersect(array_keys($report['issue_counts']), ['conflicting_value', 'duplicate_value', 'unsupported_sheet', 'unsupported_population_headers', 'reference_year_required', 'title_year_mismatch', 'invalid_numeric_or_formula']);
|
||||
abort_if($blocking !== [], 422, 'Corrija os erros estruturais antes de confirmar: '.implode(', ', $blocking));
|
||||
$plan = $this->preview($actor, $id);
|
||||
$resolved = [];
|
||||
foreach ($plan['matches'] as $match) {
|
||||
$locationId = $mapping[$match['key']] ?? $match['geo_location_id'];
|
||||
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->first();
|
||||
abort_unless($location && $location->location_type === $match['level'], 422, 'Correspondência territorial por resolver.');
|
||||
if ($match['level'] === 'district') {
|
||||
$provinceKey = self::key($match['province'], $match['province'], 'province');
|
||||
$provinceMatch = array_values(array_filter($plan['matches'], fn ($m) => $m['key'] === $provinceKey))[0] ?? null;
|
||||
abort_unless($location->parent_id === ($mapping[$provinceKey] ?? $provinceMatch['geo_location_id'] ?? null), 422, 'Distrito fora da província indicada.');
|
||||
}
|
||||
abort_if(in_array($locationId, $resolved, true), 422, 'Duas designações não podem ser fundidas na mesma localização.');
|
||||
$resolved[$match['key']] = $locationId;
|
||||
}
|
||||
$chunk = [];
|
||||
$recordCount = 0;
|
||||
foreach ($this->recordRows($report, $disk->path('demography/'.$id.'/records.ndjson')) as $row) {
|
||||
$recordCount++;
|
||||
$chunk[] = ['import_id' => $id, 'geo_location_id' => $resolved[self::key($row['province'], $row['territory'], $row['level'])],
|
||||
...array_intersect_key($row, array_flip(['year', 'indicator', 'sex', 'residence', 'age_group', 'unit', 'data_kind', 'original_value', 'numeric_value', 'sheet', 'cell'])),
|
||||
'validation_status' => $report['issues'] === [] ? 'valid' : 'warning'];
|
||||
if (count($chunk) === 500) {
|
||||
DB::table('demographic_observations')->insert($chunk);
|
||||
$chunk = [];
|
||||
}
|
||||
}
|
||||
if ($chunk !== []) {
|
||||
DB::table('demographic_observations')->insert($chunk);
|
||||
}
|
||||
abort_unless($recordCount === $report['record_count'], 409, 'O número de observações difere da prévia.');
|
||||
DB::table('demographic_imports')->where('id', $id)->update(['status' => 'imported', 'updated_at' => now()]);
|
||||
$this->event($id, $actor, 'imported', $reason, ['mapping' => $resolved, 'records' => $recordCount, 'sha256' => $sha]);
|
||||
|
||||
return ['id' => $id, 'status' => 'imported', 'records' => $recordCount, 'repeated' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $report
|
||||
* @return iterable<array<string,mixed>> */
|
||||
private function recordRows(array $report, string $path): iterable
|
||||
{
|
||||
if (! isset($report['records_file_sha256'])) {
|
||||
yield from $report['records'];
|
||||
return;
|
||||
}
|
||||
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
|
||||
$stream = new \SplFileObject($path, 'r');
|
||||
while (! $stream->eof()) {
|
||||
$line = $stream->fgets();
|
||||
if (trim($line) === '') { continue; }
|
||||
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function transition(User $actor, string $id, string $action, string $reason): void
|
||||
{
|
||||
$this->authorize($actor);
|
||||
DB::transaction(function () use ($actor, $id, $action, $reason) {
|
||||
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($batch !== null, 404);
|
||||
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
|
||||
// No deletes, replacement of other editions, or automatic publication in production.
|
||||
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
|
||||
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $details */
|
||||
private function event(string $id, User $actor, string $action, string $reason, array $details): void
|
||||
{
|
||||
DB::table('demographic_events')->insert(['import_id' => $id, 'actor_id' => $actor->id, 'action' => $action, 'reason' => $reason, 'details' => json_encode($details, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\CheckedFile;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/** Owner-authorised release; schema and data still require existing session MFA. */
|
||||
final class DemographicInstallation
|
||||
{
|
||||
public const MIGRATION = '2026_11_13_100000_create_demographic_series';
|
||||
|
||||
public const MANICA_SHA256 = '0c6d8feaefaed797fa57d61f923af5a86f37ac4b16f12d7d72ae1be8087ea698';
|
||||
|
||||
public const FILES = [
|
||||
'Manica.xlsx' => ['sha256' => self::MANICA_SHA256, 'records' => 71604],
|
||||
'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620],
|
||||
'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482],
|
||||
];
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function remainingManifest(): array
|
||||
{
|
||||
return json_decode(CheckedFile::read(database_path('data/demography-remaining-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function zambeziaManifest(): array
|
||||
{
|
||||
return json_decode(CheckedFile::read(database_path('data/demography-zambezia-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function publicDistrictRefs(): array
|
||||
{
|
||||
$refs = [self::MANICA_SHA256.':Macate', self::MANICA_SHA256.':Vanduzi'];
|
||||
foreach ([self::remainingManifest(), self::zambeziaManifest()] as $manifest) {
|
||||
foreach ($manifest['new_districts'] as $province => $names) {
|
||||
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
|
||||
foreach ($names as $name) { $refs[] = $source.':'.$name; }
|
||||
}
|
||||
}
|
||||
return $refs;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function files(): array
|
||||
{
|
||||
return ['database/data/demography-zambezia-20261113.json', 'database/data/demography-remaining-20261113.json', 'app/Services/DemographicInstallation.php', 'app/Services/DemographicImport.php', 'app/Services/DemographicEvidence.php',
|
||||
'app/Services/InvestmentGeography.php', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/DemographyController.php',
|
||||
'database/migrations/'.self::MIGRATION.'.php', 'config/demography.php', 'routes/demography.php', 'routes/web.php', 'tools/demography_workbook.py',
|
||||
'resources/js/components/demographic-context.tsx', 'resources/js/components/demographic-evidence.tsx', 'resources/js/components/simulation-form.tsx',
|
||||
'resources/js/components/structure-geography.tsx', 'resources/js/pages/investment/demography.tsx', 'resources/js/pages/investment/catalogue.tsx',
|
||||
'resources/js/pages/investment/detail.tsx', 'resources/js/pages/management/demography.tsx', 'resources/js/pages/management/demography-install.tsx',
|
||||
'resources/js/pages/management/shared.tsx'];
|
||||
}
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$files = [];
|
||||
foreach ($this->files() as $path) {
|
||||
$files[$path] = CheckedFile::sha256(base_path($path));
|
||||
}
|
||||
|
||||
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function activeTarget(): bool
|
||||
{
|
||||
$c = config('database.connections.pgsql');
|
||||
$homologation = config('app.url') === 'https://teste-investidor.tempo.co.mz'
|
||||
&& config('demography.enabled', false) && $c['database'] === 'teste_investidor';
|
||||
if (app()->environment('testing') || config('database.default') !== 'pgsql' || ! empty($c['url'])
|
||||
|| ! in_array($c['host'], ['127.0.0.1', 'localhost'], true) || (int) $c['port'] !== 5432
|
||||
|| ($c['database'] !== 'investemoz' && ! $homologation)) {
|
||||
return false;
|
||||
}
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
|
||||
return $target->database === $c['database'] && (int) $target->port === 5432;
|
||||
}
|
||||
|
||||
public function schemaAllowed(): bool
|
||||
{
|
||||
if (IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
return true;
|
||||
}
|
||||
$actor = auth()->user();
|
||||
if (! config('demography.authenticated_installation', false) || ! $actor || ! $this->activeTarget()) {
|
||||
return false;
|
||||
}
|
||||
app(PortalProfileInstallation::class)->authorize($actor, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function authorize(User $actor, bool $write): void
|
||||
{
|
||||
app(PortalProfileInstallation::class)->authorize($actor, $write);
|
||||
abort_unless($this->activeTarget() || IsolatedQaDatabase::allowsSchemaChanges(), 403, 'Destino não autorizado para esta entrega.');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor): array
|
||||
{
|
||||
$this->authorize($actor, false);
|
||||
$proofFile = storage_path('app/private/demography-deployment/readiness.json');
|
||||
$proof = is_file($proofFile) ? json_decode(CheckedFile::read($proofFile), true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$code = $this->fingerprint();
|
||||
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$qa = IsolatedQaDatabase::allowsSchemaChanges();
|
||||
$ready = $qa || (($proof['code_sha256'] ?? '') === $code && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
|
||||
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false) && ($proof['target']['database'] ?? '') === $target['database']
|
||||
&& (int) ($proof['target']['port'] ?? 0) === (int) $target['port'] && ($proof['decision'] ?? '') !== ''
|
||||
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time());
|
||||
if (! $qa) {
|
||||
foreach (['dump', 'files'] as $kind) {
|
||||
$file = $proof[$kind.'_path'] ?? '';
|
||||
$ready = $ready && is_file($file) && CheckedFile::matches($file, $proof[$kind.'_sha256'] ?? '');
|
||||
}
|
||||
$ready = $ready && CheckedFile::matches(base_path('.env'), $proof['environment_sha256'] ?? '')
|
||||
&& is_file(public_path('build/manifest.json')) && CheckedFile::matches(public_path('build/manifest.json'), $proof['release_manifest_sha256'] ?? '');
|
||||
foreach ($proof['release_files'] ?? [] as $relative => $sha) {
|
||||
$file = public_path('build/'.$relative);
|
||||
$ready = $ready && ! str_contains($relative, '..') && ! str_contains($relative, ':') && is_file($file) && CheckedFile::matches($file, $sha);
|
||||
}
|
||||
}
|
||||
$tables = array_map(fn ($t) => Schema::hasTable($t), ['demographic_imports', 'demographic_observations', 'demographic_events']);
|
||||
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
|
||||
$consistent = ($ledger && ! in_array(false, $tables, true)) || (! $ledger && ! in_array(true, $tables, true));
|
||||
$preparedFiles = self::FILES + self::remainingManifest()['files'] + self::zambeziaManifest()['files'];
|
||||
$runtimeReady = app(DemographicImport::class)->preparedSourcesReady($preparedFiles);
|
||||
try {
|
||||
if (! $runtimeReady && function_exists('proc_open')) {
|
||||
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
|
||||
$runtime->run();
|
||||
$runtimeReady = $runtime->isSuccessful();
|
||||
}
|
||||
} catch (ExceptionInterface) {
|
||||
$runtimeReady = false;
|
||||
}
|
||||
$mode = $ledger ? 'remaining' : 'initial';
|
||||
$manifest = self::remainingManifest();
|
||||
if ($ledger && DB::table('demographic_imports')->whereIn('sha256', array_column($manifest['files'], 'sha256'))->where('status', 'approved')->count() === count($manifest['files'])) { $mode = 'zambezia'; $manifest = self::zambeziaManifest(); }
|
||||
$files = $mode !== 'initial' ? $manifest['files'] : self::FILES;
|
||||
$already = $ledger ? DB::table('demographic_imports')->whereIn('sha256', array_column($files, 'sha256'))->where('status', 'approved')->pluck('sha256')->all() : [];
|
||||
$pending = array_filter($files, fn ($f) => ! in_array($f['sha256'], $already, true));
|
||||
$state = ['mode' => $mode, 'pending_count' => count($pending), 'aliases' => $mode !== 'initial' ? $manifest['aliases'] : [], 'alias_sources' => $mode !== 'initial' ? $manifest['sources'] : [], 'missing_province_source' => $manifest['missing_province_source'], 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
|
||||
'migration' => self::MIGRATION, 'code_sha256' => $code, 'backup_verified' => $ready, 'consistent' => $consistent,
|
||||
'ready' => $ready && $consistent && $runtimeReady && count($pending) > 0, 'runtime_ready' => $runtimeReady, 'installed' => $ledger, 'files' => $files, 'new_districts' => $mode !== 'initial' ? array_merge(...array_values($manifest['new_districts'])) : ['Macate', 'Vanduzi'],
|
||||
'mfa_confirmed' => app(PortalProfileAccess::class)->mfa($actor),
|
||||
'notice' => ($runtimeReady ? '' : 'O interpretador do importador está indisponível; a execução permanece bloqueada. ').'Preservar seis advertências de Manica/2031, unidades ausentes e versões estatísticas. A instalação não corrige os originais nem concede permissões.'];
|
||||
|
||||
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
|
||||
}
|
||||
|
||||
/** @param list<string> $preservedHashes
|
||||
* @return array<string,array{count:int,sha256:string}> */
|
||||
private function protectedState(array $preservedHashes = []): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (['desk_cases', 'simulation_form_drafts', 'funding_programmes', 'funding_programme_versions', 'funding_programme_interests',
|
||||
'labour_demands', 'training_capacities', 'opportunities', 'investment_dossiers', 'projects', 'economic_clusters'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
|
||||
$state[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode("\n", array_map(fn ($r) => $r->row, $rows)))];
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('demographic_imports')) {
|
||||
$ids = DB::table('demographic_imports')->whereIn('sha256', $preservedHashes ?: array_column(self::FILES, 'sha256'))->pluck('id');
|
||||
foreach (['demographic_imports' => 'id', 'demographic_observations' => 'import_id', 'demographic_events' => 'import_id'] as $table => $key) {
|
||||
$digest = hash_init('sha256');
|
||||
$count = 0;
|
||||
DB::table($table)->whereIn($key, $ids)->chunkById(500, function ($rows) use ($digest, &$count) {
|
||||
foreach ($rows as $row) {
|
||||
if ($count > 0) { hash_update($digest, "\n"); }
|
||||
hash_update($digest, json_encode($row, JSON_THROW_ON_ERROR));
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
$state['accepted_'.$table] = ['count' => $count, 'sha256' => hash_final($digest)];
|
||||
}
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function territories(User $actor): void
|
||||
{
|
||||
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', ['manica'])->get(['id']);
|
||||
abort_unless($province->count() === 1, 422, 'Província de Manica ambígua ou indisponível.');
|
||||
foreach (['Macate', 'Vanduzi'] as $name) {
|
||||
$matches = DB::table('geo_locations')->where('parent_id', $province->first()->id)->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [strtolower($name)])->get();
|
||||
if ($matches->isNotEmpty()) {
|
||||
abort_unless($matches->count() === 1 && $matches->first()->deleted_at === null, 409, 'Cadastro territorial divergente; não restaurar nem fundir automaticamente.');
|
||||
|
||||
continue;
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $province->first()->id, 'name' => $name, 'location_type' => 'district',
|
||||
'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => self::MANICA_SHA256.':'.$name,
|
||||
'data_classification' => 'public', 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name,
|
||||
'source_sha256' => self::MANICA_SHA256, 'decision' => 'Autorização expressa do proprietário em 13/11/2026; destino conferido pelo instalador.',
|
||||
'scope' => 'Identificação territorial da série INE; sem limites geográficos ou direitos territoriais presumidos.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $manifest */
|
||||
private function remainingTerritories(User $actor, array $manifest): void
|
||||
{
|
||||
foreach ($manifest['new_districts'] as $province => $names) {
|
||||
$parents = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', [mb_strtolower($province)])->pluck('id');
|
||||
abort_unless($parents->count() === 1, 409);
|
||||
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
|
||||
foreach ($names as $name) {
|
||||
$existing = DB::table('geo_locations')->where('parent_id', $parents[0])->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [mb_strtolower($name)])->get();
|
||||
if ($existing->isNotEmpty()) { abort_unless($existing->count() === 1 && $existing[0]->deleted_at === null, 409); continue; }
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parents[0], 'name' => $name, 'location_type' => 'district', 'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => $source.':'.$name, 'data_classification' => 'public', 'version_no' => 1]);
|
||||
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name, 'source_sha256' => $source, 'basis' => $manifest['authority']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function apply(User $actor, array $input): array
|
||||
{
|
||||
$this->authorize($actor, true);
|
||||
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid',
|
||||
'mode' => 'nullable|in:initial,remaining,zambezia', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
$mode = $data['mode'] ?? 'initial';
|
||||
$files = $mode === 'initial' ? self::FILES : ($mode === 'zambezia' ? self::zambeziaManifest()['files'] : self::remainingManifest()['files']);
|
||||
$preservedHashes = $mode === 'zambezia' ? array_column([...self::FILES, ...self::remainingManifest()['files']], 'sha256') : array_column(self::FILES, 'sha256');
|
||||
$action = $mode === 'zambezia' ? 'extend_zambezia' : ($mode === 'remaining' ? 'extend_provinces' : 'install_and_import');
|
||||
$payload = hash('sha256', json_encode($mode === 'initial' ? [$actor->id, $data['reason']] : [$actor->id, $data['reason'], $mode, $files], JSON_THROW_ON_ERROR));
|
||||
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
|
||||
if ($existing) {
|
||||
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
|
||||
|
||||
return [...$event['result'], 'repeated' => true];
|
||||
}
|
||||
$plan = $this->preview($actor);
|
||||
abort_unless($plan['ready'] && $plan['mode'] === $mode && hash_equals($plan['hash'], $data['hash']), 409, 'Consulte a prévia actual; código, destino ou provas mudaram.');
|
||||
set_time_limit(300);
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $payload, $mode, $files, $action, $preservedHashes) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(131126, 100000)');
|
||||
// Recheck after the lock as another request may have completed while we waited.
|
||||
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
|
||||
if ($existing) {
|
||||
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
|
||||
|
||||
return [...$event['result'], 'repeated' => true];
|
||||
}
|
||||
$before = $this->protectedState($preservedHashes);
|
||||
$previous = config('demography.authenticated_installation');
|
||||
config(['demography.authenticated_installation' => true]);
|
||||
try {
|
||||
if ($mode === 'initial') {
|
||||
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha ao instalar a estrutura demográfica.');
|
||||
} else { abort_unless(Schema::hasTable('demographic_observations'), 409); }
|
||||
} finally {
|
||||
config(['demography.authenticated_installation' => $previous]);
|
||||
}
|
||||
if ($mode === 'initial') { $this->territories($actor); } else { $this->remainingTerritories($actor, $mode === 'zambezia' ? self::zambeziaManifest() : self::remainingManifest()); }
|
||||
$receipts = [];
|
||||
$directory = config('demography.source_directory', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias');
|
||||
$service = app(DemographicImport::class);
|
||||
foreach ($files as $name => $expected) {
|
||||
$old = DB::table('demographic_imports')->where('sha256', $expected['sha256'])->first();
|
||||
if ($old && $old->status === 'approved') {
|
||||
$count = DB::table('demographic_observations')->where('import_id', $old->id)->count();
|
||||
abort_unless($count === $expected['records'], 409);
|
||||
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $old->id, 'records' => $count, 'preserved' => true];
|
||||
continue;
|
||||
}
|
||||
$file = $directory.'/'.$name;
|
||||
abort_unless(is_file($file) && CheckedFile::matches($file, $expected['sha256']), 409, 'O exemplar aprovado mudou: '.$name);
|
||||
$plan = $service->prepare($actor, $file, $name, 'Exemplar INE fornecido pela Tempo; edição não indicada; SHA256 identifica a versão', $name === 'Dados demograficos.xlsx' ? 2017 : null);
|
||||
$receipt = $service->confirm($actor, $plan['id'], $expected['sha256'], [], $data['reason']);
|
||||
abort_unless($receipt['records'] === $expected['records'], 409, 'Contagem divergente da homologação: '.$name);
|
||||
$service->transition($actor, $plan['id'], 'approved', $data['reason']);
|
||||
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $plan['id'], 'records' => $receipt['records']];
|
||||
}
|
||||
abort_unless($before === ($mode === 'initial' ? array_filter($this->protectedState($preservedHashes), fn ($key) => ! str_starts_with($key, 'accepted_'), ARRAY_FILTER_USE_KEY) : $this->protectedState($preservedHashes)), 409, 'Estado protegido alterado; instalação revertida.');
|
||||
$result = ['installed' => true, 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
|
||||
'imports' => $receipts, 'protected_state' => $before, 'record_count' => array_sum(array_column($receipts, 'records')), 'at' => now()->toIso8601String()];
|
||||
DB::table('audit_events')->insert(['source_system' => 'demography-installation', 'entity_type' => 'demographic_series',
|
||||
'actor_user_id' => $actor->id, 'action' => $action, 'correlation_id' => $data['operation_key'], 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['payload_sha256' => $payload, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,10 @@ final class InvestmentGeography
|
||||
$query->where(function ($workbook) {
|
||||
$workbook->where('source_system', 'cartografia-workbook')
|
||||
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%');
|
||||
})->orWhere(function ($demography) {
|
||||
// Owner, 13/11/2026: only these source-backed additions are authorised.
|
||||
$demography->where('source_system', 'ine-demography')->where('location_type', 'district')
|
||||
->whereIn('external_id', DemographicInstallation::publicDistrictRefs());
|
||||
})->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,6 +127,11 @@ final class InvestmentPreparationWorkspace
|
||||
{
|
||||
$objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']];
|
||||
$evidence = [];
|
||||
$demography = app(DemographicEvidence::class)->forContext($context);
|
||||
if ($demography['state'] === 'available') {
|
||||
$evidence['demography'] = ['key' => 'demography', 'label' => 'Contexto demográfico INE', 'current' => $demography,
|
||||
'notice' => 'Evidência para análise SWOT; não produz conclusões automáticas. Consulte ano, edição e limitações.'];
|
||||
}
|
||||
foreach ($context['sector_references'] ?? [] as $reference) {
|
||||
$current = hash_equals((SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? ''), $reference['source_sha256'] ?? '') ? $reference : ['unavailable' => true, 'source_sha256' => (SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? '')];
|
||||
$evidence['sector-resource:'.$reference['key']] = ['key' => 'sector-resource:'.$reference['key'], 'label' => $reference['title'], 'consulted' => $reference, 'current' => $current, 'notice' => 'Referência sectorial e de cadeia de valor; não certifica direitos, licenças nem disponibilidade de projecto.'];
|
||||
@@ -275,6 +280,24 @@ final class InvestmentPreparationWorkspace
|
||||
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId);
|
||||
}
|
||||
|
||||
/** Reuses the authorised workspace and its preserved exports; never creates an export or a review.
|
||||
* @return array<string,mixed> */
|
||||
public function reportSummary(User $actor, string $id, ?string $opportunity): array
|
||||
{
|
||||
$view = $this->view($actor, $id, $opportunity);
|
||||
$query = $view['opportunity'] ? '?opportunity='.urlencode($view['opportunity']) : '';
|
||||
$base = '/investimentos/'.$id.'/preparacao';
|
||||
|
||||
return ['case_id' => $id, 'opportunity' => $view['opportunity'], 'version' => $view['version'],
|
||||
'context_revision' => $view['context']['revision'], 'href' => $base.$query, 'api_state' => $view['api_state'],
|
||||
'api_issues' => $view['api_issues'], 'external' => $view['apiex']['external'],
|
||||
'exports' => array_map(fn ($export) => ['id' => $export['id'], 'at' => $export['at'],
|
||||
'context_revision' => $export['context_revision'], 'fingerprint' => $export['fingerprint'],
|
||||
'historical' => $export['fingerprint'] !== $view['api_fingerprint'],
|
||||
'href' => $base.'/exportar/'.$export['id'].$query], $this->rows($view['apiex']['exports'])),
|
||||
'swot' => $view['swot'], 'swot_review_required' => $view['swot_review_required']];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function taskKeys(User $actor, string $id, string $opportunity): array
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
|
||||
use App\Models\User;
|
||||
use App\Support\StructuredData;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -171,6 +172,17 @@ final class InvestorPath
|
||||
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
|
||||
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
|
||||
'results' => $results, 'requests' => $requests, 'documents' => $documents,
|
||||
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
|
||||
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
|
||||
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
|
||||
'href' => '/investidor/terra-duat/'.$landCase->id.'/imprimir'] : null,
|
||||
'preparation_report' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() && (count($allChoices) < 2 || $opportunity !== null)
|
||||
? app(InvestmentPreparationWorkspace::class)->reportSummary($actor, $id, $opportunity) : null,
|
||||
'company_reports' => ($owner || $manager) ? array_filter([
|
||||
'comparison' => isset($payload['company_comparison']) ? Arr::only($payload['company_comparison'], ['revision', 'at']) : null,
|
||||
'preparation' => isset($payload['company_preparation']) ? Arr::only($payload['company_preparation'], ['revision', 'at']) : null,
|
||||
'guides' => array_map(fn ($guide) => ['revision' => $guide['revision'], 'at' => $guide['at'], 'slug' => $guide['guide']['slug'], 'title' => $guide['guide']['title'] ?? $guide['guide']['slug']], $payload['investment_guides'] ?? []),
|
||||
]) : [],
|
||||
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
|
||||
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
|
||||
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
|
||||
|
||||
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
|
||||
}
|
||||
|
||||
$this->requireMatch($request, $draft);
|
||||
$associatedContext = json_decode($economic['context_applicability_snapshot'] ?? '', true);
|
||||
$sourceSnapshot['association_opportunity'] = $economic['context_opportunity'] ?? $associatedContext['consumer_opportunity'] ?? null;
|
||||
$sourceSnapshot['association_context_revision'] = $associatedContext['context_revision'] ?? null;
|
||||
$sources[] = $sourceSnapshot;
|
||||
$economic['simulation_sources'] = $sources;
|
||||
$economic['reuse_simulation'] = 'yes';
|
||||
@@ -223,6 +226,61 @@ final class SimulationFormDrafts
|
||||
});
|
||||
}
|
||||
|
||||
/** Metadata only, from the draft owned by this dossier's titular; no broader dossier ACL grants financial access.
|
||||
* @return list<array<string,mixed>> */
|
||||
public function dossierReportSources(User $actor, string $caseId, ?string $opportunity): array
|
||||
{
|
||||
$case = app(VirtualDesk::class)->case($actor, $caseId);
|
||||
if ((int) $case->user_id !== $actor->id || ! $case->simulation_id) { return []; }
|
||||
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
|
||||
|
||||
return DB::transaction(function () use ($actor, $case, $opportunity): array {
|
||||
$this->setContext($actor->id, null);
|
||||
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->first();
|
||||
if (! $draft) { return []; }
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$sources = [];
|
||||
$records = $economic['simulation_sources'] ?? [];
|
||||
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
|
||||
foreach ($records as $source) {
|
||||
if (! is_array($source) || ! Str::isUuid($source['simulation_id'] ?? '') || ! Str::isUuid($source['report_id'] ?? '')) { continue; }
|
||||
if (isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity) { continue; }
|
||||
$sources[] = [...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'source_updated_at', 'consented_at'])),
|
||||
'opportunity' => $source['association_opportunity'] ?? null, 'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'),
|
||||
'href' => '/investimentos/'.$case->id.'/relatorios/fiscais/'.$source['report_id'].($opportunity ? '?opportunity='.urlencode($opportunity) : '')];
|
||||
}
|
||||
|
||||
return $sources;
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the already preserved copy, including a source originally calculated anonymously.
|
||||
* @return array<string,mixed> */
|
||||
public function dossierReport(User $actor, string $caseId, string $reportId, ?string $opportunity): array
|
||||
{
|
||||
$case = app(VirtualDesk::class)->case($actor, $caseId);
|
||||
abort_unless((int) $case->user_id === $actor->id && $case->simulation_id, 403);
|
||||
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
|
||||
|
||||
return DB::transaction(function () use ($actor, $case, $reportId, $opportunity): array {
|
||||
$this->setContext($actor->id, null);
|
||||
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->firstOrFail();
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$records = $economic['simulation_sources'] ?? [];
|
||||
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
|
||||
$source = null;
|
||||
foreach ($records as $item) {
|
||||
if (is_array($item) && ($item['report_id'] ?? null) === $reportId) { $source = $item; break; }
|
||||
}
|
||||
abort_unless(is_array($source) && is_array($source['report_snapshot'] ?? null), 404, 'Cópia preservada indisponível; original não substituído.');
|
||||
abort_if(isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity, 404);
|
||||
|
||||
return ['case_id' => $case->id, 'opportunity' => $source['association_opportunity'] ?? null,
|
||||
'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'), 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
|
||||
...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'report_snapshot', 'consented_at']))];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
|
||||
public function claimPreview(Request $request): ?array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user