Integrate supplied institutions by location category and type through investor dossiers

This commit is contained in:
2026-11-10 20:59:24 +02:00
parent 5ff9402fd0
commit 5a5cc9e815
26 changed files with 33731 additions and 80 deletions
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Sector;
use App\Services\DocumentManagementAccess;
use App\Services\FundingDossierPreparation;
use App\Services\FundingInstitutionClassification;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
@@ -68,7 +69,7 @@ final class FundingProgrammeController extends Controller
return tap(Inertia::render('funding/edit', ['record' => $row ? ['id' => $row->id, 'code' => $row->code, 'version' => (int) $row->version,
'draft' => json_decode($row->draft, true), 'published' => $row->published_version_id !== null] : null,
'references' => Arr::only($references, ['organizations', 'sectors', 'territories', 'documents']),
'institutionNatures' => FundingInstitutionDetails::NATURES, 'needs' => FundingInstitutionDetails::NEEDS,
'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'institutionNatures' => FundingInstitutionDetails::NATURES, 'needs' => FundingInstitutionDetails::NEEDS,
'kinds' => FundingProgrammes::KINDS, 'windowModes' => FundingProgrammes::WINDOWS,
'history' => $id ? DB::table('funding_programme_versions')->where('programme_id', $id)->orderByDesc('version')->get(['version', 'reason', 'published_at']) : []])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
@@ -125,6 +126,7 @@ final class FundingProgrammeController extends Controller
public function index(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$filters = $request->validate(['q' => 'nullable|string|max:150', 'kind' => 'nullable|in:institution,fund,programme,incentive',
'category' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::TYPES))], 'location_mode' => 'nullable|in:physical,coverage', 'include_historical' => 'nullable|boolean',
'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'territory' => ['nullable', 'uuid', Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())],
@@ -138,7 +140,7 @@ final class FundingProgrammeController extends Controller
$query = $service->catalogueQuery($filters);
if (! empty($filters['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term]));
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
}
if (! empty($filters['kind'])) {
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
@@ -150,7 +152,7 @@ final class FundingProgrammeController extends Controller
return [...$record, 'filter_explanation' => $service->filterExplanation($record, $filters)];
});
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name'])], 'management' => $service->access($request->user())])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
@@ -158,7 +160,7 @@ final class FundingProgrammeController extends Controller
public function show(Request $request, string $id, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid']);
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid', 'category' => 'nullable|string|max:80', 'institution_type' => 'nullable|string|max:80', 'location_mode' => 'nullable|in:physical,coverage']);
$dossier = $request->user() && $request->filled('context') ? app(FundingDossierPreparation::class)->context($request->user(), $request->string('context')->toString(), $request->query('opportunity')) : null;
if ($dossier && ! $request->filled('territory') && $dossier['territory']) {
$request->merge(['territory' => $dossier['territory']]);
@@ -181,7 +183,7 @@ final class FundingProgrammeController extends Controller
$saved = $request->user() ? DB::table('funding_programme_interests as i')->join('funding_programme_versions as v', 'v.id', '=', 'i.version_id')
->where('i.programme_id', $id)->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')->first(['v.version', 'i.saved_at']) : null;
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
+16 -5
View File
@@ -34,7 +34,7 @@ final class FundingDossierPreparation
{
$data = Validator::make($input, ['context' => 'required|uuid', 'opportunity' => 'nullable|string|max:150', 'version' => 'required|integer|min:1',
'version_id' => 'required|uuid', 'product' => 'nullable|string|max:80', 'need' => ['required', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'territory' => 'required|uuid', 'operation_key' => 'required|uuid', 'consent' => 'accepted'])->validate();
'territory' => 'nullable|uuid', 'location_id' => 'nullable|string|max:100', 'location_mode' => 'nullable|in:physical,coverage', 'operation_key' => 'required|uuid', 'consent' => 'accepted'])->validate();
$intent = hash('sha256', json_encode(Arr::sortRecursive([...$data, 'actor' => $actor->id, 'programme' => $programme]), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $programme, $data, $intent): array {
@@ -52,7 +52,7 @@ final class FundingDossierPreparation
}
}
foreach ($selections as $selection) {
if (($selection['opportunity'] ?? null) === $context['opportunity'] && $selection['programme_id'] === $programme && $selection['version_id'] === $data['version_id'] && ($selection['product']['code'] ?? null) === ($data['product'] ?? null) && $selection['need'] === $data['need'] && $selection['territory'] === $data['territory']) {
if (($selection['opportunity'] ?? null) === $context['opportunity'] && $selection['programme_id'] === $programme && $selection['version_id'] === $data['version_id'] && ($selection['product']['code'] ?? null) === ($data['product'] ?? null) && $selection['need'] === $data['need'] && ($selection['territory'] ?? null) === ($data['territory'] ?? null) && ($selection['location']['id'] ?? null) === ($data['location_id'] ?? null)) {
return $context;
}
}
@@ -60,7 +60,18 @@ final class FundingDossierPreparation
$catalogue = app(FundingProgrammes::class);
$record = $catalogue->published($programme);
abort_unless($record['version_id'] === $data['version_id'], 409, 'A ficha pública mudou. Confira a versão actual.');
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $data['territory'])->exists(), 422);
if (! empty($data['territory'])) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $data['territory'])->exists(), 422);
}
$location = null;
if (! empty($data['location_id'])) {
foreach ($record['payload']['locations'] ?? [] as $candidate) {
if (($candidate['id'] ?? null) === $data['location_id']) {
$location = $candidate;
}
}
abort_unless($location, 422, 'O local seleccionado não pertence à versão consultada da instituição.');
}
$product = null;
if ($record['payload']['kind'] === 'institution') {
foreach ($record['payload']['products'] as $candidate) {
@@ -69,12 +80,12 @@ final class FundingDossierPreparation
break;
}
}
abort_unless(($record['payload']['products'] === [] && empty($data['product'])) || ($product && app(FundingInstitutionDetails::class)->match($product, $data)), 422, 'Seleccione um produto correspondente ao local e necessidade, incluindo as condições por confirmar.');
abort_unless(($record['payload']['products'] === [] && empty($data['product']) && app(FundingInstitutionClassification::class)->permitsUnknownProduct($record['payload'], $data['need'])) || ($product && app(FundingInstitutionDetails::class)->match($product, $data)), 422, 'O serviço ou produto escolhido não corresponde à informação da ficha para esta necessidade/local. Confira as condições ou seleccione outra referência.');
}
abort_if(count($selections) >= 200, 422, 'O dossier atingiu o limite de referências de financiamento.');
$selection = ['id' => $data['operation_key'], 'intent' => $intent, 'actor' => $actor->id, 'opportunity' => $context['opportunity'],
'programme_id' => $programme, 'version_id' => $record['version_id'], 'version' => $record['version'], 'title' => $record['payload']['title'],
'record' => $record['payload'], 'product' => $product, 'need' => $data['need'], 'territory' => $data['territory'], 'recorded_at' => now()->toIso8601String(),
'record' => $record['payload'], 'product' => $product, 'need' => $data['need'], 'territory' => $data['territory'] ?? null, 'location' => $location, 'location_mode' => $data['location_mode'] ?? 'coverage', 'categories' => $record['payload']['institution_categories'] ?? [], 'institution_types' => $record['payload']['institution_types'] ?? [], 'institution_key' => $record['payload']['institution_key'] ?? null, 'recorded_at' => now()->toIso8601String(),
'notice' => 'Referência para preparação. Não é candidatura, contacto enviado, aprovação ou garantia de financiamento.'];
$payload['investor_path']['funding_selections'][] = $selection;
DB::table('desk_cases')->where('id', $case->id)->update(['payload' => $desk->encode($payload), 'version' => (int) $case->version + 1, 'updated_at' => now()]);
@@ -0,0 +1,73 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Documentary classification and physical presence; neither is lending eligibility. */
final class FundingInstitutionClassification
{
public const CATEGORIES = ['banking' => 'Banca', 'credit' => 'Crédito e microfinanças', 'investment' => 'Gestão de investimento', 'pensions' => 'Fundos de pensões', 'insurance' => 'Seguros e resseguros', 'intermediation' => 'Mediação de seguros', 'exchange' => 'Câmbio', 'payments' => 'Pagamentos e serviços interbancários', 'supervision' => 'Supervisão', 'other' => 'Outras instituições'];
public const TYPES = ['commercial_bank' => 'Banco comercial', 'investment_bank' => 'Banco de investimento', 'bank' => 'Banco (tipo não especificado)', 'microbank' => 'Microbanco', 'cooperative' => 'Cooperativa de crédito', 'savings' => 'Organização de poupança e empréstimo', 'investment_manager' => 'Gestora de fundos de investimento', 'investment_company' => 'Sociedade de investimento', 'pension_manager' => 'Gestora de fundos de pensões', 'insurer' => 'Seguradora', 'reinsurer' => 'Resseguradora', 'microinsurer' => 'Micro-seguradora', 'broker' => 'Corretora de seguros', 'agent' => 'Agente de seguros', 'reinsurance_broker' => 'Corretor de resseguro', 'exchange_house' => 'Casa de câmbio', 'electronic_money' => 'Instituição de moeda electrónica', 'interbank' => 'Serviços interbancários', 'supervisor' => 'Supervisor', 'other' => 'Outro tipo documentado'];
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function normalize(array $input): array
{
return Validator::make($input, [
'institution_categories' => 'array|max:10', 'institution_categories.*' => ['required', 'distinct', Rule::in(array_keys(self::CATEGORIES))],
'institution_types' => 'array|max:10', 'institution_types.*' => ['required', 'distinct', Rule::in(array_keys(self::TYPES))],
'institution_services' => 'array|max:12', 'institution_services.*' => ['required', 'distinct', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'institution_status' => 'nullable|in:reported,historical,merged,unknown', 'successor_key' => 'nullable|string|max:100',
'register_reference' => 'nullable|string|max:2000', 'aliases' => 'array|max:100', 'aliases.*' => 'required|string|max:500',
'source_rows' => 'array|max:250', 'source_rows.*' => 'required|array:id,section,text', 'source_rows.*.id' => 'required|string|max:100',
'source_rows.*.section' => 'required|string|max:250', 'source_rows.*.text' => 'required|string|max:8000',
])->validate();
}
/** @param array<string,mixed> $filters */
public function filter(Builder $query, array $filters): void
{
foreach (['category' => 'institution_categories', 'institution_type' => 'institution_types'] as $key => $field) {
if (! empty($filters[$key])) {
$query->whereRaw('v.payload->? @> ?::jsonb', [$field, json_encode([$filters[$key]], JSON_THROW_ON_ERROR)]);
}
}
if (empty($filters['include_historical'])) {
$query->whereRaw("COALESCE(v.payload->>'institution_status','reported') NOT IN ('historical','merged')");
}
if (($filters['location_mode'] ?? 'coverage') !== 'physical') {
return;
}
$query->whereRaw("v.payload->>'kind'='institution'");
if (empty($filters['territory'])) {
return;
}
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$clauses = ["l->'territory_path' @> ?::jsonb"];
$bindings = [json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR)];
if ($unknown) {
foreach ($path as $id) {
$clauses[] = "l->>'territory_id' = ?";
$bindings[] = $id;
}
}
$status = $unknown ? '' : " AND COALESCE(l->>'evidence_status','reported') NOT IN ('to_confirm','historical','conflict','unlocated')";
$query->where(function (Builder $q) use ($clauses, $bindings, $status, $unknown): void {
$q->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE (".implode(' OR ', $clauses).')'.$status.')', $bindings);
if ($unknown) {
$q->orWhereRaw("NOT EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE NULLIF(l->>'territory_id','') IS NOT NULL)");
}
});
}
/** @param array<string,mixed> $payload */
public function permitsUnknownProduct(array $payload, ?string $need): bool
{
return ! $need || ! isset($payload['institution_services']) || in_array($need, $payload['institution_services'], true);
}
}
+36 -12
View File
@@ -11,7 +11,7 @@ final class FundingInstitutionDetails
{
public const NATURES = ['bank' => 'Banco', 'credit' => 'Instituição de crédito', 'microfinance' => 'Microfinanças', 'investment_fund' => 'Fundo de investimento', 'development' => 'Agência de desenvolvimento/financiamento', 'other' => 'Outra natureza documentada', 'unknown' => 'Natureza por confirmar'];
public const NEEDS = ['investment' => 'Investimento/equipamento', 'working_capital' => 'Fundo de maneio', 'equity' => 'Participação de capital', 'guarantee' => 'Garantia', 'technical_assistance' => 'Assistência técnica', 'other' => 'Outra necessidade documentada'];
public const NEEDS = ['investment' => 'Investimento/equipamento', 'working_capital' => 'Fundo de maneio', 'equity' => 'Participação de capital', 'guarantee' => 'Garantia', 'technical_assistance' => 'Assistência técnica', 'insurance' => 'Seguros', 'foreign_exchange' => 'Câmbio', 'payments' => 'Pagamentos', 'pension' => 'Pensões', 'investment_management' => 'Gestão de investimento', 'other' => 'Outra necessidade documentada'];
/** @param array<string,mixed> $input
* @return array<string,mixed> */
@@ -21,13 +21,13 @@ final class FundingInstitutionDetails
$rules = [
'institution_key' => 'required|string|regex:/^[a-z0-9][a-z0-9.-]{0,99}$/D',
'institution_nature' => ['required', Rule::in(array_keys(self::NATURES))],
'contacts' => 'array|max:20', 'locations' => 'array|max:50', 'products' => 'array|max:40',
'contacts.*' => 'array:kind,value,source_url,source_reference,checked_on',
'contacts' => 'array|max:100', 'locations' => 'array|max:100', 'products' => 'array|max:40',
'contacts.*' => 'array:id,kind,value,source_url,source_reference,checked_on,source_document_name,source_document_sha256,evidence_status,origin,source_refs',
'contacts.*.kind' => 'required|in:website,email,phone,address', 'contacts.*.value' => "$required|string|max:1000",
'locations.*' => 'array:role,label,address,territory_id,source_url,source_reference,checked_on',
'locations.*' => 'array:id,role,label,address,territory_id,source_url,source_reference,checked_on,source_document_name,source_document_sha256,evidence_status,origin,source_refs,location_text,hours',
'locations.*.role' => 'required|in:headquarters,branch,contact_point', 'locations.*.label' => "$required|string|max:250",
'locations.*.address' => 'nullable|string|max:1500', 'locations.*.territory_id' => 'nullable|uuid',
'products.*' => 'array:code,title,instrument,needs,coverage,coverage_text,territory_ids,conditions,amounts,availability,contact_url,source_url,source_reference,checked_on',
'products.*' => 'array:code,title,instrument,needs,coverage,coverage_text,territory_ids,conditions,amounts,availability,contact_url,source_url,source_reference,checked_on,source_document_name,source_document_sha256',
'products.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'products.*.title' => "$required|string|max:250",
'products.*.instrument' => 'nullable|string|max:250', 'products.*.needs' => 'array|max:6',
'products.*.needs.*' => ['required', 'distinct', Rule::in(array_keys(self::NEEDS))],
@@ -37,11 +37,29 @@ final class FundingInstitutionDetails
'products.*.availability' => 'required|in:documented,unknown,closed', 'products.*.contact_url' => 'nullable|url:http,https|max:2000',
];
foreach (['contacts', 'locations', 'products'] as $prefix) {
$rules[$prefix.'.*.source_url'] = "$required|url:http,https|max:2000";
$rules[$prefix.'.*.source_url'] = 'nullable|url:http,https|max:2000';
$rules[$prefix.'.*.source_document_name'] = 'nullable|string|max:250';
$rules[$prefix.'.*.source_document_sha256'] = 'nullable|string|regex:/^[a-f0-9]{64}$/D';
$rules[$prefix.'.*.source_reference'] = "$required|string|max:1500";
$rules[$prefix.'.*.checked_on'] = "$required|date_format:Y-m-d|before_or_equal:today";
}
$data = Validator::make($input, $rules)->validate();
foreach (['contacts', 'locations'] as $prefix) {
$rules[$prefix.'.*.id'] = 'nullable|string|max:100';
$rules[$prefix.'.*.evidence_status'] = 'nullable|in:reported,to_confirm,historical,conflict,unlocated';
$rules[$prefix.'.*.origin'] = 'nullable|string|max:500';
$rules[$prefix.'.*.source_refs'] = 'array|max:100';
$rules[$prefix.'.*.source_refs.*'] = 'required|string|max:100';
}
$rules['locations.*.location_text'] = 'nullable|string|max:1000';
$rules['locations.*.hours'] = 'nullable|string|max:1000';
$data = [...Validator::make($input, $rules)->validate(), ...app(FundingInstitutionClassification::class)->normalize($input)];
if ($publishing) {
foreach (['contacts', 'locations', 'products'] as $prefix) {
foreach ($data[$prefix] ?? [] as $item) {
Validator::make($item, ['source_url' => empty($item['source_document_sha256']) ? 'required|url:http,https' : 'nullable|url:http,https', 'source_document_name' => empty($item['source_url']) ? 'required|string' : 'nullable|string'])->validate();
}
}
}
foreach (['contacts', 'locations', 'products'] as $key) {
$data[$key] ??= [];
}
@@ -127,7 +145,8 @@ final class FundingInstitutionDetails
/** @param array<string,mixed> $filters */
public function applyFilters(Builder $query, array $filters): void
{
if (empty($filters['need']) && empty($filters['territory'])) {
app(FundingInstitutionClassification::class)->filter($query, $filters);
if (empty($filters['need']) && (empty($filters['territory']) || ($filters['location_mode'] ?? '') === 'physical')) {
return;
}
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
@@ -140,7 +159,7 @@ final class FundingInstitutionDetails
$conditions[] = "(p->'needs' @> ?::jsonb".($unknown ? " OR jsonb_array_length(p->'needs')=0" : '').')';
$bindings[] = json_encode([$filters['need']], JSON_THROW_ON_ERROR);
}
if (! empty($filters['territory'])) {
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$parts = ["p->>'coverage'='national'", "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p->'territory_paths','[]'::jsonb)) t WHERE t @> ?::jsonb)"];
$bindings[] = json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR);
@@ -165,11 +184,16 @@ final class FundingInstitutionDetails
}
});
}
})->orWhere(function (Builder $q) use ($sql, $bindings, $unknown): void {
$q->whereRaw("v.payload->>'kind' = 'institution'")->where(function (Builder $q) use ($sql, $bindings, $unknown): void {
})->orWhere(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->whereRaw("v.payload->>'kind' = 'institution'")->where(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->whereRaw($sql, $bindings);
if ($unknown) {
$q->orWhereRaw("jsonb_array_length(COALESCE(v.payload->'products','[]'::jsonb))=0");
$q->orWhere(function (Builder $p) use ($filters): void {
$p->whereRaw("jsonb_array_length(COALESCE(v.payload->'products','[]'::jsonb))=0");
if (! empty($filters['need'])) {
$p->where(fn ($z) => $z->whereRaw("v.payload->'institution_services' @> ?::jsonb", [json_encode([$filters['need']], JSON_THROW_ON_ERROR)])->orWhereRaw("NOT jsonb_exists(v.payload, 'institution_services')"));
}
});
}
});
});
+22 -5
View File
@@ -11,13 +11,27 @@ use Illuminate\Support\Str;
/** Packaged documentary references prepare private drafts through existing editorial authority. */
final class FundingProgrammeReferences
{
/** @return array{references:array<string,mixed>,sha256:string} */
private function package(): array
{
$references = [];
$hashes = [];
foreach (['funding-references.json', 'funding-institutions-nov2026.json'] as $file) {
$bytes = CheckedFile::read(database_path('data/'.$file));
$package = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
abort_unless($package['format'] === 'investmoz-funding-references-v1', 503);
$references = [...$references, ...$package['references']];
$hashes[$file] = hash('sha256', $bytes);
}
return ['references' => $references, 'sha256' => hash('sha256', json_encode($hashes, JSON_THROW_ON_ERROR))];
}
/** @return array<string,mixed> */
public function preview(User $actor, string $code): array
{
app(FundingProgrammes::class)->guard($actor);
$bytes = CheckedFile::read(database_path('data/funding-references.json'));
$package = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
abort_unless($package['format'] === 'investmoz-funding-references-v1', 503);
$package = $this->package();
$reference = $package['references'][$code] ?? null;
abort_unless(is_array($reference), 404);
$unresolved = [];
@@ -43,9 +57,12 @@ final class FundingProgrammeReferences
}
foreach ($reference['location_territory_names'] ?? [] as $index => $name) {
$reference['draft']['locations'][$index]['territory_id'] = $resolve($name);
if (! $reference['draft']['locations'][$index]['territory_id']) {
$reference['draft']['locations'][$index]['evidence_status'] = 'unlocated';
}
}
$plan = ['unresolved_territories' => $unresolved, 'code' => $code, 'edition' => $reference['edition'], 'draft' => $reference['draft'], 'provenance' => $reference['provenance'],
'package_sha256' => hash('sha256', $bytes)];
'package_sha256' => $package['sha256']];
$plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key'));
return $plan;
@@ -55,7 +72,7 @@ final class FundingProgrammeReferences
public function choices(User $actor): array
{
app(FundingProgrammes::class)->guard($actor);
$package = json_decode(CheckedFile::read(database_path('data/funding-references.json')), true, 512, JSON_THROW_ON_ERROR);
$package = $this->package();
$choices = [];
foreach ($package['references'] as $code => $reference) {
$choices[] = ['code' => $code, 'title' => $reference['draft']['title']];
+9 -2
View File
@@ -67,7 +67,7 @@ final class FundingProgrammes
}
});
}
if (! empty($filters['territory'])) {
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$query->where(function (Builder $scope) use ($filters, $path, $includeUnknown): void {
$scope->whereRaw("v.payload->>'kind' = 'institution'")->orWhereRaw("v.payload->>'coverage' = 'national'")
@@ -106,6 +106,10 @@ final class FundingProgrammes
if ($payload['kind'] === 'institution') {
$products = array_values(array_filter($payload['products'] ?? [], fn ($p) => app(FundingInstitutionDetails::class)->match($p, $filters)));
if (($filters['location_mode'] ?? '') === 'physical') {
return ['institution' => 'Pesquisa por presença física documentada, não por cobertura de financiamento. Confira a precisão do local e as indicações por confirmar na ficha.'];
}
return ['institution' => $products === [] ? 'Instituição identificada; produtos, cobertura e disponibilidade por confirmar. Não é uma correspondência confirmada.' : 'Consulte os produtos correspondentes e as condições por confirmar. Sede e balcões não determinam a cobertura do financiamento.'];
}
$result = [];
@@ -231,7 +235,7 @@ final class FundingProgrammes
$rules = [
'title' => "$required|string|max:250", 'kind' => ['required', Rule::in(array_keys(self::KINDS))], 'summary' => "$required|string|max:4000",
'manager_name' => 'nullable|string|max:250', 'manager_organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
'source_url' => "$required|url:http,https|max:2000", 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today",
'source_url' => 'nullable|url:http,https|max:2000', 'source_document_name' => 'nullable|string|max:250', 'source_document_sha256' => 'nullable|string|regex:/^[a-f0-9]{64}$/D', 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today",
'source_published_on' => 'nullable|date_format:Y-m-d', 'source_status' => 'required|in:identified,unknown,example',
'coverage' => 'required|in:national,territorial,unknown', 'coverage_text' => 'nullable|string|max:2000',
'territory_ids' => 'array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
@@ -258,6 +262,9 @@ final class FundingProgrammes
$rules[$prefix.'.*.explanation'] = "$required|string|max:2000";
}
$data = Validator::make($input, $rules)->validate();
if ($publishing && empty($data['source_url'])) {
Validator::make($data, ['source_document_name' => 'required|string', 'source_document_sha256' => 'required|string|size:64'])->validate();
}
if ($data['kind'] === 'institution') {
$data = [...$data, ...app(FundingInstitutionDetails::class)->normalize($input, $publishing)];
}
+1 -1
View File
@@ -180,7 +180,7 @@ final class InvestorPath
->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id))
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'effective_from' => $s->effective_from ?? null]),
'tools' => ['funding' => ($owner || $manager) && app(FundingProgrammes::class)->installed() ? '/fundos?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null, 'simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'tools' => ['funding' => ($owner || $manager) && app(FundingProgrammes::class)->installed() ? '/fundos?kind=institution&location_mode=physical&context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null, 'simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'preparation' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? '/investimentos/'.$id.'/preparacao'.($opportunity ? '?opportunity='.urlencode($opportunity) : '') : null,
'company' => '/investimentos/'.$id.'/forma-juridica',
'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,23 @@
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
Original read-only `Notas/Instituições financeiras em Moçambique.docx`, edição Novembro2026, SHA256 `119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108`. Texto,509linhas de tabelas e nomes/contactos presentes nos parágrafos reconciliados. **270instituições/operadores,393referências de localização física e168referências de contacto**, não270ofertas de financiamento nem393moradas independentemente confirmadas. Instituições repetidas são reunidas por identidade explícita/aliases documentados ou número de registo citado; divergências, moradas alternativas, terceiros, dados históricos e identidades por confirmar permanecem identificados, sem substituir factos. Entidades sem morada continuam consultáveis como não localizadas. Totais estatísticos/redes incompletas não criam entidades ou agências fictícias. Listas antigas não confirmam licença actual; ABC permanece fundida/extinta segundo o documento, ligada à referência Access, sem atribuir a origem dos balcões por inferência.
Reutilizado o catálogo/versionamento/editorial existente: categorias banca,crédito/microfinanças,gestão de investimento,pensões,seguros/resseguros,mediação,câmbio,pagamentos e supervisão; uma entidade pode ter mais de uma categoria/função sem duplicação. Filtros por categoria,tipo e presença física são distintos da cobertura do produto. Província do cadastro é associada apenas por correspondência inequívoca; rua/localidade e precisão limitada conservam-se como declaradas. Pesquisa por cidade/balcão/morada/alias permite localizar informação textual (por exemplo Beira), sem geocodificação inventada. Informação por confirmar não é correspondência confirmada; ausência de registo não prova ausência de oferta. Oito gestoras de pensões continuam distintas de produtos de crédito, e seguradora não é apresentada automaticamente como financiadora/garantia de crédito.
**Continuidade no mesmo dossier:** ferramentas do investimento abrem a pesquisa contextual; seleccionam-se necessidade (incluindo seguros), instituição e, opcionalmente, sede/balcão. Guarda-se identidade estável, categorias/tipos, versão pública, local exacto escolhido, fonte/documento/SHA/linha, estado de confirmação e produto quando existente. Banco e seguradora coexistem; gravação repetida não duplica escolha. Actualização posterior dos pressupostos do investimento conserva a escolha; retirada da ficha pública não apaga a fotografia consultada. Dados/etapas/autoridade de decisão anteriores não são alterados. Consulta pública livre, operações privadas e edição editorial com as guardas existentes; nenhuma candidatura, email ou contacto enviado às instituições, novo perfil/grant/porta/schema ou ligação fictícia.
Provas no QA55441 existente, com rollback: **5casos API/735asserções únicas** (2desta fonte/608 e3de compatibilidade/127),28frontend dirigidos incluindo S08/navegação e recuperação da tentativa com balcão escolhido, tipos/PHPStan/lint/Pint/compilação QA aprovados. Uma tentativa de suite paralela foi recusada pela guarda da mesma base; compatibilidade executada sequencialmente sem remover a guarda ou criar outra base. `outputs/funding-directory-20261110/institutions/{content,verification}.json` e **`catalogo-qa.html` com270fichas ligadas**; renderização estática dos componentes, não conferência visual de navegador. Provas e pacote anteriores das11fichas/BNI Capital/FDEL mantidos. As7instituições comuns são enriquecidas pela mesma identidade; não há segundo directório. Fontes/documento original não foram alterados.
**Saldo concreto:** conteúdo e percurso local/categoria/tipo → banco/seguradora → fonte/contacto → mesmo dossier → continuação/recuperação demonstrados em QA. Restam instalação e disponibilização/revisão/publicação autenticada pelo fluxo editorial existente, consulta efectiva no destino e visual (conector indisponível). Preparação documental não publica lote automaticamente, conformeAGENTS.md; não declarar informação já disponível na homologação por existir este código. Restantes módulos e resíduosL1–L7/restanteL8/L9–L16 mantêm o saldo anterior.
### Consulta de bancos, seguradoras e outras instituições durante o processo
No investimento escolha **Instituições financeiras e apoios**; no menu comum há **Instituições financeiras e seguros**. Seleccione categoria/tipo e escolha entre **Sede, balcão ou presença física** e **Cobertura do financiamento/produto**. Use a localização do cadastro e, se necessário, palavras da cidade, balcão ou morada. Confira a fonte e as reservas: “reportado no documento”, “a confirmar”, “histórico”, “identidade por confirmar” e “localização por confirmar” não são equivalentes a confirmação actual de licença/oferta.
Na ficha, seleccione o dossier/oportunidade existentes, a necessidade (Seguros quando procura uma seguradora) e o balcão desejado, se identificado. Guardar conserva uma referência privada; não contrata seguro, não solicita empréstimo nem confirma garantia. No dossier, **Instituições e serviços ligados ao processo** apresenta banco e seguradora separadamente com categoria,tipo,balcão e origem. Os pressupostos/etapas seguintes não apagam essas escolhas; a ficha actual pode mudar mas a versão consultada fica no histórico privado. Não é preciso preencher um pedido externo para consultar os contactos.
## 10/11/2026 — ACT-20261029-12: directório financeiro com conteúdo em QA
**Homologação mantém1c75a30a; este conjunto não está instalado nem publicado.** A leitura autenticada do destino confirmou catálogo /fundos HTTP200, schema instalado e zero fichas/versões. O requisito não fica encerrado por essa disponibilidade técnica. APIEX/SWOT mantêm a entrega instalada própria, sem reabertura.
@@ -1,3 +1,17 @@
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
Original read-only `Notas/Instituições financeiras em Moçambique.docx`, edição Novembro2026, SHA256 `119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108`. Texto,509linhas de tabelas e nomes/contactos presentes nos parágrafos reconciliados. **270instituições/operadores,393referências de localização física e168referências de contacto**, não270ofertas de financiamento nem393moradas independentemente confirmadas. Instituições repetidas são reunidas por identidade explícita/aliases documentados ou número de registo citado; divergências, moradas alternativas, terceiros, dados históricos e identidades por confirmar permanecem identificados, sem substituir factos. Entidades sem morada continuam consultáveis como não localizadas. Totais estatísticos/redes incompletas não criam entidades ou agências fictícias. Listas antigas não confirmam licença actual; ABC permanece fundida/extinta segundo o documento, ligada à referência Access, sem atribuir a origem dos balcões por inferência.
Reutilizado o catálogo/versionamento/editorial existente: categorias banca,crédito/microfinanças,gestão de investimento,pensões,seguros/resseguros,mediação,câmbio,pagamentos e supervisão; uma entidade pode ter mais de uma categoria/função sem duplicação. Filtros por categoria,tipo e presença física são distintos da cobertura do produto. Província do cadastro é associada apenas por correspondência inequívoca; rua/localidade e precisão limitada conservam-se como declaradas. Pesquisa por cidade/balcão/morada/alias permite localizar informação textual (por exemplo Beira), sem geocodificação inventada. Informação por confirmar não é correspondência confirmada; ausência de registo não prova ausência de oferta. Oito gestoras de pensões continuam distintas de produtos de crédito, e seguradora não é apresentada automaticamente como financiadora/garantia de crédito.
**Continuidade no mesmo dossier:** ferramentas do investimento abrem a pesquisa contextual; seleccionam-se necessidade (incluindo seguros), instituição e, opcionalmente, sede/balcão. Guarda-se identidade estável, categorias/tipos, versão pública, local exacto escolhido, fonte/documento/SHA/linha, estado de confirmação e produto quando existente. Banco e seguradora coexistem; gravação repetida não duplica escolha. Actualização posterior dos pressupostos do investimento conserva a escolha; retirada da ficha pública não apaga a fotografia consultada. Dados/etapas/autoridade de decisão anteriores não são alterados. Consulta pública livre, operações privadas e edição editorial com as guardas existentes; nenhuma candidatura, email ou contacto enviado às instituições, novo perfil/grant/porta/schema ou ligação fictícia.
Provas no QA55441 existente, com rollback: **5casos API/735asserções únicas** (2desta fonte/608 e3de compatibilidade/127),28frontend dirigidos incluindo S08/navegação e recuperação da tentativa com balcão escolhido, tipos/PHPStan/lint/Pint/compilação QA aprovados. Uma tentativa de suite paralela foi recusada pela guarda da mesma base; compatibilidade executada sequencialmente sem remover a guarda ou criar outra base. `outputs/funding-directory-20261110/institutions/{content,verification}.json` e **`catalogo-qa.html` com270fichas ligadas**; renderização estática dos componentes, não conferência visual de navegador. Provas e pacote anteriores das11fichas/BNI Capital/FDEL mantidos. As7instituições comuns são enriquecidas pela mesma identidade; não há segundo directório. Fontes/documento original não foram alterados.
**Saldo concreto:** conteúdo e percurso local/categoria/tipo → banco/seguradora → fonte/contacto → mesmo dossier → continuação/recuperação demonstrados em QA. Restam instalação e disponibilização/revisão/publicação autenticada pelo fluxo editorial existente, consulta efectiva no destino e visual (conector indisponível). Preparação documental não publica lote automaticamente, conformeAGENTS.md; não declarar informação já disponível na homologação por existir este código. Restantes módulos e resíduosL1–L7/restanteL8/L9–L16 mantêm o saldo anterior.
## 10/11/2026 — ACT-20261029-12: directório financeiro com conteúdo em QA
**Homologação mantém1c75a30a; este conjunto não está instalado nem publicado.** A leitura autenticada do destino confirmou catálogo /fundos HTTP200, schema instalado e zero fichas/versões. O requisito não fica encerrado por essa disponibilidade técnica. APIEX/SWOT mantêm a entrega instalada própria, sem reabertura.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,17 @@
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
Original read-only `Notas/Instituições financeiras em Moçambique.docx`, edição Novembro2026, SHA256 `119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108`. Texto,509linhas de tabelas e nomes/contactos presentes nos parágrafos reconciliados. **270instituições/operadores,393referências de localização física e168referências de contacto**, não270ofertas de financiamento nem393moradas independentemente confirmadas. Instituições repetidas são reunidas por identidade explícita/aliases documentados ou número de registo citado; divergências, moradas alternativas, terceiros, dados históricos e identidades por confirmar permanecem identificados, sem substituir factos. Entidades sem morada continuam consultáveis como não localizadas. Totais estatísticos/redes incompletas não criam entidades ou agências fictícias. Listas antigas não confirmam licença actual; ABC permanece fundida/extinta segundo o documento, ligada à referência Access, sem atribuir a origem dos balcões por inferência.
Reutilizado o catálogo/versionamento/editorial existente: categorias banca,crédito/microfinanças,gestão de investimento,pensões,seguros/resseguros,mediação,câmbio,pagamentos e supervisão; uma entidade pode ter mais de uma categoria/função sem duplicação. Filtros por categoria,tipo e presença física são distintos da cobertura do produto. Província do cadastro é associada apenas por correspondência inequívoca; rua/localidade e precisão limitada conservam-se como declaradas. Pesquisa por cidade/balcão/morada/alias permite localizar informação textual (por exemplo Beira), sem geocodificação inventada. Informação por confirmar não é correspondência confirmada; ausência de registo não prova ausência de oferta. Oito gestoras de pensões continuam distintas de produtos de crédito, e seguradora não é apresentada automaticamente como financiadora/garantia de crédito.
**Continuidade no mesmo dossier:** ferramentas do investimento abrem a pesquisa contextual; seleccionam-se necessidade (incluindo seguros), instituição e, opcionalmente, sede/balcão. Guarda-se identidade estável, categorias/tipos, versão pública, local exacto escolhido, fonte/documento/SHA/linha, estado de confirmação e produto quando existente. Banco e seguradora coexistem; gravação repetida não duplica escolha. Actualização posterior dos pressupostos do investimento conserva a escolha; retirada da ficha pública não apaga a fotografia consultada. Dados/etapas/autoridade de decisão anteriores não são alterados. Consulta pública livre, operações privadas e edição editorial com as guardas existentes; nenhuma candidatura, email ou contacto enviado às instituições, novo perfil/grant/porta/schema ou ligação fictícia.
Provas no QA55441 existente, com rollback: **5casos API/735asserções únicas** (2desta fonte/608 e3de compatibilidade/127),28frontend dirigidos incluindo S08/navegação e recuperação da tentativa com balcão escolhido, tipos/PHPStan/lint/Pint/compilação QA aprovados. Uma tentativa de suite paralela foi recusada pela guarda da mesma base; compatibilidade executada sequencialmente sem remover a guarda ou criar outra base. `outputs/funding-directory-20261110/institutions/{content,verification}.json` e **`catalogo-qa.html` com270fichas ligadas**; renderização estática dos componentes, não conferência visual de navegador. Provas e pacote anteriores das11fichas/BNI Capital/FDEL mantidos. As7instituições comuns são enriquecidas pela mesma identidade; não há segundo directório. Fontes/documento original não foram alterados.
**Saldo concreto:** conteúdo e percurso local/categoria/tipo → banco/seguradora → fonte/contacto → mesmo dossier → continuação/recuperação demonstrados em QA. Restam instalação e disponibilização/revisão/publicação autenticada pelo fluxo editorial existente, consulta efectiva no destino e visual (conector indisponível). Preparação documental não publica lote automaticamente, conformeAGENTS.md; não declarar informação já disponível na homologação por existir este código. Restantes módulos e resíduosL1–L7/restanteL8/L9–L16 mantêm o saldo anterior.
## 10/11/2026 — ACT-20261029-12: directório financeiro com conteúdo em QA
**Homologação mantém1c75a30a; este conjunto não está instalado nem publicado.** A leitura autenticada do destino confirmou catálogo /fundos HTTP200, schema instalado e zero fichas/versões. O requisito não fica encerrado por essa disponibilidade técnica. APIEX/SWOT mantêm a entrega instalada própria, sem reabertura.
@@ -0,0 +1,195 @@
import { card, input } from '@/pages/investment/shared';
export const financialCategories: Record<string, string> = {
banking: 'Banca',
credit: 'Crédito e microfinanças',
investment: 'Gestão de investimento',
pensions: 'Fundos de pensões',
insurance: 'Seguros e resseguros',
intermediation: 'Mediação de seguros',
exchange: 'Câmbio',
payments: 'Pagamentos e serviços interbancários',
supervision: 'Supervisão',
other: 'Outras instituições',
};
export const financialTypes: Record<string, string> = {
commercial_bank: 'Banco comercial',
investment_bank: 'Banco de investimento',
bank: 'Banco (tipo não especificado)',
microbank: 'Microbanco',
cooperative: 'Cooperativa de crédito',
savings: 'Organização de poupança e empréstimo',
investment_manager: 'Gestora de fundos de investimento',
investment_company: 'Sociedade de investimento',
pension_manager: 'Gestora de fundos de pensões',
insurer: 'Seguradora',
reinsurer: 'Resseguradora',
microinsurer: 'Micro-seguradora',
broker: 'Corretora de seguros',
agent: 'Agente de seguros',
reinsurance_broker: 'Corretor de resseguro',
exchange_house: 'Casa de câmbio',
electronic_money: 'Instituição de moeda electrónica',
interbank: 'Serviços interbancários',
supervisor: 'Supervisor',
other: 'Outro tipo documentado',
};
export type Classification = {
institution_categories?: string[];
institution_types?: string[];
institution_services?: string[];
institution_status?: string;
successor_key?: string | null;
register_reference?: string | null;
source_document_name?: string | null;
source_document_sha256?: string | null;
aliases?: string[];
source_rows?: { id: string; section: string; text: string }[];
};
export const evidenceLabels: Record<string, string> = {
reported: 'Reportado no documento',
to_confirm: 'A confirmar',
historical: 'Referência histórica',
conflict: 'Informação divergente/identidade por confirmar',
unlocated: 'Localização por confirmar',
};
export function ClassificationSummary({ value }: { value: Classification }) {
return (
<section className={card}>
<h2>Categoria e tipo da instituição</h2>
<p>
Categorias:{' '}
{(value.institution_categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ') || 'Por classificar'}
</p>
<p>
Tipos:{' '}
{(value.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ') || 'Por classificar'}
</p>
<p>
Situação segundo a fonte:{' '}
{value.institution_status === 'merged'
? 'Fundida/extinta segundo o documento; não apresentar como instituição activa'
: value.institution_status === 'historical'
? 'Registo histórico'
: value.institution_status === 'unknown'
? 'Situação actual por confirmar'
: 'Reportada no documento; não é confirmação independente de licença actual'}
{value.successor_key
? ` · Sucessora indicada: ${value.successor_key}`
: ''}
</p>
{value.register_reference && (
<p>
Referência de registo no documento:{' '}
{value.register_reference}
</p>
)}
<details>
<summary>
Rastrear o conteúdo até ao documento e à linha de origem
</summary>
<p>
{value.source_document_name} · SHA-256{' '}
{value.source_document_sha256}
</p>
{(value.source_rows ?? []).map((row) => (
<article key={row.id}>
<h3>{row.section}</h3>
<p>{row.id}</p>
<p>{row.text}</p>
</article>
))}
</details>
<p>
Categoria e presença física não confirmam cobertura de crédito,
oferta de seguro específica, garantia ou aceitação do processo.
</p>
</section>
);
}
export function ClassificationFields({
value,
change,
needs,
}: {
value: Classification;
change: (patch: Partial<Classification>) => void;
needs: Record<string, string>;
}) {
const multiple = (
key:
| 'institution_categories'
| 'institution_types'
| 'institution_services',
label: string,
options: Record<string, string>,
) => (
<label className="my-3 block">
{label}
<select
multiple
className={input}
value={value[key] ?? []}
onChange={(e) =>
change({
[key]: Array.from(
e.target.selectedOptions,
(o) => o.value,
),
})
}
>
{Object.entries(options).map(([id, title]) => (
<option key={id} value={id}>
{title}
</option>
))}
</select>
</label>
);
return (
<details className={card}>
<summary>Categoria, tipo e serviço — Alterar</summary>
{multiple(
'institution_categories',
'Categorias documentadas',
financialCategories,
)}
{multiple(
'institution_types',
'Tipos documentados',
financialTypes,
)}
{multiple(
'institution_services',
'Serviços identificados (não é oferta garantida)',
needs,
)}
<label>
Situação
<select
className={input}
value={value.institution_status ?? 'unknown'}
onChange={(e) =>
change({ institution_status: e.target.value })
}
>
<option value="unknown">Por confirmar</option>
<option value="reported">Reportada no documento</option>
<option value="historical">Histórica</option>
<option value="merged">
Fundida/extinta segundo a fonte
</option>
</select>
</label>
<p>
As linhas originais e as classificações anteriores conservam-se
nas versões. Não complete lacunas por inferência.
</p>
</details>
);
}
@@ -27,6 +27,7 @@ export function FundingDossierChoice({
}) {
const [need, setNeed] = useState(selection.need ?? '');
const [product, setProduct] = useState('');
const [location, setLocation] = useState('');
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
@@ -42,6 +43,8 @@ export function FundingDossierChoice({
version: dossier.version,
version_id: record.version_id,
product: product || null,
location_id: location || null,
location_mode: selection.location_mode ?? 'coverage',
need,
territory: selection.territory,
consent,
@@ -164,12 +167,46 @@ export function FundingDossierChoice({
</select>
</label>
)}
{record.payload.kind === 'institution' && (
<label>
Sede/balcão a conservar na preparação
<select
className={input}
value={location}
onChange={(e) =>
setLocation(e.target.value)
}
>
<option value="">
Sem local específico seleccionado / por
confirmar
</option>
{(record.payload.locations ?? [])
.filter((l) => l.id)
.map((l) => (
<option key={l.id} value={l.id}>
{l.label} ·{' '}
{l.location_text ||
l.territory_path
?.map((n) => n.name)
.join(' / ') ||
'Local por confirmar'}{' '}
·{' '}
{l.evidence_status ===
'to_confirm'
? 'A confirmar'
: ''}
</option>
))}
</select>
</label>
)}
<p>
Local seleccionado:{' '}
{selection.territory
? selection.territory_label ||
'Conservado da pesquisa; consulte a cobertura do produto.'
: 'Volte à pesquisa e escolha a localização antes de guardar.'}
: 'Local de interesse por identificar; a referência fica guardada com essa lacuna explícita.'}
</p>
<label>
<input
@@ -187,8 +224,7 @@ export function FundingDossierChoice({
disabled={
busy ||
dossier.closed ||
(!pending.current &&
(!consent || !need || !selection.territory))
(!pending.current && (!consent || !need))
}
onClick={() => void save()}
>
+114 -31
View File
@@ -1,12 +1,26 @@
import {
ClassificationFields,
ClassificationSummary,
evidenceLabels,
type Classification,
} from '@/components/funding-classification';
import { card, input, button } from '@/pages/investment/shared';
export type FundingSource = {
source_url: string;
source_url: string | null;
source_document_name?: string | null;
source_document_sha256?: string | null;
evidence_status?: string | null;
origin?: string;
source_refs?: string[];
source_reference: string;
checked_on: string;
};
export type FundingContact = FundingSource & { kind: string; value: string };
export type FundingLocation = FundingSource & {
id?: string;
location_text?: string;
hours?: string;
role: string;
label: string;
address: string;
@@ -27,7 +41,7 @@ export type FundingProduct = FundingSource & {
availability: string;
contact_url: string;
};
export type InstitutionDetails = {
export type InstitutionDetails = Classification & {
institution_key: string;
institution_nature: string;
contacts: FundingContact[];
@@ -83,9 +97,45 @@ function Sources({
<Text
label="Endereço da fonte"
type="url"
value={value.source_url}
value={value.source_url ?? ''}
change={(source_url) => change({ source_url })}
/>
{!value.source_url && (
<>
<Text
label="Documento de origem"
value={value.source_document_name ?? ''}
change={(source_document_name) =>
change({ source_document_name })
}
/>
<Text
label="SHA-256 do original"
value={value.source_document_sha256 ?? ''}
change={(source_document_sha256) =>
change({ source_document_sha256 })
}
/>
</>
)}
{value.source_document_sha256 && (
<label>
Situação desta informação
<select
className={input}
value={value.evidence_status ?? 'to_confirm'}
onChange={(e) =>
change({ evidence_status: e.target.value })
}
>
{Object.entries(evidenceLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
)}
<Text
label="Referência/edição"
value={value.source_reference}
@@ -130,6 +180,7 @@ export function InstitutionFields({
A sede ou balcão não determina a cobertura do financiamento. Não
preencha condições desconhecidas por inferência.
</p>
<ClassificationFields value={value} change={change} needs={needs} />
<Text
label="Identidade estável da instituição (referência do cadastro/fonte)"
value={value.institution_key}
@@ -502,40 +553,63 @@ export function InstitutionFields({
}
function Source({ value }: { value: FundingSource }) {
return (
<p>
<a
className="underline"
href={value.source_url}
target="_blank"
rel="noopener noreferrer"
>
{value.source_reference}
</a>{' '}
· consulta {value.checked_on}
</p>
<div>
<p>
{value.source_url ? (
<a
className="underline"
href={value.source_url}
target="_blank"
rel="noopener noreferrer"
>
{value.source_reference}
</a>
) : (
value.source_reference
)}{' '}
· consulta {value.checked_on}
</p>
{value.source_document_name && (
<p>
Documento: {value.source_document_name} · SHA-256{' '}
{value.source_document_sha256}
</p>
)}
{value.evidence_status && (
<p>
{evidenceLabels[value.evidence_status] ??
value.evidence_status}{' '}
· {value.origin}
</p>
)}
</div>
);
}
export function InstitutionView({ value }: { value: InstitutionDetails }) {
return (
<section className={card}>
<h2>Instituição financeira</h2>
<p>
Natureza:{' '}
{
(
{
bank: 'Banco',
credit: 'Instituição de crédito',
microfinance: 'Microfinanças',
investment_fund: 'Fundo de investimento',
development:
'Agência de desenvolvimento/financiamento',
other: 'Outra natureza documentada',
unknown: 'Por confirmar',
} as Record<string, string>
)[value.institution_nature]
}
</p>
<ClassificationSummary value={value} />
{!value.institution_types?.length && (
<p>
Natureza:{' '}
{
(
{
bank: 'Banco',
credit: 'Instituição de crédito',
microfinance: 'Microfinanças',
investment_fund: 'Fundo de investimento',
development:
'Agência de desenvolvimento/financiamento',
other: 'Outra natureza documentada',
unknown: 'Por confirmar',
} as Record<string, string>
)[value.institution_nature]
}
</p>
)}
<h3>Contactos</h3>
{value.contacts.length === 0 && <p>Contactos por confirmar.</p>}
{value.contacts.map((c, i) => (
@@ -575,6 +649,15 @@ export function InstitutionView({ value }: { value: InstitutionDetails }) {
{l.territory_path?.map((n) => n.name).join(' / ') ||
'Correspondência ao cadastro territorial por confirmar'}
</p>
{l.location_text && (
<p>Local indicado: {l.location_text}</p>
)}
{l.hours && (
<p>
Horário indicado na fonte (pode ter mudado):{' '}
{l.hours}
</p>
)}
<Source value={l} />
</article>
))}
+1 -1
View File
@@ -511,7 +511,7 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
? [
link(
'funding',
'Fundos e programas de apoio',
'Instituições financeiras e seguros',
'/fundos',
),
...(access.authenticated
+15 -1
View File
@@ -279,7 +279,9 @@ function Editor({
}
function text(
key: {
[K in keyof Draft]: Draft[K] extends string ? K : never;
[K in keyof Draft]-?: Draft[K] extends string | null | undefined
? K
: never;
}[keyof Draft],
label: string,
type = 'text',
@@ -427,6 +429,18 @@ function Editor({
{!draft.manager_organization_id &&
text('manager_name', 'Gestor indicado pela fonte')}
{text('source_url', 'Endereço da fonte', 'url')}
{!draft.source_url && (
<>
{text(
'source_document_name',
'Documento de origem',
)}
{text(
'source_document_sha256',
'SHA-256 do documento',
)}
</>
)}
{text('source_reference', 'Documento, edição e referência')}
{text(
'source_checked_on',
+89 -1
View File
@@ -1,3 +1,7 @@
import {
financialCategories,
financialTypes,
} from '@/components/funding-classification';
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { type Programme, windowLabels } from './shared';
@@ -24,6 +28,10 @@ export default function Index({
need?: string;
context?: string;
opportunity?: string;
category?: string;
institution_type?: string;
location_mode?: string;
include_historical?: string;
};
options: {
sectors: { id: number; name: string }[];
@@ -34,6 +42,10 @@ export default function Index({
management: boolean;
}) {
const form = useForm({
category: filters.category ?? '',
institution_type: filters.institution_type ?? '',
location_mode: filters.location_mode ?? 'coverage',
include_historical: filters.include_historical ?? '0',
context: filters.context ?? '',
opportunity: filters.opportunity ?? '',
need: filters.need ?? '',
@@ -66,7 +78,74 @@ export default function Index({
}}
>
<label>
Necessidade de financiamento
Categoria
<select
className="block rounded border p-2"
value={form.data.category}
onChange={(e) =>
form.setData('category', e.target.value)
}
>
<option value="">Todas</option>
{Object.entries(financialCategories).map(
([id, label]) => (
<option key={id} value={id}>
{label}
</option>
),
)}
</select>
</label>
<label>
Tipo
<select
className="block rounded border p-2"
value={form.data.institution_type}
onChange={(e) =>
form.setData('institution_type', e.target.value)
}
>
<option value="">Todos</option>
{Object.entries(financialTypes).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<label>
Localização a pesquisar
<select
className="block rounded border p-2"
value={form.data.location_mode}
onChange={(e) =>
form.setData('location_mode', e.target.value)
}
>
<option value="physical">
Sede, balcão ou presença física
</option>
<option value="coverage">
Cobertura do financiamento/produto
</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={form.data.include_historical === '1'}
onChange={(e) =>
form.setData(
'include_historical',
e.target.checked ? '1' : '0',
)
}
/>
Incluir instituições históricas/fundidas, identificadas como
tal
</label>
<label>
Necessidade do processo
<select
className="block rounded border p-2"
value={form.data.need}
@@ -195,6 +274,15 @@ export default function Index({
</Link>
</h2>
<p>{item.payload.summary}</p>
<p>
{(item.payload.institution_categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ')}{' '}
·{' '}
{(item.payload.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ')}
</p>
<p>Gestor: {item.payload.manager_name}</p>
{Object.entries(item.filter_explanation ?? {}).map(
([key, message]) => (
+8 -2
View File
@@ -85,13 +85,19 @@ export default function Show({
<p>
<a
className="underline"
href={payload.source_url}
href={payload.source_url || undefined}
target="_blank"
rel="noopener noreferrer"
>
Consultar fonte: {payload.source_reference}
</a>
. Consultada em {payload.source_checked_on}.
. Consultada em {payload.source_checked_on}.{' '}
{payload.source_document_name && (
<span>
Documento: {payload.source_document_name} · SHA-256{' '}
{payload.source_document_sha256}
</span>
)}
</p>
<section className={card}>
<h2 className="text-xl font-semibold">
+54 -2
View File
@@ -1,3 +1,8 @@
import {
financialCategories,
financialTypes,
evidenceLabels,
} from '@/components/funding-classification';
import {
ProactiveSummary,
proactiveView,
@@ -45,6 +50,18 @@ type Path = {
programme_id: string;
version: number;
need: string;
institution_key?: string;
categories?: string[];
institution_types?: string[];
location?: {
id: string;
label: string;
address?: string;
source_reference: string;
source_document_sha256?: string;
evidence_status?: string;
location_text?: string;
} | null;
opportunity: string | null;
notice: string;
product?: {
@@ -1077,14 +1094,14 @@ export default function InvestmentPath(path: Path) {
id="financiamento"
className="rounded-xl border bg-white p-4"
>
<h2>Referências de financiamento do dossier</h2>
<h2>Instituições e serviços ligados ao processo</h2>
<p>
Escolhas privadas para preparação; não são candidaturas
enviadas nem concessões de financiamento.
</p>
{path.tools.funding && (
<Link className="underline" href={path.tools.funding}>
Pesquisar instituições e apoios para este investimento
Pesquisar bancos, seguradoras e outras instituições
</Link>
)}
{(path.funding_selections ?? []).map((row) => (
@@ -1101,6 +1118,41 @@ export default function InvestmentPath(path: Path) {
'Condições por confirmar'}
</p>
<p>{row.notice}</p>
<p>
Identidade:{' '}
{row.institution_key ?? 'Referência anterior'} ·
Categoria:{' '}
{(row.categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ') || 'Por classificar'}{' '}
· Tipo:{' '}
{(row.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ') || 'Por classificar'}
</p>
{row.location && (
<section>
<h4>
Sede/balcão escolhido: {row.location.label}
</h4>
<p>
{row.location.location_text} ·{' '}
{row.location.address}
</p>
<p>
{row.location.evidence_status
? evidenceLabels[
row.location.evidence_status
]
: ''}{' '}
· {row.location.source_reference}
</p>
<p>
SHA-256 da origem:{' '}
{row.location.source_document_sha256}
</p>
</section>
)}
<Link
className="underline"
href={`/fundos/${row.programme_id}?context=${path.case.id}${row.opportunity ? '&opportunity=' + encodeURIComponent(row.opportunity) : ''}`}
@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\FundingDossierPreparation;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
final class FinancialInstitutionsSourceTest extends TestCase
{
use DatabaseTransactions;
public function test_city_or_branch_text_is_searchable_without_treating_wildcards_as_search_commands(): void
{
$this->withoutVite();
Http::preventStrayRequests();
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
$references = app(FundingProgrammeReferences::class);
$plan = $references->preview($actor, 'arko');
$id = $references->prepare($actor, 'arko', $plan['hash'])['id'];
$service = app(FundingProgrammes::class);
$publication = $service->preview($actor, $id);
$service->publish($actor, $id, ['version' => $publication['version'], 'hash' => $publication['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA city search through the existing controller, rolled back.']);
$province = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Sofala')->value('id');
$filters = ['q' => 'Beira', 'territory' => $province, 'location_mode' => 'physical', 'category' => 'insurance', 'institution_type' => 'insurer'];
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->where('items.data.0.id', $id));
$this->get('/fundos?'.http_build_query([...$filters, 'q' => '%']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->has('items.data', 0));
$record = $service->published($id);
$location = collect($record['payload']['locations'])->first(fn ($loc) => $loc['territory_id'] === $province && $loc['role'] === 'branch');
$case = $this->postJson('/investimentos', ['title' => 'SYNTHETIC continuity during investor preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$tracking = app(FundingDossierPreparation::class);
$context = $tracking->context($actor, $case, null);
$saved = $this->postJson('/fundos/'.$id.'/dossier', ['context' => $case, 'version' => $context['version'], 'version_id' => $record['version_id'], 'location_id' => $location['id'], 'location_mode' => 'physical', 'territory' => $province, 'need' => 'insurance', 'operation_key' => (string) Str::uuid(), 'consent' => true])->assertOk()->json();
$this->post('/investimentos/'.$case.'/aplicabilidade', ['version' => $saved['version'], 'land_needed' => 'unknown', 'external_funding' => 'yes', 'viability' => 'unknown', 'reason' => 'SYNTHETIC next preparation step retains selected insurer and provenance.'])->assertRedirect();
$continued = $tracking->context($actor, $case, null);
$this->assertSame($saved['selections'], $continued['selections']);
$this->assertGreaterThan($saved['version'], $continued['version']);
$this->assertSame('insurance', $continued['selections'][0]['need']);
Http::assertNothingSent();
}
public function test_supplied_institutions_can_be_found_by_physical_location_category_and_type_and_remain_traceable_in_one_dossier(): void
{
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->withoutVite();
Http::preventStrayRequests();
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
$source = json_decode(file_get_contents(database_path('data/funding-institutions-nov2026.json')), true, 512, JSON_THROW_ON_ERROR);
$references = app(FundingProgrammeReferences::class);
$catalogue = app(FundingProgrammes::class);
$records = [];
foreach ($source['references'] as $code => $reference) {
$plan = $references->preview($actor, $code);
$id = $references->prepare($actor, $code, $plan['hash'])['id'];
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'), $code);
$ready = $catalogue->preview($actor, $id);
$catalogue->publish($actor, $id, ['version' => $ready['version'], 'hash' => $ready['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA publication of supplied documentary facts; rolled back.']);
$records[$code] = $catalogue->published($id);
$this->assertSame($reference['draft']['institution_key'], $records[$code]['payload']['institution_key']);
}
$this->assertCount(270, $records);
$this->assertCount(270, array_unique(array_map(fn ($r) => $r['payload']['institution_key'], $records)));
$province = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Nampula')->value('id');
$filters = ['territory' => $province, 'location_mode' => 'physical', 'category' => 'insurance', 'institution_type' => 'insurer', 'certainty' => 'documented'];
$found = $catalogue->catalogueQuery($filters)->pluck('p.code')->all();
$this->assertContains('arko', $found);
$this->assertNotContains('bci', $found);
$this->assertNotContains('issm', $found);
$this->assertFalse($catalogue->catalogueQuery(['territory' => $province, 'need' => 'investment'])->where('p.id', $records['arko']['id'])->exists(), 'Insurer is not silently a credit provider');
$this->assertCount(8, $catalogue->catalogueQuery(['institution_type' => 'pension_manager'])->get());
$this->assertFalse($catalogue->catalogueQuery([])->where('p.id', $records['abc-historical']['id'])->exists());
$this->assertTrue($catalogue->catalogueQuery(['include_historical' => true])->where('p.id', $records['abc-historical']['id'])->exists());
$this->assertSame('access-bank', $records['abc-historical']['payload']['successor_key']);
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($p) => $p->component('funding/index')->where('filters.category', 'insurance')->where('filters.institution_type', 'insurer'));
$case = $this->postJson('/investimentos', ['title' => 'SYNTHETIC bank and insurance preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$tracking = app(FundingDossierPreparation::class);
$before = $tracking->context($actor, $case, null);
$bank = ['context' => $case, 'version' => $before['version'], 'version_id' => $records['bci']['version_id'], 'need' => 'investment', 'territory' => $province, 'product' => 'bci-oferta', 'operation_key' => (string) Str::uuid(), 'consent' => true];
$afterBank = $this->postJson('/fundos/'.$records['bci']['id'].'/dossier', $bank)->assertOk()->json();
$location = collect($records['arko']['payload']['locations'])->first(fn ($loc) => $loc['territory_id'] === $province && $loc['role'] === 'branch');
$this->assertNotNull($location);
$insurance = ['context' => $case, 'version' => $afterBank['version'], 'version_id' => $records['arko']['version_id'], 'need' => 'insurance', 'territory' => $province, 'location_mode' => 'physical', 'location_id' => $location['id'], 'operation_key' => (string) Str::uuid(), 'consent' => true];
$saved = $this->postJson('/fundos/'.$records['arko']['id'].'/dossier', $insurance)->assertOk()->assertJsonCount(2, 'selections')->json();
$this->postJson('/fundos/'.$records['arko']['id'].'/dossier', $insurance)->assertOk()->assertJsonCount(2, 'selections');
$this->postJson('/fundos/'.$records['arko']['id'].'/dossier', [...$insurance, 'version' => $saved['version'], 'operation_key' => (string) Str::uuid(), 'location_id' => 'foreign-location'])->assertUnprocessable();
$catalogue->withdraw($actor, $records['arko']['id'], $records['arko']['version'], 'SYNTHETIC QA withdrawal without rewriting investor history.');
$recovered = $tracking->context($actor, $case, null);
$this->assertSame($saved['selections'], $recovered['selections']);
$chosen = $recovered['selections'][1];
$this->assertSame('arko', $chosen['institution_key']);
$this->assertSame(['insurance'], $chosen['categories']);
$this->assertContains('insurer', $chosen['institution_types']);
$this->assertSame($location['id'], $chosen['location']['id']);
$this->assertSame($source['source']['sha256'], $chosen['location']['source_document_sha256']);
$this->getJson('/investimentos/'.$case)->assertOk()->assertJsonCount(2, 'funding_selections');
$directory = base_path('outputs/funding-directory-20261110/institutions');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
file_put_contents($directory.'/content.json', json_encode(['synthetic_editorial_publication_rolled_back' => true, 'source' => $source['source'], 'records' => $records, 'nampula_insurers' => $found, 'dossier_recovered' => $recovered, 'no_external_submission' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
Http::assertNothingSent();
}
}
+3 -1
View File
@@ -26,7 +26,7 @@ function load(file, mocks = {}) {
(name) =>
name in mocks
? mocks[name]
: name === '@/pages/investment/shared'
: name === '@/components/funding-classification' ? load('components/funding-classification.tsx',mocks) : name === '@/pages/investment/shared'
? { card: 'card', input: 'input', button: 'button' }
: require(name),
module,
@@ -189,6 +189,7 @@ test('actual dossier continuation freezes a lost response retry and retains doss
let tree = render();
const selects = flatten(tree).filter((n) => n.type === 'select');
selects[1].props.onChange({ target: { value: product.code } });
selects[2].props.onChange({ target: { value: 'qa-branch' } });
flatten(tree)
.find((n) => n.type === 'input' && n.props.type === 'checkbox')
.props.onChange({ target: { checked: true } });
@@ -214,6 +215,7 @@ test('actual dossier continuation freezes a lost response retry and retains doss
assert.equal(calls[0].body.context, 'qa-dossier');
assert.equal(calls[0].body.opportunity, 'qa-opportunity');
assert.equal(calls[0].body.product, product.code);
assert.equal(calls[0].body.location_id, 'qa-branch');
tree = render();
assert.equal(
flatten(tree).find((n) => n.type === 'a').props.href,
+42
View File
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {compileFunction} from 'node:vm';
import {createRequire} from 'node:module';
import {createHash} from 'node:crypto';
import ts from 'typescript';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
const require=createRequire(import.meta.url),root=new URL('../',import.meta.url),out=new URL('outputs/funding-directory-20261110/institutions/',root);
const native=JSON.parse(fs.readFileSync(new URL('content.json',out),'utf8'));
const source=JSON.parse(fs.readFileSync(new URL('database/data/funding-institutions-nov2026.json',root),'utf8'));
const records=Object.values(native.records),ids=Object.fromEntries(records.map(r=>[r.id,r.code]));
assert.equal(records.length,270);assert.equal(new Set(records.map(r=>r.payload.institution_key)).size,270);
assert.equal(source.source.sha256,'119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108');
assert.equal(source.source_coverage.length,509);
for(const row of source.source_coverage){if(row.status.startsWith('represented'))assert.ok(row.institutions.every(key=>native.records[key]));else assert.equal(row.status,'aggregate_or_gap_not_an_institution');}
assert.ok(native.nampula_insurers.includes('arko'));assert.ok(!native.nampula_insurers.includes('bci'));
assert.equal(native.dossier_recovered.selections.length,2);
const chosen=native.dossier_recovered.selections[1];assert.equal(chosen.need,'insurance');assert.equal(chosen.institution_key,'arko');assert.equal(chosen.location.source_document_sha256,source.source.sha256);assert.ok(chosen.institution_types.includes('insurer'));
const cache={};
function load(file){
if(cache[file])return cache[file];
const module={exports:{}};cache[file]=module.exports;
const code=ts.transpileModule(fs.readFileSync(new URL('resources/js/'+file,root),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
compileFunction(code,['require','module','exports'])(name=>{
if(name==='@inertiajs/react')return {Link:({href,children,...props})=>{let target=href;if(href==='/fundos')target='../catalogo-qa.html';else if(typeof href==='string'&&href.startsWith('/fundos/')){const id=href.split('/')[2].split('?')[0];if(ids[id])target='fichas/'+ids[id]+'.html';}return React.createElement('a',{...props,href:target},children);},useForm:data=>({data,processing:false,setData:()=>{},get:()=>{}}),router:{}};
if(name==='@/pages/investment/shared')return {Frame:({title,children})=>React.createElement('main',{},React.createElement('h1',{},title),children),card:'card',button:'button'};
if(name==='./shared')return {windowLabels:{closed:'Janela encerrada',unknown:'Janela por confirmar',open:'Segundo datas documentadas'}};
if(name==='@/components/funding-classification')return load('components/funding-classification.tsx');
if(name==='@/components/funding-institution')return load('components/funding-institution.tsx');
if(name==='@/components/funding-dossier-choice')return {};
if(name==='@/components/funding-criteria-assessment')return {default:()=>null};
return require(name);
},module,module.exports);cache[file]=module.exports;return module.exports;
}
const wrap=body=>'<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Instituições por localização categoria e tipo — QA</title><style>body{font:16px/1.5 system-ui;max-width:1100px;margin:auto;padding:24px;color:#172a29;background:#f5f7f6}.card{background:white;border:1px solid #ccd8d1;border-radius:10px;padding:18px;margin:16px 0}a{color:#006546}label{display:block;margin:8px}input,select{padding:8px;max-width:100%}article{overflow-wrap:anywhere}details{margin:16px 0}</style><p><strong>Revisão QA — ainda não instalado/publicado na homologação.</strong> Dados do documento fornecido, com dúvidas, proveniência e histórico conservados; não é confirmação independente de licença ou oferta. Renderização estática dos componentes; os filtros funcionais e a continuidade foram provados pela API QA. Sem candidaturas enviadas.</p>'+body+'</html>';
const Show=load('pages/funding/show.tsx').default,Index=load('pages/funding/index.tsx').default;
fs.mkdirSync(new URL('fichas/',out),{recursive:true});
for(const record of records){const html=renderToStaticMarkup(React.createElement(Show,{record,saved:null,authenticated:false}));fs.writeFileSync(new URL('fichas/'+record.code+'.html',out),wrap(html));}
const catalogue=renderToStaticMarkup(React.createElement(Index,{items:{data:records,links:[]},filters:{location_mode:'physical'},kinds:{institution:'Instituição'},management:false,options:{territories:[],sectors:[]},needs:{investment:'Investimento',insurance:'Seguros'}}));fs.writeFileSync(new URL('catalogo-qa.html',out),wrap(catalogue));
const categories={};for(const r of records)for(const c of r.payload.institution_categories??[])categories[c]=(categories[c]??0)+1;
const proof={source:source.source,institutions:records.length,physical_location_assertions:records.reduce((n,r)=>n+r.payload.locations.length,0),contact_assertions:records.reduce((n,r)=>n+r.payload.contacts.length,0),categories,source_table_rows_accounted:source.source_coverage.length,identity_aliases_and_regulator_numbers_preserved:true,physical_presence_separate_from_product_coverage:true,uncertain_historical_and_unlocated_assertions_explicit:true,no_insurer_presented_as_credit_provider:true,bank_and_insurer_saved_in_same_dossier:true,selected_branch_source_and_type_recovered_after_withdrawal:true,qa_transactions_rolled_back:true,no_new_schema_port_or_rights:true,installed:false,published_live:false,visual_review:'pending; connector unavailable',data_sha256:createHash('sha256').update(fs.readFileSync(new URL('database/data/funding-institutions-nov2026.json',root))).digest('hex')};fs.writeFileSync(new URL('verification.json',out),JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));
+1
View File
@@ -23,6 +23,7 @@ function load(file){
if(name==='@inertiajs/react')return {Link:({href,children,...props})=>{let target=href;if(href==='/fundos')target='../catalogo-qa.html';else if(typeof href==='string'&&href.startsWith('/fundos/')){const id=href.split('/')[2].split('?')[0];if(ids[id])target='fichas/'+ids[id]+'.html';}return React.createElement('a',{...props,href:target},children);},useForm:data=>({data,processing:false,setData:()=>{},get:()=>{}}),router:{}};
if(name==='@/pages/investment/shared')return {Frame:({title,children})=>React.createElement('main',{},React.createElement('h1',{},title),children),card:'card',button:'button'};
if(name==='./shared')return {windowLabels:{closed:'Janela encerrada',unknown:'Janela por confirmar',open:'Segundo datas documentadas'}};
if(name==='@/components/funding-classification')return load('components/funding-classification.tsx');
if(name==='@/components/funding-institution')return load('components/funding-institution.tsx');
if(name==='@/components/funding-dossier-choice')return {};
if(name==='@/components/funding-criteria-assessment')return {default:()=>null};
+3 -3
View File
@@ -27,15 +27,15 @@ if ($identity['name'] !== 'simulator_fiscal_delta_20261021_test' || (int) $ident
throw new RuntimeException('Isolated QA identity required.');
}
if ($argv[1] === 'analyse') {
$argv = ['phpstan', 'analyse', '--no-progress', '--memory-limit=512M', '--configuration='.$root.'/outputs/funding-directory-20261110/phpstan.neon', 'app/Services/FundingInstitutionDetails.php', 'app/Services/FundingDossierPreparation.php', 'app/Services/FundingProgrammes.php', 'app/Services/FundingProgrammeReferences.php', 'app/Http/Controllers/FundingProgrammeController.php'];
$argv = ['phpstan', 'analyse', '--no-progress', '--memory-limit=512M', '--configuration='.$root.'/outputs/funding-directory-20261110/phpstan.neon', 'app/Services/FundingInstitutionClassification.php', 'app/Services/FundingInstitutionDetails.php', 'app/Services/FundingDossierPreparation.php', 'app/Services/FundingProgrammes.php', 'app/Services/FundingProgrammeReferences.php', 'app/Http/Controllers/FundingProgrammeController.php'];
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = count($argv);
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpstan/phpstan/phpstan';
exit;
}
$case = 'FundingDirectoryTest';
$filter = $argv[2] ?? null;
$case = ($argv[2] ?? null) === 'source' ? 'FinancialInstitutionsSourceTest' : 'FundingDirectoryTest';
$filter = $case === 'FinancialInstitutionsSourceTest' ? ($argv[3] ?? null) : ($argv[2] ?? null);
if ($filter !== null && (strlen($filter) > 128 || ! preg_match('/^[a-zA-Z0-9_|]+$/D', $filter))) {
throw new RuntimeException('Explicit QA test filter required.');
}
@@ -0,0 +1,228 @@
"""Read the supplied source; prepare existing catalogue references, never live data."""
from pathlib import Path
import argparse, hashlib, json, re, unicodedata, zipfile
import xml.etree.ElementTree as ET
parser=argparse.ArgumentParser()
parser.add_argument('source', type=Path)
parser.add_argument('--output', type=Path, required=True)
args=parser.parse_args()
assert args.source.name == 'Instituições financeiras em Moçambique.docx'
ns={'w':'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
raw=args.source.read_bytes();digest=hashlib.sha256(raw).hexdigest()
assert digest=='119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108', 'Reconcile a changed source before preparing another edition'
def clean(s): return re.sub(r'\s+',' ',s).strip()
def norm(s): return re.sub(r'[^a-z0-9]+',' ',unicodedata.normalize('NFKD',s).encode('ascii','ignore').decode().lower()).strip()
def slug(s): return norm(s).replace(' ','-')[:75]
with zipfile.ZipFile(args.source) as z:
body=ET.fromstring(z.read('word/document.xml')).find('w:body',ns)
tables={};paragraphs=[];heading='';number=0
for node in body:
if node.tag.endswith('}p'):
text=clean(''.join(node.itertext()))
if not text or 'PAGEREF' in text or text.startswith('TOC '): continue
if re.match(r'^\d+\.',text): heading=text
paragraphs.append((heading,text))
elif node.tag.endswith('}tbl'):
number+=1
tables[number]=(heading,[[clean(' '.join(c.itertext())) for c in r.findall('w:tc',ns)] for r in node.findall('w:tr',ns)])
base=json.loads((Path(__file__).resolve().parents[1]/'database/data/funding-references.json').read_text(encoding='utf-8'))
refs={};coverage=[]
category={'commercial_bank':'banking','investment_bank':'banking','bank':'banking','microbank':'credit','cooperative':'credit','savings':'credit','investment_manager':'investment','investment_company':'investment','pension_manager':'pensions','insurer':'insurance','reinsurer':'insurance','microinsurer':'insurance','broker':'intermediation','agent':'intermediation','reinsurance_broker':'intermediation','exchange_house':'exchange','electronic_money':'payments','interbank':'payments','supervisor':'supervision','other':'other'}
services={'banking':['investment','working_capital','guarantee','payments'],'credit':['investment','working_capital'],'investment':['investment_management','equity'],'pensions':['pension'],'insurance':['insurance'],'intermediation':['insurance'],'exchange':['foreign_exchange'],'payments':['payments'],'supervision':['other'],'other':['other']}
def key(name):
n=norm(name)
if 'gestora' in n and 'standard bank' in n: return 'standard-bank-pension-manager'
if 'nbc' in n and ('sgfp' in n or 'pensoes' in n): return 'nbc-pension-manager'
if 'letshego' in n and 'financial services' in n: return 'letshego-microbank-historical'
patterns=[('standard bank','standard-bank'),('millennium','millennium-bim'),('bci','bci'),('moza banco','moza'),('banco nacional de investimento','bni'),('bni','bni'),('gapi','gapi'),('bfs','bfs'),('socremo','socremo'),('banco letshego','letshego-bank'),('letshego','letshego-bank'),('ac microbanco','ac-microbanco'),('xtenda','xtenda'),('yingwe microbanco','yingwe-microbanco'),('my bucks','my-bucks'),('mybucks','my-bucks'),('microbanco confianca','confianca-microbanco'),('caixa financeira de catandica','caixa-catandica'),('caixa financeira de caia','caixa-caia'),('futuro microbanco','futuro-microbanco'),('access bank','access-bank'),('african banking corporation','abc-historical'),('banco big','banco-big'),('first capital','first-capital-bank'),('ecobank','ecobank'),('nedbank','nedbank'),('uba','uba'),('vista bank','vista-bank'),('fnb','fnb'),('absa','absa'),('issm','issm'),('global alliance','global-alliance'),('fidelidade','fidelidade'),('sanlam mocambique vida','sanlam-vida'),('mocambique companhia de seguros','mcs-seguradora'),('empresa mocambicana de seguros','emose'),('emose','emose'),('emergitus','emeritus'),('emeritus','emeritus'),('companhia de seguros indico','indico'),('britam','britam'),('arko companhia','arko'),('palma companhia','palma'),('hollard vida','hollard-vida'),('hollard mocambique','hollard-mocambique'),('confianca micro seguros','confianca-microseguros'),('caixa das mulheres de nacala','women-nacala-credit-cooperative'),('cooperativa de credito das mulheres de nacala','women-nacala-credit-cooperative'),('sociedade cooperativa de credito das mulheres de nacala','women-nacala-credit-cooperative')]
for needle,value in patterns:
if n==needle or n.startswith(needle+' ') or needle in n and needle in ['sociedade cooperativa de credito das mulheres de nacala','empresa mocambicana de seguros','banco nacional de investimento']:return value
n=re.sub(r' (?:s a|sa|sarl|scrl|lda|ei|su)$','',n)
if 'cooperativa' in n and 'pemba' in n and 'mulheres' in n:return 'women-pemba-credit-cooperative'
return 'fin-'+slug(n)
def nature(t):
return 'bank' if t in ['commercial_bank','investment_bank','bank'] else 'microfinance' if t in ['microbank','cooperative','savings'] else 'other'
def evidence(table,row,text):return {'id':digest[:16]+f':t{table}:r{row}','section':tables[table][0],'text':clean(text)}
def ensure(name,t,identity=None):
existing=identity or key(name)
if len(existing)>80:existing=existing[:67]+'-'+hashlib.sha256(existing.encode()).hexdigest()[:12]
if t is None: t=refs[existing]['draft']['institution_types'][0] if existing in refs else 'other'
k=existing
if k not in refs:
if k in base['references'] and base['references'][k]['draft']['kind']=='institution': ref=json.loads(json.dumps(base['references'][k]))
else:
ref={'edition':'institutions-nov2026-v1','provenance':[], 'draft':{'title':name,'kind':'institution','summary':'Instituição identificada no documento fornecido. Categoria, localização e contactos são informativos; produtos, disponibilidade e condições não documentados permanecem por confirmar.','manager_name':name,'source_url':None,'source_reference':args.source.name+' — Novembro de 2026','source_checked_on':'2026-11-10','source_status':'identified','coverage':'unknown','coverage_text':'Cobertura dos serviços/financiamento por confirmar. A presença física não determina a cobertura.','territory_ids':[],'sectors_scope':'unknown','sector_ids':[],'resource_ids':[],'criteria':[],'windows':[],'institution_key':k,'institution_nature':nature(t),'contacts':[],'locations':[],'products':[]}}
d=ref['draft'];d.update({'source_document_name':args.source.name,'source_document_sha256':digest,'institution_categories':[],'institution_types':[],'institution_services':[],'institution_status':'reported','aliases':[],'source_rows':[]})
ref['edition']='institutions-nov2026-v1'
ref['provenance'].append({'reference':args.source.name+'; edição Novembro de 2026; afirmações e limites do original conservados','sha256':digest,'checked_on':'2026-11-10'})
refs[k]=ref
d=refs[k]['draft']
for field,value in [('institution_types',t),('institution_categories',category[t])]:
if value not in d[field]: d[field].append(value)
for service in services[category[t]]:
if service not in d['institution_services']:d['institution_services'].append(service)
if name not in d['aliases']:d['aliases'].append(name)
return k
def attach(k,e):
if e not in refs[k]['draft']['source_rows']:refs[k]['draft']['source_rows'].append(e)
def origin(text):
n=norm(text)
if any(x in n for x in ['confirmar','directorio','diretorio','nao encontrada','nao encontrado','nao obtida','nao confirmad','nao e a mesma','nao confirmei']):return 'to_confirm'
if any(x in n for x in ['antiga','2021','2023','historica']):return 'historical'
return 'reported'
def province(text):
n=norm(text)
pairs=[('maputo provincia','Maputo Província'),('provincia de maputo','Maputo Província'),('cidade de maputo','Maputo Cidade'),('maputo cidade','Maputo Cidade'),('matola','Maputo Província'),('marracuene','Maputo Província'),('boane','Maputo Província'),('matutuine','Maputo Província'),('gaza','Gaza'),('xai xai','Gaza'),('inhambane','Inhambane'),('maxixe','Inhambane'),('morrumbene','Inhambane'),('sofala','Sofala'),('beira','Sofala'),('manica','Manica'),('chimoio','Manica'),('catandica','Manica'),('tete','Tete'),('angonia','Tete'),('zambezia','Zambezia'),('quelimane','Zambezia'),('nampula','Nampula'),('nacala','Nampula'),('niassa','Niassa'),('lichinga','Niassa'),('cuamba','Niassa'),('cabo delgado','Cabo Delgado'),('pemba','Cabo Delgado'),('montepuez','Cabo Delgado'),('maputo','Maputo Cidade')]
for needle,target in pairs:
if needle in n:return target
return None
def source(e,state):return {'source_url':None,'source_reference':args.source.name+' — '+e['section']+'; '+e['id']+'; fonte declarada no original','source_document_name':args.source.name,'source_document_sha256':digest,'checked_on':'2026-11-10','evidence_status':state,'origin':'Documento fornecido; não é uma nova verificação do regulador','source_refs':[e['id']]}
def location(k,label,address,place,e,role='branch',state=None,hours=''):
d=refs[k]['draft'];state=state or origin(address+' '+place)
if not address or any(x in norm(address) for x in ['nao encontrad','nao obtid','moradas nao confirmad']):state='unlocated'
identity=hashlib.sha256((k+'|'+role+'|'+norm(label)+'|'+norm(address)+'|'+norm(place)).encode()).hexdigest()[:24]
for old in d['locations']:
if old.get('id')==identity:
if e['id'] not in old['source_refs']:old['source_refs'].append(e['id'])
return
loc={**source(e,state),'id':identity,'role':role,'label':label[:250],'address':address[:1500],'territory_id':None,'location_text':place[:1000],'hours':hours[:1000]}
d['locations'].append(loc)
p=province(place)
if p:refs[k].setdefault('location_territory_names',{})[str(len(d['locations'])-1)]=p
def contacts(k,text,e,state=None):
if not text or norm(text) in ['nao encontrado','nao encontrada','nao obtido','nao encontrada nas 115 entradas lidas','']:return
d=refs[k]['draft'];state=state or origin(text)
emails=re.findall(r'[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}',text)
values=[('email',v) for v in emails]
# Preserve the exact business telephone assertion, including qualifiers; do not fabricate missing digits.
if re.search(r'\d{2}[\s\d()+/;-]{5,}',text):values.append(('phone',text[:1000]))
for kind,value in values:
identity=hashlib.sha256((k+'|'+kind+'|'+norm(value)).encode()).hexdigest()[:24]
if not any(c.get('id')==identity or c['kind']==kind and c['value']==value for c in d['contacts']):d['contacts'].append({**source(e,state),'id':identity,'kind':kind,'value':value})
def add(table,row,k,e):coverage.append({'table':table,'row':row,'institutions':[k],'source_row':e['id'],'status':'represented'});attach(k,e)
def typ(text):
n=norm(text)
for part,result in [('gestora de fundos de investimento','investment_manager'),('gestora de fundos de pensoes','pension_manager'),('cooperativa','cooperative'),('microbanco','microbank'),('microbancos','microbank'),('micro segur','microinsurer'),('supervisor','supervisor'),('investimento publico','investment_bank'),('banco de investimento','investment_bank'),('banco comercial','commercial_bank'),('moeda electronica','electronic_money'),('interbancarios','interbank'),('sociedade de investimento','investment_company')]:
if part in n:return result
return 'bank' if 'banco' in n else 'other'
# Lists with stable regulator numbers are identities, not fuzzy matches by name.
broker_names={}
for row in tables[26][1][1:]:
for n in [0,2]:
if row[n]:broker_names[int(row[n])]=row[n+1]
for t,(section,rows) in tables.items():
for j,row in enumerate(rows[1:],1):
e=evidence(t,j,' | '.join(row))
if t in [12,19,29]:coverage.append({'table':t,'row':j,'status':'aggregate_or_gap_not_an_institution','source_row':e['id']});continue
if t==26:
keys=[]
for n in [0,2]:
if row[n]:
k=ensure(row[n+1],'broker','issm-broker-'+row[n].zfill(3));attach(k,e);keys.append(k)
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented'});continue
if t==27:
keys=[]
for n in [0,2]:
if row[n]:
k=ensure(row[n+1],'agent','issm-agent-'+row[n].zfill(3));attach(k,e);keys.append(k)
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented'});continue
if t==25:
m=re.match(r'Sim\s*\(\s*n[ºo°]?\s*(\d+)',row[5],re.I)
k=ensure(row[0],'broker','issm-broker-'+m.group(1).zfill(3) if m else 'broker-unconfirmed-'+slug(row[0]));add(t,j,k,e)
refs[k]['draft']['register_reference']=row[5]
location(k,'Morada indicada para a corretora',row[1],row[1],e,'contact_point',origin(row[1]+' '+row[3]))
contacts(k,row[2]+' '+row[4],e,origin(row[3]+' '+row[4]));continue
if t in [1,3,6,13,14]:
if t==14 and j in [4,5]:
names=['Bayport Financial Services','Caixa Mulher'] if j==4 else ['CCOM Maputo Norte','CCOM Maputo Sul','CCOM Matola','Associação dos Camponeses de Macuvulana','Caixa Comunitária de Chókwè','Diocese de Nacala','CCOM Montepuez','CCOM Chiúre','CCOM Namuno','CCOM Ancuabe','CCOM Balama']
keys=[]
for name in names:
k=ensure(name,'microbank' if j==4 else 'savings');attach(k,e);keys.append(k)
if j==5 and name=='Diocese de Nacala':location(k,'Contacto documentado','Paço Episcopal S. Justino de Jacobis, Nacala-Porto','Nampula',e,'contact_point','historical')
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented_split_explicit_names'});continue
k=ensure(row[0],typ(row[1]));add(t,j,k,e)
if t==1:location(k,'Sede',row[2],'Cidade de Maputo',e,'headquarters');contacts(k,row[3],e)
elif t==3:location(k,'Localização da gestora indicada na fonte',row[2],row[2],e,'contact_point');refs[k]['draft']['register_reference']=row[3]
elif t==13:location(k,'Local indicado na lista do Banco de Moçambique',row[3],row[2],e,'contact_point','historical')
elif t==14:
if j==1:refs[k]['draft'].update({'institution_status':'merged','successor_key':'access-bank'})
elif j in [2,3]:location(k,'Sede por confirmar',row[2],'Maputo',e,'headquarters','to_confirm')
continue
if t in [2,4,5,7,8,9,10,11,15]:
if t==2:name='Standard Bank';place,label,address,hours=row
elif t==4:name,label,address,quality=row;place='Cidade de Maputo';hours=''
elif t in [5,7,9,10]:name={5:'Standard Bank',7:'Socremo',9:'Millennium bim',10:'BCI'}[t];place,label,address=row;hours=''
elif t==8:name,place,address,quality=row;label='Sede' if 'sede' in norm(name+' '+address) else place;hours=''
elif t==11:name='BCI';place,label,address,quality=row;hours=''
else:name,place,label,address,quality=row;hours=''
k=ensure(name,'microbank' if t in [7,8] and not norm(name).startswith('banco letshego') else 'commercial_bank');add(t,j,k,e)
state=origin(' | '.join(row))
if t==4 and 'BCI' in name and j>1:state='to_confirm'
location(k,label,address,place,e,'headquarters' if 'sede' in norm(label) else 'branch',state,hours);continue
if t in [16,18,20,28]:
if t==16:name,address,phone,quality,email=row;place='Cidade de Maputo';tpe=None
elif t==18:name,address,phone,hours=row;place=address;tpe='exchange_house';quality='Directório — a confirmar';email=''
elif t==20:place,name,phone,quality=row;address='';email='';tpe='supervisor' if 'ISSM' in name else 'investment_manager' if 'BFS' in name else None
else:name,address,phone,_=row;place=address;quality='Resumo do documento';email='';tpe='reinsurer' if 'resseguradora' in name else 'insurer'
k=ensure(name,tpe);add(t,j,k,e);contacts(k,phone+' '+email,e,origin(quality+' '+phone))
if address:location(k,'Sede ou contacto indicado na fonte',address,place,e,'contact_point',origin(quality+' '+address))
continue
if t==17:
names=row[0].split(', ') if j==7 else [row[0]]
keys=[]
for name in names:
k=ensure(name,'exchange_house');attach(k,e);keys.append(k);location(k,'Morada indicada no documento',row[2],row[1],e,'contact_point','unlocated' if j==7 else 'historical')
coverage.append({'table':t,'row':j,'institutions':keys,'source_row':e['id'],'status':'represented'});continue
if t in [21,22,23,24]:
if t==24:place,name,address,phone=row;tpe='insurer';label=name.split('–')[-1].strip();base_name=name.split('–')[0].strip()
else:place,name,ramo,address,phone=row;tpe={21:'insurer',22:'reinsurer',23:'microinsurer'}[t];label='Sede';base_name=name
k=ensure(base_name,tpe);add(t,j,k,e);location(k,label,address,place,e,'branch' if t==24 else 'headquarters');contacts(k,phone,e)
continue
raise RuntimeError(f'Unaccounted table {t}, row {j}')
# Named entries outside tables; retain uncertainty instead of executing prose as instructions.
for section,text in paragraphs:
if section.startswith('15.6') and not re.match(r'^15\.6',text):
for name in ['Chicomo','Renaissance','Pioneer Reinsurance Brokers','Makhaly','Gateway']:
k=ensure(name,'reinsurance_broker');attach(k,{'id':digest[:16]+':p15.6:'+slug(name),'section':section,'text':text})
k=ensure('Safeline','microinsurer');attach(k,{'id':digest[:16]+':p15.6:safeline','section':section,'text':text})
if section.startswith('11.') and 'Executivo Câmbios' in text:
for name in ['Executivo Câmbios','Sara Moçambique','Al-Meca Câmbios','Acácio Câmbios']:
k=ensure(name,'exchange_house');refs[k]['draft']['institution_status']='historical';attach(k,{'id':digest[:16]+':p11:'+slug(name),'section':section,'text':text})
for k,ref in refs.items():
types=ref['draft']['institution_types']
if 'bank' in types and any(t in types for t in ['commercial_bank','investment_bank']):types.remove('bank')
if k=='letshego-microbank-historical':ref['draft']['institution_status']='unknown'
# Explicit branch/contact facts in prose are not discarded merely because they are outside a table.
def paragraph_evidence(section,text,tag):return {'id':digest[:16]+':p:'+tag,'section':section,'text':text}
for section,text in paragraphs:
if section.startswith('7.') and text.startswith('Além dos Mpoint'):
e=paragraph_evidence(section,text,'bim-commercial-names');k=ensure('Millennium bim','commercial_bank');attach(k,e)
names=['Rovuma 191','Rovuma 522','Shoprite Maputo','Baía Mall','Zimpeto Mall','Mica','Marés','Zimpeto Mercado','Shoprite Matola','Parque dos Poetas Prestige','Polana Shopping','Quedas (Centro Comercial Shoprite)','Manica Shopping Center','Matema (Centro Comercial Terrace Africa)','Shoprite Beira','Muahivire']
for name in names:location(k,name,'Morada por confirmar',name,e,'branch','unlocated')
elif section.startswith('7.') and text.startswith('Morada encontrada: Polana Shopping'):
e=paragraph_evidence(section,text,'bim-directory-addresses');k=ensure('Millennium bim','commercial_bank');attach(k,e)
location(k,'Polana Shopping','Loja 43, piso 0, Polana Shopping & Office Center, Av. 24 de Julho, nº 11','Maputo',e,'branch','reported')
location(k,'Rovuma — correspondência aos balcões por confirmar','Rua da Sé, nº 114, R/C, Maputo','Maputo',e,'contact_point','to_confirm');contacts(k,'21 312 471',e,'to_confirm')
elif section.startswith('15.5') and text.startswith('Lista do ISSM, com os 115'):
e=paragraph_evidence(section,text,'older-broker-names')
for name in ['Real Risk','Quorus']:
k=ensure(name,'broker','broker-unconfirmed-'+slug(name));refs[k]['draft']['institution_status']='unknown';attach(k,e)
k=key('Safeline')
if k in refs:refs[k]['draft']['institution_status']='unknown';refs[k]['draft']['register_reference']='A fonte menciona Safeline em listas de corretoras e como micro-seguradora; identidade/categoria actual por confirmar.';attach(k,e)
elif section.startswith('15.6') and text.startswith('O ISSM licencia 39'):
e=paragraph_evidence(section,text,'agent-and-reinsurance-contacts')
for k,label,address,state in [('issm-agent-026','Capital Consultoria & Agentes de Seguros','Av. 25 de Setembro, nº 1147, 2A, Maputo','to_confirm'),('issm-agent-027','Carlyle Partners','Rua Eça de Queiroz, nº 135, Coop, Maputo','to_confirm'),('issm-agent-030','Possível morada de homónimo CBM; identidade por confirmar','Rua Fernão Lopes, nº 218, Sommerschield, Maputo','conflict'),('issm-agent-016','Possível morada de homónimo Prestígio; identidade por confirmar','Av. Albert Lithuli, Maputo','conflict')]:
attach(k,e);location(k,label,address,'Cidade de Maputo',e,'contact_point',state)
contacts('issm-agent-026','86 442 8633',e,'to_confirm');contacts('issm-agent-001','+258 21 083 820; geral@segurodirecto.co.mz',e,'reported')
for name,address in [('Renaissance','Av. 25 de Setembro, nº 1230, edifício 33 Andares, R/C, porta 3'),('Gateway','Rua Rofinho de Oliveira, nº 68, 1º andar, Bairro Central')]:
k=key(name);attach(k,e);location(k,'Contacto de directório, a confirmar',address,'Cidade de Maputo',e,'contact_point','to_confirm')
elif section.startswith('15.4') and text.startswith('Só encontrei delegações publicadas'):
e=paragraph_evidence(section,text,'palma-delegations-without-address');k=ensure('Palma Companhia de Seguros','insurer');attach(k,e)
for name in ['Pemba','Quelimane','Beira','Nampula','Tete']:location(k,'Agência indicada: '+name,'Morada por confirmar',name,e,'branch','unlocated')
# Whole source notes remain documentary evidence, never operational commands.
notes=[{'section':s,'text':t} for s,t in paragraphs if not re.match(r'^\d+\.',t)]
result={'format':'investmoz-funding-references-v1','source':{'name':args.source.name,'sha256':digest,'edition':'Novembro de 2026'},'references':refs,'source_coverage':coverage,'source_notes':notes}
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps({'institutions':len(refs),'locations':sum(len(x['draft']['locations']) for x in refs.values()),'contacts':sum(len(x['draft']['contacts']) for x in refs.values()),'table_rows_accounted':len(coverage),'categories':sorted({c for x in refs.values() for c in x['draft']['institution_categories']}),'source_sha256':digest}))