Prepare supplied financial directory with product coverage and native dossier continuity
This commit is contained in:
@@ -4,10 +4,15 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Sector;
|
||||
use App\Services\DocumentManagementAccess;
|
||||
use App\Services\FundingDossierPreparation;
|
||||
use App\Services\FundingInstitutionDetails;
|
||||
use App\Services\FundingProgrammeReferences;
|
||||
use App\Services\FundingProgrammes;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestorContext;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\Procurement;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -43,7 +48,7 @@ final class FundingProgrammeController extends Controller
|
||||
$items->through(fn ($row) => ['id' => $row->id, 'title' => json_decode($row->draft, true)['title'] ?? 'Rascunho sem título',
|
||||
'version' => (int) $row->version, 'published' => $row->published_version_id !== null]);
|
||||
|
||||
return tap(Inertia::render('funding/manage', ['items' => $items])->toResponse($request),
|
||||
return tap(Inertia::render('funding/manage', ['items' => $items, 'references' => app(FundingProgrammeReferences::class)->choices($request->user())])->toResponse($request),
|
||||
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
@@ -63,6 +68,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,
|
||||
'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'));
|
||||
@@ -118,11 +124,17 @@ 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:fund,programme,incentive',
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:150', 'kind' => 'nullable|in:institution,fund,programme,incentive',
|
||||
'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())],
|
||||
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
|
||||
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
|
||||
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
|
||||
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
|
||||
if ($dossier && empty($filters['territory']) && $dossier['territory']) {
|
||||
$filters['territory'] = $dossier['territory'];
|
||||
}
|
||||
$query = $service->catalogueQuery($filters);
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
|
||||
@@ -138,7 +150,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,
|
||||
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, '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'));
|
||||
@@ -146,14 +158,38 @@ 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']);
|
||||
$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']]);
|
||||
}
|
||||
$record = $service->published($id);
|
||||
$destinations = [];
|
||||
if ($request->user()) {
|
||||
foreach (app(InvestorPath::class)->contexts($request->user()) as $item) {
|
||||
$case = app(VirtualDesk::class)->case($request->user(), $item['id']);
|
||||
$state = app(InvestorContext::class)->state($case);
|
||||
$choices = $state['opportunity_choices'] ?? [];
|
||||
if ($choices === []) {
|
||||
$destinations[] = ['id' => $item['id'], 'opportunity' => null, 'title' => $item['title']];
|
||||
}
|
||||
foreach ($choices as $choice) {
|
||||
$destinations[] = ['id' => $item['id'], 'opportunity' => $choice['opportunity']['key'], 'title' => $item['title'].' — '.$choice['opportunity']['title']];
|
||||
}
|
||||
}
|
||||
}
|
||||
$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])
|
||||
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])
|
||||
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function dossier(Request $request, string $id, FundingDossierPreparation $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->save($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function assess(Request $request, string $id, FundingProgrammes $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->assess($id, $request->all()))->header('Cache-Control', 'private, no-store');
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/** A selected public funding reference in the existing encrypted investment dossier. */
|
||||
final class FundingDossierPreparation
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function context(User $actor, string $id, ?string $opportunity): array
|
||||
{
|
||||
$case = app(VirtualDesk::class)->case($actor, $id);
|
||||
abort_unless(app(VirtualDesk::class)->owns($actor, $case) || $actor->can('manage-portal'), 403);
|
||||
$state = app(InvestorContext::class)->state($case);
|
||||
$scope = app(InvestmentConsumerScope::class);
|
||||
$opportunity = $scope->key($state, $opportunity, true);
|
||||
$specific = $scope->context($state, $opportunity);
|
||||
$selections = app(VirtualDesk::class)->decode($case->payload)['investor_path']['funding_selections'] ?? [];
|
||||
|
||||
return ['id' => $id, 'version' => (int) $case->version, 'closed' => $case->closed_at !== null, 'opportunity' => $opportunity,
|
||||
'territory' => $specific['selection']['territory']['id'] ?? null,
|
||||
'selections' => array_values(array_filter($selections, fn ($row) => ($row['opportunity'] ?? null) === $opportunity)),
|
||||
'href' => '/investimentos/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : '').'#financiamento'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function save(User $actor, string $programme, array $input): array
|
||||
{
|
||||
$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();
|
||||
$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 {
|
||||
$desk = app(VirtualDesk::class);
|
||||
$case = $desk->case($actor, $data['context'], true, true);
|
||||
abort_if($case->closed_at, 409, 'O dossier está encerrado.');
|
||||
$context = $this->context($actor, $case->id, $data['opportunity'] ?? null);
|
||||
$payload = $desk->decode($case->payload);
|
||||
$selections = $payload['investor_path']['funding_selections'] ?? [];
|
||||
foreach ($selections as $selection) {
|
||||
if ($selection['id'] === $data['operation_key']) {
|
||||
abort_unless(hash_equals($selection['intent'], $intent), 409, 'A intenção já tem outro conteúdo.');
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
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']) {
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
abort_unless((int) $case->version === (int) $data['version'], 409, 'O dossier mudou. Consulte-o antes de guardar esta escolha.');
|
||||
$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);
|
||||
$product = null;
|
||||
if ($record['payload']['kind'] === 'institution') {
|
||||
foreach ($record['payload']['products'] as $candidate) {
|
||||
if (($candidate['code'] ?? null) === ($data['product'] ?? null)) {
|
||||
$product = $candidate;
|
||||
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_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(),
|
||||
'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()]);
|
||||
$desk->event($case, $actor, 'funding_preparation_saved', ['selection_id' => $selection['id'], 'programme_id' => $programme, 'version_id' => $record['version_id'], 'opportunity' => $context['opportunity']]);
|
||||
|
||||
return $this->context($actor, $case->id, $context['opportunity']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/** Additional factual fields in the existing versioned funding catalogue. */
|
||||
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'];
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function normalize(array $input, bool $publishing): array
|
||||
{
|
||||
$required = $publishing ? 'required' : 'nullable';
|
||||
$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.*.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.*.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.*.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))],
|
||||
'products.*.coverage' => 'required|in:national,territorial,unknown', 'products.*.coverage_text' => 'nullable|string|max:2000',
|
||||
'products.*.territory_ids' => 'array|max:30', 'products.*.territory_ids.*' => 'required|uuid|distinct',
|
||||
'products.*.conditions' => 'nullable|string|max:4000', 'products.*.amounts' => 'nullable|string|max:2000',
|
||||
'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_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', 'products'] as $key) {
|
||||
$data[$key] ??= [];
|
||||
}
|
||||
foreach ($data['contacts'] as $contact) {
|
||||
Validator::make(['value' => $contact['value'] ?? null], ['value' => match ($contact['kind']) {
|
||||
'website' => "$required|url:http,https|max:1000",
|
||||
'email' => "$required|email|max:250",
|
||||
default => "$required|string|max:1000",
|
||||
}])->validate();
|
||||
}
|
||||
foreach ($data['locations'] as &$location) {
|
||||
if (! empty($location['territory_id'])) {
|
||||
$this->territory($location['territory_id']);
|
||||
if ($publishing) {
|
||||
$location['territory_path'] = $this->path($location['territory_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($location);
|
||||
foreach ($data['products'] as &$product) {
|
||||
$product['needs'] ??= [];
|
||||
$product['territory_ids'] ??= [];
|
||||
Validator::make($product, ['territory_ids' => $product['coverage'] === 'territorial' ? ($publishing ? 'required|array|min:1' : 'array') : 'array|size:0'])->validate();
|
||||
// Territorial drafts may be incomplete; other coverage modes never inherit the headquarters.
|
||||
foreach ($product['territory_ids'] as $id) {
|
||||
$this->territory($id);
|
||||
}
|
||||
if ($publishing) {
|
||||
$product['territory_paths'] = array_map($this->path(...), $product['territory_ids']);
|
||||
}
|
||||
}
|
||||
unset($product);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function territory(string $id): void
|
||||
{
|
||||
Validator::make(['territory' => $id], ['territory' => [Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())]])->validate();
|
||||
}
|
||||
|
||||
/** @return list<array{id:string,name:string,type:string}> */
|
||||
private function path(string $id): array
|
||||
{
|
||||
return array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id));
|
||||
}
|
||||
|
||||
/** Same product must satisfy both location and need; branch addresses never imply lending coverage.
|
||||
* @param array<string,mixed> $product
|
||||
* @param array<string,mixed> $filters */
|
||||
public function match(array $product, array $filters): bool
|
||||
{
|
||||
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
if ($product['availability'] === 'closed') {
|
||||
return false;
|
||||
}
|
||||
if (! $unknown && $product['availability'] === 'unknown') {
|
||||
return false;
|
||||
}
|
||||
if (! empty($filters['need']) && ! in_array($filters['need'], $product['needs'], true) && ! ($unknown && $product['needs'] === [])) {
|
||||
return false;
|
||||
}
|
||||
if (empty($filters['territory'])) {
|
||||
return true;
|
||||
}
|
||||
if ($product['coverage'] === 'national') {
|
||||
return true;
|
||||
}
|
||||
if ($product['coverage'] === 'unknown') {
|
||||
return $unknown;
|
||||
}
|
||||
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
||||
foreach ($product['territory_paths'] ?? [] as $covered) {
|
||||
$ids = array_column($covered, 'id');
|
||||
if (array_intersect($path, $product['territory_ids']) !== [] || in_array($filters['territory'], $ids, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
public function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
if (empty($filters['need']) && empty($filters['territory'])) {
|
||||
return;
|
||||
}
|
||||
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
$conditions = ["p->>'availability' <> 'closed'"];
|
||||
$bindings = [];
|
||||
if (! $unknown) {
|
||||
$conditions[] = "p->>'availability' <> 'unknown'";
|
||||
}
|
||||
if (! empty($filters['need'])) {
|
||||
$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'])) {
|
||||
$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);
|
||||
foreach ($path as $id) {
|
||||
$parts[] = "p->'territory_ids' @> ?::jsonb";
|
||||
$bindings[] = json_encode([$id], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
if ($unknown) {
|
||||
$parts[] = "p->>'coverage'='unknown'";
|
||||
}
|
||||
$conditions[] = '('.implode(' OR ', $parts).')';
|
||||
}
|
||||
$sql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) p WHERE ".implode(' AND ', $conditions).')';
|
||||
$query->where(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
|
||||
$q->where(function (Builder $q) use ($unknown, $filters): void {
|
||||
$q->whereRaw("v.payload->>'kind' <> 'institution'");
|
||||
if (! empty($filters['need'])) {
|
||||
$q->where(function (Builder $q) use ($filters, $unknown): void {
|
||||
$q->whereRaw("v.payload->'funding_needs' @> ?::jsonb", [json_encode([$filters['need']], JSON_THROW_ON_ERROR)]);
|
||||
if ($unknown) {
|
||||
$q->orWhereRaw("jsonb_array_length(COALESCE(v.payload->'funding_needs','[]'::jsonb))=0");
|
||||
}
|
||||
});
|
||||
}
|
||||
})->orWhere(function (Builder $q) use ($sql, $bindings, $unknown): void {
|
||||
$q->whereRaw("v.payload->>'kind' = 'institution'")->where(function (Builder $q) use ($sql, $bindings, $unknown): void {
|
||||
$q->whereRaw($sql, $bindings);
|
||||
if ($unknown) {
|
||||
$q->orWhereRaw("jsonb_array_length(COALESCE(v.payload->'products','[]'::jsonb))=0");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ use App\Models\User;
|
||||
use App\Support\CheckedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Packaged official references prepare private drafts through existing editorial authority. */
|
||||
/** Packaged documentary references prepare private drafts through existing editorial authority. */
|
||||
final class FundingProgrammeReferences
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
@@ -19,13 +20,50 @@ final class FundingProgrammeReferences
|
||||
abort_unless($package['format'] === 'investmoz-funding-references-v1', 503);
|
||||
$reference = $package['references'][$code] ?? null;
|
||||
abort_unless(is_array($reference), 404);
|
||||
$plan = ['code' => $code, 'edition' => $reference['edition'], 'draft' => $reference['draft'], 'provenance' => $reference['provenance'],
|
||||
$unresolved = [];
|
||||
$resolve = function (string $name) use (&$unresolved): ?string {
|
||||
$matches = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->get(['id', 'name'])->filter(fn ($row) => Str::lower(Str::ascii($row->name)) === Str::lower(Str::ascii($name)));
|
||||
if ($matches->count() !== 1) {
|
||||
$unresolved[] = $name;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches->first()->id;
|
||||
};
|
||||
if (! empty($reference['territory_names'])) {
|
||||
$ids = array_values(array_filter(array_map($resolve, $reference['territory_names'])));
|
||||
if (count($ids) === count($reference['territory_names'])) {
|
||||
$reference['draft']['territory_ids'] = $ids;
|
||||
} else {
|
||||
$reference['draft']['coverage'] = 'unknown';
|
||||
$reference['draft']['territory_ids'] = [];
|
||||
$reference['draft']['coverage_text'] .= ' Correspondência ao cadastro territorial por confirmar.';
|
||||
}
|
||||
}
|
||||
foreach ($reference['location_territory_names'] ?? [] as $index => $name) {
|
||||
$reference['draft']['locations'][$index]['territory_id'] = $resolve($name);
|
||||
}
|
||||
$plan = ['unresolved_territories' => $unresolved, 'code' => $code, 'edition' => $reference['edition'], 'draft' => $reference['draft'], 'provenance' => $reference['provenance'],
|
||||
'package_sha256' => hash('sha256', $bytes)];
|
||||
$plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key'));
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** @return list<array{code:string,title:string}> */
|
||||
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);
|
||||
$choices = [];
|
||||
foreach ($package['references'] as $code => $reference) {
|
||||
$choices[] = ['code' => $code, 'title' => $reference['draft']['title']];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/** @return array{id:string,created:bool} */
|
||||
public function prepare(User $actor, string $code, string $hash): array
|
||||
{
|
||||
@@ -39,7 +77,7 @@ final class FundingProgrammeReferences
|
||||
return ['id' => $existing->id, 'created' => false];
|
||||
}
|
||||
$id = app(FundingProgrammes::class)->save($actor, null, ['version' => 0, 'code' => $code,
|
||||
'reason' => 'Preparação de referência oficial '.$plan['edition'].'; rascunho sem publicação automática.', 'draft' => $plan['draft']]);
|
||||
'reason' => 'Preparação de referência documental '.$plan['edition'].'; rascunho sem publicação automática.', 'draft' => $plan['draft']]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'funding-catalogue', 'entity_type' => 'funding_programmes', 'entity_id' => $id,
|
||||
'actor_user_id' => $actor->id, 'action' => 'reference_prepared', 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['edition' => $plan['edition'], 'package_sha256' => $plan['package_sha256'], 'provenance' => $plan['provenance'], 'effect' => 'private_draft_only'], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
/** Informational funding catalogue, separate from applications, credit decisions and fiscal calculations. */
|
||||
final class FundingProgrammes
|
||||
{
|
||||
public const KINDS = ['fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo'];
|
||||
public const KINDS = ['institution' => 'Instituição financeira', 'fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo'];
|
||||
|
||||
public const WINDOWS = ['dated' => 'Datas documentadas', 'rolling' => 'Recepção contínua documentada', 'unknown' => 'Janela por confirmar', 'closed' => 'Encerrada segundo a fonte'];
|
||||
|
||||
@@ -56,6 +56,7 @@ final class FundingProgrammes
|
||||
public function catalogueQuery(array $filters): Builder
|
||||
{
|
||||
$query = $this->publicQuery();
|
||||
app(FundingInstitutionDetails::class)->applyFilters($query, $filters);
|
||||
$includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
if (! empty($filters['sector'])) {
|
||||
$query->where(function (Builder $scope) use ($filters, $includeUnknown): void {
|
||||
@@ -69,7 +70,7 @@ final class FundingProgrammes
|
||||
if (! empty($filters['territory'])) {
|
||||
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
||||
$query->where(function (Builder $scope) use ($filters, $path, $includeUnknown): void {
|
||||
$scope->whereRaw("v.payload->>'coverage' = 'national'")
|
||||
$scope->whereRaw("v.payload->>'kind' = 'institution'")->orWhereRaw("v.payload->>'coverage' = 'national'")
|
||||
->orWhereJsonContains('v.payload->territory_ancestor_ids', $filters['territory']);
|
||||
foreach ($path as $id) {
|
||||
$scope->orWhereJsonContains('v.payload->territory_ids', $id);
|
||||
@@ -102,6 +103,11 @@ final class FundingProgrammes
|
||||
public function filterExplanation(array $record, array $filters): array
|
||||
{
|
||||
$payload = $record['payload'];
|
||||
if ($payload['kind'] === 'institution') {
|
||||
$products = array_values(array_filter($payload['products'] ?? [], fn ($p) => app(FundingInstitutionDetails::class)->match($p, $filters)));
|
||||
|
||||
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 = [];
|
||||
if (! empty($filters['sector'])) {
|
||||
$result['sector'] = $payload['sectors_scope'] === 'unknown'
|
||||
@@ -200,6 +206,13 @@ final class FundingProgrammes
|
||||
if (! $row && DB::table('funding_programmes')->where('code', $data['code'])->exists()) {
|
||||
throw ValidationException::withMessages(['code' => 'Este identificador já pertence a uma ficha. Abra-a para actualizar ou acrescentar uma janela.']);
|
||||
}
|
||||
if ($draft['kind'] === 'institution') {
|
||||
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', ['institution:'.$draft['institution_key']]);
|
||||
$duplicate = DB::table('funding_programmes')->whereRaw("draft->>'kind' = 'institution'")->whereRaw("draft->>'institution_key' = ?", [$draft['institution_key']])->when($id, fn ($q) => $q->where('id', '<>', $id))->exists();
|
||||
if ($duplicate) {
|
||||
throw ValidationException::withMessages(['institution_key' => 'A instituição já tem ficha. Reutilize a identidade existente; não substitua as suas edições.']);
|
||||
}
|
||||
}
|
||||
$id ??= (string) Str::uuid();
|
||||
DB::table('funding_programmes')->updateOrInsert(['id' => $id], ['code' => $data['code'], 'version' => (int) $data['version'] + 1,
|
||||
'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'created_by' => $row->created_by ?? $actor->id,
|
||||
@@ -225,6 +238,7 @@ final class FundingProgrammes
|
||||
'sectors_scope' => 'required|in:all,selected,unknown', 'sectors_text' => 'nullable|string|max:2000', 'sector_ids' => 'array|max:40',
|
||||
'sector_ids.*' => ['required', 'integer', 'distinct', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
|
||||
'resource_ids' => 'array|max:30', 'resource_ids.*' => 'required|integer|distinct|exists:portal_resources,id',
|
||||
'funding_needs' => 'array|max:6', 'funding_needs.*' => ['required', 'distinct', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
|
||||
'criteria' => 'array|max:30', 'windows' => 'array|max:20',
|
||||
'windows.*' => 'required|array:code,title,mode,start_on,end_on,coverage,coverage_text,territory_ids,source_url,source_reference,checked_on,application_url,criteria',
|
||||
'windows.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'windows.*.title' => "$required|string|max:250",
|
||||
@@ -244,6 +258,9 @@ final class FundingProgrammes
|
||||
$rules[$prefix.'.*.explanation'] = "$required|string|max:2000";
|
||||
}
|
||||
$data = Validator::make($input, $rules)->validate();
|
||||
if ($data['kind'] === 'institution') {
|
||||
$data = [...$data, ...app(FundingInstitutionDetails::class)->normalize($input, $publishing)];
|
||||
}
|
||||
$data = [...$data, 'territory_ids' => $data['territory_ids'] ?? [], 'sector_ids' => $data['sector_ids'] ?? [], 'resource_ids' => $data['resource_ids'] ?? [],
|
||||
'criteria' => $data['criteria'] ?? [], 'windows' => $data['windows'] ?? []];
|
||||
if ($data['coverage'] !== 'territorial' && $data['territory_ids'] !== []) {
|
||||
|
||||
@@ -162,7 +162,7 @@ final class InvestorPath
|
||||
|
||||
$preparationTaskKeys = $opportunity !== null && ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->taskKeys($actor, $id, $opportunity) : null;
|
||||
|
||||
return ['preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, 'private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
|
||||
return ['funding_selections' => ($owner || $manager) ? array_values(array_filter($path['funding_selections'] ?? [], fn ($row) => ($row['opportunity'] ?? null) === $opportunity)) : [], 'preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, 'private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
|
||||
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
|
||||
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
|
||||
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
|
||||
@@ -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' => ['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?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',
|
||||
|
||||
@@ -4,8 +4,18 @@
|
||||
"fdel": {
|
||||
"edition": "fdel-br43-20250305-tete20251020-v1",
|
||||
"provenance": [
|
||||
{ "url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf", "sha256": "0ea99c5f894760e3b715e377e87e9715fad9423e322e5753de1b9bb345c64753", "checked_on": "2026-11-02", "reference": "BR I Série n.º 43, 05/03/2025, pp. 157–160; Decreto n.º 4/2025 e Regulamento anexo" },
|
||||
{ "url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/", "sha256": "c69c2c3883766aa6a238ae44d8ee3fc99570adca2f3e647e50df59fde2786403", "checked_on": "2026-11-02", "reference": "Aviso municipal FDEL, prazo 20/10/2025" }
|
||||
{
|
||||
"url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf",
|
||||
"sha256": "0ea99c5f894760e3b715e377e87e9715fad9423e322e5753de1b9bb345c64753",
|
||||
"checked_on": "2026-11-02",
|
||||
"reference": "BR I Série n.º 43, 05/03/2025, pp. 157–160; Decreto n.º 4/2025 e Regulamento anexo"
|
||||
},
|
||||
{
|
||||
"url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/",
|
||||
"sha256": "c69c2c3883766aa6a238ae44d8ee3fc99570adca2f3e647e50df59fde2786403",
|
||||
"checked_on": "2026-11-02",
|
||||
"reference": "Aviso municipal FDEL, prazo 20/10/2025"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Fundo de Desenvolvimento Económico Local — FDEL",
|
||||
@@ -25,8 +35,22 @@
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [
|
||||
{ "code": "nationality-individual", "question": "Tem nacionalidade moçambicana?", "applies_to": "individual", "expected": "yes", "source_reference": "Decreto n.º 4/2025, art. 3", "explanation": "Condição declarada; não substitui a apreciação competente." },
|
||||
{ "code": "economic-viability", "question": "O projecto apresenta elementos de viabilidade económica?", "applies_to": "all", "expected": "yes", "source_reference": "Decreto n.º 4/2025, art. 3; Regulamento, art. 13", "explanation": "A avaliação do projecto cabe às entidades competentes." }
|
||||
{
|
||||
"code": "nationality-individual",
|
||||
"question": "Tem nacionalidade moçambicana?",
|
||||
"applies_to": "individual",
|
||||
"expected": "yes",
|
||||
"source_reference": "Decreto n.º 4/2025, art. 3",
|
||||
"explanation": "Condição declarada; não substitui a apreciação competente."
|
||||
},
|
||||
{
|
||||
"code": "economic-viability",
|
||||
"question": "O projecto apresenta elementos de viabilidade económica?",
|
||||
"applies_to": "all",
|
||||
"expected": "yes",
|
||||
"source_reference": "Decreto n.º 4/2025, art. 3; Regulamento, art. 13",
|
||||
"explanation": "A avaliação do projecto cabe às entidades competentes."
|
||||
}
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
@@ -42,15 +66,788 @@
|
||||
"checked_on": "2026-11-02",
|
||||
"application_url": "https://cmct.gov.mz/anexo-5-plano-de-negocio/",
|
||||
"criteria": [
|
||||
{ "code": "tete-nuit", "question": "Dispõe de NUIT?", "applies_to": "all", "expected": "yes", "source_reference": "Aviso municipal, Requisitos", "explanation": "Documentação indicada para esta chamada histórica." },
|
||||
{ "code": "tete-identity", "question": "Dispõe de cópia do BI?", "applies_to": "individual", "expected": "yes", "source_reference": "Aviso municipal, Candidatos individuais", "explanation": "Aplica-se a pessoas singulares nesta chamada." },
|
||||
{ "code": "tete-registration", "question": "A entidade dispõe de comprovativo de registo legal?", "applies_to": "entity", "expected": "yes", "source_reference": "Aviso municipal, Entidades", "explanation": "Aplica-se às entidades indicadas no aviso." },
|
||||
{ "code": "tete-idoneity", "question": "Dispõe de atestado de idoneidade emitido pela sede do bairro?", "applies_to": "individual", "expected": "yes", "source_reference": "Aviso municipal, Candidatos individuais", "explanation": "Documento indicado no aviso." },
|
||||
{ "code": "tete-national-entity", "question": "A entidade é constituída por cidadãos nacionais?", "applies_to": "entity", "expected": "yes", "source_reference": "Aviso municipal, Entidades", "explanation": "Condição específica desta chamada." }
|
||||
{
|
||||
"code": "tete-nuit",
|
||||
"question": "Dispõe de NUIT?",
|
||||
"applies_to": "all",
|
||||
"expected": "yes",
|
||||
"source_reference": "Aviso municipal, Requisitos",
|
||||
"explanation": "Documentação indicada para esta chamada histórica."
|
||||
},
|
||||
{
|
||||
"code": "tete-identity",
|
||||
"question": "Dispõe de cópia do BI?",
|
||||
"applies_to": "individual",
|
||||
"expected": "yes",
|
||||
"source_reference": "Aviso municipal, Candidatos individuais",
|
||||
"explanation": "Aplica-se a pessoas singulares nesta chamada."
|
||||
},
|
||||
{
|
||||
"code": "tete-registration",
|
||||
"question": "A entidade dispõe de comprovativo de registo legal?",
|
||||
"applies_to": "entity",
|
||||
"expected": "yes",
|
||||
"source_reference": "Aviso municipal, Entidades",
|
||||
"explanation": "Aplica-se às entidades indicadas no aviso."
|
||||
},
|
||||
{
|
||||
"code": "tete-idoneity",
|
||||
"question": "Dispõe de atestado de idoneidade emitido pela sede do bairro?",
|
||||
"applies_to": "individual",
|
||||
"expected": "yes",
|
||||
"source_reference": "Aviso municipal, Candidatos individuais",
|
||||
"explanation": "Documento indicado no aviso."
|
||||
},
|
||||
{
|
||||
"code": "tete-national-entity",
|
||||
"question": "A entidade é constituída por cidadãos nacionais?",
|
||||
"applies_to": "entity",
|
||||
"expected": "yes",
|
||||
"source_reference": "Aviso municipal, Entidades",
|
||||
"explanation": "Condição específica desta chamada."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bci": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
|
||||
"url": "https://www.bci.co.mz/bci-exclusivo-pme/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "BCI",
|
||||
"kind": "institution",
|
||||
"summary": "Conta corrente caucionada, empréstimo de médio/longo prazo, garantias e comércio externo. Público/finalidade: ENI, micro, pequenas e médias empresas.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "BCI",
|
||||
"source_url": "https://www.bci.co.mz/bci-exclusivo-pme/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; atendimento conforme rede e canais do banco.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"working_capital",
|
||||
"investment",
|
||||
"guarantee"
|
||||
],
|
||||
"institution_key": "bci",
|
||||
"institution_nature": "bank",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.bci.co.mz/bci-exclusivo-pme/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "website",
|
||||
"value": "https://www.bci.co.mz/bci-exclusivo-pme/"
|
||||
}
|
||||
],
|
||||
"locations": [],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.bci.co.mz/bci-exclusivo-pme/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "bci-oferta",
|
||||
"title": "BCI — Soluções PME",
|
||||
"instrument": "Conta corrente caucionada, empréstimo de médio/longo prazo, garantias e comércio externo.",
|
||||
"needs": [
|
||||
"working_capital",
|
||||
"investment",
|
||||
"guarantee"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; atendimento conforme rede e canais do banco.",
|
||||
"territory_ids": [],
|
||||
"conditions": "ENI, micro, pequenas e médias empresas. Oferta permanente publicada. A antiga “Linha BCI Negócios PME” terminou em 31 de Dezembro de 2018 e fica excluída das oportunidades activas.",
|
||||
"amounts": "Variável segundo necessidade, produto e análise.",
|
||||
"availability": "documented",
|
||||
"contact_url": "https://www.bci.co.mz/bci-exclusivo-pme/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"millennium-bim": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
|
||||
"url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Millennium bim",
|
||||
"kind": "institution",
|
||||
"summary": "Conta empréstimo, conta corrente caucionada, factoring, confirming , leasing e garantias. Público/finalidade: Empresas e ENI elegíveis.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "Millennium bim",
|
||||
"source_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; balcões e canais do banco.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"working_capital",
|
||||
"investment",
|
||||
"guarantee"
|
||||
],
|
||||
"institution_key": "millennium-bim",
|
||||
"institution_nature": "bank",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "website",
|
||||
"value": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento"
|
||||
}
|
||||
],
|
||||
"locations": [],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "millennium-bim-oferta",
|
||||
"title": "Millennium bim — Financiamento empresarial",
|
||||
"instrument": "Conta empréstimo, conta corrente caucionada, factoring, confirming , leasing e garantias.",
|
||||
"needs": [
|
||||
"working_capital",
|
||||
"investment",
|
||||
"guarantee"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; balcões e canais do banco.",
|
||||
"territory_ids": [],
|
||||
"conditions": "Empresas e ENI elegíveis. Oferta permanente publicada. Condições finais dependem da análise de risco e contrato.",
|
||||
"amounts": "Conta empréstimo: investimento entre 3 e 180 meses na ficha publicada; restantes termos após análise.",
|
||||
"availability": "documented",
|
||||
"contact_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"absa": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
|
||||
"url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Absa",
|
||||
"kind": "institution",
|
||||
"summary": "CAPEX , activos, importação, descoberto e leasing. Público/finalidade: PME e empresas com capacidade de endividamento.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "Absa",
|
||||
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; localização da empresa é recolhida no pedido.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"working_capital",
|
||||
"investment"
|
||||
],
|
||||
"institution_key": "absa",
|
||||
"institution_nature": "bank",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "website",
|
||||
"value": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/"
|
||||
},
|
||||
{
|
||||
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "phone",
|
||||
"value": "+258 21 344 400"
|
||||
},
|
||||
{
|
||||
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "email",
|
||||
"value": "linhacliente@absa.africa"
|
||||
}
|
||||
],
|
||||
"locations": [],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "absa-oferta",
|
||||
"title": "Absa — Empréstimos PME e activos",
|
||||
"instrument": "CAPEX , activos, importação, descoberto e leasing.",
|
||||
"needs": [
|
||||
"working_capital",
|
||||
"investment"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; localização da empresa é recolhida no pedido.",
|
||||
"territory_ids": [],
|
||||
"conditions": "PME e empresas com capacidade de endividamento. Oferta permanente publicada. Critérios, documentos, garantias e ausência de registo desabonatório aplicam-se conforme produto.",
|
||||
"amounts": "Leasing com prazo publicado até 5 anos; demais condições conforme análise.",
|
||||
"availability": "documented",
|
||||
"contact_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"standard-bank": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
|
||||
"url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Standard Bank",
|
||||
"kind": "institution",
|
||||
"summary": "Crédito de médio/longo prazo para bens de capital e CAPEX . Público/finalidade: Empresas com capacidade de endividamento.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "Standard Bank",
|
||||
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; balcões e canais empresariais.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"investment"
|
||||
],
|
||||
"institution_key": "standard-bank",
|
||||
"institution_nature": "bank",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "website",
|
||||
"value": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo"
|
||||
},
|
||||
{
|
||||
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "phone",
|
||||
"value": "+258 21 355700"
|
||||
}
|
||||
],
|
||||
"locations": [],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "standard-bank-oferta",
|
||||
"title": "Standard Bank — Conta Empréstimo Empresas",
|
||||
"instrument": "Crédito de médio/longo prazo para bens de capital e CAPEX .",
|
||||
"needs": [
|
||||
"investment"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; balcões e canais empresariais.",
|
||||
"territory_ids": [],
|
||||
"conditions": "Empresas com capacidade de endividamento. Produto activo e publicado. Sujeito a análise e garantias.",
|
||||
"amounts": "Até 7 anos; limite definido segundo capacidade e investimento; garantias reais.",
|
||||
"availability": "documented",
|
||||
"contact_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"moza": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
|
||||
"url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Moza Banco",
|
||||
"kind": "institution",
|
||||
"summary": "Tesouraria, investimento, construção e equipamento. Público/finalidade: Pequenas e médias empresas.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "Moza Banco",
|
||||
"source_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; sem restrição territorial indicada no produto geral.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"working_capital",
|
||||
"investment"
|
||||
],
|
||||
"institution_key": "moza",
|
||||
"institution_nature": "bank",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "website",
|
||||
"value": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/"
|
||||
}
|
||||
],
|
||||
"locations": [],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "moza-oferta",
|
||||
"title": "Moza Banco — Crédito PME",
|
||||
"instrument": "Tesouraria, investimento, construção e equipamento.",
|
||||
"needs": [
|
||||
"working_capital",
|
||||
"investment"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; sem restrição territorial indicada no produto geral.",
|
||||
"territory_ids": [],
|
||||
"conditions": "Pequenas e médias empresas. Produto apresentado no portal. A ficha contém condições que devem ser reconfirmadas antes da publicação, incluindo montante, comissão e garantias.",
|
||||
"amounts": "Ficha publicada: 500.000–30.000.000 MT; 6–60 meses; Prime + spread.",
|
||||
"availability": "unknown",
|
||||
"contact_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bni": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
|
||||
"url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "BNI Capital: veículo do BNI, registado como produto sem duplicar a instituição",
|
||||
"url": "https://www.bni.co.mz/banca-de-investimento/capital-de-risco/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "BNI",
|
||||
"kind": "institution",
|
||||
"summary": "Crédito para sectores produtivos, infra-estruturas e projectos com impacto. Público/finalidade: Empresas e projectos de desenvolvimento.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "BNI",
|
||||
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional ou conforme fundo/linha específica.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"investment"
|
||||
],
|
||||
"institution_key": "bni",
|
||||
"institution_nature": "bank",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "website",
|
||||
"value": "https://www.bni.co.mz/banca-de-desenvolvimento/"
|
||||
},
|
||||
{
|
||||
"source_url": "https://www.bni.co.mz/contactos/",
|
||||
"source_reference": "BNI — Contactos, endereço e telefone",
|
||||
"checked_on": "2026-11-10",
|
||||
"kind": "phone",
|
||||
"value": "+258 21 498 581"
|
||||
}
|
||||
],
|
||||
"locations": [
|
||||
{
|
||||
"source_url": "https://www.bni.co.mz/contactos/",
|
||||
"source_reference": "BNI — Contactos, endereço e telefone",
|
||||
"checked_on": "2026-11-10",
|
||||
"role": "contact_point",
|
||||
"label": "BNI — endereço institucional de Maputo",
|
||||
"address": "Av. Julius Nyerere, 3504, Bloco A2, 4668 Maputo, Moçambique",
|
||||
"territory_id": null
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "bni-oferta",
|
||||
"title": "BNI — Crédito de desenvolvimento",
|
||||
"instrument": "Crédito para sectores produtivos, infra-estruturas e projectos com impacto.",
|
||||
"needs": [
|
||||
"investment"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional ou conforme fundo/linha específica.",
|
||||
"territory_ids": [],
|
||||
"conditions": "Empresas e projectos de desenvolvimento. Serviço institucional publicado. Cada linha deve ter o seu próprio estado e regulamento.",
|
||||
"amounts": "Definido por operação; o simulador oficial é apenas indicativo.",
|
||||
"availability": "documented",
|
||||
"contact_url": "https://www.bni.co.mz/banca-de-desenvolvimento/"
|
||||
},
|
||||
{
|
||||
"source_url": "https://www.bni.co.mz/banca-de-investimento/capital-de-risco/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Capital de Risco",
|
||||
"checked_on": "2026-11-10",
|
||||
"code": "bni-capital-oferta",
|
||||
"title": "BNI Capital",
|
||||
"instrument": "Participação temporária e geralmente minoritária no capital; investimento em fundos.",
|
||||
"needs": [
|
||||
"equity"
|
||||
],
|
||||
"coverage": "national",
|
||||
"coverage_text": "Moçambique, conforme tese e avaliação de investimento.",
|
||||
"territory_ids": [],
|
||||
"conditions": "Empresas não cotadas com crescimento, impacto, gestão profissional e insuficiência de capital. Serviço institucional publicado. Manifestação e processo dependem de avaliação do BNI .",
|
||||
"amounts": "Negociado por operação; não é crédito.",
|
||||
"availability": "documented",
|
||||
"contact_url": "https://www.bni.co.mz/banca-de-investimento/capital-de-risco/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"location_territory_names": {
|
||||
"0": "Maputo Cidade"
|
||||
}
|
||||
},
|
||||
"gapi": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
|
||||
"url": "https://www.gapi.co.mz/",
|
||||
"checked_on": "2026-10-04"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Gapi",
|
||||
"kind": "institution",
|
||||
"summary": "Crédito, garantias, capacitação e apoio empresarial. Público/finalidade: MPME , empresas rurais, agronegócio, jovens e mulheres, conforme programa.. Referência informativa, não concessão de financiamento.",
|
||||
"manager_name": "Gapi",
|
||||
"source_url": "https://www.gapi.co.mz/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
|
||||
"source_checked_on": "2026-10-04",
|
||||
"source_status": "identified",
|
||||
"coverage": "unknown",
|
||||
"coverage_text": "Presença nacional, com programas de cobertura territorial específica.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"working_capital",
|
||||
"investment",
|
||||
"guarantee",
|
||||
"technical_assistance"
|
||||
],
|
||||
"institution_key": "gapi",
|
||||
"institution_nature": "other",
|
||||
"contacts": [
|
||||
{
|
||||
"source_url": "https://www.gapi.co.mz/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
|
||||
"checked_on": "2026-10-04",
|
||||
"kind": "website",
|
||||
"value": "https://www.gapi.co.mz/"
|
||||
}
|
||||
],
|
||||
"locations": [],
|
||||
"products": [
|
||||
{
|
||||
"source_url": "https://www.gapi.co.mz/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
|
||||
"checked_on": "2026-10-04",
|
||||
"code": "gapi-oferta",
|
||||
"title": "Gapi — Financiamento e investimento de impacto",
|
||||
"instrument": "Crédito, garantias, capacitação e apoio empresarial.",
|
||||
"needs": [
|
||||
"working_capital",
|
||||
"investment",
|
||||
"guarantee",
|
||||
"technical_assistance"
|
||||
],
|
||||
"coverage": "unknown",
|
||||
"coverage_text": "Presença nacional, com programas de cobertura territorial específica.",
|
||||
"territory_ids": [],
|
||||
"conditions": "MPME , empresas rurais, agronegócio, jovens e mulheres, conforme programa. Actividade de financiamento publicada. A Gapi declara não usar intermediários para conceder financiamento.",
|
||||
"amounts": "Variável por linha; avaliação própria.",
|
||||
"availability": "unknown",
|
||||
"contact_url": "https://www.gapi.co.mz/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"fecop": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Comunicado oficial Millennium bim",
|
||||
"url": "https://www.millenniumbim.co.mz/media/gusl5uoe/20260624-comunicado-de-imprensa_millennium-bim-reforca-solucoes-de-financiamento-para-as-pme-atraves-do-fecop.pdf",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "FECOP — crédito por bancos aderentes",
|
||||
"kind": "fund",
|
||||
"summary": "Partilha de risco, crédito e assistência técnica. Público/finalidade: MPME e cooperativas de produtores elegíveis.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Dotação global anunciada de cerca de €17 milhões; condições por banco e operação. Operacionalização confirmada em Junho de 2026. O Millennium bim mantém comunicado oficial; critérios e canal exacto devem ser confirmados em cada banco.",
|
||||
"manager_name": "Entidades do programa identificadas na fonte",
|
||||
"source_url": "https://www.millenniumbim.co.mz/media/gusl5uoe/20260624-comunicado-de-imprensa_millennium-bim-reforca-solucoes-de-financiamento-para-as-pme-atraves-do-fecop.pdf",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Comunicado oficial Millennium bim",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Moçambique; operacionalizado por Millennium bim , BCI e Vista Bank .",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"investment",
|
||||
"working_capital",
|
||||
"technical_assistance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"semeia": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Semeia Negócios",
|
||||
"url": "https://semeia.gapi.co.mz/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "Semeia Negócios — FECOP",
|
||||
"kind": "programme",
|
||||
"summary": "Subvenção em espécie, formalização, capacitação e equipamento produtivo. Público/finalidade: Startups , micro e pequenas empresas; foco em jovens e mulheres.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Até 450.000 MT; até 75% do investimento; subvenção sem juro. Candidaturas encerradas. O portal permite consultar candidaturas já submetidas.",
|
||||
"manager_name": "Gapi / FECOP",
|
||||
"source_url": "https://semeia.gapi.co.mz/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Semeia Negócios",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Moçambique; província e distrito constam da candidatura.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [
|
||||
{
|
||||
"code": "quadro-20261004",
|
||||
"title": "Chamada indicada no quadro fornecido — encerrada",
|
||||
"mode": "closed",
|
||||
"coverage": "programme",
|
||||
"territory_ids": [],
|
||||
"source_url": "https://semeia.gapi.co.mz/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Semeia Negócios",
|
||||
"checked_on": "2026-11-10",
|
||||
"application_url": "https://semeia.gapi.co.mz/",
|
||||
"criteria": []
|
||||
}
|
||||
],
|
||||
"funding_needs": [
|
||||
"investment",
|
||||
"technical_assistance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fcid-viii": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; VIII Edição FCID",
|
||||
"url": "https://fundocatalitico.gov.mz/viii-edicao-fcid/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "FCID VIII — Jobs Now",
|
||||
"kind": "fund",
|
||||
"summary": "Subvenção comparticipada para expansão produtiva, cadeias de valor e emprego. Público/finalidade: Pequenas e médias empresas e grandes empresas âncora formalizadas.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Pequenas: USD 50.000–150.000; médias: USD 151.000–250.000; âncora: USD 251.000–500.000. Activo na data da verificação, com prazo iminente. O portal oficial mostrava candidaturas abertas e contagem decrescente; exige validação diária até ao encerramento.",
|
||||
"manager_name": "Entidades do programa identificadas na fonte",
|
||||
"source_url": "https://fundocatalitico.gov.mz/viii-edicao-fcid/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; VIII Edição FCID",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "territorial",
|
||||
"coverage_text": "Províncias de Maputo, Gaza, Inhambane, Manica, Sofala e Zambézia. Não pressupõe cobertura nacional. Disponibilidade actual da janela por confirmar; a página indica candidaturas sem prazo final inequívoco.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"investment"
|
||||
]
|
||||
},
|
||||
"territory_names": [
|
||||
"Maputo Província",
|
||||
"Gaza",
|
||||
"Inhambane",
|
||||
"Manica",
|
||||
"Sofala",
|
||||
"Zambezia"
|
||||
]
|
||||
},
|
||||
"fae": {
|
||||
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
|
||||
"provenance": [
|
||||
{
|
||||
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
|
||||
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
|
||||
"checked_on": "2026-11-10"
|
||||
},
|
||||
{
|
||||
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; FAE",
|
||||
"url": "https://www.bni.co.mz/banca-de-desenvolvimento/fae/",
|
||||
"checked_on": "2026-11-10"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"title": "FAE — BNI /Agência do Zambeze",
|
||||
"kind": "programme",
|
||||
"summary": "Crédito em desembolso único, tranches ou espécie para agronegócio e empreendedorismo. Público/finalidade: Recém-graduados, MPME , cooperativas e associações lucrativas elegíveis.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Segundo necessidade real do ciclo de produção e comercialização. Linha e processo publicados. A página não indica uma janela corrente com prazo; marcar “confirmação necessária” antes de aceitar candidatura.",
|
||||
"manager_name": "BNI / Agência do Zambeze",
|
||||
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/fae/",
|
||||
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; FAE",
|
||||
"source_checked_on": "2026-11-10",
|
||||
"source_status": "identified",
|
||||
"coverage": "unknown",
|
||||
"coverage_text": "Vale do Zambeze; projectos e actividades elegíveis na região.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [],
|
||||
"windows": [],
|
||||
"funding_needs": [
|
||||
"investment",
|
||||
"working_capital"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
## 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.
|
||||
|
||||
Localizada e reutilizada a lista fornecida em `Relatorios/benchmark de melhorias investeMoz.docx`, SHA256 `065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617`, quadro de04/10/2026. Doze entradas documentais correspondem a **11fichas:7instituições e4programas/fundos** — BCI,Millennium bim,Absa,Standard Bank,Moza,BNI,Gapi,FECOP,Semeia Negócios,FCID VIII eFAE; **BNI Capital** integra os produtos do BNI, sem duplicar instituição ou presumir personalidade jurídica autónoma. Agregado genérico de linhas BNI não gera fundos fictícios. Referência FDEL anterior conservada, sem a contar novamente nesta entrega.
|
||||
|
||||
O cadastro/versionamento/competência editorial existentes passam a conservar natureza factual, identidade estável, contactos com fonte/data, locais físicos (sede/balcão/ponto institucional) e produtos próprios. Moradas não determinam cobertura financeira. Produto tem finalidade, instrumento, condições/montantes, disponibilidade e cobertura próprios; vazios são identificados como por confirmar. Cobertura e necessidade têm de corresponder ao **mesmo produto**, antes da paginaçãoSQL. Critério documentado pode excluir desconhecidos; consulta geral distingue resultados por confirmar de correspondência sustentada. FCID conserva seis províncias documentadas, sem confundir Maputo Província/Cidade; FAE mantém o Vale do Zambeze como âmbito textual com vínculo geográfico por confirmar, sem inventar distritos. A localização institucional do BNI não limita o financiamento a Maputo. Semeia conserva a chamada encerrada; a antiga linha BCI2018 não é reaberta. Condições e janelas históricas não se tornam ofertas actuais pela importação. Fontes indicadas consultadas para âmbito/disponibilidade; falha de recolha da Gapi não revoga o original, cuja data04/10 e disponibilidade por confirmar ficam explícitas.
|
||||
|
||||
Demonstrado em QA55441 existente: localização/necessidade → consulta das fichas reais fornecidas → fonte/contacto/condições → escolha privada no mesmo dossier e oportunidade → recuperação. Snapshot preserva versão pública e produto consultados mesmo após retirada/alteração da ficha. Repetição da intenção e recuperação após recarregar não duplicam a escolha/evento; outra oportunidade/actor e dossier encerrado mantêm guardas. Não envia candidatura, contactos ou dados às instituições, não concede crédito nem classifica o investidor automaticamente. Sem pontuação/IA, schema/base/porta nova, novas competências ou alteração de dados activos.
|
||||
|
||||
Provas dirigidas: **3casos API/127asserções e13frontend**, PHPStan nível7 sem supressões, tipos/lint/Pint/compilação QA aprovados. Testes transaccionais revertidos; sequências QA podem avançar. `outputs/funding-directory-20261110/verification.json`, `content-qa.json`, `dossier-qa.json`; **catálogo consultável de revisão** `outputs/funding-directory-20261110/catalogo-qa.html` e11fichasHTML ligadas. Renderização estática dos componentes afectados, não conferência visual de navegador nem publicação no destino. Provas anteriores preservadas e reutilizadas.
|
||||
|
||||
**Saldo ACT-12:** recorte funcional e conteúdo fornecido demonstrados em QA, preparado para disponibilização pelo fluxo editorial. **Restam instalação do conjunto, preparação/revisão/publicação autenticada das11fichas no destino e confirmação da consulta real pelo investidor**; visual continua pendente com conector indisponível. Não declarar ACT-12 encerrado ou o portal integral. Dados de sede/balcões, condições ou cobertura não documentados conservam o estado por confirmar; não bloqueiam os restantes dados fundamentados. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos mantêm o saldo anterior.
|
||||
|
||||
### Utilização do directório financeiro
|
||||
|
||||
No dossier, escolha **Instituições financeiras e apoios**; o contexto da oportunidade é conservado. No catálogo /fundos seleccione **Local de interesse** e **Necessidade de financiamento**, pesquise e confira as mensagens de cobertura/condições por confirmar. Na ficha, leia separadamente presença física, cobertura do produto, fonte/data, contacto e disponibilidade. Escolha o produto e confirme apenas a conservação da referência privada; **Guardar e continuar no dossier** não envia candidatura. A secção **Referências de financiamento do dossier** conserva a fotografia consultada e permite abrir a ficha actual, que poderá ter mudado ou sido retirada. Resposta incerta permite repetir a mesma intenção sem duplicação; o dossier permite recuperar a escolha.
|
||||
|
||||
Para a equipa editorial: **Gerir fundos e programas → Preparar referências fornecidas** apresenta as referências documentais (incluindo FDEL anterior). Escolha cada ficha, confira a fonte, prepare o rascunho pelo botão existente e complete apenas dados fundamentados. Repetir a preparação abre a ficha já existente, sem substituir edições. Use **Alterar** nas secções de contactos/locais/produtos; guarde, confira a prévia da versão e publique com confirmação/motivo no fluxo existente. Não publicar linhas históricas como activas nem marcar oferta desconhecida como aberta. Não é necessário novo perfil, módulo ou cadastro de organizações duplicado.
|
||||
|
||||
## 10/11/2026 — APIEX, SWOT e acesso destacado a Concursos instalados
|
||||
|
||||
**Homologação instalada e conferida: `1c75a30a601ef00fc187ddf5a64183446d329e6f`**, https://teste-investidor.tempo.co.mz. Inclui APIEX/SWOT37854d02 e correcção do menu autorizada pela Tempo. **Concursos e anúncios** tem agora grupo próprio no menu horizontal comum, com consulta pública e acompanhamentos para utilizadores autenticados; endereço https://teste-investidor.tempo.co.mz/concursos. O módulo já estava activo e apenas a entrada estava dentro de Descoberta; não foi necessário activá-lo novamente. Um rascunho privado e zero versões publicadas preservados.
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
## 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.
|
||||
|
||||
Localizada e reutilizada a lista fornecida em `Relatorios/benchmark de melhorias investeMoz.docx`, SHA256 `065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617`, quadro de04/10/2026. Doze entradas documentais correspondem a **11fichas:7instituições e4programas/fundos** — BCI,Millennium bim,Absa,Standard Bank,Moza,BNI,Gapi,FECOP,Semeia Negócios,FCID VIII eFAE; **BNI Capital** integra os produtos do BNI, sem duplicar instituição ou presumir personalidade jurídica autónoma. Agregado genérico de linhas BNI não gera fundos fictícios. Referência FDEL anterior conservada, sem a contar novamente nesta entrega.
|
||||
|
||||
O cadastro/versionamento/competência editorial existentes passam a conservar natureza factual, identidade estável, contactos com fonte/data, locais físicos (sede/balcão/ponto institucional) e produtos próprios. Moradas não determinam cobertura financeira. Produto tem finalidade, instrumento, condições/montantes, disponibilidade e cobertura próprios; vazios são identificados como por confirmar. Cobertura e necessidade têm de corresponder ao **mesmo produto**, antes da paginaçãoSQL. Critério documentado pode excluir desconhecidos; consulta geral distingue resultados por confirmar de correspondência sustentada. FCID conserva seis províncias documentadas, sem confundir Maputo Província/Cidade; FAE mantém o Vale do Zambeze como âmbito textual com vínculo geográfico por confirmar, sem inventar distritos. A localização institucional do BNI não limita o financiamento a Maputo. Semeia conserva a chamada encerrada; a antiga linha BCI2018 não é reaberta. Condições e janelas históricas não se tornam ofertas actuais pela importação. Fontes indicadas consultadas para âmbito/disponibilidade; falha de recolha da Gapi não revoga o original, cuja data04/10 e disponibilidade por confirmar ficam explícitas.
|
||||
|
||||
Demonstrado em QA55441 existente: localização/necessidade → consulta das fichas reais fornecidas → fonte/contacto/condições → escolha privada no mesmo dossier e oportunidade → recuperação. Snapshot preserva versão pública e produto consultados mesmo após retirada/alteração da ficha. Repetição da intenção e recuperação após recarregar não duplicam a escolha/evento; outra oportunidade/actor e dossier encerrado mantêm guardas. Não envia candidatura, contactos ou dados às instituições, não concede crédito nem classifica o investidor automaticamente. Sem pontuação/IA, schema/base/porta nova, novas competências ou alteração de dados activos.
|
||||
|
||||
Provas dirigidas: **3casos API/127asserções e13frontend**, PHPStan nível7 sem supressões, tipos/lint/Pint/compilação QA aprovados. Testes transaccionais revertidos; sequências QA podem avançar. `outputs/funding-directory-20261110/verification.json`, `content-qa.json`, `dossier-qa.json`; **catálogo consultável de revisão** `outputs/funding-directory-20261110/catalogo-qa.html` e11fichasHTML ligadas. Renderização estática dos componentes afectados, não conferência visual de navegador nem publicação no destino. Provas anteriores preservadas e reutilizadas.
|
||||
|
||||
**Saldo ACT-12:** recorte funcional e conteúdo fornecido demonstrados em QA, preparado para disponibilização pelo fluxo editorial. **Restam instalação do conjunto, preparação/revisão/publicação autenticada das11fichas no destino e confirmação da consulta real pelo investidor**; visual continua pendente com conector indisponível. Não declarar ACT-12 encerrado ou o portal integral. Dados de sede/balcões, condições ou cobertura não documentados conservam o estado por confirmar; não bloqueiam os restantes dados fundamentados. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos mantêm o saldo anterior.
|
||||
|
||||
## 10/11/2026 — APIEX, SWOT e acesso destacado a Concursos instalados
|
||||
|
||||
**Homologação instalada e conferida: `1c75a30a601ef00fc187ddf5a64183446d329e6f`**, https://teste-investidor.tempo.co.mz. Inclui APIEX/SWOT37854d02 e correcção do menu autorizada pela Tempo. **Concursos e anúncios** tem agora grupo próprio no menu horizontal comum, com consulta pública e acompanhamentos para utilizadores autenticados; endereço https://teste-investidor.tempo.co.mz/concursos. O módulo já estava activo e apenas a entrada estava dentro de Descoberta; não foi necessário activá-lo novamente. Um rascunho privado e zero versões publicadas preservados.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,17 @@
|
||||
## 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.
|
||||
|
||||
Localizada e reutilizada a lista fornecida em `Relatorios/benchmark de melhorias investeMoz.docx`, SHA256 `065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617`, quadro de04/10/2026. Doze entradas documentais correspondem a **11fichas:7instituições e4programas/fundos** — BCI,Millennium bim,Absa,Standard Bank,Moza,BNI,Gapi,FECOP,Semeia Negócios,FCID VIII eFAE; **BNI Capital** integra os produtos do BNI, sem duplicar instituição ou presumir personalidade jurídica autónoma. Agregado genérico de linhas BNI não gera fundos fictícios. Referência FDEL anterior conservada, sem a contar novamente nesta entrega.
|
||||
|
||||
O cadastro/versionamento/competência editorial existentes passam a conservar natureza factual, identidade estável, contactos com fonte/data, locais físicos (sede/balcão/ponto institucional) e produtos próprios. Moradas não determinam cobertura financeira. Produto tem finalidade, instrumento, condições/montantes, disponibilidade e cobertura próprios; vazios são identificados como por confirmar. Cobertura e necessidade têm de corresponder ao **mesmo produto**, antes da paginaçãoSQL. Critério documentado pode excluir desconhecidos; consulta geral distingue resultados por confirmar de correspondência sustentada. FCID conserva seis províncias documentadas, sem confundir Maputo Província/Cidade; FAE mantém o Vale do Zambeze como âmbito textual com vínculo geográfico por confirmar, sem inventar distritos. A localização institucional do BNI não limita o financiamento a Maputo. Semeia conserva a chamada encerrada; a antiga linha BCI2018 não é reaberta. Condições e janelas históricas não se tornam ofertas actuais pela importação. Fontes indicadas consultadas para âmbito/disponibilidade; falha de recolha da Gapi não revoga o original, cuja data04/10 e disponibilidade por confirmar ficam explícitas.
|
||||
|
||||
Demonstrado em QA55441 existente: localização/necessidade → consulta das fichas reais fornecidas → fonte/contacto/condições → escolha privada no mesmo dossier e oportunidade → recuperação. Snapshot preserva versão pública e produto consultados mesmo após retirada/alteração da ficha. Repetição da intenção e recuperação após recarregar não duplicam a escolha/evento; outra oportunidade/actor e dossier encerrado mantêm guardas. Não envia candidatura, contactos ou dados às instituições, não concede crédito nem classifica o investidor automaticamente. Sem pontuação/IA, schema/base/porta nova, novas competências ou alteração de dados activos.
|
||||
|
||||
Provas dirigidas: **3casos API/127asserções e13frontend**, PHPStan nível7 sem supressões, tipos/lint/Pint/compilação QA aprovados. Testes transaccionais revertidos; sequências QA podem avançar. `outputs/funding-directory-20261110/verification.json`, `content-qa.json`, `dossier-qa.json`; **catálogo consultável de revisão** `outputs/funding-directory-20261110/catalogo-qa.html` e11fichasHTML ligadas. Renderização estática dos componentes afectados, não conferência visual de navegador nem publicação no destino. Provas anteriores preservadas e reutilizadas.
|
||||
|
||||
**Saldo ACT-12:** recorte funcional e conteúdo fornecido demonstrados em QA, preparado para disponibilização pelo fluxo editorial. **Restam instalação do conjunto, preparação/revisão/publicação autenticada das11fichas no destino e confirmação da consulta real pelo investidor**; visual continua pendente com conector indisponível. Não declarar ACT-12 encerrado ou o portal integral. Dados de sede/balcões, condições ou cobertura não documentados conservam o estado por confirmar; não bloqueiam os restantes dados fundamentados. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos mantêm o saldo anterior.
|
||||
|
||||
## 10/11/2026 — APIEX, SWOT e acesso destacado a Concursos instalados
|
||||
|
||||
**Homologação instalada e conferida: `1c75a30a601ef00fc187ddf5a64183446d329e6f`**, https://teste-investidor.tempo.co.mz. Inclui APIEX/SWOT37854d02 e correcção do menu autorizada pela Tempo. **Concursos e anúncios** tem agora grupo próprio no menu horizontal comum, com consulta pública e acompanhamentos para utilizadores autenticados; endereço https://teste-investidor.tempo.co.mz/concursos. O módulo já estava activo e apenas a entrada estava dentro de Descoberta; não foi necessário activá-lo novamente. Um rascunho privado e zero versões publicadas preservados.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { card, input, button } from '@/pages/investment/shared';
|
||||
import type { Programme } from '@/pages/funding/shared';
|
||||
|
||||
export type FundingDossier = {
|
||||
id: string;
|
||||
version: number;
|
||||
opportunity: string | null;
|
||||
closed: boolean;
|
||||
href: string;
|
||||
};
|
||||
export function FundingDossierChoice({
|
||||
record,
|
||||
dossier,
|
||||
selection,
|
||||
needs,
|
||||
destinations,
|
||||
}: {
|
||||
record: Programme;
|
||||
dossier: FundingDossier | null;
|
||||
selection: Record<string, string>;
|
||||
needs: Record<string, string>;
|
||||
destinations: { id: string; opportunity: string | null; title: string }[];
|
||||
}) {
|
||||
const [need, setNeed] = useState(selection.need ?? '');
|
||||
const [product, setProduct] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<FundingDossier | null>(null);
|
||||
const pending = useRef<Record<string, unknown> | null>(null);
|
||||
const running = useRef(false);
|
||||
async function save() {
|
||||
if (!dossier || running.current) return;
|
||||
if (!pending.current)
|
||||
pending.current = {
|
||||
context: dossier.id,
|
||||
opportunity: dossier.opportunity,
|
||||
version: dossier.version,
|
||||
version_id: record.version_id,
|
||||
product: product || null,
|
||||
need,
|
||||
territory: selection.territory,
|
||||
consent,
|
||||
operation_key: createOperationKey(),
|
||||
};
|
||||
running.current = true;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
setResult(
|
||||
(await profileJson(
|
||||
`/fundos/${record.id}/dossier`,
|
||||
pending.current,
|
||||
'POST',
|
||||
)) as FundingDossier,
|
||||
);
|
||||
pending.current = null;
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Resultado por confirmar. Repita a mesma intenção.',
|
||||
);
|
||||
if (
|
||||
e instanceof ProfileValidationError &&
|
||||
e.status !== null &&
|
||||
e.status >= 400 &&
|
||||
e.status < 500 &&
|
||||
e.status !== 408
|
||||
)
|
||||
pending.current = null;
|
||||
} finally {
|
||||
running.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className={card}>
|
||||
<h2>Continuar a preparação no meu dossier</h2>
|
||||
<p>
|
||||
Conserva a referência, versão, produto, local e necessidade. Não
|
||||
envia candidatura ou dados à instituição.
|
||||
</p>
|
||||
{!dossier ? (
|
||||
<label>
|
||||
Escolher dossier e oportunidade existentes
|
||||
<select
|
||||
className={input}
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const chosen = destinations[Number(e.target.value)];
|
||||
if (chosen)
|
||||
router.get(`/fundos/${record.id}`, {
|
||||
...selection,
|
||||
context: chosen.id,
|
||||
opportunity: chosen.opportunity ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{destinations.map((d, i) => (
|
||||
<option key={`${d.id}:${d.opportunity}`} value={i}>
|
||||
{d.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : result ? (
|
||||
<Link className={button} href={result.href}>
|
||||
Escolha guardada — continuar no mesmo dossier
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<fieldset
|
||||
disabled={
|
||||
busy || Boolean(pending.current) || dossier.closed
|
||||
}
|
||||
>
|
||||
<label>
|
||||
Necessidade
|
||||
<select
|
||||
className={input}
|
||||
value={need}
|
||||
onChange={(e) => setNeed(e.target.value)}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{Object.entries(needs).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{record.payload.kind === 'institution' &&
|
||||
(record.payload.products ?? []).length > 0 && (
|
||||
<label>
|
||||
Programa/produto
|
||||
<select
|
||||
className={input}
|
||||
value={product}
|
||||
onChange={(e) =>
|
||||
setProduct(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar e conferir
|
||||
cobertura/condições
|
||||
</option>
|
||||
{record.payload.products?.map((p) => (
|
||||
<option key={p.code} value={p.code}>
|
||||
{p.title} ·{' '}
|
||||
{p.availability === 'unknown'
|
||||
? 'Disponibilidade por confirmar'
|
||||
: p.availability ===
|
||||
'closed'
|
||||
? 'Encerrado'
|
||||
: 'Oferta identificada'}
|
||||
</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.'}
|
||||
</p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
/>
|
||||
Conservar esta referência privada no dossier;
|
||||
financiamento não garantido.
|
||||
</label>
|
||||
</fieldset>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
busy ||
|
||||
dossier.closed ||
|
||||
(!pending.current &&
|
||||
(!consent || !need || !selection.territory))
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{pending.current
|
||||
? 'Repetir a mesma intenção'
|
||||
: 'Guardar e continuar no dossier'}
|
||||
</button>
|
||||
{dossier.closed && (
|
||||
<p>Dossier encerrado; consulta preservada.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import { card, input, button } from '@/pages/investment/shared';
|
||||
|
||||
export type FundingSource = {
|
||||
source_url: string;
|
||||
source_reference: string;
|
||||
checked_on: string;
|
||||
};
|
||||
export type FundingContact = FundingSource & { kind: string; value: string };
|
||||
export type FundingLocation = FundingSource & {
|
||||
role: string;
|
||||
label: string;
|
||||
address: string;
|
||||
territory_id: string;
|
||||
territory_path?: { name: string }[];
|
||||
};
|
||||
export type FundingProduct = FundingSource & {
|
||||
code: string;
|
||||
title: string;
|
||||
instrument: string;
|
||||
needs: string[];
|
||||
coverage: string;
|
||||
coverage_text: string;
|
||||
territory_ids: string[];
|
||||
territory_paths?: { name: string }[][];
|
||||
conditions: string;
|
||||
amounts: string;
|
||||
availability: string;
|
||||
contact_url: string;
|
||||
};
|
||||
export type InstitutionDetails = {
|
||||
institution_key: string;
|
||||
institution_nature: string;
|
||||
contacts: FundingContact[];
|
||||
locations: FundingLocation[];
|
||||
products: FundingProduct[];
|
||||
};
|
||||
export const emptyInstitution: InstitutionDetails = {
|
||||
institution_key: '',
|
||||
institution_nature: 'unknown',
|
||||
contacts: [],
|
||||
locations: [],
|
||||
products: [],
|
||||
};
|
||||
const source: FundingSource = {
|
||||
source_url: '',
|
||||
source_reference: '',
|
||||
checked_on: '',
|
||||
};
|
||||
function Text({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
change: (v: string) => void;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="my-2 block">
|
||||
{label}
|
||||
<input
|
||||
className={input}
|
||||
type={type}
|
||||
value={value ?? ''}
|
||||
maxLength={type === 'text' ? 1500 : undefined}
|
||||
onChange={(e) => change(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function Sources({
|
||||
value,
|
||||
change,
|
||||
}: {
|
||||
value: FundingSource;
|
||||
change: (v: Partial<FundingSource>) => void;
|
||||
}) {
|
||||
return (
|
||||
<details>
|
||||
<summary>Fonte e data deste elemento — Alterar</summary>
|
||||
<Text
|
||||
label="Endereço da fonte"
|
||||
type="url"
|
||||
value={value.source_url}
|
||||
change={(source_url) => change({ source_url })}
|
||||
/>
|
||||
<Text
|
||||
label="Referência/edição"
|
||||
value={value.source_reference}
|
||||
change={(source_reference) => change({ source_reference })}
|
||||
/>
|
||||
<Text
|
||||
label="Data de consulta"
|
||||
type="date"
|
||||
value={value.checked_on}
|
||||
change={(checked_on) => change({ checked_on })}
|
||||
/>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export function InstitutionFields({
|
||||
value,
|
||||
change,
|
||||
territories,
|
||||
natures,
|
||||
needs,
|
||||
}: {
|
||||
value: InstitutionDetails;
|
||||
change: (patch: Partial<InstitutionDetails>) => void;
|
||||
territories: { id: string | number; name?: string }[];
|
||||
natures: Record<string, string>;
|
||||
needs: Record<string, string>;
|
||||
}) {
|
||||
const update = <K extends 'contacts' | 'locations' | 'products'>(
|
||||
key: K,
|
||||
index: number,
|
||||
patch: Partial<InstitutionDetails[K][number]>,
|
||||
) =>
|
||||
change({
|
||||
[key]: value[key].map((row, i) =>
|
||||
i === index ? { ...row, ...patch } : row,
|
||||
),
|
||||
});
|
||||
return (
|
||||
<section className={card}>
|
||||
<h2>Instituição, presença física e produtos</h2>
|
||||
<p>
|
||||
A sede ou balcão não determina a cobertura do financiamento. Não
|
||||
preencha condições desconhecidas por inferência.
|
||||
</p>
|
||||
<Text
|
||||
label="Identidade estável da instituição (referência do cadastro/fonte)"
|
||||
value={value.institution_key}
|
||||
change={(institution_key) => change({ institution_key })}
|
||||
/>
|
||||
<label>
|
||||
Natureza
|
||||
<select
|
||||
className={input}
|
||||
value={value.institution_nature}
|
||||
onChange={(e) =>
|
||||
change({ institution_nature: e.target.value })
|
||||
}
|
||||
>
|
||||
{Object.entries(natures).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<details>
|
||||
<summary>Contactos ({value.contacts.length}) — Alterar</summary>
|
||||
{value.contacts.map((row, i) => (
|
||||
<fieldset className={card} key={i}>
|
||||
<legend>Contacto {i + 1}</legend>
|
||||
<select
|
||||
className={input}
|
||||
value={row.kind}
|
||||
onChange={(e) =>
|
||||
update('contacts', i, { kind: e.target.value })
|
||||
}
|
||||
>
|
||||
{Object.entries({
|
||||
website: 'Página institucional',
|
||||
email: 'Email',
|
||||
phone: 'Telefone',
|
||||
address: 'Endereço postal',
|
||||
}).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Text
|
||||
label="Contacto documentado"
|
||||
value={row.value}
|
||||
change={(v) => update('contacts', i, { value: v })}
|
||||
/>
|
||||
<Sources
|
||||
value={row}
|
||||
change={(v) => update('contacts', i, v)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
change({
|
||||
contacts: value.contacts.filter(
|
||||
(_, n) => n !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remover contacto
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={value.contacts.length >= 20}
|
||||
onClick={() =>
|
||||
change({
|
||||
contacts: [
|
||||
...value.contacts,
|
||||
{ ...source, kind: 'website', value: '' },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Acrescentar contacto
|
||||
</button>
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
Sede e balcões ({value.locations.length}) — Alterar
|
||||
</summary>
|
||||
{value.locations.map((row, i) => (
|
||||
<fieldset className={card} key={i}>
|
||||
<legend>Local físico {i + 1}</legend>
|
||||
<select
|
||||
className={input}
|
||||
value={row.role}
|
||||
onChange={(e) =>
|
||||
update('locations', i, { role: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="headquarters">Sede</option>
|
||||
<option value="branch">Balcão</option>
|
||||
<option value="contact_point">
|
||||
Contacto institucional — tipo por confirmar
|
||||
</option>
|
||||
</select>
|
||||
<Text
|
||||
label="Designação"
|
||||
value={row.label}
|
||||
change={(label) =>
|
||||
update('locations', i, { label })
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
label="Morada documentada"
|
||||
value={row.address}
|
||||
change={(address) =>
|
||||
update('locations', i, { address })
|
||||
}
|
||||
/>
|
||||
<label>
|
||||
Local do cadastro
|
||||
<select
|
||||
className={input}
|
||||
value={row.territory_id ?? ''}
|
||||
onChange={(e) =>
|
||||
update('locations', i, {
|
||||
territory_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Correspondência por confirmar
|
||||
</option>
|
||||
{territories.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Sources
|
||||
value={row}
|
||||
change={(v) => update('locations', i, v)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
change({
|
||||
locations: value.locations.filter(
|
||||
(_, n) => n !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remover local
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={value.locations.length >= 50}
|
||||
onClick={() =>
|
||||
change({
|
||||
locations: [
|
||||
...value.locations,
|
||||
{
|
||||
...source,
|
||||
role: 'headquarters',
|
||||
label: '',
|
||||
address: '',
|
||||
territory_id: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Acrescentar sede/balcão
|
||||
</button>
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
Programas/produtos ({value.products.length}) — Alterar
|
||||
</summary>
|
||||
{value.products.map((row, i) => (
|
||||
<fieldset className={card} key={row.code}>
|
||||
<legend>Produto {i + 1}</legend>
|
||||
<Text
|
||||
label="Nome documentado"
|
||||
value={row.title}
|
||||
change={(title) => update('products', i, { title })}
|
||||
/>
|
||||
<Text
|
||||
label="Instrumento"
|
||||
value={row.instrument}
|
||||
change={(instrument) =>
|
||||
update('products', i, { instrument })
|
||||
}
|
||||
/>
|
||||
<label>
|
||||
Necessidades documentadas
|
||||
<select
|
||||
multiple
|
||||
className={input}
|
||||
value={row.needs}
|
||||
onChange={(e) =>
|
||||
update('products', i, {
|
||||
needs: Array.from(
|
||||
e.target.selectedOptions,
|
||||
(o) => o.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(needs).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Cobertura deste produto
|
||||
<select
|
||||
className={input}
|
||||
value={row.coverage}
|
||||
onChange={(e) =>
|
||||
update('products', i, {
|
||||
coverage: e.target.value,
|
||||
territory_ids: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="unknown">Por confirmar</option>
|
||||
<option value="national">
|
||||
Nacional, segundo a fonte
|
||||
</option>
|
||||
<option value="territorial">
|
||||
Locais documentados
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{row.coverage === 'territorial' && (
|
||||
<label>
|
||||
Locais abrangidos
|
||||
<select
|
||||
multiple
|
||||
className={input}
|
||||
value={row.territory_ids}
|
||||
onChange={(e) =>
|
||||
update('products', i, {
|
||||
territory_ids: Array.from(
|
||||
e.target.selectedOptions,
|
||||
(o) => o.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{territories.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<Text
|
||||
label="Explicação da cobertura"
|
||||
value={row.coverage_text}
|
||||
change={(coverage_text) =>
|
||||
update('products', i, { coverage_text })
|
||||
}
|
||||
/>
|
||||
<label>
|
||||
Condições — vazio significa por confirmar
|
||||
<textarea
|
||||
className={input}
|
||||
value={row.conditions}
|
||||
maxLength={4000}
|
||||
onChange={(e) =>
|
||||
update('products', i, {
|
||||
conditions: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Text
|
||||
label="Montantes/moeda/prazos documentados — se conhecidos"
|
||||
value={row.amounts}
|
||||
change={(amounts) =>
|
||||
update('products', i, { amounts })
|
||||
}
|
||||
/>
|
||||
<label>
|
||||
Disponibilidade
|
||||
<select
|
||||
className={input}
|
||||
value={row.availability}
|
||||
onChange={(e) =>
|
||||
update('products', i, {
|
||||
availability: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="unknown">Por confirmar</option>
|
||||
<option value="documented">
|
||||
Oferta identificada na fonte (não é
|
||||
concessão)
|
||||
</option>
|
||||
<option value="closed">
|
||||
Encerrada segundo a fonte
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<Text
|
||||
label="Canal de contacto do produto"
|
||||
type="url"
|
||||
value={row.contact_url}
|
||||
change={(contact_url) =>
|
||||
update('products', i, { contact_url })
|
||||
}
|
||||
/>
|
||||
<Sources
|
||||
value={row}
|
||||
change={(v) => update('products', i, v)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
change({
|
||||
products: value.products.filter(
|
||||
(_, n) => n !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remover produto
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={value.products.length >= 40}
|
||||
onClick={() =>
|
||||
change({
|
||||
products: [
|
||||
...value.products,
|
||||
{
|
||||
...source,
|
||||
code: crypto.randomUUID(),
|
||||
title: '',
|
||||
instrument: '',
|
||||
needs: [],
|
||||
coverage: 'unknown',
|
||||
coverage_text: '',
|
||||
territory_ids: [],
|
||||
conditions: '',
|
||||
amounts: '',
|
||||
availability: 'unknown',
|
||||
contact_url: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Acrescentar programa/produto
|
||||
</button>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
<h3>Contactos</h3>
|
||||
{value.contacts.length === 0 && <p>Contactos por confirmar.</p>}
|
||||
{value.contacts.map((c, i) => (
|
||||
<article key={i}>
|
||||
<p>
|
||||
{c.kind === 'website' ? (
|
||||
<a
|
||||
className="underline"
|
||||
href={c.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{c.value}
|
||||
</a>
|
||||
) : (
|
||||
c.value
|
||||
)}
|
||||
</p>
|
||||
<Source value={c} />
|
||||
</article>
|
||||
))}
|
||||
<h3>Sede e balcões — presença física</h3>
|
||||
{value.locations.length === 0 && (
|
||||
<p>Localização física por confirmar.</p>
|
||||
)}
|
||||
{value.locations.map((l, i) => (
|
||||
<article key={i}>
|
||||
<p>
|
||||
{l.role === 'headquarters'
|
||||
? 'Sede'
|
||||
: l.role === 'branch'
|
||||
? 'Balcão'
|
||||
: 'Ponto de contacto institucional'}
|
||||
: {l.label} · {l.address}
|
||||
</p>
|
||||
<p>
|
||||
{l.territory_path?.map((n) => n.name).join(' / ') ||
|
||||
'Correspondência ao cadastro territorial por confirmar'}
|
||||
</p>
|
||||
<Source value={l} />
|
||||
</article>
|
||||
))}
|
||||
<h3>Produtos e programas</h3>
|
||||
{value.products.length === 0 && (
|
||||
<p>
|
||||
Produtos e condições por confirmar; a identificação da
|
||||
instituição não confirma oferta disponível.
|
||||
</p>
|
||||
)}
|
||||
{value.products.map((p) => (
|
||||
<article className={card} key={p.code}>
|
||||
<h4>{p.title}</h4>
|
||||
<p>{p.instrument}</p>
|
||||
<p>
|
||||
Cobertura do financiamento:{' '}
|
||||
{p.coverage === 'national'
|
||||
? 'Nacional, segundo a fonte'
|
||||
: p.coverage === 'territorial'
|
||||
? 'Locais documentados'
|
||||
: 'Por confirmar'}{' '}
|
||||
· {p.coverage_text}
|
||||
</p>
|
||||
{p.territory_paths?.map((path, i) => (
|
||||
<p key={i}>{path.map((n) => n.name).join(' / ')}</p>
|
||||
))}
|
||||
<p>Condições: {p.conditions || 'Por confirmar'}</p>
|
||||
<p>
|
||||
Montantes/moeda/prazos: {p.amounts || 'Por confirmar'}
|
||||
</p>
|
||||
<p>
|
||||
Disponibilidade:{' '}
|
||||
{p.availability === 'unknown'
|
||||
? 'Por confirmar'
|
||||
: p.availability === 'closed'
|
||||
? 'Encerrada segundo a fonte'
|
||||
: 'Oferta identificada na fonte; confirmar condições actuais com a instituição'}
|
||||
</p>
|
||||
{p.contact_url && (
|
||||
<a
|
||||
href={p.contact_url}
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Contactar/consultar o produto
|
||||
</a>
|
||||
)}
|
||||
<Source value={p} />
|
||||
</article>
|
||||
))}
|
||||
<p>
|
||||
Uma referência não é elegibilidade confirmada, aprovação nem
|
||||
garantia de financiamento. O portal não envia pedidos ou dados à
|
||||
instituição.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
InstitutionFields,
|
||||
emptyInstitution,
|
||||
type InstitutionDetails,
|
||||
} from '@/components/funding-institution';
|
||||
import { Link, router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -21,7 +26,8 @@ type WindowDraft = {
|
||||
application_url: string;
|
||||
criteria: Criterion[];
|
||||
};
|
||||
type Draft = {
|
||||
type Draft = InstitutionDetails & {
|
||||
funding_needs: string[];
|
||||
title: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
@@ -51,6 +57,8 @@ type RecordData = {
|
||||
};
|
||||
type Plan = { version: number; hash: string; payload: Draft };
|
||||
const empty: Draft = {
|
||||
...emptyInstitution,
|
||||
funding_needs: [],
|
||||
title: '',
|
||||
kind: 'fund',
|
||||
summary: '',
|
||||
@@ -239,6 +247,8 @@ function Editor({
|
||||
kinds,
|
||||
windowModes,
|
||||
history,
|
||||
institutionNatures = {},
|
||||
needs = {},
|
||||
}: {
|
||||
record: RecordData | null;
|
||||
references: Record<
|
||||
@@ -247,6 +257,8 @@ function Editor({
|
||||
>;
|
||||
kinds: Record<string, string>;
|
||||
windowModes: Record<string, string>;
|
||||
institutionNatures?: Record<string, string>;
|
||||
needs?: Record<string, string>;
|
||||
history: { version: number; reason: string; published_at: string }[];
|
||||
}) {
|
||||
const form = useForm({
|
||||
@@ -359,6 +371,31 @@ function Editor({
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{draft.kind !== 'institution' && (
|
||||
<label>
|
||||
Finalidades documentadas
|
||||
<select
|
||||
multiple
|
||||
className={input}
|
||||
value={draft.funding_needs}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
funding_needs: Array.from(
|
||||
e.target.selectedOptions,
|
||||
(o) => o.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(needs).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>Sem selecção: finalidade por confirmar.</span>
|
||||
</label>
|
||||
)}
|
||||
<Field label="Resumo">
|
||||
<textarea
|
||||
className={input}
|
||||
@@ -419,8 +456,17 @@ function Editor({
|
||||
</select>
|
||||
</Field>
|
||||
</section>
|
||||
{draft.kind === 'institution' && (
|
||||
<InstitutionFields
|
||||
value={draft}
|
||||
change={change}
|
||||
territories={references.territories}
|
||||
natures={institutionNatures}
|
||||
needs={needs}
|
||||
/>
|
||||
)}
|
||||
<section className={card}>
|
||||
<Field label="Cobertura do programa">
|
||||
<Field label="Cobertura do programa/instituição — distinta da presença física">
|
||||
<select
|
||||
className={input}
|
||||
value={draft.coverage}
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function Index({
|
||||
kinds,
|
||||
management,
|
||||
options,
|
||||
needs = {},
|
||||
}: {
|
||||
items: {
|
||||
data: Programme[];
|
||||
@@ -20,15 +21,22 @@ export default function Index({
|
||||
territory?: string;
|
||||
certainty?: string;
|
||||
state?: string;
|
||||
need?: string;
|
||||
context?: string;
|
||||
opportunity?: string;
|
||||
};
|
||||
options: {
|
||||
sectors: { id: number; name: string }[];
|
||||
territories: { id: string; name: string }[];
|
||||
};
|
||||
kinds: Record<string, string>;
|
||||
needs?: Record<string, string>;
|
||||
management: boolean;
|
||||
}) {
|
||||
const form = useForm({
|
||||
context: filters.context ?? '',
|
||||
opportunity: filters.opportunity ?? '',
|
||||
need: filters.need ?? '',
|
||||
q: filters.q ?? '',
|
||||
kind: filters.kind ?? '',
|
||||
sector: filters.sector ?? '',
|
||||
@@ -37,7 +45,7 @@ export default function Index({
|
||||
state: filters.state ?? '',
|
||||
});
|
||||
return (
|
||||
<Frame title="Fundos e programas de apoio">
|
||||
<Frame title="Instituições financeiras, fundos e programas de apoio">
|
||||
{management && (
|
||||
<Link className={button} href="/management/fundos">
|
||||
Gerir fundos e programas
|
||||
@@ -57,6 +65,21 @@ export default function Index({
|
||||
form.get('/fundos');
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Necessidade de financiamento
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.need}
|
||||
onChange={(e) => form.setData('need', e.target.value)}
|
||||
>
|
||||
<option value="">Todas / por identificar</option>
|
||||
{Object.entries(needs).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Palavras-chave
|
||||
<input
|
||||
@@ -164,7 +187,10 @@ export default function Index({
|
||||
{items.data.map((item) => (
|
||||
<article className={card} key={item.id}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Link className="underline" href={`/fundos/${item.id}`}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
|
||||
>
|
||||
{item.payload.title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Link } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
export default function Manage({
|
||||
items,
|
||||
references = [],
|
||||
}: {
|
||||
references?: { code: string; title: string }[];
|
||||
items: {
|
||||
data: {
|
||||
id: string;
|
||||
@@ -21,12 +23,25 @@ export default function Manage({
|
||||
<Link className="ml-4 underline" href="/fundos">
|
||||
Catálogo público
|
||||
</Link>
|
||||
<Link
|
||||
className="ml-4 underline"
|
||||
href="/management/fundos/referencias/fdel"
|
||||
>
|
||||
Preparar referência FDEL
|
||||
</Link>
|
||||
<details className={card}>
|
||||
<summary>
|
||||
Preparar referências fornecidas ({references.length})
|
||||
</summary>
|
||||
<p>
|
||||
Prepara rascunhos privados; repetições reutilizam a ficha e
|
||||
preservam edições. Publicação é conferida separadamente.
|
||||
</p>
|
||||
{references.map((r) => (
|
||||
<p key={r.code}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/fundos/referencias/${r.code}`}
|
||||
>
|
||||
{r.title}
|
||||
</Link>
|
||||
</p>
|
||||
))}
|
||||
</details>
|
||||
{items.data.map((item) => (
|
||||
<article className={card} key={item.id}>
|
||||
<Link
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
InstitutionView,
|
||||
type InstitutionDetails,
|
||||
} from '@/components/funding-institution';
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import type { Criterion } from './shared';
|
||||
@@ -7,6 +11,7 @@ type Reference = {
|
||||
edition: string;
|
||||
provenance: { url: string; checked_on: string; reference: string }[];
|
||||
draft: {
|
||||
kind?: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
manager_name: string;
|
||||
@@ -61,6 +66,11 @@ export default function ReferencePage({ reference }: { reference: Reference }) {
|
||||
<p>{reference.draft.sectors_text}</p>
|
||||
<Criteria items={reference.draft.criteria} />
|
||||
</section>
|
||||
{reference.draft.kind === 'institution' && (
|
||||
<InstitutionView
|
||||
value={reference.draft as unknown as InstitutionDetails}
|
||||
/>
|
||||
)}
|
||||
<section className={card}>
|
||||
<h2>Chamadas documentadas</h2>
|
||||
{reference.draft.windows.map((window) => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InstitutionDetails } from '@/components/funding-institution';
|
||||
export type Criterion = {
|
||||
code: string;
|
||||
question: string;
|
||||
@@ -28,7 +29,7 @@ export type Programme = {
|
||||
version_id: string;
|
||||
version: number;
|
||||
published_at: string;
|
||||
payload: {
|
||||
payload: Partial<InstitutionDetails> & {
|
||||
title: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
InstitutionView,
|
||||
type InstitutionDetails,
|
||||
} from '@/components/funding-institution';
|
||||
import {
|
||||
FundingDossierChoice,
|
||||
type FundingDossier,
|
||||
} from '@/components/funding-dossier-choice';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import FundingCriteriaAssessment from '@/components/funding-criteria-assessment';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
@@ -7,10 +15,18 @@ export default function Show({
|
||||
record,
|
||||
saved,
|
||||
authenticated,
|
||||
dossier = null,
|
||||
selection = {},
|
||||
needs = {},
|
||||
destinations = [],
|
||||
}: {
|
||||
record: Programme;
|
||||
saved: { version: number; saved_at: string } | null;
|
||||
authenticated: boolean;
|
||||
dossier?: FundingDossier | null;
|
||||
selection?: Record<string, string>;
|
||||
needs?: Record<string, string>;
|
||||
destinations?: { id: string; opportunity: string | null; title: string }[];
|
||||
}) {
|
||||
const payload = record.payload;
|
||||
return (
|
||||
@@ -18,10 +34,26 @@ export default function Show({
|
||||
<Link className="underline" href="/fundos/guardados">
|
||||
Programas que guardei
|
||||
</Link>
|
||||
<Link className="underline" href="/fundos">
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fundos?${new URLSearchParams(selection)}`}
|
||||
>
|
||||
Voltar aos fundos e programas
|
||||
</Link>
|
||||
<p>{payload.summary}</p>
|
||||
{payload.kind === 'institution' && (
|
||||
<InstitutionView value={payload as InstitutionDetails} />
|
||||
)}
|
||||
{authenticated && (
|
||||
<FundingDossierChoice
|
||||
key={`${record.id}:${record.version_id}:${dossier?.id ?? ''}:${dossier?.opportunity ?? ''}:${selection.territory ?? ''}`}
|
||||
record={record}
|
||||
dossier={dossier}
|
||||
selection={selection}
|
||||
needs={needs}
|
||||
destinations={destinations}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
Gestor: {payload.manager_name}. Versão publicada:{' '}
|
||||
{record.version}.
|
||||
|
||||
@@ -39,6 +39,20 @@ type Step = {
|
||||
source_paragraph: number;
|
||||
};
|
||||
type Path = {
|
||||
funding_selections?: {
|
||||
id: string;
|
||||
title: string;
|
||||
programme_id: string;
|
||||
version: number;
|
||||
need: string;
|
||||
opportunity: string | null;
|
||||
notice: string;
|
||||
product?: {
|
||||
title: string;
|
||||
conditions?: string;
|
||||
availability: string;
|
||||
} | null;
|
||||
}[];
|
||||
preparation_action?: {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -786,6 +800,8 @@ export default function InvestmentPath(path: Path) {
|
||||
{
|
||||
(
|
||||
{
|
||||
funding:
|
||||
'Instituições financeiras e apoios',
|
||||
preparation: 'Preparação APIEX e SWOT',
|
||||
simulator: 'Plano e simulador',
|
||||
desk: 'Balcão e histórico',
|
||||
@@ -1057,6 +1073,44 @@ export default function InvestmentPath(path: Path) {
|
||||
Anexar ou conferir no Balcão
|
||||
</Link>
|
||||
</section>
|
||||
<section
|
||||
id="financiamento"
|
||||
className="rounded-xl border bg-white p-4"
|
||||
>
|
||||
<h2>Referências de financiamento do dossier</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
|
||||
</Link>
|
||||
)}
|
||||
{(path.funding_selections ?? []).map((row) => (
|
||||
<article key={row.id}>
|
||||
<h3>
|
||||
{row.title} · versão consultada {row.version}
|
||||
</h3>
|
||||
<p>
|
||||
{row.product?.title ?? 'Produto por confirmar'} ·{' '}
|
||||
{row.need}
|
||||
</p>
|
||||
<p>
|
||||
{row.product?.conditions ||
|
||||
'Condições por confirmar'}
|
||||
</p>
|
||||
<p>{row.notice}</p>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fundos/${row.programme_id}?context=${path.case.id}${row.opportunity ? '&opportunity=' + encodeURIComponent(row.opportunity) : ''}`}
|
||||
>
|
||||
Consultar ficha actual (pode ter sido alterada ou
|
||||
retirada)
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<details id="tarefas" className="space-y-3 rounded-xl border p-4">
|
||||
<summary className="cursor-pointer text-lg font-semibold">
|
||||
Acompanhamento interno
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::get('/fundos', [Funding::class, 'index'])->name('funding.index');
|
||||
Route::get('/fundos/guardados', [Funding::class, 'saved'])->middleware(['auth', 'verified'])->name('funding.saved');
|
||||
Route::get('/fundos/{id}', [Funding::class, 'show'])->whereUuid('id')->name('funding.show');
|
||||
Route::post('/fundos/{id}/dossier', [Funding::class, 'dossier'])->whereUuid('id')->middleware(['auth', 'verified', 'throttle:30,1']);
|
||||
Route::post('/fundos/{id}/criterios', [Funding::class, 'assess'])->whereUuid('id')->middleware('throttle:30,1');
|
||||
Route::get('/fundos/{id}/versoes/{version}/documentos/{resource}', [Funding::class, 'download'])->whereUuid('id')->whereUuid('version')->whereNumber('resource');
|
||||
Route::put('/fundos/{id}/interesse', [Funding::class, 'saveInterest'])->whereUuid('id')->middleware(['auth', 'verified', 'throttle:30,1']);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?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 Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class FundingDirectoryTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private User $editor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$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);
|
||||
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
|
||||
Http::preventStrayRequests();
|
||||
$this->editor = User::factory()->create();
|
||||
config(['portal.managers' => [$this->editor->email]]);
|
||||
$this->actingAs($this->editor);
|
||||
}
|
||||
|
||||
private function prepared(string $code): string
|
||||
{
|
||||
$references = app(FundingProgrammeReferences::class);
|
||||
$plan = $references->preview($this->editor, $code);
|
||||
|
||||
return $references->prepare($this->editor, $code, $plan['hash'])['id'];
|
||||
}
|
||||
|
||||
private function publish(string $id): array
|
||||
{
|
||||
$service = app(FundingProgrammes::class);
|
||||
$plan = $service->preview($this->editor, $id);
|
||||
$service->publish($this->editor, $id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA editorial publication; rolled back, not live content.']);
|
||||
|
||||
return $service->published($id);
|
||||
}
|
||||
|
||||
private function province(string $name): string
|
||||
{
|
||||
return DB::table('geo_locations')->where('location_type', 'province')->where('name', $name)->value('id');
|
||||
}
|
||||
|
||||
public function test_provided_content_is_queryable_with_sources_contacts_coverage_and_no_automatic_publication_or_duplicates(): void
|
||||
{
|
||||
$references = app(FundingProgrammeReferences::class);
|
||||
$service = app(FundingProgrammes::class);
|
||||
$before = $service->publicQuery()->count();
|
||||
$codes = ['bci', 'millennium-bim', 'absa', 'standard-bank', 'moza', 'bni', 'gapi', 'fecop', 'semeia', 'fcid-viii', 'fae'];
|
||||
$records = [];
|
||||
foreach ($codes as $code) {
|
||||
$id = $this->prepared($code);
|
||||
$this->assertSame($id, $this->prepared($code));
|
||||
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'));
|
||||
$this->assertSame($before + count($records), $service->publicQuery()->count());
|
||||
$records[$code] = $this->publish($id);
|
||||
$this->assertNotEmpty($records[$code]['payload']['source_url']);
|
||||
}
|
||||
$this->assertSame($before + 11, $service->publicQuery()->count());
|
||||
$this->assertCount(2, $records['bni']['payload']['products']);
|
||||
$this->assertContains('Maputo Cidade', array_column($records['bni']['payload']['locations'][0]['territory_path'], 'name'));
|
||||
$this->assertSame('national', $records['bni']['payload']['products'][0]['coverage']);
|
||||
$this->assertCount(6, $records['fcid-viii']['payload']['territory_ids']);
|
||||
$this->assertSame('closed', $records['semeia']['payload']['windows'][0]['state']);
|
||||
$this->assertSame('unknown', $records['fae']['payload']['coverage']);
|
||||
$filters = ['territory' => $this->province('Cabo Delgado'), 'need' => 'investment'];
|
||||
$found = $service->catalogueQuery($filters)->pluck('p.code')->all();
|
||||
$this->assertContains('bci', $found);
|
||||
$this->assertContains('bni', $found);
|
||||
$this->assertNotContains('fcid-viii', $found);
|
||||
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($p) => $p->component('funding/index')->where('filters.need', 'investment'));
|
||||
$this->get('/fundos/'.$records['bni']['id'])->assertOk()->assertInertia(fn ($p) => $p->component('funding/show')->where('record.payload.contacts.1.value', '+258 21 498 581'));
|
||||
file_put_contents(base_path('outputs/funding-directory-20261110/content-qa.json'), json_encode(['synthetic_editorial_publication_rolled_back' => true, 'provided_source_sha256' => '065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617', 'records' => $records, 'cabo_delgado_investment_results' => $found], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_location_and_need_must_match_the_same_product_and_unknown_is_explicit(): void
|
||||
{
|
||||
$service = app(FundingProgrammes::class);
|
||||
$draft = app(FundingProgrammeReferences::class)->preview($this->editor, 'bci')['draft'];
|
||||
$draft['institution_key'] = 'synthetic-same-product';
|
||||
$draft['title'] = 'SYNTHETIC QA — cross product scope';
|
||||
$draft['products'][0]['coverage'] = 'territorial';
|
||||
$draft['products'][0]['territory_ids'] = [$this->province('Maputo Província')];
|
||||
$draft['products'][0]['needs'] = ['investment'];
|
||||
$other = $draft['products'][0];
|
||||
$other['code'] = 'synthetic-working';
|
||||
$other['needs'] = ['working_capital'];
|
||||
$other['territory_ids'] = [$this->province('Nampula')];
|
||||
$draft['products'][] = $other;
|
||||
$id = $service->save($this->editor, null, ['version' => 0, 'code' => 'synthetic-cross', 'reason' => 'SYNTHETIC QA cross product coverage fixture.', 'draft' => $draft]);
|
||||
$this->publish($id);
|
||||
$base = ['territory' => $this->province('Nampula')];
|
||||
$this->assertFalse($service->catalogueQuery([...$base, 'need' => 'investment'])->where('p.id', $id)->exists());
|
||||
$this->assertTrue($service->catalogueQuery([...$base, 'need' => 'working_capital'])->where('p.id', $id)->exists());
|
||||
$draft['products'][0]['coverage'] = 'unknown';
|
||||
$draft['products'][0]['territory_ids'] = [];
|
||||
$draft['products'][0]['availability'] = 'unknown';
|
||||
$service->save($this->editor, $id, ['version' => 1, 'code' => 'synthetic-cross', 'reason' => 'SYNTHETIC QA unknown coverage, not absence.', 'draft' => $draft]);
|
||||
$this->publish($id);
|
||||
$this->assertTrue($service->catalogueQuery([...$base, 'need' => 'investment'])->where('p.id', $id)->exists());
|
||||
$this->assertFalse($service->catalogueQuery([...$base, 'need' => 'investment', 'certainty' => 'documented'])->where('p.id', $id)->exists());
|
||||
$this->expectException(ValidationException::class);
|
||||
$service->save($this->editor, null, ['version' => 0, 'code' => 'synthetic-duplicate', 'reason' => 'SYNTHETIC QA duplicate stable institution guard.', 'draft' => $draft]);
|
||||
}
|
||||
|
||||
public function test_selection_continues_in_existing_dossier_and_recovers_snapshot_without_sending_an_application(): void
|
||||
{
|
||||
$id = $this->prepared('bni');
|
||||
$record = $this->publish($id);
|
||||
$caseId = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA funding preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$context = app(FundingDossierPreparation::class)->context($this->editor, $caseId, null);
|
||||
$this->get('/fundos?'.http_build_query(['context' => $caseId, 'territory' => $this->province('Nampula'), 'need' => 'investment']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->where('items.data', fn ($rows) => collect($rows)->contains('id', $id)));
|
||||
$this->get('/fundos/'.$id.'?'.http_build_query(['context' => $caseId, 'territory' => $this->province('Nampula'), 'need' => 'investment']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/show')->where('record.id', $id)->where('dossier.id', $caseId)->where('record.payload.contacts.1.value', '+258 21 498 581'));
|
||||
$body = ['context' => $caseId, 'version' => $context['version'], 'version_id' => $record['version_id'], 'product' => 'bni-oferta', 'need' => 'investment', 'territory' => $this->province('Nampula'), 'operation_key' => (string) Str::uuid(), 'consent' => true];
|
||||
$saved = $this->postJson('/fundos/'.$id.'/dossier', $body)->assertOk()->json();
|
||||
$this->assertCount(1, $saved['selections']);
|
||||
$this->assertSame($record['version_id'], $saved['selections'][0]['version_id']);
|
||||
$this->postJson('/fundos/'.$id.'/dossier', $body)->assertOk();
|
||||
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'operation_key' => (string) Str::uuid()])->assertOk()->assertJsonCount(1, 'selections');
|
||||
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'need' => 'equity'])->assertConflict();
|
||||
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'opportunity' => 'opportunity:not-in-this-dossier', 'operation_key' => (string) Str::uuid()])->assertUnprocessable();
|
||||
$this->assertSame(1, DB::table('desk_events')->where('case_id', $caseId)->where('action', 'funding_preparation_saved')->count());
|
||||
$this->getJson('/investimentos/'.$caseId)->assertOk()->assertJsonCount(1, 'funding_selections');
|
||||
$service = app(FundingProgrammes::class);
|
||||
$service->withdraw($this->editor, $id, $record['version'], 'SYNTHETIC QA withdrawal preserves consulted reference.');
|
||||
$recovered = app(FundingDossierPreparation::class)->context($this->editor, $caseId, null);
|
||||
$this->assertSame($saved['selections'], $recovered['selections']);
|
||||
$this->actingAs(User::factory()->create())->postJson('/fundos/'.$id.'/dossier', $body)->assertForbidden();
|
||||
$this->actingAs($this->editor);
|
||||
DB::table('desk_cases')->where('id', $caseId)->update(['closed_at' => now()]);
|
||||
$this->postJson('/fundos/'.$id.'/dossier', $body)->assertConflict();
|
||||
$this->assertCount(1, app(FundingDossierPreparation::class)->context($this->editor, $caseId, null)['selections']);
|
||||
file_put_contents(base_path('outputs/funding-directory-20261110/dossier-qa.json'), json_encode(['synthetic' => true, 'recovered' => $recovered, 'one_native_event' => true, 'foreign_actor_refused' => true, 'closed_dossier_read_only' => true, 'no_external_submission' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { compileFunction } from 'node:vm';
|
||||
import { createRequire } from 'node:module';
|
||||
import ts from 'typescript';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
const require = createRequire(import.meta.url);
|
||||
function load(file, mocks = {}) {
|
||||
const output = ts.transpileModule(
|
||||
fs.readFileSync(
|
||||
new URL('../../resources/js/' + file, import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
compileFunction(output, ['require', 'module', 'exports'])(
|
||||
(name) =>
|
||||
name in mocks
|
||||
? mocks[name]
|
||||
: name === '@/pages/investment/shared'
|
||||
? { card: 'card', input: 'input', button: 'button' }
|
||||
: require(name),
|
||||
module,
|
||||
module.exports,
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
const source = {
|
||||
source_url: 'https://example.invalid/source',
|
||||
source_reference: 'SYNTHETIC QA source',
|
||||
checked_on: '2026-11-10',
|
||||
};
|
||||
const product = {
|
||||
...source,
|
||||
code: 'qa-investment',
|
||||
title: 'SYNTHETIC QA investment',
|
||||
instrument: 'Investment credit',
|
||||
needs: ['investment'],
|
||||
coverage: 'national',
|
||||
coverage_text: 'SYNTHETIC national coverage',
|
||||
territory_ids: [],
|
||||
conditions: '',
|
||||
amounts: '',
|
||||
availability: 'unknown',
|
||||
contact_url: 'https://example.invalid/contact',
|
||||
};
|
||||
const details = {
|
||||
institution_key: 'synthetic',
|
||||
institution_nature: 'bank',
|
||||
contacts: [
|
||||
{
|
||||
...source,
|
||||
kind: 'website',
|
||||
value: 'https://example.invalid/contact',
|
||||
},
|
||||
],
|
||||
locations: [
|
||||
{
|
||||
...source,
|
||||
role: 'headquarters',
|
||||
label: 'SYNTHETIC Maputo office',
|
||||
address: 'SYNTHETIC address',
|
||||
territory_id: 'qa-maputo',
|
||||
territory_path: [{ name: 'Maputo' }],
|
||||
},
|
||||
],
|
||||
products: [product],
|
||||
};
|
||||
test('actual institution view keeps physical address separate from product coverage and exposes unknown terms, source and contact', () => {
|
||||
const { InstitutionView } = load('components/funding-institution.tsx');
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(InstitutionView, { value: details }),
|
||||
);
|
||||
assert.match(html, /Sede: SYNTHETIC Maputo office/);
|
||||
assert.match(html, /Nacional, segundo a fonte/);
|
||||
assert.match(html, /Condições: Por confirmar/);
|
||||
assert.match(html, /Disponibilidade: Por confirmar/);
|
||||
assert.match(html, /https:\/\/example.invalid\/contact/);
|
||||
assert.match(html, /SYNTHETIC QA source/);
|
||||
assert.match(html, /não é elegibilidade confirmada/);
|
||||
});
|
||||
test('adding a physical location does not fill product financing coverage', () => {
|
||||
const { InstitutionFields } = load('components/funding-institution.tsx');
|
||||
let patch;
|
||||
const tree = InstitutionFields({
|
||||
value: details,
|
||||
change: (p) => (patch = p),
|
||||
territories: [],
|
||||
natures: { bank: 'Banco' },
|
||||
needs: { investment: 'Investimento' },
|
||||
});
|
||||
const nodes = flatten(tree);
|
||||
nodes
|
||||
.find(
|
||||
(n) => n.type === 'button' && text(n) === 'Acrescentar sede/balcão',
|
||||
)
|
||||
.props.onClick();
|
||||
assert.equal(patch.locations.length, 2);
|
||||
assert.equal(patch.locations[1].territory_id, '');
|
||||
assert.equal('products' in patch, false);
|
||||
assert.equal(details.products[0].coverage, 'national');
|
||||
});
|
||||
const flatten = (node) =>
|
||||
Array.isArray(node)
|
||||
? node.flatMap(flatten)
|
||||
: node && typeof node === 'object'
|
||||
? [node, ...flatten(node.props?.children)]
|
||||
: [];
|
||||
const text = (node) =>
|
||||
Array.isArray(node)
|
||||
? node.map(text).join('')
|
||||
: node && typeof node === 'object'
|
||||
? text(node.props?.children)
|
||||
: String(node ?? '');
|
||||
test('actual dossier continuation freezes a lost response retry and retains dossier/opportunity and selected product', async () => {
|
||||
const states = [],
|
||||
refs = [];
|
||||
let stateIndex = 0,
|
||||
refIndex = 0;
|
||||
const calls = [];
|
||||
const hooks = {
|
||||
...React,
|
||||
useState(initial) {
|
||||
const i = stateIndex++;
|
||||
if (!(i in states)) states[i] = initial;
|
||||
return [states[i], (value) => (states[i] = value)];
|
||||
},
|
||||
useRef(initial) {
|
||||
const i = refIndex++;
|
||||
refs[i] ??= { current: initial };
|
||||
return refs[i];
|
||||
},
|
||||
};
|
||||
class Validation extends Error {}
|
||||
const { FundingDossierChoice } = load(
|
||||
'components/funding-dossier-choice.tsx',
|
||||
{
|
||||
react: hooks,
|
||||
'@inertiajs/react': { Link: 'a', router: {} },
|
||||
'@/lib/operation-key': { createOperationKey: () => 'qa-operation' },
|
||||
'@/lib/profile-json': {
|
||||
ProfileValidationError: Validation,
|
||||
profileJson: async (url, body) => {
|
||||
calls.push({ url, body: structuredClone(body) });
|
||||
if (calls.length === 1)
|
||||
throw new Error('SYNTHETIC lost response');
|
||||
return {
|
||||
href: '/investimentos/qa-dossier?opportunity=qa#financiamento',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const props = {
|
||||
record: {
|
||||
id: 'qa-programme',
|
||||
version_id: 'qa-version',
|
||||
payload: { kind: 'institution', products: [product] },
|
||||
},
|
||||
dossier: {
|
||||
id: 'qa-dossier',
|
||||
version: 4,
|
||||
opportunity: 'qa-opportunity',
|
||||
closed: false,
|
||||
href: '/investimentos/qa-dossier',
|
||||
},
|
||||
selection: {
|
||||
need: 'investment',
|
||||
territory: 'qa-location',
|
||||
territory_label: 'SYNTHETIC Nampula',
|
||||
},
|
||||
needs: { investment: 'Investment' },
|
||||
destinations: [],
|
||||
};
|
||||
const render = () => {
|
||||
stateIndex = 0;
|
||||
refIndex = 0;
|
||||
return FundingDossierChoice(props);
|
||||
};
|
||||
let tree = render();
|
||||
const selects = flatten(tree).filter((n) => n.type === 'select');
|
||||
selects[1].props.onChange({ target: { value: product.code } });
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'input' && n.props.type === 'checkbox')
|
||||
.props.onChange({ target: { checked: true } });
|
||||
tree = render();
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'button')
|
||||
.props.onClick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find((n) => n.type === 'fieldset').props.disabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
text(flatten(tree).find((n) => n.type === 'button')),
|
||||
'Repetir a mesma intenção',
|
||||
);
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'button')
|
||||
.props.onClick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls[1], calls[0]);
|
||||
assert.equal(calls[0].body.context, 'qa-dossier');
|
||||
assert.equal(calls[0].body.opportunity, 'qa-opportunity');
|
||||
assert.equal(calls[0].body.product, product.code);
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find((n) => n.type === 'a').props.href,
|
||||
'/investimentos/qa-dossier?opportunity=qa#financiamento',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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/',root);
|
||||
const content=JSON.parse(fs.readFileSync(new URL('content-qa.json',out),'utf8')),dossier=JSON.parse(fs.readFileSync(new URL('dossier-qa.json',out),'utf8'));
|
||||
const records=Object.values(content.records),ids=Object.fromEntries(records.map(r=>[r.id,r.code]));
|
||||
assert.equal(records.length,11);assert.equal(records.filter(r=>r.payload.kind==='institution').length,7);
|
||||
assert.ok(content.cabo_delgado_investment_results.includes('bni'));assert.ok(!content.cabo_delgado_investment_results.includes('fcid-viii'));
|
||||
assert.equal(content.records.bni.payload.products[1].needs[0],'equity');assert.equal(content.records.semeia.payload.windows[0].state,'closed');
|
||||
assert.equal(dossier.recovered.selections.length,1);assert.equal(dossier.recovered.selections[0].title,content.records.bni.payload.title);
|
||||
assert.equal(dossier.no_external_submission,true);
|
||||
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-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>Directório financeiro — revisão 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%}h2,h3{margin-bottom:8px}article{overflow-wrap:anywhere}</style><p><strong>Revisão QA — conteúdo fornecido; sem publicação na homologação.</strong> Renderização estática dos componentes; não é conferência visual num navegador. Não envia dados ou candidaturas.</p>'+body+'</html>';
|
||||
fs.mkdirSync(new URL('fichas/',out),{recursive:true});
|
||||
const Show=load('pages/funding/show.tsx').default,Index=load('pages/funding/index.tsx').default;
|
||||
for(const record of records){const html=renderToStaticMarkup(React.createElement(Show,{record,saved:null,authenticated:false}));assert.ok(html.includes(record.payload.title.replace(/&/g,'&')));fs.writeFileSync(new URL('fichas/'+record.code+'.html',out),wrap(html));}
|
||||
const catalogue=renderToStaticMarkup(React.createElement(Index,{items:{data:records,links:[]},filters:{},kinds:{institution:'Instituição financeira',fund:'Fundo',programme:'Programa'},management:false,options:{territories:[],sectors:[]},needs:{investment:'Investimento',working_capital:'Fundo de maneio'}}));
|
||||
fs.writeFileSync(new URL('catalogo-qa.html',out),wrap(catalogue));
|
||||
const pkg=JSON.parse(fs.readFileSync(new URL('database/data/funding-references.json',root),'utf8'));
|
||||
assert.equal(Object.keys(pkg.references).length,12);assert.ok(pkg.references.fdel);
|
||||
const proof={provided_source_sha256:content.provided_source_sha256,provided_entries:12,prepared_fiches:11,institutions:7,programmes_or_funds:4,bni_capital:'BNI product; no duplicate institution',headquarters_and_lending_coverage_distinct:true,one_product_must_match_need_and_territory:true,unknowns_explicit:true,fcid_six_provinces:true,semeia_closed:true,dossier_save_recovery_native:true,one_selection_without_duplicate_event:true,withdrawn_public_source_snapshot_retained:true,qa_transactions_rolled_back:true,qa_port:55441,installed:false,live_publication:false,visual_review:'pending; browser connector unavailable',package_data_sha256:createHash('sha256').update(fs.readFileSync(new URL('database/data/funding-references.json',root))).digest('hex')};
|
||||
fs.writeFileSync(new URL('verification.json',out),JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
if (! in_array($argv[1] ?? '', ['test', 'analyse'], true) || basename($root) !== 'portal-investidor') {
|
||||
throw new RuntimeException('Explicit local test operation required.');
|
||||
}
|
||||
$proofDirectory = $root.'/outputs/funding-directory-20261110';
|
||||
if (! is_dir($proofDirectory)) {
|
||||
mkdir($proofDirectory, 0700, true);
|
||||
}
|
||||
$analysisConfiguration = "includes:\n - ../../phpstan.neon\nparameters:\n tmpDir: phpstan-cache\n";
|
||||
if (! is_file($proofDirectory.'/phpstan.neon')) {
|
||||
file_put_contents($proofDirectory.'/phpstan.neon', $analysisConfiguration);
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:'.base64_encode(str_repeat('Q', 32)),
|
||||
'APP_CONFIG_CACHE' => $root.'/outputs/funding-directory-20261110/no-config.php',
|
||||
'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
|
||||
'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'investmoz_test', 'DB_PASSWORD' => '',
|
||||
'DB_URL' => '', 'DATABASE_URL' => '', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array',
|
||||
'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SUITECRM_ENABLED' => 'false'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=simulator_fiscal_delta_20261021_test', 'investmoz_test', '');
|
||||
$identity = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
|
||||
if ($identity['name'] !== 'simulator_fiscal_delta_20261021_test' || (int) $identity['port'] !== 55441) {
|
||||
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'];
|
||||
$_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;
|
||||
if ($filter !== null && (strlen($filter) > 128 || ! preg_match('/^[a-zA-Z0-9_|]+$/D', $filter))) {
|
||||
throw new RuntimeException('Explicit QA test filter required.');
|
||||
}
|
||||
$argv = ['phpunit', '-c', 'phpunit.investmoz.xml', '--do-not-cache-result', 'tests/Feature/'.$case.'.php'];
|
||||
if ($filter !== null) {
|
||||
$argv[] = '--filter';
|
||||
$argv[] = $filter;
|
||||
}
|
||||
$argc = count($argv);
|
||||
$_SERVER['argv'] = $argv;
|
||||
$_SERVER['argc'] = $argc;
|
||||
require $root.'/vendor/autoload.php';
|
||||
require $root.'/vendor/phpunit/phpunit/phpunit';
|
||||
Reference in New Issue
Block a user