resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-07 19:46:35 +02:00
parent 0a086c3fa5
commit bdd766c68a
74 changed files with 6401 additions and 32 deletions
@@ -49,13 +49,16 @@ class InvestmentDiscoveryController extends Controller
if (in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$query->whereNotNull($filters['dimension'].'_id');
}
foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'geo_location_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) {
if (! empty($filters['geo_location_id'])) {
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id']));
}
foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) {
if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) {
return $query->whereRaw('false')->get();
}
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
}
foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) {
if (! empty($filters[$field])) {
@@ -80,7 +83,17 @@ class InvestmentDiscoveryController extends Controller
{
$record = Opportunity::published()->with('sector:id,name,slug')->where('slug', $slug)->firstOrFail();
$data = $request->validate(['zone_id' => ['nullable', 'uuid']]);
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = false;
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true;
}
}
abort_unless($linked, 404);
}
$origin = null;
if (! empty($data['zone_id'])) {
$origin = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
@@ -89,9 +102,10 @@ class InvestmentDiscoveryController extends Controller
}
$returnFilters = $request->query();
unset($returnFilters['zone_id']);
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
@@ -3,11 +3,12 @@
namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\InvestmentSubmission;
use App\Services\InvestmentZones;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -21,6 +22,12 @@ class InvestmentPortfolioController extends Controller
{
public static function related(InvestmentDossier $first, InvestmentDossier $second): bool
{
foreach ([[$first, $second], [$second, $first]] as [$cluster, $project]) {
if ($cluster->cluster_id && $project->project_id && app(InvestmentRelations::class)->memberships()
->where('cp.cluster_id', $cluster->cluster_id)->where('cp.project_id', $project->project_id)->exists()) {
return true;
}
}
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
@@ -60,7 +67,7 @@ class InvestmentPortfolioController extends Controller
$query->whereNotNull($filters['kind'].'_id');
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
}
$territorySelection = app(InvestmentGeography::class)->selection($filters);
if ($territorySelection !== []) {
@@ -125,6 +132,7 @@ class InvestmentPortfolioController extends Controller
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentRelationshipRepair;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class InvestmentRelationshipRepairController extends Controller
{
public function preview(InvestmentRelationshipRepair $repair): Response
{
return Inertia::render('management/relationship-repair', $repair->preview());
}
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
{
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'row_id' => ['nullable', 'string', 'size:64'],
'validation_hash' => ['required_with:row_id', 'nullable', 'string', 'size:64']]);
$result = $repair->apply($data['hash'], $request->user()->id, $data['row_id'] ?? null, $data['validation_hash'] ?? null);
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Este plano já foi aplicado; nenhum duplicado criado.' : 'Associações documentadas aplicadas. Registos de oportunidades permanecem preparados sem publicação automática.');
}
}
@@ -0,0 +1,401 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching;
use App\Services\PartnershipSources;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PartnershipExchangeController extends Controller
{
public function source(Request $request, string $type, string $id): JsonResponse
{
if (! Schema::hasTable('partnership_origins')) {
return response()->json(['available' => false, 'items' => []]);
}
app(PartnershipSources::class)->resolve($type, $id, $request->user());
return response()->json(['available' => true, 'items' => DB::table('portal_partnerships as p')->join('partnership_origins as origin', 'origin.partnership_id', '=', 'p.id')
->where('origin.type', $type)->where('origin.target_id', $id)->where('p.status', 'published')->orderByDesc('p.id')->get(['p.id', 'p.title'])])->header('Cache-Control', 'private, no-store');
}
public function index(Request $request): Response|JsonResponse
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer', 'partnership_type' => 'nullable|string|max:40',
'kind' => 'nullable|in:offering,seeking', 'territory_id' => 'nullable|uuid', 'origin_type' => 'nullable|string|max:40', 'origin_id' => 'nullable|string|max:100']);
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) {
$query->where('p.'.$field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn ($q) => $q->where('p.title', 'ilike', $term)->orWhere('p.sector', 'ilike', $term)
->orWhereRaw("p.payload->>'summary' ILIKE ?", [$term])->orWhere(fn ($details) => $details->whereRaw("COALESCE(p.payload->>'confidentiality','public')='public'")->where(fn ($body) => $body->where('p.offer', 'ilike', $term)->orWhere('p.seeking', 'ilike', $term))));
}
if (! empty($filters['origin_type']) && ! empty($filters['origin_id'])) {
// Authenticate access to a private master before exposing a backlink query.
app(PartnershipSources::class)->resolve($filters['origin_type'], $filters['origin_id'], $request->user());
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', $filters['origin_type'])->where('target_id', $filters['origin_id']));
}
if (! empty($filters['territory_id'])) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $filters['territory_id'])->exists(), 422);
$ids = app(InvestmentGeography::class)->descendants($filters['territory_id']);
$investmentIds = app(InvestmentCatalogue::class)->query(['geo_location_id' => $filters['territory_id']])->pluck('id')->map(fn ($id) => (string) $id)->all();
$query->where(fn ($q) => $q->whereExists(fn ($places) => $places->selectRaw('1')->from('partnership_locations')->whereColumn('partnership_id', 'p.id')->whereIn('geo_location_id', $ids))
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins as origin')->join('project_locations as pl', DB::raw('pl.project_id::text'), '=', 'origin.target_id')
->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'project')->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', 'investment')->whereIn('target_id', $investmentIds))
->orWhereExists(fn ($members) => $members->selectRaw('1')->from('partnership_origins as origin')
->joinSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'member', DB::raw('member.cluster_id::text'), '=', 'origin.target_id')
->join('project_locations as pl', 'pl.project_id', '=', 'member.project_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'cluster')
->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
->orWhereExists(fn ($zones) => $zones->selectRaw('1')->from('partnership_origins as origin')->join('investment_zone_locations as zl', DB::raw('zl.zone_id::text'), '=', 'origin.target_id')
->join('investment_zones as zone', 'zone.id', '=', 'zl.zone_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'zone')->where('zone.publication_status', 'published')
->whereNull('zone.deleted_at')->where('zl.validation_status', 'validated')->whereIn('zl.geo_location_id', $ids)));
}
$results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at'])
->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString();
if ($request->expectsJson()) {
return response()->json($results);
}
$mapPoints = [];
foreach ($results as $partnership) {
$locations = app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $partnership->id)->get()
->map(fn ($location) => ['id' => $location->geo_location_id, 'role' => $location->role, 'source_reference' => $location->source_reference])->all()));
foreach ($locations as $location) {
if ($location['location_type'] !== 'site') {
continue;
}
foreach ($location['coordinates'] as $coordinate) {
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
'location' => $location['name'].' · '.$location['role'].' · '.$location['source_reference'], 'url' => '/parcerias/'.$partnership->id]];
}
}
foreach (app(PartnershipSources::class)->forPartnership($partnership->id) as $source) {
$context = $source['geography']['locations'] ?? [];
foreach ($source['geography']['members'] ?? [] as $member) {
$context = [...$context, ...$member['locations']];
}
foreach ($context as $location) {
foreach ($location['coordinates'] as $coordinate) {
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
'location' => 'Contexto da origem: '.$location['name'].'; implantação da parceria não confirmada', 'url' => '/parcerias/'.$partnership->id]];
}
}
}
}
return Inertia::render('partnerships/catalogue', ['results' => $results, 'filters' => $filters, 'mapPoints' => $mapPoints, ...$this->catalogues($request)]);
}
public function dashboard(Request $request): Response
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get();
$interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id')
->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id')
->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations))
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get();
$moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : [];
return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation,
'notifications' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]);
}
public function edit(Request $request, ?int $id = null): Response
{
$record = $id ? app(PartnershipExchange::class)->find($id) : null;
abort_if($record && ! app(PartnershipExchange::class)->owns($request->user(), $record), 403);
$origins = $record ? DB::table('partnership_origins')->where('partnership_id', $id)->get() : [];
if (! $record && $request->filled('origin_type') && $request->filled('origin_id')) {
$source = app(PartnershipSources::class)->resolve($request->string('origin_type')->toString(), $request->string('origin_id')->toString(), $request->user());
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
}
return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins,
'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]);
}
public function save(Request $request, ?int $id = null): RedirectResponse|JsonResponse
{
$record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id);
return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.');
}
public function show(Request $request, int $id): Response|JsonResponse
{
$record = app(PartnershipExchange::class)->find($id);
$owner = $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record, false);
$moderator = $request->user() && Gate::forUser($request->user())->allows('manage-portal');
abort_unless($record->status === 'published' || $owner || $moderator, 404);
$payload = json_decode($record->payload, true);
unset($payload['_creation_hash']);
$privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator;
$mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect();
$interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine;
$interestData = $interests->map(function ($interest) use ($record) {
return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true),
'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')];
});
$props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind,
'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline,
'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload],
'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator,
'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData,
'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())),
'history' => ($owner || $moderator) ? DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->orderBy('occurred_at')->get(['event_type', 'actor_id', 'occurred_at', 'payload']) : [],
...$this->catalogues($request)];
if ($request->user()) {
Inertia::encryptHistory();
}
return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props);
}
public function transition(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']);
$record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason']);
return $request->expectsJson() ? response()->json(['status' => $record->status, 'version' => $record->version]) : back()->with('success', 'Decisão registada.');
}
public function interest(Request $request, int $id): RedirectResponse|JsonResponse
{
$interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version]) : back()->with('success', 'Interesse guardado.');
}
public function decide(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']);
$interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? []);
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.');
}
public function room(Request $request, int $id): Response|JsonResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null],
'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'u.name']),
'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))
->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']),
'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(),
'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])];
return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props);
}
public function message(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
$key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']];
$existing = DB::table('partnership_messages')->where($key)->first();
if ($existing) {
abort_unless($existing->body === $data['body'], 409, 'Chave já utilizada com outro conteúdo.');
return;
}
DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json(['sent' => true]) : back()->with('success', 'Mensagem registada.');
}
public function agreement(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|in:record,confirm,start,complete,cancel,renew', 'title' => 'required_if:action,record|nullable|string|max:200',
'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado.');
$agreement = $room->agreement ? json_decode($room->agreement, true) : null;
$status = $room->status;
if ($data['action'] === 'record') {
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
$previous = $agreement['previous_versions'] ?? [];
if ($agreement) {
$previous[] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations'], 'recorded_at' => $agreement['recorded_at']];
}
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $request->user()->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [],
'parties' => [$context['partnership']->user_id, $context['interest']->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous];
$status = 'agreement_recorded';
} elseif ($data['action'] === 'confirm') {
abort_unless($status === 'agreement_recorded' && $agreement, 409);
$party = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) ? 'proponent' : 'candidate';
$agreement['confirmations'][$party] = ['actor_id' => $request->user()->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
if (count($agreement['confirmations']) === 2) {
$status = 'agreed';
}
} else {
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
abort_unless(in_array($status, $allowed[$data['action']] ?? [], true), 409);
if ($data['action'] === 'complete') {
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
}
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$data['action']];
if ($data['action'] === 'renew') {
$agreement['previous_versions'][] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations']];
$agreement['confirmations'] = [];
}
}
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_agreement.changed.v1', ['room_id' => $id, 'status' => $status, 'version' => $room->version + 1, 'reason' => $data['reason']], 'Acordo: '.$status, [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json(['recorded' => true]) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.');
}
public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed',
'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']);
DB::transaction(function () use ($request, $id, $milestone, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && in_array($locked->status, ['agreement_recorded', 'agreed', 'execution'], true), 409);
$responsible = User::query()->whereKey($data['responsible_id'])->first();
abort_unless($responsible !== null && (app(PartnershipExchange::class)->owns($responsible, $context['partnership']) || app(PartnershipExchange::class)->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.');
if ($milestone) {
$existing = DB::table('partnership_milestones')->where('room_id', $id)->where('id', $milestone)->lockForUpdate()->first();
abort_unless($existing !== null, 404);
abort_unless((int) $existing->version === (int) $data['version'], 409);
DB::table('partnership_milestones')->where('id', $milestone)->update([...$data, 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
unset($data['version']);
$milestone = DB::table('partnership_milestones')->insertGetId([...$data, 'room_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_milestone.changed.v1', ['milestone_id' => $milestone, 'status' => $data['status']], 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Marco guardado.');
}
public function upload(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']);
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$file = $request->file('file');
$documentId = (string) Str::uuid();
$stored = $file->storeAs('partnerships/'.$id, $documentId, 'local');
abort_unless(is_string($stored) && $stored !== '', 503);
try {
DB::transaction(function () use ($request, $id, $data, $file, $documentId, $stored, $context) {
app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined']);
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
});
} catch (\Throwable $e) {
Storage::disk('local')->delete($stored);
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
}
public function download(Request $request, int $id, int $attachment): BinaryFileResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.storage_key', 'd.file_name', 'd.sha256']);
abort_unless($document !== null, 404);
$root = realpath(Storage::disk('local')->path(''));
$path = realpath(Storage::disk('local')->path($document->storage_key));
abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404);
$checksum = hash_file('sha256', $path);
abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum), 404);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado');
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse
{
$data = $request->validate(['shared' => 'required|boolean']);
DB::transaction(function () use ($request, $id, $attachment, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, (bool) $data['shared']);
$link = DB::table('partnership_documents')->where('room_id', $id)->where('id', $attachment)->lockForUpdate()->first();
abort_unless($link !== null && (int) $link->owner_id === $request->user()->id, 403, 'Só o autor pode autorizar ou retirar a partilha.');
DB::table('partnership_documents')->where('id', $attachment)->update(['shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_sharing_changed.v1', ['room_id' => $id, 'attachment_id' => $attachment, 'shared' => $data['shared']], 'Autorização documental atualizada');
});
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Autorização de partilha atualizada.');
}
public function notification(Request $request, int $id): RedirectResponse
{
abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404);
return back();
}
/** @return array<string,mixed> */
private function catalogues(Request $request): array
{
$sources = [];
foreach (InvestmentPortfolioController::visible()->where(fn ($q) => $q->whereNotNull('project_id')->orWhereNotNull('cluster_id'))->get(['id', 'title', 'project_id', 'cluster_id']) as $dossier) {
$sources[] = ['type' => $dossier->project_id ? 'project' : 'cluster', 'target_id' => $dossier->project_id ?? $dossier->cluster_id, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id];
}
foreach (DB::table('opportunities')->where('status', 'published')->whereNotNull('published_at')->get(['id', 'title', 'slug']) as $investment) {
$sources[] = ['type' => 'investment', 'target_id' => (string) $investment->id, 'title' => $investment->title, 'href' => '/oportunidades/'.$investment->slug];
}
foreach (DB::table('investment_zones')->where('publication_status', 'published')->whereNull('deleted_at')->get(['id', 'name']) as $zone) {
$sources[] = ['type' => 'zone', 'target_id' => $zone->id, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id];
}
foreach (ExistingBusiness::query()->published()->get(['id', 'public_title', 'public_slug']) as $business) {
$sources[] = ['type' => 'business', 'target_id' => $business->id, 'title' => $business->public_title, 'href' => '/negocios/'.$business->public_slug];
}
if ($request->user()) {
foreach (DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->get(['id']) as $company) {
$sources[] = ['type' => 'company', 'target_id' => $company->id, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile'];
}
foreach (DB::table('simulation_form_drafts')->where('owner_user_id', $request->user()->id)->whereNull('deleted_at')->get(['id', 'created_at']) as $draft) {
$sources[] = ['type' => 'simulation', 'target_id' => $draft->id, 'title' => 'Ficha de projeto · '.$draft->created_at, 'href' => '/simulador-fiscal?draft='.$draft->id];
}
}
return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => DB::table('sectors')->where('is_active', true)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']),
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']),
'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Services\PartnershipInstallation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PartnershipInstallationController extends Controller
{
public function preview(PartnershipInstallation $installation): Response
{
return Inertia::render('partnerships/installation', $installation->preview());
}
public function apply(Request $request, PartnershipInstallation $installation): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64']);
$installation->apply($data['hash'], $request->user());
return back()->with('success', 'Bolsa de Parcerias instalada. Registos antigos preservados.');
}
}
@@ -11,6 +11,7 @@ use App\Services\Malware\MalwareScanner;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -46,9 +47,12 @@ class PortalServicesController extends Controller
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
}
public function page(Request $request): Response
public function page(Request $request): Response|JsonResponse
{
$page = $request->route()->defaults['page'];
if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return app(PartnershipExchangeController::class)->index($request);
}
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
@@ -477,6 +481,9 @@ class PortalServicesController extends Controller
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
@@ -0,0 +1,227 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use App\Services\VirtualDesk;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
class VirtualDeskController extends Controller
{
public function catalogue(Request $request, VirtualDesk $desk)
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
$installed = $desk->installed();
$services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect();
$services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE)), mb_strtolower($filters['q'])))
&& (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values();
$page = max(1, (int) $request->query('page', 1));
$data = ['installed' => $installed, 'services' => $services->slice(($page - 1) * 12, 12)->values(), 'total' => $services->count(), 'page' => $page, 'filters' => $filters,
'prepared' => $installed ? [] : config('virtual-desk.services'), 'source' => config('virtual-desk.source'), 'authenticated' => (bool) $request->user(), 'support' => config('portal.support')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/catalogue', $data);
}
public function panel(Request $request, VirtualDesk $desk)
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']);
$queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id))
->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']);
$data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(),
'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->orderByDesc('created_at')->get(['reference', 'status', 'created_at']),
'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']),
'submissions' => DB::table('investment_submissions')->where('user_id', $actor->id)->get(['id', 'status']),
'organizations' => DB::table('organizations')->whereIn('id', $organizations)->get(['id', 'name']), 'manager' => $actor->can('manage-portal'), 'states' => config('virtual-desk.request_states')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/panel', $data);
}
public function createCase(Request $request, VirtualDesk $desk)
{
$case = $desk->createCase($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['id' => $case->id, 'version' => $case->version], 201) : redirect('/balcao-virtual/dossiers/'.$case->id);
}
public function showCase(Request $request, string $id, VirtualDesk $desk)
{
$case = $desk->case($request->user(), $id); $owner = $desk->owns($request->user(), $case, false);
$this->auditRead($request, 'desk_cases', $id);
$data = ['case' => [...(array) $case, 'payload' => \Illuminate\Support\Arr::except($desk->decode($case->payload), ['_creation_hash']), 'workflow' => json_decode($case->workflow, true)],
'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]),
'documents' => $this->documents($id), 'events' => $this->events($id, $desk), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'),
'links' => DB::table('desk_institutional_links')->where('status', 'active')->whereNotNull('verified_at')->get(['id', 'stage', 'entity', 'purpose', 'contact', 'verified_at']), 'source' => config('virtual-desk.source')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
private function documents(string $case)
{
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at')
->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']);
}
private function events(string $case, VirtualDesk $desk)
{
return DB::table('desk_events as e')->join('users as u', 'u.id', '=', 'e.actor_id')->where('e.case_id', $case)->orderBy('e.created_at')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => [...(array) $e, 'payload' => $desk->decode($e->payload)]);
}
public function stage(Request $request, string $id, VirtualDesk $desk)
{
$case = $desk->stage($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['version' => $case->version]) : back()->with('success', 'Etapa e evidência registadas.');
}
public function createRequest(Request $request, string $id, VirtualDesk $desk)
{
$data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid']);
$row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key']);
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id);
}
public function showRequest(Request $request, string $id, VirtualDesk $desk)
{
$row = $desk->request($request->user(), $id); $case = $desk->case($request->user(), $row->case_id); $payload = $desk->decode($row->payload);
$this->auditRead($request, 'desk_requests', $id);
$data = ['request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'definition' => $desk->definition($row),
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id)->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => \Illuminate\Support\Facades\Crypt::decryptString($m->body)]),
'events' => $this->events($case->id, $desk)->filter(fn ($e) => $e['request_id'] === $id)->values(),
'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]),
'payments' => DB::table('desk_payments')->where('request_id', $id)->get(),
'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [],
'territories' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/request', $data);
}
public function save(Request $request, string $id, VirtualDesk $desk)
{
$row = $desk->save($request->user(), $id, $request->all(), $request->boolean('submit'));
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'status' => $row->status, 'protocol' => $row->protocol]) : back()->with('success', $request->boolean('submit') ? 'Pedido submetido; protocolo disponível.' : 'Rascunho guardado.');
}
public function transition(Request $request, string $id, VirtualDesk $desk)
{
$row = $desk->transition($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.');
}
public function assign(Request $request, string $id, VirtualDesk $desk)
{
abort_unless($request->user()->can('manage-portal'), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true); $case = $desk->case($request->user(), $row->case_id);
abort_unless((int) $row->version === (int) $data['version'] && ! in_array($row->status, ['draft', 'cancelled', 'closed'], true), 409);
$analyst = User::findOrFail($data['analyst_id']); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
DB::table('desk_requests')->where('id', $id)->update(['analyst_id' => $analyst->id, 'due_at' => $data['due_at'] ?? null, 'version' => $row->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'analyst_assigned', [...$data, 'deadline_basis' => 'internal_target_not_legal_deadline'], DB::table('desk_requests')->where('id', $id)->first());
});
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Analista e meta interna atribuídos.');
}
public function message(Request $request, string $id, VirtualDesk $desk)
{
$data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true); $case = $desk->case($request->user(), $row->case_id);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.');
$existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first();
if ($existing) { abort_unless(\Illuminate\Support\Facades\Crypt::decryptString($existing->body) === $data['body'], 409); return; }
DB::table('desk_messages')->insert(['id' => (string) Str::uuid(), 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => \Illuminate\Support\Facades\Crypt::encryptString($data['body'])]);
$desk->event($case, $request->user(), 'message_added', [], $row);
});
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.');
}
public function receipt(Request $request, string $id, VirtualDesk $desk)
{
$row = $desk->request($request->user(), $id); abort_unless($row->protocol && $row->submitted_snapshot, 404);
$snapshot = $desk->decode($row->submitted_snapshot);
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
return response()->streamDownload(function () use ($row, $snapshot) {
echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL;
echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}, $row->protocol.'.txt', ['Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-store']);
}
public function attach(Request $request, string $id, VirtualDesk $desk)
{
$data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240',
'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']);
$key = null; $newId = null;
try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
$case = $desk->case($request->user(), $id, true, true); abort_unless((int) $case->version === (int) $data['version'], 409);
if ($data['stage'] ?? null) abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422);
if ($data['request_id'] ?? null) {
$row = $desk->request($request->user(), $data['request_id'], true, true);
abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.');
}
abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.');
if ($request->hasFile('file')) {
$file = $request->file('file'); $hash = hash_file('sha256', $file->getRealPath());
$duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first();
if ($duplicate) return;
$newId = (string) Str::uuid(); $key = $file->storeAs('virtual-desk/'.$id, $newId, 'local'); abort_unless($key, 500);
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
$documentId = $newId;
} else {
$documentId = $data['document_id'];
$document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first(); abort_unless($document, 404);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.');
if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) return;
}
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null,
'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
});
} catch (\Throwable $e) { if ($key) Storage::disk('local')->delete($key); throw $e; }
if ($newId) ScanDocumentForMalware::dispatch($newId)->afterCommit();
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.');
}
public function download(Request $request, string $id, string $document, VirtualDesk $desk)
{
$desk->case($request->user(), $id);
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.id', $document)->whereNull('d.deleted_at')->first(['d.*']);
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 404);
$path = realpath(Storage::disk('local')->path($doc->storage_key)); $root = realpath(Storage::disk('local')->path(''));
abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 404);
$this->auditRead($request, 'documents', $doc->id, 'document_downloaded');
return response()->download($path, $doc->file_name, ['Cache-Control' => 'private, no-store']);
}
public function notification(Request $request, int $id)
{
DB::table('desk_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]);
return back();
}
public function redirectLink(Request $request, string $id, VirtualDesk $desk)
{
$case = $desk->case($request->user(), $request->validate(['case_id' => 'required|uuid'])['case_id']);
$link = DB::table('desk_institutional_links')->where('id', $id)->where('status', 'active')->whereNotNull('verified_at')->first(); abort_unless($link, 404);
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
$desk->event($case, $request->user(), 'institutional_referral', ['link_id' => $id, 'version' => $link->version, 'purpose' => $link->purpose]);
// No personal data, case ID or protocol appended to the external URL.
return redirect()->away($link->url)->withHeaders(['Referrer-Policy' => 'no-referrer']);
}
private function auditRead(Request $request, string $type, string $id, string $action = 'consulted'): void
{
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $type, 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action]);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class PartnershipInstalled
{
/** @param Closure(Request):Response $next */
public function handle(Request $request, Closure $next): Response
{
if (! Schema::hasTable('partnership_interests')) {
if ($request->expectsJson()) {
return response()->json(['code' => 'partnership_installation_pending', 'message' => 'A extensão da Bolsa aguarda aplicação autenticada; o catálogo existente continua disponível.'], 503);
}
return redirect('/parcerias')->with('success', 'A extensão da Bolsa aguarda aplicação pela gestão. As propostas existentes continuam preservadas.');
}
return $next($request);
}
}
+47 -12
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
@@ -54,6 +55,7 @@ class InvestmentCatalogue
$territory->whereIn('opportunities.primary_territory_id', $ids)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
if ($includeNational) {
$territory->orWhere('opportunities.geography_scope', 'national');
}
@@ -83,7 +85,13 @@ class InvestmentCatalogue
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term)
->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term)));
->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term))
->orWhereExists(fn ($d) => $d->selectRaw('1')->from('investment_dossiers as searchable_dossier')
->whereNull('searchable_dossier.deleted_at')->where('searchable_dossier.publication_status', 'published')
->where(fn ($text) => $text->where('searchable_dossier.title', 'ilike', $term)->orWhereRaw('searchable_dossier.content::text ILIKE ?', [$term]))
->where(fn ($relation) => $relation
->whereExists(fn ($p) => $p->selectRaw('1')->from('opportunity_projects as search_project')->whereColumn('search_project.opportunity_id', 'opportunities.id')->whereColumn('search_project.project_id', 'searchable_dossier.project_id'))
->orWhereExists(fn ($c) => $c->selectRaw('1')->from('opportunity_clusters as search_cluster')->whereColumn('search_cluster.opportunity_id', 'opportunities.id')->whereColumn('search_cluster.cluster_id', 'searchable_dossier.cluster_id')))));
}
if (! empty($filters['sector'])) {
$query->whereHas('sector', fn ($s) => $s->where('slug', $filters['sector']));
@@ -99,11 +107,14 @@ class InvestmentCatalogue
? app(InvestmentGeography::class)->descendants((string) $filters[$foreign])
: null;
if ($relation === 'locations') {
$query->where(fn ($matches) => $matches->whereIn('opportunities.primary_territory_id', $locationIds ?? [])
->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link')
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
->whereNull('target.deleted_at')->where('target.data_classification', 'public')));
$query->where(function ($matches) use ($locationIds) {
$matches->whereIn('opportunities.primary_territory_id', $locationIds ?? [])
->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link')
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []);
});
continue;
}
@@ -113,9 +124,8 @@ class InvestmentCatalogue
->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign)));
} else {
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign)
->whereColumn('link.opportunity_id', 'opportunities.id')->when($relation === 'locations', fn ($q) => $q->whereIn('link.'.$foreign, $locationIds ?? []), fn ($q) => $q->where('link.'.$foreign, $filters[$foreign]))->whereNull('target.deleted_at')
->when($relation === 'locations', fn ($q) => $q->where('target.data_classification', 'public'),
fn ($q) => $q->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign))));
->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at')
->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)));
}
}
}
@@ -149,7 +159,8 @@ class InvestmentCatalogue
};
}
/** @return array<string,mixed> */
/** @param array<string,mixed> $territoryFilters
* @return array<string,mixed> */
public function references(bool $management = false, array $territoryFilters = []): array
{
$result = ['options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
@@ -203,6 +214,15 @@ class InvestmentCatalogue
}
}
$result[$relation] = $query->get(['t.id', 't.name']);
if (in_array($relation, ['projects', 'clusters'], true)) {
$result[$relation] = $result[$relation]->map(function ($entity) use ($foreign, $management) {
$dossiers = ($management ? InvestmentDossier::query() : InvestmentPortfolioController::visible())->where($foreign, $entity->id)->get();
$entity->dossiers = $dossiers->map(fn ($dossier) => ['id' => $dossier->id, 'title' => $dossier->title, 'source' => $dossier->source]);
$entity->geography = $dossiers->first() ? app(InvestmentRelations::class)->dossier($dossiers->first(), $management) : null;
return $entity;
});
}
}
$result['zones'] = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
@@ -224,6 +244,13 @@ class InvestmentCatalogue
$ids = $records->pluck('id');
$points = DB::table('opportunities as o')->join('geo_locations as g', function ($join) {
$join->on('g.id', '=', 'o.primary_territory_id')->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_locations as l')->whereColumn('l.opportunity_id', 'o.id')->whereColumn('l.geo_location_id', 'g.id'));
$join->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_projects as map_project')
->join('projects as p', 'p.id', '=', 'map_project.project_id')->whereColumn('map_project.opportunity_id', 'o.id')->whereNull('p.deleted_at')
->whereNotIn('p.data_classification', ['confidential', 'restricted'])->whereIn('p.id', app(InvestmentRelations::class)->projectIds())
->where(fn ($site) => $site->whereColumn('p.primary_territory_id', 'g.id')
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as map_location')
->whereColumn('map_location.project_id', 'p.id')->whereColumn('map_location.geo_location_id', 'g.id')
->whereNull('map_location.deleted_at')->where('map_location.data_classification', 'public')->where('map_location.role', '!=', 'impact'))));
})->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id')
->whereIn('o.id', $ids)->where('g.data_classification', 'public')->where('shape.data_classification', 'public')
->where('g.location_type', 'site')->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')
@@ -234,7 +261,14 @@ class InvestmentCatalogue
) SELECT 1 FROM parents WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)")
->whereRaw('ST_IsValid(shape.geom) AND NOT ST_IsEmpty(shape.geom) AND ST_SRID(shape.geom)=4326')
->orderBy('o.id')->orderByRaw("CASE WHEN g.source_system='investment-site-editor' THEN 0 ELSE 1 END")->orderBy('g.id')->orderBy('shape.id')
->get(['o.id', 'g.name', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')])->unique('id')->keyBy('id');
->get(['o.id', 'g.name', 'g.id as location_id', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'),
DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'opportunity_site' ELSE 'linked_project_site' END as location_basis")])
->groupBy('id')->map(function ($candidates) {
$direct = $candidates->where('location_basis', 'opportunity_site');
$selected = $direct->isNotEmpty() ? $direct : $candidates;
return $selected->unique('location_id')->count() === 1 && $selected->unique('geometry')->count() === 1 ? $selected->first() : null;
})->filter();
$relations = [];
foreach (['clusters', 'corridors', 'projects'] as $relation) {
[$foreign, $table] = self::RELATIONS[$relation];
@@ -251,7 +285,8 @@ class InvestmentCatalogue
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
if ($point = $points->get($record->id)) {
$features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties, 'location' => trim($properties['location'].' — '.$point->name)]];
$features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties,
'location_basis' => $point->location_basis, 'location' => trim($properties['location'].' — '.$point->name).($point->location_basis === 'linked_project_site' ? ' (local documentado do projecto associado; implantação da oportunidade por confirmar)' : '')]];
} else {
$missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates'];
}
+29 -6
View File
@@ -2,8 +2,9 @@
namespace App\Services;
use Illuminate\Database\Query\Builder;
use App\Models\InvestmentDossier;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -13,14 +14,25 @@ final class InvestmentGeography
/**
* Restrict published dossier searches using direct territories and public, validated structure links.
* @param EloquentBuilder<\App\Models\InvestmentDossier> $query
* @param list<string> $territoryIds
* @return EloquentBuilder<\App\Models\InvestmentDossier>
*
* @param EloquentBuilder<InvestmentDossier> $query
* @param list<string> $territoryIds
* @return EloquentBuilder<InvestmentDossier>
*/
public function constrainDossiersToTerritories(EloquentBuilder $query, array $territoryIds): EloquentBuilder
{
return $query->where(function ($matches) use ($territoryIds): void {
$matches->whereIn('project_id', DB::table('projects')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id'))
$matches->orWhereExists(fn ($coverage) => $coverage->selectRaw('1')->from('economic_clusters as coverage_cluster')
->whereColumn('coverage_cluster.id', 'investment_dossiers.cluster_id')->whereNull('coverage_cluster.deleted_at')
->whereRaw(app(InvestmentRelations::class)->coverageSql('coverage_cluster.metadata', $territoryIds), $territoryIds));
$matches->orWhereExists(fn ($member) => $member->selectRaw('1')->fromSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'documented_member')
->join('projects as member_project', 'member_project.id', '=', 'documented_member.project_id')
->whereColumn('documented_member.cluster_id', 'investment_dossiers.cluster_id')
->where(fn ($area) => $area->whereIn('member_project.primary_territory_id', $territoryIds)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as member_location')
->whereColumn('member_location.project_id', 'member_project.id')->whereNull('member_location.deleted_at')
->where('member_location.data_classification', 'public')->whereIn('member_location.geo_location_id', $territoryIds))));
$matches->orWhereIn('project_id', DB::table('projects')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id'))
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as pl')
->whereColumn('pl.project_id', 'investment_dossiers.project_id')->whereNull('pl.deleted_at')
->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $territoryIds))
@@ -177,7 +189,18 @@ final class InvestmentGeography
? DB::table('geo_locations')->whereNull('deleted_at')
: $this->publicReferences();
return array_values($query->whereIn('id', array_map(fn ($row) => $row->id, $rows))->pluck('id')->map(fn ($id) => (string) $id)->all());
$treeIds = array_map(fn ($row) => $row->id, $rows);
$ids = $query->whereIn('id', $treeIds)->pluck('id')->map(fn ($id) => (string) $id)->all();
if (! $includeNonPublic) {
// Documented sites need not be administrative registry entries themselves.
// Their parent must belong to the same public, trusted administrative hierarchy.
$sites = DB::table('geo_locations')->whereIn('id', $treeIds)->where('location_type', 'site')
->whereNull('deleted_at')->where('data_classification', 'public')
->whereIn('parent_id', $this->publicReferences()->select('id'))->pluck('id')->all();
$ids = array_merge($ids, $sites);
}
return array_values(array_unique($ids));
}
/** @return list<\stdClass> */
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/** Resolve documented relationships without generating geography or spreading opportunity memberships. */
class InvestmentRelations
{
public function projectIds(): Builder
{
return InvestmentPortfolioController::visible()->whereNotNull('project_id')->select('project_id')->toBase();
}
public function memberships(): Builder
{
// Legacy proposal links are consultable when both dossiers disclose the exact project code.
// Their pending/legal status remains independent from permission to consult the source.
return DB::table('cluster_projects as cp')->join('projects as member', 'member.id', '=', 'cp.project_id')
->whereNull('cp.deleted_at')->whereNull('member.deleted_at')->whereNotIn('member.data_classification', ['confidential', 'restricted'])
->where('cp.validation_status', '!=', 'revoked')
->where(fn ($q) => $q->whereNull('cp.valid_from')->orWhereDate('cp.valid_from', '<=', today()))
->where(fn ($q) => $q->whereNull('cp.valid_until')->orWhereDate('cp.valid_until', '>=', today()))
->whereIn('cp.project_id', $this->projectIds())
->whereExists(fn ($q) => $q->selectRaw('1')->from('investment_dossiers as disclosed_cluster')
->whereColumn('disclosed_cluster.cluster_id', 'cp.cluster_id')->where('disclosed_cluster.publication_status', 'published')->whereNull('disclosed_cluster.deleted_at')
->where(fn ($authorized) => $authorized->where('cp.data_classification', 'public')
->orWhere(fn ($legacy) => $legacy->where('cp.source_system', 'upgrade-v4')
->whereRaw("(disclosed_cluster.content->>'anchors') ~ ('\\m' || member.code || '\\M')"))));
}
/** @return list<array<string,mixed>> */
public function projectLocations(string $id, bool $management = false): array
{
$project = DB::table('projects')->where('id', $id)->whereNull('deleted_at')->first();
if (! $project || (! $management && (in_array($project->data_classification, ['confidential', 'restricted'], true)
|| ! InvestmentPortfolioController::visible()->where('project_id', $id)->exists()))) {
return [];
}
$links = DB::table('project_locations')->where('project_id', $id)->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('role')->get();
$items = [];
$metadata = json_decode($project->metadata, true);
foreach ($links as $link) {
$items[] = ['id' => $link->geo_location_id, 'role' => $link->role, 'relation_id' => $link->id,
'source_reference' => $metadata['documented_location_sources'][$link->geo_location_id.':'.$link->role]['source_reference'] ?? $link->source_system,
'source_quote' => $metadata['documented_location_sources'][$link->geo_location_id.':'.$link->role]['source_quote'] ?? null,
'external_id' => $link->external_id];
}
if ($project->primary_territory_id && ! collect($items)->contains(fn ($item) => $item['id'] === $project->primary_territory_id && $item['role'] === 'primary')) {
$items[] = ['id' => $project->primary_territory_id, 'role' => 'primary', 'source_reference' => $project->source_system, 'relation_id' => null];
}
return $this->locations($items, $management);
}
/** @param list<array<string,mixed>> $items
* @return list<array<string,mixed>> */
public function locations(array $items, bool $management = false): array
{
$result = [];
foreach ($items as $item) {
$path = app(InvestmentGeography::class)->path($item['id'], $management);
// Site nodes are not administrative references; use their documented parent path.
$site = DB::table('geo_locations')->where('id', $item['id'])->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->first();
if (! $site) {
continue;
}
if ($site->location_type === 'site') {
$path = app(InvestmentGeography::class)->path($site->parent_id, $management);
if ($site->parent_id && ! $path) {
continue;
}
} elseif (! $path) {
continue;
}
$shapes = DB::table('geo_geometries')->where('geo_location_id', $item['id'])->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))
->where('geometry_type', 'point')->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326')
->orderBy('id')->get(['id', 'source_system', DB::raw('ST_AsGeoJSON(geom) as geometry')]);
$coordinates = $shapes->map(fn ($shape) => ['geometry_id' => $shape->id, 'source_reference' => $shape->source_system,
'geometry' => json_decode($shape->geometry, true)])->all();
$result[] = [...$item, 'name' => $site->name, 'location_type' => $site->location_type, 'path' => $path, 'coordinates' => $coordinates];
}
return $result;
}
/** @return array<string,mixed> */
public function dossier(InvestmentDossier $dossier, bool $management = false): array
{
$result = ['locations' => [], 'members' => [], 'clusters' => [], 'chainLinks' => [], 'chains' => []];
if ($dossier->project_id) {
$result['locations'] = $this->projectLocations($dossier->project_id, $management);
$memberships = $this->memberships()->where('cp.project_id', $dossier->project_id)->get(['cp.cluster_id', 'cp.role', 'cp.validation_status', 'cp.source_reference']);
foreach ($memberships as $membership) {
foreach (InvestmentPortfolioController::visible()->where('cluster_id', $membership->cluster_id)->get() as $cluster) {
$result['clusters'][] = ['id' => $cluster->cluster_id, 'dossier_id' => $cluster->id, 'title' => $cluster->title,
'role' => $membership->role, 'validation_status' => $membership->validation_status, 'source_reference' => $membership->source_reference ?? $cluster->source];
}
}
}
if ($dossier->cluster_id) {
$result['chains'] = DB::table('value_chains')->where('cluster_id', $dossier->cluster_id)->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('code')->get(['id', 'code', 'name', 'stage'])->all();
$cluster = DB::table('economic_clusters')->where('id', $dossier->cluster_id)->first();
$metadata = json_decode($cluster->metadata ?? '{}', true);
$coverage = array_values(array_filter($metadata['documented_coverage'] ?? [], fn ($item) => isset($item['id'], $item['source_reference']) && ($management || ($item['data_classification'] ?? '') === 'public')));
if ($cluster?->primary_territory_id) {
$coverage[] = ['id' => $cluster->primary_territory_id, 'role' => 'coverage', 'source_reference' => $cluster->source_system];
}
$result['locations'] = $this->locations($coverage, $management);
foreach ($this->memberships()->where('cp.cluster_id', $dossier->cluster_id)->get(['cp.project_id', 'member.code', 'member.name', 'cp.role', 'cp.validation_status', 'cp.source_reference']) as $member) {
$projectDossier = InvestmentPortfolioController::visible()->where('project_id', $member->project_id)->first();
if ($projectDossier) {
$result['members'][] = ['id' => $member->project_id, 'dossier_id' => $projectDossier->id, 'title' => $projectDossier->title,
'code' => $member->code, 'role' => $member->role, 'validation_status' => $member->validation_status,
'source_reference' => $member->source_reference ?? $dossier->source, 'locations' => $this->projectLocations($member->project_id, $management)];
}
}
$result['chainLinks'] = DB::table('value_chain_links as link')->join('value_chains as origin', 'origin.id', '=', 'link.from_value_chain_id')
->join('value_chains as destination', 'destination.id', '=', 'link.to_value_chain_id')
->where('origin.cluster_id', $dossier->cluster_id)->where('destination.cluster_id', $dossier->cluster_id)
->whereNull('link.deleted_at')->whereNull('origin.deleted_at')->whereNull('destination.deleted_at')
->when(! $management, fn ($q) => $q->where('link.data_classification', 'public')->where('origin.data_classification', 'public')->where('destination.data_classification', 'public'))
->get(['link.id', 'origin.name as origin', 'destination.name as destination', 'link.link_type'])->all();
}
return $result;
}
/** Add explicitly linked project and cluster member territories without creating opportunity-project links.
* @param Builder|\Illuminate\Database\Eloquent\Builder<Opportunity> $query
* @param list<string> $ids */
public function opportunityTerritories(Builder|\Illuminate\Database\Eloquent\Builder $query, array $ids): void
{
$query->orWhereExists(fn ($links) => $links->selectRaw('1')->from('opportunity_projects as geographic_project')
->join('projects as documented_project', 'documented_project.id', '=', 'geographic_project.project_id')
->whereColumn('geographic_project.opportunity_id', 'opportunities.id')->whereNull('documented_project.deleted_at')
->whereIn('documented_project.id', $this->projectIds())
->where(fn ($area) => $area->whereIn('documented_project.primary_territory_id', $ids)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as documented_location')
->whereColumn('documented_location.project_id', 'documented_project.id')->whereNull('documented_location.deleted_at')
->where('documented_location.data_classification', 'public')->whereIn('documented_location.geo_location_id', $ids))));
$query->orWhereExists(fn ($links) => $links->selectRaw('1')->from('opportunity_clusters as geographic_cluster')
->join('economic_clusters as documented_cluster', 'documented_cluster.id', '=', 'geographic_cluster.cluster_id')
->whereColumn('geographic_cluster.opportunity_id', 'opportunities.id')->whereNull('documented_cluster.deleted_at')
->whereIn('documented_cluster.id', InvestmentPortfolioController::visible()->whereNotNull('cluster_id')->select('cluster_id'))
->where(fn ($area) => $area->whereIn('documented_cluster.primary_territory_id', $ids)
->orWhereRaw($this->coverageSql('documented_cluster.metadata', $ids), $ids)
->orWhereExists(fn ($members) => $members->selectRaw('1')->fromSub($this->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'territorial_member')
->join('projects as member_project', 'member_project.id', '=', 'territorial_member.project_id')->whereColumn('territorial_member.cluster_id', 'documented_cluster.id')
->where(fn ($territory) => $territory->whereIn('member_project.primary_territory_id', $ids)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as member_location')
->whereColumn('member_location.project_id', 'member_project.id')->whereNull('member_location.deleted_at')
->where('member_location.data_classification', 'public')->whereIn('member_location.geo_location_id', $ids))))));
}
/** @param list<string> $ids */
public function coverageSql(string $column, array $ids): string
{
if (! in_array($column, ['documented_cluster.metadata', 'coverage_cluster.metadata'], true)) {
throw new \InvalidArgumentException('Unsupported coverage column');
}
if (! $ids) {
return 'false';
}
return "EXISTS (SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof({$column}->'documented_coverage')='array' THEN {$column}->'documented_coverage' ELSE '[]'::jsonb END) AS coverage_entry
WHERE coverage_entry->>'data_classification'='public' AND NULLIF(coverage_entry->>'source_reference','') IS NOT NULL
AND coverage_entry->>'id' IN (".implode(',', array_fill(0, count($ids), '?')).'))';
}
}
@@ -0,0 +1,213 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** @phpstan-type RepairPlan array{target:array<string,mixed>,locations:list<array<string,mixed>>,memberships:list<array<string,mixed>>,pending:list<array<string,mixed>>,prepared_opportunities:list<array<string,mixed>>} */
class InvestmentRelationshipRepair
{
public function directory(): string
{
return config('investment.repair_directory', storage_path('app/private/anchor-project-reconciliation'));
}
/** @return array{hash:string,plan:RepairPlan,territorial:list<array<string,mixed>>,history:list<array<string,mixed>>,unproposed:list<array<string,mixed>>} */
public function preview(): array
{
$file = $this->directory().'/preview.json';
abort_unless(is_file($file), 404, 'Pré-visualização não preparada.');
$contents = file_get_contents($file);
abort_if($contents === false, 503, 'Pré-visualização não pode ser lida.');
$hash = hash('sha256', $contents);
$plan = $this->decodePlan($contents);
$history = $this->history($hash);
$validation = app(InvestmentTerritorialValidation::class);
$territorial = [];
foreach ($plan['locations'] as $row) {
$result = $validation->validate($row);
$result['applied'] = $this->wasApplied($history, $result['row_id']);
$result['validation_hash'] = hash('sha256', json_encode($result, JSON_THROW_ON_ERROR));
$territorial[] = $result;
}
$unproposed = [];
foreach (array_unique(array_column($plan['pending'], 'code')) as $code) {
if (! preg_match('/^A\d{2}$/', $code) || collect($plan['locations'])->contains('code', $code)) {
continue;
}
$project = DB::table('projects')->where('code', $code)->whereNull('deleted_at')->first();
if (! $project) {
continue;
}
$dossier = DB::table('investment_dossiers')->where('project_id', $project->id)->whereNull('deleted_at')->first();
$unproposed[] = ['code' => $code, 'current' => app(InvestmentRelations::class)->projectLocations($project->id, true),
'source' => $dossier?->source, 'location_text' => $dossier ? (json_decode($dossier->content, true)['location'] ?? null) : null,
'reasons' => collect($plan['pending'])->where('code', $code)->pluck('reason')->all()];
}
return ['hash' => $hash, 'plan' => $plan, 'territorial' => $territorial, 'history' => $history, 'unproposed' => $unproposed];
}
/** @return RepairPlan */
private function decodePlan(string $contents): array
{
$plan = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
abort_unless(is_array($plan) && is_array($plan['target'] ?? null) && is_array($plan['locations'] ?? null) && is_array($plan['memberships'] ?? null), 422, 'Plano de associações inválido.');
$target = [];
foreach ($plan['target'] as $key => $value) {
abort_unless(is_string($key), 422, 'Destino inválido.');
$target[$key] = $value;
}
return ['target' => $target, 'locations' => $this->decodeRows($plan['locations']), 'memberships' => $this->decodeRows($plan['memberships']),
'pending' => $this->decodeRows($plan['pending'] ?? []), 'prepared_opportunities' => $this->decodeRows($plan['prepared_opportunities'] ?? [])];
}
/** @return list<array<string,mixed>> */
private function decodeRows(mixed $rows): array
{
abort_unless(is_array($rows), 422, 'Linhas do plano inválidas.');
$result = [];
foreach ($rows as $row) {
abort_unless(is_array($row), 422, 'Linha do plano inválida.');
$item = [];
foreach ($row as $key => $value) {
abort_unless(is_string($key), 422, 'Campo do plano inválido.');
$item[$key] = $value;
}
$result[] = $item;
}
return $result;
}
/** @return list<array<string,mixed>> */
private function history(string $hash): array
{
return array_values(DB::table('audit_events as event')->leftJoin('users as actor', 'actor.id', '=', 'event.actor_user_id')
->where('event.source_system', 'documented-relations-repair')->whereRaw("event.after_data->>'plan_hash' = ?", [$hash])
->orderBy('event.created_at')->orderBy('event.id')->get(['event.id', 'event.created_at', 'event.actor_user_id', 'actor.name as actor_name', 'event.after_data'])
->map(fn ($event) => ['id' => $event->id, 'date' => $event->created_at, 'actor_id' => $event->actor_user_id, 'actor' => $event->actor_name,
'data' => json_decode($event->after_data, true)])->all());
}
/** @param list<array<string,mixed>> $history */
private function wasApplied(array $history, string $rowId): bool
{
foreach ($history as $event) {
if (! isset($event['data']['location_row_ids']) || in_array($rowId, $event['data']['location_row_ids'], true)) {
return true;
}
}
return false;
}
/** @return array<string,mixed> */
public function apply(string $hash, int $actor, ?string $rowId = null, ?string $validationHash = null): array
{
$preview = $this->preview();
abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada. Reveja as associações.');
$proofFile = $this->directory().'/backup-proof.json';
abort_unless(is_file($proofFile), 422, 'Backup e restauro isolado ainda não verificados.');
$contents = file_get_contents($proofFile);
abort_if($contents === false, 503, 'Evidência de restauro não pode ser lida.');
$proof = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
$dumpHash = is_file($proof['dump_path'] ?? '') ? hash_file('sha256', $proof['dump_path']) : false;
abort_unless(($proof['restore_verified'] ?? false) && is_file($proof['dump_path'] ?? '')
&& $dumpHash !== false && hash_equals($proof['sha256'], $dumpHash), 422, 'Evidência de backup/restauro inválida.');
$plan = $preview['plan'];
$validation = app(InvestmentTerritorialValidation::class);
if ($rowId !== null) {
abort_unless(collect($plan['locations'])->contains(fn ($row) => $validation->rowId($row) === $rowId), 422, 'Associação não pertence ao plano.');
}
$identity = DB::selectOne('select current_database() as database, inet_server_port() as port');
abort_unless($identity->database === $plan['target']['database'] && (int) $identity->port === (int) $plan['target']['port']
&& ($proof['target'] ?? null) === $plan['target'], 409, 'Base de destino diferente da pré-visualização e do backup.');
if (app()->environment('testing')) {
abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base *_test isolada na porta 55441.');
}
return DB::transaction(function () use ($plan, $hash, $actor, $rowId, $validation, $validationHash): array {
DB::select('select pg_advisory_xact_lock(71026, 150000)');
$history = $this->history($hash);
if (($rowId !== null && $this->wasApplied($history, $rowId)) || ($rowId === null && collect($history)->contains(fn ($event) => ! isset($event['data']['location_row_ids']) || ($event['data']['batch_complete'] ?? false)))) {
return ['replayed' => true, 'locations_created' => 0, 'memberships_created' => 0];
}
DB::statement('LOCK TABLE geo_locations, geo_geometries IN SHARE MODE');
$created = $memberships = 0;
$projects = $appliedRows = $sources = $before = [];
foreach ($plan['locations'] as $row) {
$id = $validation->rowId($row);
if (($rowId !== null && $rowId !== $id) || $this->wasApplied($history, $id)) {
continue;
}
$territory = $validation->validate($row);
if ($rowId !== null) {
$territory['applied'] = false;
abort_unless($validationHash !== null && hash_equals(hash('sha256', json_encode($territory, JSON_THROW_ON_ERROR)), $validationHash), 409, 'Validação territorial alterada. Reveja a proposta.');
}
if ($territory['status'] !== 'grounded') {
abort_if($rowId !== null, 409, 'Associação pendente ou contraditória; não pode ser aplicada.');
continue;
}
$project = $projects[$row['project_id']] ?? DB::table('projects')->where('id', $row['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
$increments = collect($history)->filter(fn ($event) => in_array($row['project_id'], $event['data']['project_ids'] ?? [], true))->count();
abort_unless($project && (int) $project->version_no === (int) $row['expected_project_version'] + $increments, 409, 'Projecto alterado desde a pré-visualização.');
$dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($dossier && $dossier->project_id === $project->id && (int) $dossier->version_no === (int) $row['dossier_version'], 409, 'Dossier alterado.');
$content = json_decode($dossier->content, true);
abort_unless(($content['location'] ?? null) === $row['source_quote'] && $dossier->source === $row['source_reference'], 409, 'Citação documental alterada.');
abort_unless($row['data_classification'] !== 'public' || $dossier->publication_status === 'published', 409, 'Autorização de divulgação alterada.');
abort_unless(app(InvestmentGeography::class)->references(true)->where('id', $row['geo_location_id'])->exists(), 409, 'Referência territorial indisponível.');
$appliedRows[] = $id;
$before[] = ['row_id' => $id, 'project_id' => $project->id, 'current' => $territory['current']];
$sources[] = ['row_id' => $id, 'project_id' => $project->id, 'geo_location_id' => $row['geo_location_id'], 'role' => $row['role'],
'source_reference' => $row['source_reference'], 'source_quote' => $row['source_quote'], 'dossier_id' => $dossier->id, 'reason' => $row['reason']];
$key = ['project_id' => $project->id, 'geo_location_id' => $row['geo_location_id'], 'role' => $row['role']];
if (! DB::table('project_locations')->where($key)->whereNull('deleted_at')->exists()) {
DB::table('project_locations')->insert([...$key, 'id' => (string) Str::uuid(), 'source_system' => 'documented-relations-repair',
'external_id' => $row['dossier_id'].':'.$row['geo_location_id'].':'.$row['role'], 'data_classification' => $row['data_classification']]);
$metadata = json_decode($project->metadata, true);
$metadata['documented_location_sources'][$row['geo_location_id'].':'.$row['role']] = ['source_reference' => $row['source_reference'], 'source_quote' => $row['source_quote'], 'dossier_id' => $dossier->id, 'plan_hash' => $hash, 'reason' => $row['reason'], 'actor_user_id' => $actor, 'applied_at' => now()->toIso8601String()];
$project->metadata = json_encode($metadata, JSON_THROW_ON_ERROR);
$projects[$project->id] = $project;
$created++;
}
}
foreach ($projects as $project) {
DB::table('projects')->where('id', $project->id)->update(['metadata' => $project->metadata, 'version_no' => $project->version_no + 1]);
}
foreach ($rowId === null ? $plan['memberships'] : [] as $row) {
if ($row['existing_id']) {
continue;
}
$key = ['cluster_id' => $row['cluster_id'], 'project_id' => $row['project_id']];
$dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($dossier && $dossier->cluster_id === $row['cluster_id'] && (int) $dossier->version_no === (int) $row['dossier_version']
&& (json_decode($dossier->content, true)['anchors'] ?? null) === $row['source_quote'], 409, 'Fonte da relação do cluster alterada.');
abort_unless(DB::table('projects')->where('id', $row['project_id'])->where('code', $row['project_code'])->whereNull('deleted_at')->exists(), 409, 'Identidade do projecto alterada.');
if (! DB::table('cluster_projects')->where($key)->whereNull('deleted_at')->exists()) {
DB::table('cluster_projects')->insert([...$key, 'id' => (string) Str::uuid(), 'role' => $row['role'], 'source_system' => 'documented-relations-repair',
'source_reference' => $row['source_reference'], 'evidence_reference' => $row['dossier_id'], 'editor_id' => $actor,
'data_classification' => $dossier->publication_status === 'published' ? 'public' : 'internal', 'validation_status' => 'pending', 'association_type' => 'potential']);
$memberships++;
}
}
if (! $appliedRows && ! $memberships) {
return ['replayed' => true, 'locations_created' => 0, 'memberships_created' => 0];
}
DB::table('audit_events')->insert(['source_system' => 'documented-relations-repair', 'entity_type' => 'project_locations',
'actor_user_id' => $actor, 'action' => 'documented_relations_applied', 'before_data' => json_encode($before, JSON_THROW_ON_ERROR), 'after_data' => json_encode(['plan_hash' => $hash,
'locations_created' => $created, 'memberships_created' => $memberships, 'project_ids' => array_keys($projects),
'location_row_ids' => $appliedRows, 'sources' => $sources,
'batch_complete' => $rowId === null && count(array_filter($plan['locations'], fn ($row) => ! in_array($validation->rowId($row), $appliedRows, true) && ! $this->wasApplied($history, $validation->rowId($row)))) === 0], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'locations_created' => $created, 'memberships_created' => $memberships];
});
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
/** Validate only the territory explicitly proposed in the documentary plan. */
class InvestmentTerritorialValidation
{
/** @param array<string,mixed> $row */
public function rowId(array $row): string
{
return hash('sha256', json_encode($row, JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $row
* @return array<string,mixed>
*/
public function validate(array $row): array
{
$issues = $warnings = $path = $visited = [];
$pending = false;
$project = DB::table('projects')->where('id', $row['project_id'])->whereNull('deleted_at')->first();
$dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->first();
if (! $project || ! $dossier || $dossier->project_id !== $row['project_id']) {
$issues[] = 'Projeto ou documento de origem indisponível ou de outra identidade.';
} elseif (($row['source_reference'] ?? '') === '' || ($row['source_quote'] ?? '') === '') {
$pending = true;
$warnings[] = 'Falta referência ou citação documental da localização.';
} elseif ($dossier->source !== $row['source_reference'] || (json_decode($dossier->content, true)['location'] ?? null) !== $row['source_quote']
|| (int) $dossier->version_no !== (int) $row['dossier_version']) {
$issues[] = 'Fonte, citação ou versão do dossier diverge da proposta.';
}
if (($row['data_classification'] ?? null) === 'public' && $dossier && $dossier->publication_status !== 'published') {
$issues[] = 'A autorização de divulgação do dossier já não corresponde à proposta pública.';
}
if (! in_array($row['role'] ?? null, ['primary', 'secondary', 'impact'], true)) {
$issues[] = 'Papel territorial inválido.';
}
$id = $row['geo_location_id'];
while ($id) {
if (isset($visited[$id])) {
$issues[] = 'Ciclo na hierarquia territorial.';
break;
}
$visited[$id] = true;
$node = DB::table('geo_locations')->where('id', $id)->first();
if (! $node || $node->deleted_at || ! in_array($node->data_classification, ['public', 'internal'], true)) {
$issues[] = 'Território ou antepassado indisponível para esta associação.';
break;
}
array_unshift($path, ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type]);
$id = $node->parent_id;
}
$levels = ['country' => 0, 'province' => 1, 'district' => 2, 'admin_post' => 3, 'locality' => 4, 'site' => 5];
$previous = null;
foreach ($path as $node) {
$level = $levels[$node['type']] ?? null;
if ($level === null || ($previous !== null && $level !== $previous + 1 && $node['type'] !== 'site')) {
$issues[] = 'Hierarquia incompatível: província → distrito → posto administrativo → localidade.';
}
$previous = $level;
}
if (! collect($path)->contains('type', 'province')) {
$pending = true;
$warnings[] = 'A hierarquia não identifica uma província.';
}
foreach (InvestmentGeography::FIELDS as $field => $type) {
if (isset($row[$field]) && ! collect($path)->contains(fn ($node) => $node['type'] === $type && $node['id'] === $row[$field])) {
$issues[] = 'O nível indicado em '.$field.' não pertence à hierarquia proposta.';
}
}
$points = DB::table('geo_geometries')->where('geo_location_id', $row['geo_location_id'])->whereNull('deleted_at')
->whereIn('data_classification', ['public', 'internal'])->where('geometry_type', 'point')
->get(['id', 'source_system', DB::raw('ST_AsGeoJSON(geom) as geometry'), DB::raw('ST_SRID(geom) as srid')]);
$coordinates = [];
foreach ($points as $point) {
$geometry = json_decode($point->geometry, true);
$coordinates[] = ['geometry_id' => $point->id, 'source' => $point->source_system, 'coordinates' => $geometry['coordinates'] ?? [], 'srid' => $point->srid];
}
if (isset($row['coordinates'])) {
$coordinates[] = ['geometry_id' => null, 'source' => $row['source_reference'], 'coordinates' => $row['coordinates'], 'srid' => 4326];
}
$checks = [];
foreach ($coordinates as $point) {
$xy = $point['coordinates'];
if (count($xy) < 2 || ! is_numeric($xy[0]) || ! is_numeric($xy[1]) || ! is_finite((float) $xy[0]) || ! is_finite((float) $xy[1])
|| abs((float) $xy[0]) > 180 || abs((float) $xy[1]) > 90 || (int) $point['srid'] !== 4326) {
$issues[] = 'Coordenadas inválidas ou sistema de referência incompatível com WGS84.';
continue;
}
$boundaries = DB::table('geo_geometries')->whereIn('geo_location_id', array_column($path, 'id'))->whereNull('deleted_at')
->whereIn('data_classification', ['public', 'internal'])->whereIn('geometry_type', ['polygon', 'multipolygon'])
->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326')
->select(['id', 'geo_location_id', 'source_system'])
->selectRaw('ST_Covers(geom, ST_SetSRID(ST_MakePoint(?, ?),4326)) as compatible', [(float) $xy[0], (float) $xy[1]])->get();
if ($boundaries->isEmpty()) {
$warnings[] = 'Coordenadas com formato válido; sem limites cartográficos documentados para confirmar inclusão territorial.';
}
foreach ($boundaries as $boundary) {
$checks[] = ['geometry_id' => $point['geometry_id'], 'territory_id' => $boundary->geo_location_id, 'source' => $boundary->source_system, 'compatible' => (bool) $boundary->compatible];
if (! $boundary->compatible) {
$issues[] = 'Coordenadas fora do território '.(collect($path)->firstWhere('id', $boundary->geo_location_id)['name'] ?? $boundary->geo_location_id).'.';
}
}
}
if (! $coordinates) {
$warnings[] = 'Sem coordenadas; a ausência não impede validar a localização documental.';
}
$current = [];
if ($project) {
$links = DB::table('project_locations')->where('project_id', $project->id)->whereNull('deleted_at')->get();
foreach ($links as $link) {
$location = DB::table('geo_locations')->where('id', $link->geo_location_id)->first();
$current[] = ['id' => $link->geo_location_id, 'name' => $location->name ?? 'Território indisponível', 'role' => $link->role,
'path' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($link->geo_location_id, true))];
}
if ($project->primary_territory_id && ! collect($current)->contains(fn ($item) => $item['id'] === $project->primary_territory_id && $item['role'] === 'primary')) {
$location = DB::table('geo_locations')->where('id', $project->primary_territory_id)->first();
$current[] = ['id' => $project->primary_territory_id, 'name' => $location->name ?? 'Território indisponível', 'role' => 'primary',
'path' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($project->primary_territory_id, true))];
}
}
return ['row_id' => $this->rowId($row), 'status' => $issues ? 'contradictory' : ($pending ? 'pending' : 'grounded'),
'issues' => array_values(array_unique($issues)), 'warnings' => array_values(array_unique($warnings)), 'current' => $current,
'proposed' => ['id' => $row['geo_location_id'], 'name' => collect($path)->last()['name'] ?? ($row['name'] ?? 'Território indisponível'), 'role' => $row['role'], 'path' => $path],
'coordinates' => $coordinates, 'coordinate_checks' => $checks];
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use stdClass;
class PartnershipExchange
{
public function represents(User $actor, ?string $organization, bool $write = true): bool
{
return $organization !== null && DB::table('memberships')->where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at')
->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists()
&& DB::table('organizations')->where('id', $organization)->whereNull('deleted_at')->exists();
}
public function owns(User $actor, stdClass $record, bool $write = true): bool
{
// An organization record follows its current representatives, including the original author.
return $record->organization_id ? $this->represents($actor, $record->organization_id, $write) : (int) $record->user_id === $actor->id;
}
public function find(int $id, bool $lock = false): stdClass
{
$record = DB::table('portal_partnerships')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
abort_unless($record !== null, 404);
return $record;
}
/** @param array<string,mixed> $data */
public function save(User $actor, array $data, ?int $id = null): stdClass
{
$data = Validator::make($data, [
'client_key' => [$id ? 'nullable' : 'required', 'uuid'], 'version' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
'title' => ['nullable', 'string', 'max:200'], 'offer' => ['nullable', 'string', 'max:15000'], 'seeking' => ['nullable', 'string', 'max:15000'],
'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))],
'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')],
'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:summary,requirements,mandatory,confidentiality,consent,availability'],
'payload.summary' => ['nullable', 'string', 'max:15000'], 'payload.requirements' => ['nullable', 'string', 'max:10000'],
'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))],
'payload.confidentiality' => ['in:public,private'], 'payload.consent' => ['boolean'], 'payload.availability' => ['nullable', 'string', 'max:1000'],
'locations' => ['array', 'max:30'], 'locations.*.geo_location_id' => ['required', 'uuid'], 'locations.*.role' => ['required', 'in:primary,operating,coverage'],
'locations.*.source_reference' => ['required', 'string', 'max:2000'],
'origins' => ['array', 'max:20'], 'origins.*.type' => ['required', 'string'], 'origins.*.target_id' => ['required', 'string', 'max:100'],
])->validate();
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.');
$creationHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $data, $id, $creationHash) {
DB::select('select pg_advisory_xact_lock(71026, ?)', [$actor->id]);
if ($id) {
$record = $this->find($id, true);
abort_unless($this->owns($actor, $record), 403);
abort_unless((int) $record->version === (int) $data['version'], 409, 'Rascunho alterado; recarregue.');
abort_unless(in_array($record->status, ['draft', 'returned', 'rejected', 'published'], true), 409, 'Estado não permite edição.');
} else {
$existing = DB::table('portal_partnerships')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first();
if ($existing) {
abort_unless((json_decode($existing->payload, true)['_creation_hash'] ?? null) === $creationHash, 409, 'Chave de rascunho já utilizada com outro conteúdo.');
return $existing;
}
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $actor->id, 'client_key' => $data['client_key'], 'title' => '', 'sector' => '', 'province' => '', 'offer' => '', 'seeking' => '', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]);
$record = $this->find($id, true);
}
$origins = $data['origins'] ?? [];
foreach ($origins as $origin) {
app(PartnershipSources::class)->resolve($origin['type'], $origin['target_id'], $actor);
}
$locations = $data['locations'] ?? [];
foreach ($locations as $location) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location['geo_location_id'])->exists(), 422, 'Localização não pertence ao cadastro territorial público.');
}
DB::table('portal_partnerships')->where('id', $id)->update(['title' => $data['title'] ?? '', 'offer' => $data['offer'] ?? '', 'seeking' => $data['seeking'] ?? '',
'organization_id' => $data['organization_id'] ?? null, 'sector_id' => $data['sector_id'] ?? null, 'kind' => $data['kind'], 'partnership_type' => $data['partnership_type'] ?? null,
'deadline' => $data['deadline'] ?? null, 'payload' => json_encode([...($data['payload'] ?? []), '_creation_hash' => json_decode($record->payload, true)['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR),
'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]);
DB::table('partnership_origins')->where('partnership_id', $id)->delete();
foreach ($origins as $origin) {
DB::table('partnership_origins')->insertOrIgnore(['partnership_id' => $id, 'type' => $origin['type'], 'target_id' => $origin['target_id']]);
}
DB::table('partnership_locations')->where('partnership_id', $id)->delete();
foreach ($locations as $location) {
DB::table('partnership_locations')->insertOrIgnore(['partnership_id' => $id, 'geo_location_id' => $location['geo_location_id'], 'role' => $location['role'], 'source_reference' => $location['source_reference']]);
}
$saved = $this->find($id);
$this->event($saved, $actor, 'partnership_opportunity.changed.v1', ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations], 'Rascunho guardado', [$record->user_id]);
return $saved;
});
}
public function transition(User $actor, int $id, int $version, string $action, string $reason): stdClass
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason) {
$record = $this->find($id, true);
abort_unless((int) $record->version === $version, 409, 'Proposta alterada; recarregue.');
$moderation = in_array($action, ['publish', 'return', 'reject', 'suspend'], true);
if ($moderation) {
Gate::forUser($actor)->authorize('manage-portal');
abort_if($this->owns($actor, $record), 403, 'Não pode moderar a própria proposta.');
} else {
abort_unless($this->owns($actor, $record), 403);
}
$from = $record->status;
$allowed = ['submit' => ['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'],
'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($from, $allowed[$action], true), 409, 'Transição inválida.');
if ($action === 'submit') {
$payload = json_decode($record->payload, true);
Validator::make(['title' => $record->title, 'type' => $record->partnership_type, 'offer' => $record->offer, 'seeking' => $record->seeking, 'consent' => $payload['consent'] ?? false],
['title' => 'required', 'type' => 'required', 'offer' => 'required_without:seeking', 'seeking' => 'required_without:offer', 'consent' => 'accepted'])->validate();
}
$status = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft'][$action];
DB::table('portal_partnerships')->where('id', $id)->update(['status' => $status, 'version' => $version + 1, 'reviewed_by' => $moderation ? $actor->id : $record->reviewed_by,
'published_at' => $action === 'publish' ? now() : $record->published_at, 'updated_at' => now()]);
$saved = $this->find($id);
$this->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', ['from' => $from, 'to' => $status, 'reason' => $reason], 'Proposta: '.$status, [$record->user_id]);
return $saved;
});
}
/** @param array<string,mixed> $data */
public function interest(User $actor, int $id, array $data): stdClass
{
$data = Validator::make($data, ['version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'],
'payload' => ['array:capacity,availability,contribution,conflicts,consent,sector_id,territory_id'], 'payload.consent' => ['accepted'],
'payload.capacity' => ['nullable', 'string', 'max:5000'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'payload.contribution' => ['nullable', 'string', 'max:5000'], 'payload.conflicts' => ['nullable', 'string', 'max:5000'],
'payload.sector_id' => ['nullable', 'integer', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id']])->validate();
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403);
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->find($id, true);
abort_unless($record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString()), 409, 'Receção de interesses encerrada.');
abort_if((int) $record->user_id === $actor->id || $this->owns($actor, $record, false) || ($data['organization_id'] ?? null) && $data['organization_id'] === $record->organization_id, 403, 'Auto-candidatura não permitida.');
$existing = DB::table('partnership_interests')->where('partnership_id', $id)->where(fn ($q) => $q->where('user_id', $actor->id)
->when($data['organization_id'] ?? null, fn ($org) => $org->orWhere('organization_id', $data['organization_id'])))->orderBy('id')->lockForUpdate()->first();
if ($data['organization_id'] ?? null) {
abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $data['organization_id'])->where('status', '!=', 'withdrawn')
->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.');
}
if ($existing) {
abort_unless($this->owns($actor, $existing), 403);
abort_unless(isset($data['version']) && (int) $existing->version === (int) $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.');
abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409);
$interestId = $existing->id;
DB::table('partnership_interests')->where('id', $interestId)->update(['proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'organization_id' => $data['organization_id'] ?? null,
'status' => 'submitted', 'assessment' => '{}', 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
$interestId = DB::table('partnership_interests')->insertGetId(['partnership_id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null,
'proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'created_at' => now(), 'updated_at' => now()]);
}
$this->event($record, $actor, 'partnership_interest.submitted.v1', ['interest_id' => $interestId, 'interest_version' => $existing ? $existing->version + 1 : 1], 'Manifestação de interesse recebida', [$record->user_id, $actor->id]);
return DB::table('partnership_interests')->where('id', $interestId)->first();
});
}
/** @param array<string,mixed> $assessment */
public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = []): stdClass
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment) {
$interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest !== null, 404);
$record = $this->find($interest->partnership_id, true);
abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.');
if ($action === 'withdraw') {
abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.');
abort_unless($this->owns($actor, $interest), 403);
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
} else {
abort_unless($this->owns($actor, $record), 403);
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
}
$states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status];
abort_unless(isset($states[$action]), 422);
foreach ($assessment as $code => $criterion) {
abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422);
Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate();
$assessment[$code]['assessed_by'] = $actor->id;
$assessment[$code]['assessed_at'] = now()->toIso8601String();
}
$assessed = array_replace(json_decode($interest->assessment, true), $assessment);
if (in_array($action, ['shortlist', 'connect'], true)) {
$match = app(PartnershipMatching::class)->explain($record, $interest, $assessed);
abort_if($match['eligibility'] === 'unmet', 422, 'Existe requisito obrigatório comprovadamente incumprido.');
}
if ($action === 'connect') {
abort_unless($interest->status === 'shortlisted', 409, 'Selecione primeiro a candidatura para lista curta.');
DB::table('partnership_rooms')->insertOrIgnore(['interest_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
DB::table('partnership_interests')->where('id', $id)->update(['status' => $states[$action], 'assessment' => json_encode($assessed), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]);
$this->event($record, $actor, 'partnership_interest.changed.v1', ['interest_id' => $id, 'interest_version' => $version + 1, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed], 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]);
return DB::table('partnership_interests')->where('id', $id)->first();
});
}
/** @return array{room:stdClass,interest:stdClass,partnership:stdClass} */
public function room(User $actor, int $id, bool $write = false): array
{
$room = DB::table('partnership_rooms')->where('id', $id)->first();
abort_unless($room !== null, 404);
$interest = DB::table('partnership_interests')->where('id', $room->interest_id)->first();
$record = $this->find($interest->partnership_id);
abort_unless($this->owns($actor, $record, $write) || $this->owns($actor, $interest, $write), 403, 'Sala privada reservada aos participantes.');
abort_if($write && in_array($room->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
return ['room' => $room, 'interest' => $interest, 'partnership' => $record];
}
/** @param array<string,mixed> $payload
* @param list<int> $recipients
*/
public function event(stdClass $record, User $actor, string $type, array $payload, string $title, array $recipients = []): void
{
DB::transaction(function () use ($record, $actor, $type, $payload, $title, $recipients): void {
DB::select('select pg_advisory_xact_lock(hashtextextended(?,0))', ['partnership-events:'.$record->id]);
$sequence = (DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version') ?? 0) + 1;
$eventId = (string) Str::uuid();
$envelope = ['event_id' => $eventId, 'event_type' => $type, 'event_version' => 1, 'source' => 'investmoz.partnerships', 'subject_id' => $record->id,
'occurred_at' => now()->toIso8601String(), 'producer_version' => '1.0', 'correlation_id' => $eventId, 'causation_id' => null, 'data_classification' => 'internal',
'aggregate_id' => $record->id, 'aggregate_version' => $sequence, 'publication_version' => $record->version, 'payload' => $payload, 'external_delivery' => 'not_configured'];
DB::table('partnership_events')->insert(['id' => $eventId, 'partnership_id' => $record->id, 'actor_id' => $actor->id, 'event_type' => $type, 'aggregate_version' => $sequence, 'payload' => json_encode($envelope)]);
DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id,
'action' => $type, 'after_data' => json_encode($envelope)]);
foreach (array_unique($recipients) as $recipient) {
DB::table('partnership_notifications')->insert(['event_id' => $eventId, 'user_id' => $recipient, 'title' => $title, 'href' => '/parcerias/'.$record->id]);
}
});
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class PartnershipInstallation
{
private const MIGRATION = '2026_10_07_160000_extend_internal_partnership_exchange';
public function fingerprint(): string
{
$files = [...(glob(app_path('Services/Partnership*.php')) ?: []), app_path('Http/Controllers/PartnershipExchangeController.php'), app_path('Http/Controllers/PartnershipInstallationController.php'),
app_path('Http/Middleware/PartnershipInstalled.php'), database_path('migrations/'.self::MIGRATION.'.php'), config_path('partnerships.php'), base_path('routes/partnerships.php'),
...(glob(resource_path('js/pages/partnerships/*.tsx')) ?: []), resource_path('js/components/partnership-source-links.tsx'), resource_path('js/components/partnership-search-results.tsx'), resource_path('js/components/simulation-form.tsx'),
app_path('Http/Controllers/PortalServicesController.php'), base_path('routes/web.php'), resource_path('js/pages/portfolio/show.tsx'), resource_path('js/pages/portfolio/shared.tsx'),
...array_map(fn ($name) => resource_path('js/pages/investment/'.$name.'.tsx'), ['business-opportunity', 'catalogue', 'detail', 'profile', 'zone'])];
sort($files);
$hashes = [];
foreach ($files as $file) {
$hashes[str_replace(base_path().DIRECTORY_SEPARATOR, '', $file)] = hash_file('sha256', $file);
}
return hash('sha256', json_encode($hashes, JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function preview(): array
{
$identity = DB::selectOne('select current_database() as database, inet_server_port() as port');
$rows = DB::table('portal_partnerships')->orderBy('id')->get(['id', 'user_id', 'title', 'sector', 'province', 'offer', 'seeking', 'status', 'reviewed_by', 'created_at', 'updated_at'])->all();
$hash = hash('sha256', json_encode(['target' => $identity, 'code' => $this->fingerprint(), 'records' => $rows], JSON_THROW_ON_ERROR));
$proof = $this->read('backup-proof.json');
$qa = $this->read('qa-evidence.json');
$verified = ($proof['restore_verified'] ?? false) && ($proof['target']['database'] ?? null) === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port
&& is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null);
$qaVerified = ($qa['passed'] ?? false) && ($qa['code_hash'] ?? null) === $this->fingerprint() && str_ends_with($qa['database'] ?? '', '_test') && (int) ($qa['port'] ?? 0) === 55441;
return ['hash' => $hash, 'installed' => Schema::hasTable('partnership_interests'), 'target' => $identity, 'records' => $rows, 'records_hash' => hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR)), 'backupVerified' => $verified, 'qaVerified' => $qaVerified,
'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Extensão de portal_partnerships com campos opcionais e versão, sem substituir os registos.',
'Entidades próprias de candidaturas, salas, mensagens, referências documentais, marcos, eventos e notificações locais.',
'Nenhuma cópia de contas, organizações, documentos, setores, territórios ou oportunidades de investimento.', 'Estados e autores antigos preservados; publicação nova continua moderada.']];
}
/** @return array<string,mixed> */
private function read(string $file): array
{
$path = config('partnerships.proof_directory', storage_path('app/private/partnership-exchange')).'/'.$file;
if (! is_file($path)) {
return [];
}
$contents = file_get_contents($path);
abort_unless(is_string($contents), 503, 'Evidência privada indisponível.');
return json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
}
public function apply(string $hash, User $actor): void
{
DB::transaction(function () use ($hash, $actor) {
DB::statement('LOCK TABLE portal_partnerships IN ACCESS EXCLUSIVE MODE');
$preview = $this->preview();
abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada; reveja a instalação.');
if ($preview['installed']) {
return;
}
abort_unless($preview['backupVerified'] && $preview['qaVerified'], 422, 'Backup/restauro e QA desta versão ainda não estão comprovados.');
config(['partnerships.installation_authorized' => true]);
try {
$migration = require database_path('migrations/'.self::MIGRATION.'.php');
$migration->up();
} finally {
config(['partnerships.installation_authorized' => false]);
}
$rows = DB::table('portal_partnerships')->orderBy('id')->get(['id', 'user_id', 'title', 'sector', 'province', 'offer', 'seeking', 'status', 'reviewed_by', 'created_at', 'updated_at'])->all();
abort_unless(hash_equals($preview['records_hash'], hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR))), 409, 'Os registos antigos não foram preservados.');
DB::table('migrations')->insert(['migration' => self::MIGRATION, 'batch' => (DB::table('migrations')->max('batch') ?? 0) + 1]);
DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id,
'action' => 'partnership_module_installed', 'after_data' => json_encode(['preview_hash' => $hash, 'code_hash' => $this->fingerprint(), 'preserved_records' => count($rows), 'migration' => self::MIGRATION])]);
});
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use stdClass;
class PartnershipMatching
{
/** @param array<string,mixed>|null $assessment
* @return array<string,mixed>
*/
public function explain(stdClass $partnership, stdClass $interest, ?array $assessment = null): array
{
$assessment ??= json_decode($interest->assessment, true);
$payload = json_decode($partnership->payload, true);
$weights = config('partnerships.type_weights.'.$partnership->partnership_type, config('partnerships.weights'));
$criteria = [];
$known = $unknown = 0;
$mandatoryUnmet = $mandatoryUnknown = false;
foreach ($weights as $code => $weight) {
$entry = $assessment[$code] ?? [];
$state = $entry['state'] ?? 'unknown';
if (in_array($state, ['met', 'unmet'], true) && empty($entry['evidence'])) {
$state = 'unknown';
}
$mandatory = in_array($code, $payload['mandatory'] ?? [], true);
$known += $state === 'met' ? $weight : 0;
$unknown += $state === 'unknown' ? $weight : 0;
$mandatoryUnmet = $mandatoryUnmet || ($mandatory && $state === 'unmet');
$mandatoryUnknown = $mandatoryUnknown || ($mandatory && $state === 'unknown');
$criteria[] = ['code' => $code, 'label' => config('partnerships.criteria.'.$code), 'weight' => $weight, 'state' => $state,
'mandatory' => $mandatory, 'evidence' => $entry['evidence'] ?? null,
'explanation' => $state === 'unknown' ? 'Informação insuficiente; declarações sem avaliação documental não provam cumprimento ou incumprimento.' : 'Avaliação fundamentada do responsável; não equivale a certificação ou aprovação.'];
}
$total = array_sum($weights);
return ['algorithm_version' => 'transparent-rules.v1', 'weights_hash' => hash('sha256', json_encode($weights, JSON_THROW_ON_ERROR)), 'score_min' => $total ? round(100 * $known / $total, 1) : null,
'score_max' => $total ? round(100 * ($known + $unknown) / $total, 1) : null,
'eligibility' => $mandatoryUnmet ? 'unmet' : ($mandatoryUnknown ? 'unknown' : 'met'), 'criteria' => $criteria,
'unknown_weight' => $unknown, 'recommendation_is_approval' => false];
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PartnershipSources
{
/** @return array<string,mixed> */
public function resolve(string $type, string $id, ?User $actor = null): array
{
$result = ['type' => $type, 'target_id' => $id, 'geography' => null, 'public' => true];
if (in_array($type, ['project', 'cluster', 'dossier', 'zone', 'business', 'company', 'simulation'], true)) {
abort_unless(Str::isUuid($id), 422, 'Identificador da origem inválido.');
}
if (in_array($type, ['project', 'cluster', 'dossier'], true)) {
$dossier = InvestmentPortfolioController::visible()->where($type === 'dossier' ? 'id' : $type.'_id', $id)->first();
abort_unless($dossier !== null, 404, 'Dossier público de origem não disponível.');
return [...$result, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id, 'geography' => app(InvestmentRelations::class)->dossier($dossier)];
}
if ($type === 'investment') {
abort_unless(ctype_digit($id), 422);
$record = Opportunity::query()->published()->whereKey((int) $id)->first();
abort_unless($record !== null, 404);
return [...$result, 'title' => $record->title, 'href' => '/oportunidades/'.$record->slug];
}
if ($type === 'zone') {
$record = DB::table('investment_zones')->where('id', $id)->where('publication_status', 'published')->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
return [...$result, 'title' => $record->name, 'href' => '/zonas/'.$id];
}
if ($type === 'business') {
$record = ExistingBusiness::query()->published()->whereKey($id)->first();
abort_unless($record !== null, 404);
return [...$result, 'title' => $record->public_title, 'href' => '/negocios/'.$record->public_slug];
}
if ($type === 'company') {
abort_unless($actor !== null, 403);
$record = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $actor->id)->first();
abort_unless($record !== null, 404);
return [...$result, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile', 'public' => false];
}
if ($type === 'simulation') {
abort_unless($actor !== null, 403);
$record = DB::table('simulation_form_drafts')->where('id', $id)->where('owner_user_id', $actor->id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
return [...$result, 'title' => 'Ficha de projeto do simulador', 'href' => '/simulador-fiscal?draft='.$id, 'public' => false];
}
abort(422, 'Tipo de origem não suportado.');
}
/** @return list<array<string,mixed>> */
public function forPartnership(int $id, ?User $actor = null): array
{
$result = [];
foreach (DB::table('partnership_origins')->where('partnership_id', $id)->get() as $origin) {
try {
$result[] = $this->resolve($origin->type, $origin->target_id, $actor);
} catch (HttpException $e) {
if (! in_array($e->getStatusCode(), [403, 404], true)) {
throw $e;
}
// Private or withdrawn source identifiers are not disclosed by a public proposal.
}
}
return $result;
}
}
+254
View File
@@ -0,0 +1,254 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class VirtualDesk
{
public function installed(): bool
{
return \Illuminate\Support\Facades\Schema::hasTable('desk_cases');
}
public function encode(array $data): string
{
return Crypt::encryptString(json_encode($data, JSON_THROW_ON_ERROR));
}
public function decode(?string $data): array
{
return $data ? json_decode(Crypt::decryptString($data), true, 512, JSON_THROW_ON_ERROR) : [];
}
public function represents(User $actor, string $organization, bool $write = true): bool
{
return DB::table('memberships')->where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at')
->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists();
}
public function owns(User $actor, object $case, bool $write = true): bool
{
return $case->organization_id ? $this->represents($actor, $case->organization_id, $write) : (int) $case->user_id === $actor->id;
}
public function case(User $actor, string $id, bool $write = false, bool $lock = false): object
{
$case = DB::table('desk_cases')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
abort_unless($case, 404);
abort_unless($this->owns($actor, $case, $write) || (! $write && ($actor->can('manage-portal') || DB::table('desk_requests')->where('case_id', $id)->where('analyst_id', $actor->id)->exists())), 403);
return $case;
}
public function request(User $actor, string $id, bool $ownerOnly = false, bool $lock = false): object
{
$request = DB::table('desk_requests')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
abort_unless($request, 404);
$case = $this->case($actor, $request->case_id, $ownerOnly);
abort_unless($this->owns($actor, $case, $ownerOnly) || (! $ownerOnly && ($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id)), 403);
return $request;
}
public function createCase(User $actor, array $input): object
{
$data = Validator::make($input, ['title' => 'required|string|max:200', 'client_key' => 'required|uuid', 'reuse_consent' => 'accepted',
'organization_id' => 'nullable|uuid', 'company_id' => 'nullable|uuid', 'submission_id' => 'nullable|uuid', 'simulation_id' => 'nullable|uuid'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::table('users')->where('id', $actor->id)->lockForUpdate()->first();
$existing = DB::table('desk_cases')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first();
$inputHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
if ($existing) {
abort_unless(($this->decode($existing->payload)['_creation_hash'] ?? '') === $inputHash, 409, 'Chave de criação usada com outro conteúdo.');
abort_unless($this->owns($actor, $existing), 403);
return $existing;
}
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403);
$payload = ['profile' => app(InvestmentWorkspace::class)->account($actor->id)['payload'], 'name' => $actor->name, 'email' => $actor->email, '_creation_hash' => $inputHash, 'reuse_consent' => ['actor_id' => $actor->id, 'date' => now()->toIso8601String()]];
if ($data['company_id'] ?? null) {
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $actor->id)->first();
abort_unless($company, 404);
$payload['company'] = $this->decode($company->payload); $payload['company_version'] = $company->version;
}
if ($data['submission_id'] ?? null) {
$submission = \App\Models\InvestmentSubmission::where('id', $data['submission_id'])->where('user_id', $actor->id)->firstOrFail();
$payload['investment'] = $submission->payload; $payload['investment_version'] = $submission->version;
}
if ($data['simulation_id'] ?? null) app(PartnershipSources::class)->resolve('simulation', $data['simulation_id'], $actor);
// One case per explicitly reused investment; retries never create parallel dossiers.
if ($data['submission_id'] ?? null) {
$linked = DB::table('desk_cases')->where('submission_id', $data['submission_id'])->where('user_id', $actor->id)->first();
if ($linked) { abort_unless($this->owns($actor, $linked), 403); return $linked; }
}
$id = (string) Str::uuid();
DB::table('desk_cases')->insert(['id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null, 'company_id' => $data['company_id'] ?? null,
'submission_id' => $data['submission_id'] ?? null, 'simulation_id' => $data['simulation_id'] ?? null, 'title' => $data['title'], 'client_key' => $data['client_key'],
'payload' => $this->encode($payload), 'workflow' => json_encode(array_map(fn ($s) => [...$s, 'status' => 'not_started', 'evidence' => null], config('virtual-desk.steps'))), 'created_at' => now(), 'updated_at' => now()]);
$case = DB::table('desk_cases')->where('id', $id)->first();
$this->event($case, $actor, 'case_created', ['reuse_consent' => true]);
return $case;
});
}
public function createRequest(User $actor, string $caseId, string $serviceId, string $clientKey): object
{
abort_unless(Str::isUuid($serviceId) && Str::isUuid($clientKey), 422);
return DB::transaction(function () use ($actor, $caseId, $serviceId, $clientKey) {
$case = $this->case($actor, $caseId, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
$service = DB::table('desk_service_versions')->where('id', $serviceId)->where('status', 'published')->first();
abort_unless($service, 422, 'Versão do serviço indisponível para novos pedidos.');
$existing = DB::table('desk_requests')->where('case_id', $caseId)->where('client_key', $clientKey)->first();
if ($existing) { abort_unless($existing->service_version_id === $serviceId, 409); return $existing; }
$id = (string) Str::uuid(); $base = $this->decode($case->payload);
$payload = ['title' => $case->title, 'description' => $base['investment']['objective'] ?? '', 'regime' => 'unknown'];
DB::table('desk_requests')->insert(['id' => $id, 'case_id' => $caseId, 'service_version_id' => $serviceId, 'client_key' => $clientKey, 'payload' => $this->encode($payload), 'created_at' => now(), 'updated_at' => now()]);
$request = DB::table('desk_requests')->where('id', $id)->first(); $this->event($case, $actor, 'draft_created', [], $request);
return $request;
});
}
public function definition(object $request): array
{
return json_decode(DB::table('desk_service_versions')->where('id', $request->service_version_id)->value('definition'), true, 512, JSON_THROW_ON_ERROR);
}
public function validateFields(array $payload, array $definition, bool $submitting): array
{
$rules = ['payload' => ['required', 'array:'.implode(',', array_keys($definition['fields']))]];
foreach ($definition['fields'] as $key => $field) {
$active = ! isset($field['when']) || ($payload[$field['when']['field']] ?? null) === $field['when']['equals'];
$required = $submitting && ($field['required'] ?? false) && $active;
$rule = [$required ? 'required' : 'nullable'];
$rule = [...$rule, ...match ($field['type']) {
'number' => ['numeric', 'min:'.($field['min'] ?? 0), 'max:999999999999'], 'date' => ['date_format:Y-m-d'],
'select' => [\Illuminate\Validation\Rule::in(array_keys($field['options']))], 'territory' => ['uuid'],
default => ['string', 'max:'.($field['type'] === 'textarea' ? 15000 : 1000)],
}];
$rules['payload.'.$key] = $rule;
}
$validated = Validator::make(['payload' => $payload], $rules)->validate()['payload'];
if ($validated['territory_id'] ?? null) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $validated['territory_id'])->exists(), 422, 'Localização indisponível.');
if ($submitting && trim($validated['location_source'] ?? '') === '') throw ValidationException::withMessages(['payload.location_source' => 'Indique a fonte da localização.']);
}
return $validated;
}
public function findings(array $payload): array
{
$findings = [];
if (isset($payload['investment_amount'], $payload['own_finance'], $payload['other_finance']) && abs((float) $payload['investment_amount'] - (float) $payload['own_finance'] - (float) $payload['other_finance']) > 0.01) {
$findings[] = ['code' => 'finance_difference', 'severity' => 'error', 'field' => 'investment_amount', 'explanation' => 'A soma do financiamento difere do valor do investimento.', 'recommendation' => 'Confirme valores e moeda.', 'method' => 'deterministic', 'confidence' => null];
}
if (! ($payload['territory_id'] ?? null)) $findings[] = ['code' => 'location_unknown', 'severity' => 'warning', 'field' => 'territory_id', 'explanation' => 'Localização ainda não documentada.', 'recommendation' => 'Complete quando houver fonte.', 'method' => 'deterministic', 'confidence' => null];
$findings[] = ['code' => 'official_form_pending', 'severity' => 'warning', 'field' => null, 'explanation' => 'Adaptação digital do plano; não foi identificada uma versão oficial homologada do formulário APIEX no acervo.', 'recommendation' => 'A preparação e análise interna continuam disponíveis; confirme a versão exigida pela entidade para o ato oficial.', 'method' => 'documentary', 'confidence' => null];
return $findings;
}
public function save(User $actor, string $id, array $input, bool $submit = false): object
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'payload' => 'required|array', 'consent' => $submit ? 'accepted' : 'nullable|boolean', 'reason' => 'nullable|string|max:3000'])->validate();
return DB::transaction(function () use ($actor, $id, $data, $submit) {
$request = $this->request($actor, $id, true, true);
if ($submit && $request->status === 'submitted' && $request->version === (int) $data['version'] + 1 && hash_equals(hash('sha256', json_encode(Arr::sortRecursive($this->decode($request->payload)))), hash('sha256', json_encode(Arr::sortRecursive($data['payload']))))) return $request;
abort_unless(in_array($request->status, ['draft', 'returned'], true) && $request->version === (int) $data['version'], 409, 'Pedido alterado ou já submetido.');
$payload = $this->validateFields($data['payload'], $this->definition($request), $submit);
if ($request->status === 'returned') {
$editable = json_decode($request->editable_fields ?? '[]', true); $previous = $this->decode($request->payload);
foreach (array_unique([...array_keys($previous), ...array_keys($payload)]) as $key) {
if (! in_array($key, $editable, true) && ($previous[$key] ?? null) !== ($payload[$key] ?? null)) throw ValidationException::withMessages(['payload.'.$key => 'Campo não autorizado para esta correção.']);
}
abort_if($submit && trim($data['reason'] ?? '') === '', 422, 'Justifique a correção.');
}
$findings = $this->findings($payload);
if ($submit && array_filter($findings, fn ($f) => $f['severity'] === 'error')) throw ValidationException::withMessages(['payload.investment_amount' => $findings[0]['explanation']]);
$case = $this->case($actor, $request->case_id, true);
$snapshot = ['payload' => $payload, 'service_version_id' => $request->service_version_id, 'documents' => DB::table('desk_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.case_id', $case->id)->where(fn ($q) => $q->whereNull('link.request_id')->orWhere('link.request_id', $id))->get(['link.document_id', 'd.sha256', 'd.version_no', 'link.source_reference'])->all(), 'findings' => $findings, 'consent' => $submit, 'source' => config('virtual-desk.source')];
DB::table('desk_revisions')->insert(['request_id' => $id, 'version' => $request->version, 'snapshot' => $this->encode(['payload' => $this->decode($request->payload), 'status' => $request->status, 'submitted_snapshot' => $request->submitted_snapshot ? $this->decode($request->submitted_snapshot) : null])]);
$updates = ['payload' => $this->encode($payload), 'version' => $request->version + 1, 'updated_at' => now()];
if ($submit) $updates = [...$updates, 'status' => 'submitted', 'protocol' => $request->protocol ?? 'BV-'.date('Y').'-'.strtoupper(str_replace('-', '', $id)), 'submitted_at' => now(), 'submitted_snapshot' => $this->encode($snapshot)];
DB::table('desk_requests')->where('id', $id)->update($updates); $request = DB::table('desk_requests')->where('id', $id)->first();
$this->event($case, $actor, $submit ? 'submitted' : 'draft_saved', ['reason' => $data['reason'] ?? '', 'findings' => $findings], $request);
return $request;
});
}
public function transition(User $actor, string $id, array $data): object
{
$data = Validator::make($data, ['version' => 'required|integer|min:1', 'status' => ['required', \Illuminate\Validation\Rule::in(array_keys(config('virtual-desk.request_states')))], 'reason' => 'required|string|min:3|max:5000', 'editable_fields' => 'nullable|array', 'editable_fields.*' => 'string|max:100'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$request = $this->request($actor, $id, false, true); $case = $this->case($actor, $request->case_id);
abort_unless($request->version === (int) $data['version'], 409);
$cancel = $data['status'] === 'cancelled';
if ($cancel) { abort_unless($this->owns($actor, $case) && in_array($request->status, ['draft', 'returned'], true), 403); }
else {
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) abort_unless($actor->can('manage-portal'), 403);
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
}
$editable = $data['editable_fields'] ?? [];
abort_if($data['status'] === 'returned' && ! $editable, 422, 'Indique os campos autorizados para correção.');
abort_if(array_diff($editable, array_keys($this->definition($request)['fields'])), 422);
DB::table('desk_requests')->where('id', $id)->update(['status' => $data['status'], 'version' => $request->version + 1, 'editable_fields' => json_encode($editable), 'closed_at' => in_array($data['status'], ['closed', 'cancelled'], true) ? now() : null, 'updated_at' => now()]);
$request = DB::table('desk_requests')->where('id', $id)->first(); $this->event($case, $actor, 'request_'.$data['status'], ['reason' => $data['reason'], 'editable_fields' => $editable], $request);
return $request;
});
}
public function stage(User $actor, string $id, array $data): object
{
$data = Validator::make($data, ['version' => 'required|integer|min:1', 'stage' => ['required', \Illuminate\Validation\Rule::in(array_keys(config('virtual-desk.steps')))],
'status' => ['required', \Illuminate\Validation\Rule::in(array_keys(config('virtual-desk.step_states')))], 'reason' => 'required|string|min:3|max:3000',
'evidence' => 'nullable|string|max:5000', 'document_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'exception' => 'nullable|boolean'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, false, true);
abort_unless($this->owns($actor, $case) || $actor->can('manage-portal'), 403);
abort_unless((int) $case->version === (int) $data['version'], 409);
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR); $current = $workflow[$data['stage']];
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
if ($decision) {
abort_unless($actor->can('manage-portal') && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
foreach ($current['dependencies'] as $dependency) {
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
}
if ($data['stage'] !== 'E0') abort_unless($data['document_id'] ?? null, 422, 'Ligue o documento de suporte ou de não aplicabilidade.');
}
if ($data['document_id'] ?? null) {
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.id', $data['document_id'])->whereNull('d.deleted_at')->first(['d.status', 'd.malware_scan_status', 'd.sha256', 'd.malware_scanned_sha256', 'l.valid_until']);
abort_unless($document, 422);
if ($decision) abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean' && trim($document->sha256) === $document->malware_scanned_sha256 && (! $document->valid_until || $document->valid_until >= now()->toDateString()), 422, 'Documento ainda não consultável ou expirado.');
}
if (in_array($data['status'], ['referred', 'waiting'], true)) abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Registe referência e canal do encaminhamento.');
// Reopening an upstream step must never leave dependent conclusions falsely valid.
if (! in_array($data['status'], ['completed', 'not_applicable'], true)) {
$affected = [$data['stage']];
do { $changed = false; foreach ($workflow as $code => &$step) { if (! in_array($code, $affected, true) && array_intersect($step['dependencies'], $affected)) { $affected[] = $code; $changed = true; if ($step['status'] === 'completed') $step['status'] = 'pending'; } } unset($step); } while ($changed);
}
$workflow[$data['stage']] = [...$current, 'status' => $data['status'], 'evidence' => $data['evidence'] ?? null, 'document_id' => $data['document_id'] ?? null,
'valid_until' => $data['valid_until'] ?? null, 'reason' => $data['reason'], 'reviewed_by' => $actor->id, 'updated_at' => now()->toIso8601String()];
DB::table('desk_cases')->where('id', $id)->update(['workflow' => json_encode($workflow), 'version' => $case->version + 1, 'updated_at' => now()]);
$case = DB::table('desk_cases')->where('id', $id)->first(); $this->event($case, $actor, 'stage_updated', $data);
return $case;
});
}
public function event(object $case, User $actor, string $action, array $payload, ?object $request = null): void
{
$id = (string) Str::uuid(); $version = $request->version ?? $case->version;
DB::table('desk_events')->insert(['id' => $id, 'case_id' => $case->id, 'request_id' => $request->id ?? null, 'actor_id' => $actor->id, 'action' => $action, 'version' => $version, 'payload' => $this->encode($payload)]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $request ? 'desk_requests' : 'desk_cases', 'entity_id' => $request->id ?? $case->id, 'actor_user_id' => $actor->id, 'action' => $action,
'after_data' => json_encode(['event_id' => $id, 'event_version' => 1, 'version' => $version, 'case_id' => $case->id, 'source_hash' => config('virtual-desk.source.sha256')])]);
$recipients = [(int) $case->user_id];
if ($case->organization_id) $recipients = [...$recipients, ...DB::table('memberships')->where('organization_id', $case->organization_id)->whereNull('deleted_at')->pluck('user_id')->all()];
if ($request?->analyst_id) $recipients[] = (int) $request->analyst_id;
foreach (array_unique($recipients) as $user) DB::table('desk_notifications')->insert(['event_id' => $id, 'user_id' => $user, 'title' => config('virtual-desk.request_states.'.$request?->status, 'Dossier atualizado'), 'href' => $request ? '/balcao-virtual/pedidos/'.$request->id : '/balcao-virtual/dossiers/'.$case->id]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
return [
'types' => [
'supply' => 'Fornecimento e subcontratação',
'distribution' => 'Distribuição, representação comercial e franquia',
'joint_venture' => 'Joint venture, consórcio ou co-investimento',
'technology' => 'Transferência de tecnologia, licenciamento e inovação',
'finance' => 'Financiamento, investimento e garantias',
'training' => 'Formação, assistência técnica e certificação',
'research' => 'Investigação, desenvolvimento e ligação academia–empresa',
'logistics' => 'Logística, exportação, mercados e cadeias de valor',
'public_impact' => 'Parcerias público-privadas e impacto social',
],
'weights' => ['sector' => 25, 'capacity' => 20, 'requirements' => 20, 'territory' => 10, 'experience' => 10, 'availability' => 10, 'impact' => 5],
'type_weights' => [],
'criteria' => ['sector' => 'Setor e tipo', 'capacity' => 'Capacidade técnica/produtiva', 'requirements' => 'Requisitos e certificações',
'territory' => 'Localização e cobertura', 'experience' => 'Experiência comprovada', 'availability' => 'Disponibilidade e prazo', 'impact' => 'Impacto e conteúdo local'],
'installation_authorized' => false,
];
+45
View File
@@ -0,0 +1,45 @@
<?php
$source = ['identifier' => 'Plano de Incorporação e Optimização do Módulo Balcão Virtual — Portal do Investidor',
'sha256' => 'f167d9156c2572c0253949a6f0ed88aafc28450cce902e8f445e3d979119aa37', 'edition' => 'Plano fornecido em 7 outubro 2026',
'legal_status' => 'not_a_legal_instrument', 'form_status' => 'digital_preparation', 'rule_use' => 'internal_workflow_only'];
$steps = [
'E0' => ['Perfil e preparação', [], 'Portal do Investidor', 'Dados mínimos do promotor e do projeto; reutilização consentida.'],
'E1' => ['Reserva do nome', ['E0'], 'SARN', 'Nome aprovado, identificação do pedido, comprovativo e validade.'],
'E2' => ['Constituição e registo', ['E1'], 'SARN / Conservatória', 'Certidão, NUEL ou comprovativo de registo; reserva válida.'],
'E3' => ['Beneficiários efectivos', ['E2'], 'SARN / Conservatória', 'Declaração ou recibo; aplicabilidade documentada.'],
'E4' => ['NUIT colectivo', ['E2'], 'Autoridade Tributária', 'NUIT e carta; Modelo M01C quando aplicável.'],
'E5' => ['Dossier societário', ['E2', 'E3', 'E4'], 'Representante legal', 'Índice de documentos, versões e validade.'],
'E6' => ['Licenciamento da actividade', ['E5'], 'BAU / entidade sectorial', 'Licença, alvará, registo ou fundamento de não aplicabilidade.'],
'E7' => ['Início de actividade fiscal', ['E4', 'E6'], 'Autoridade Tributária', 'Modelo M02, data efectiva e comprovativo.'],
'E8' => ['Inscrição da empresa no INSS', ['E7'], 'INSS', 'Número de contribuinte e comprovativo. Prazo documental por confirmar no caso.'],
'E9' => ['Trabalhadores e contribuições', ['E8'], 'INSS', 'Inscrições, calendário e comprovativos quando aplicável.'],
'E10' => ['Projeto de investimento', ['E0'], 'APIEX', 'Preparação paralela; formulário oficial, evidências e decisão competente tratados separadamente.'],
'E11' => ['Pós-estabelecimento', ['E7'], 'Entidades competentes', 'Licenças, obrigações e marcos aplicáveis; não presume aprovação de E10.'],
];
$field = fn ($label, $type = 'text', $required = false, $extra = []) => ['label' => $label, 'type' => $type, 'required' => $required, ...$extra];
$common = ['title' => $field('Nome do projeto', 'text', true), 'description' => $field('Descrição e objetivo', 'textarea', true),
'territory_id' => $field('Localização documentada', 'territory'), 'location_source' => $field('Fonte da localização'),
'regime' => $field('Regime declarado', 'select', false, ['options' => ['unknown' => 'Por determinar', 'registration' => 'Mero registo declarado', 'authorization' => 'Autorização declarada', 'special' => 'Regime especial declarado']])];
return [
'installation_authorized' => false,
'source' => $source,
'steps' => array_map(fn ($s) => ['title' => $s[0], 'dependencies' => $s[1], 'entity' => $s[2], 'requirements' => $s[3], 'cost' => null, 'legal_deadline' => null], $steps),
'step_states' => ['not_started' => 'Não iniciado', 'preparing' => 'Em preparação', 'ready' => 'Pronto para encaminhamento', 'referred' => 'Encaminhado ao sector', 'waiting' => 'A aguardar resposta', 'pending' => 'Com pendência', 'completed' => 'Concluído documentalmente', 'not_applicable' => 'Não aplicável', 'expired' => 'Expirado'],
'request_states' => ['draft' => 'Rascunho', 'submitted' => 'Submetido', 'reviewing' => 'Em análise', 'returned' => 'Devolvido para correção', 'opinion' => 'Com parecer', 'accepted' => 'Aceite internamente', 'rejected' => 'Rejeitado internamente', 'closed' => 'Encerrado', 'cancelled' => 'Cancelado'],
'transitions' => ['submitted' => ['reviewing', 'returned', 'rejected'], 'reviewing' => ['returned', 'opinion', 'rejected'], 'opinion' => ['accepted', 'returned', 'rejected'], 'accepted' => ['closed'], 'rejected' => ['closed']],
'services' => [
'project-preparation' => ['title' => 'Preparar e submeter dossier de investimento', 'theme' => 'Investimento', 'profile' => 'Investidor ou representante', 'stage' => 'E10', 'entity' => 'APIEX — preparação e análise interna',
'result' => 'Protocolo e apreciação interna do dossier; não substitui o ato da entidade competente.', 'fields' => [...$common,
'investment_amount' => $field('Valor do investimento', 'number', true, ['min' => 0.01]), 'currency' => $field('Moeda', 'select', true, ['options' => ['MZN' => 'MZN', 'USD' => 'USD', 'EUR' => 'EUR']]),
'own_finance' => $field('Financiamento próprio', 'number'), 'other_finance' => $field('Outro financiamento', 'number'), 'jobs' => $field('Empregos previstos', 'number'),
'foreign_capital' => $field('Origem do capital', 'select', false, ['options' => ['unknown' => 'Por determinar', 'national' => 'Nacional', 'foreign' => 'Estrangeiro', 'mixed' => 'Misto']]),
'schedule' => $field('Cronograma', 'textarea'), 'special_regime_details' => $field('Fundamento do regime especial', 'textarea', true, ['when' => ['field' => 'regime', 'equals' => 'special']])]],
'project-change' => ['title' => 'Comunicar alteração do projeto', 'theme' => 'Alterações', 'profile' => 'Investidor ou representante', 'stage' => 'E10', 'entity' => 'APIEX — análise interna',
'result' => 'Registo versionado da alteração e parecer interno.', 'fields' => [...$common, 'original_reference' => $field('Referência original', 'text', true), 'change_reason' => $field('Motivo e alcance da alteração', 'textarea', true)]],
'project-follow-up' => ['title' => 'Acompanhar investimento e pós-estabelecimento', 'theme' => 'Acompanhamento', 'profile' => 'Investidor ou representante', 'stage' => 'E11', 'entity' => 'Portal / entidades competentes',
'result' => 'Relatório interno de execução, pendências e marcos.', 'fields' => [...$common, 'original_reference' => $field('Referência do investimento', 'text', true), 'progress' => $field('Progresso e evidências', 'textarea', true), 'next_milestone' => $field('Próximo marco'), 'due_date' => $field('Data prevista', 'date')]],
],
'ai' => ['available' => false, 'reason' => 'Modelo de análise documental não configurado; a revisão determinística e humana funciona internamente.'],
'external_channels' => ['email' => 'not_configured', 'sms' => 'not_configured', 'payments' => 'documentary_manual_only'],
];
@@ -0,0 +1,138 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$identity = DB::selectOne('select current_database() as database, inet_server_port() as port');
if (! (str_ends_with($identity->database, '_test') && (int) $identity->port === 55441) && ! config('partnerships.installation_authorized')) {
throw new RuntimeException('Use isolated QA, or the authenticated partnership installation with verified backup/restore.');
}
Schema::table('portal_partnerships', function (Blueprint $t) {
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
$t->foreignId('sector_id')->nullable()->constrained('sectors')->restrictOnDelete();
$t->unsignedInteger('version')->default(1);
$t->string('kind')->default('seeking');
$t->string('partnership_type')->nullable();
$t->jsonb('payload')->default('{}');
$t->uuid('client_key')->nullable();
$t->date('deadline')->nullable();
$t->timestampTz('published_at')->nullable();
$t->unique(['user_id', 'client_key']);
$t->index(['status', 'sector_id', 'partnership_type']);
});
Schema::create('partnership_origins', function (Blueprint $t) {
$t->id();
$t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete();
$t->string('type');
$t->string('target_id');
$t->unique(['partnership_id', 'type', 'target_id']);
$t->index(['type', 'target_id']);
});
Schema::create('partnership_locations', function (Blueprint $t) {
$t->id();
$t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete();
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
$t->string('role');
$t->text('source_reference');
$t->unique(['partnership_id', 'geo_location_id', 'role']);
});
Schema::create('partnership_interests', function (Blueprint $t) {
$t->id();
$t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete();
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
$t->text('proposal');
$t->string('status')->default('submitted');
$t->unsignedInteger('version')->default(1);
$t->jsonb('payload')->default('{}');
$t->jsonb('assessment')->default('{}');
$t->text('decision_reason')->nullable();
$t->timestampsTz();
$t->unique(['partnership_id', 'user_id']);
});
Schema::create('partnership_rooms', function (Blueprint $t) {
$t->id();
$t->foreignId('interest_id')->unique()->constrained('partnership_interests')->restrictOnDelete();
$t->string('status')->default('negotiation');
$t->unsignedInteger('version')->default(1);
$t->jsonb('agreement')->nullable();
$t->timestampsTz();
});
Schema::create('partnership_messages', function (Blueprint $t) {
$t->id();
$t->foreignId('room_id')->constrained('partnership_rooms')->restrictOnDelete();
$t->foreignId('author_id')->constrained('users')->restrictOnDelete();
$t->uuid('client_key');
$t->text('body');
$t->timestampTz('created_at')->useCurrent();
$t->unique(['room_id', 'author_id', 'client_key']);
});
Schema::create('partnership_documents', function (Blueprint $t) {
$t->id();
$t->foreignId('room_id')->constrained('partnership_rooms')->restrictOnDelete();
$t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
$t->foreignId('owner_id')->constrained('users')->restrictOnDelete();
$t->boolean('shared')->default(false);
$t->timestampTz('created_at')->useCurrent();
$t->unique(['room_id', 'document_id']);
});
Schema::create('partnership_milestones', function (Blueprint $t) {
$t->id();
$t->foreignId('room_id')->constrained('partnership_rooms')->restrictOnDelete();
$t->foreignId('responsible_id')->constrained('users')->restrictOnDelete();
$t->string('title');
$t->date('due_date')->nullable();
$t->string('status')->default('pending');
$t->text('evidence')->nullable();
$t->unsignedInteger('version')->default(1);
$t->timestampsTz();
});
Schema::create('partnership_events', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->string('event_type');
$t->unsignedInteger('aggregate_version');
$t->jsonb('payload');
$t->timestampTz('occurred_at')->useCurrent();
});
Schema::create('partnership_notifications', function (Blueprint $t) {
$t->id();
$t->foreignUuid('event_id')->constrained('partnership_events')->restrictOnDelete();
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->string('title');
$t->string('href');
$t->timestampTz('read_at')->nullable();
$t->unique(['event_id', 'user_id']);
});
}
public function down(): void
{
$identity = DB::selectOne('select current_database() as database, inet_server_port() as port');
if (! (str_ends_with($identity->database, '_test') && (int) $identity->port === 55441)) {
throw new RuntimeException('Rollback is restricted to isolated QA; preserve active records and restore a verified backup instead.');
}
foreach (['partnership_events', 'partnership_interests', 'partnership_origins', 'partnership_locations'] as $table) {
if (DB::table($table)->exists()) {
throw new RuntimeException('Preserve partnership data and history before rollback.');
}
}
foreach (['partnership_notifications', 'partnership_events', 'partnership_milestones', 'partnership_documents', 'partnership_messages', 'partnership_rooms', 'partnership_interests', 'partnership_locations', 'partnership_origins'] as $table) {
Schema::drop($table);
}
Schema::table('portal_partnerships', function (Blueprint $t) {
$t->dropForeign(['organization_id']);
$t->dropForeign(['sector_id']);
$t->dropUnique(['user_id', 'client_key']);
$t->dropIndex(['status', 'sector_id', 'partnership_type']);
$t->dropColumn(['organization_id', 'sector_id', 'version', 'kind', 'partnership_type', 'payload', 'client_key', 'deadline', 'published_at']);
});
}
};
@@ -0,0 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function guard(bool $install = false): void
{
$identity = DB::selectOne('select current_database() as database, inet_server_port() as port');
if (! (str_ends_with($identity->database, '_test') && (int) $identity->port === 55441) && ! ($install && config('virtual-desk.installation_authorized'))) {
throw new RuntimeException('Use isolated QA or the authenticated virtual desk installation with verified restore.');
}
}
public function up(): void
{
$this->guard(true);
Schema::create('desk_service_versions', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->string('code'); $t->unsignedInteger('version'); $t->string('status')->default('draft');
$t->jsonb('definition'); $t->jsonb('source'); $t->foreignId('author_id')->constrained('users')->restrictOnDelete();
$t->foreignId('published_by')->nullable()->constrained('users')->restrictOnDelete(); $t->timestampTz('published_at')->nullable(); $t->timestampsTz(); $t->unique(['code', 'version']);
});
Schema::create('desk_cases', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
$t->foreignUuid('company_id')->nullable()->constrained('investment_company_declarations')->restrictOnDelete();
$t->foreignUuid('submission_id')->nullable()->constrained('investment_submissions')->restrictOnDelete();
$t->uuid('simulation_id')->nullable(); $t->string('title'); $t->uuid('client_key'); $t->unsignedInteger('version')->default(1);
$t->text('payload'); $t->jsonb('workflow'); $t->timestampTz('closed_at')->nullable(); $t->timestampsTz(); $t->unique(['user_id', 'client_key']);
});
Schema::create('desk_requests', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->foreignUuid('case_id')->constrained('desk_cases')->restrictOnDelete();
$t->foreignUuid('service_version_id')->constrained('desk_service_versions')->restrictOnDelete();
$t->foreignId('analyst_id')->nullable()->constrained('users')->restrictOnDelete(); $t->string('status')->default('draft');
$t->unsignedInteger('version')->default(1); $t->uuid('client_key'); $t->text('payload'); $t->string('protocol')->nullable()->unique();
$t->text('submitted_snapshot')->nullable(); $t->jsonb('editable_fields')->nullable(); $t->timestampTz('submitted_at')->nullable();
$t->timestampTz('due_at')->nullable(); $t->timestampTz('closed_at')->nullable(); $t->timestampsTz(); $t->unique(['case_id', 'client_key']);
});
Schema::create('desk_documents', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->foreignUuid('case_id')->constrained('desk_cases')->restrictOnDelete();
$t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); $t->foreignUuid('request_id')->nullable()->constrained('desk_requests')->restrictOnDelete();
$t->string('category'); $t->string('stage')->nullable(); $t->date('valid_until')->nullable(); $t->text('source_reference');
$t->foreignId('linked_by')->constrained('users')->restrictOnDelete(); $t->timestampTz('created_at')->useCurrent(); $t->unique(['case_id', 'document_id']);
});
Schema::create('desk_events', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->foreignUuid('case_id')->constrained('desk_cases')->restrictOnDelete();
$t->foreignUuid('request_id')->nullable()->constrained('desk_requests')->restrictOnDelete(); $t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->string('action'); $t->unsignedInteger('version'); $t->text('payload'); $t->timestampTz('created_at')->useCurrent();
});
Schema::create('desk_revisions', function (Blueprint $t) {
$t->id(); $t->foreignUuid('request_id')->constrained('desk_requests')->restrictOnDelete(); $t->unsignedInteger('version');
$t->text('snapshot'); $t->timestampTz('created_at')->useCurrent(); $t->unique(['request_id', 'version']);
});
Schema::create('desk_messages', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->foreignUuid('request_id')->constrained('desk_requests')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); $t->uuid('client_key'); $t->text('body'); $t->timestampTz('created_at')->useCurrent();
$t->unique(['request_id', 'actor_id', 'client_key']);
});
Schema::create('desk_notifications', function (Blueprint $t) {
$t->id(); $t->foreignUuid('event_id')->constrained('desk_events')->restrictOnDelete(); $t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->string('title'); $t->string('href'); $t->timestampTz('read_at')->nullable(); $t->unique(['event_id', 'user_id']);
});
Schema::create('desk_institutional_links', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->string('stage'); $t->string('entity'); $t->string('purpose'); $t->text('url'); $t->string('contact')->nullable();
$t->string('status')->default('pending'); $t->unsignedInteger('version')->default(1); $t->text('source_reference');
$t->timestampTz('verified_at')->nullable(); $t->foreignId('verified_by')->nullable()->constrained('users')->restrictOnDelete(); $t->timestampsTz();
});
Schema::create('desk_payments', function (Blueprint $t) {
$t->uuid('id')->primary(); $t->foreignUuid('request_id')->constrained('desk_requests')->restrictOnDelete();
$t->string('reference')->unique(); $t->decimal('amount', 18, 2); $t->string('currency', 3); $t->text('source_reference');
$t->string('status')->default('pending'); $t->foreignUuid('document_id')->nullable()->constrained('documents')->restrictOnDelete();
$t->foreignId('verified_by')->nullable()->constrained('users')->restrictOnDelete(); $t->text('reason')->nullable(); $t->unsignedInteger('version')->default(1); $t->timestampsTz();
});
DB::statement("ALTER TABLE desk_requests ADD CONSTRAINT desk_request_status CHECK (status IN ('draft','submitted','reviewing','returned','opinion','accepted','rejected','closed','cancelled'))");
DB::statement("ALTER TABLE desk_requests ADD CONSTRAINT desk_request_receipt CHECK (status IN ('draft','cancelled') OR (protocol IS NOT NULL AND submitted_snapshot IS NOT NULL AND submitted_at IS NOT NULL))");
DB::statement('ALTER TABLE desk_payments ADD CONSTRAINT desk_payment_amount CHECK(amount>0)');
}
public function down(): void
{
$this->guard();
foreach (['desk_cases', 'desk_service_versions', 'desk_institutional_links'] as $table) {
if (DB::table($table)->exists()) throw new RuntimeException('Preserve virtual desk data before rollback.');
}
foreach (['desk_payments', 'desk_institutional_links', 'desk_notifications', 'desk_messages', 'desk_revisions', 'desk_events', 'desk_documents', 'desk_requests', 'desk_cases', 'desk_service_versions'] as $table) Schema::drop($table);
}
};
+28
View File
@@ -0,0 +1,28 @@
# Entrega da Bolsa de Parcerias
O percurso local foi implementado sobre `portal_partnerships`, preservando IDs, autores e estados antigos. A matriz está em [requirements.md](requirements.md), e os contratos internos e estados em [contracts.md](contracts.md). A especificação original, inventário do acervo e hashes permanecem privados; não foram publicados automaticamente.
## Implementado e verificado em QA
- Catálogo, pesquisa, filtros territoriais hierárquicos, paginação, detalhe e mapa com coordenadas existentes. Locais documentados da origem são contexto identificado, não implantação presumida da parceria.
- Nove tipos de parceria; oferta/procura pessoal ou por representante autorizado; rascunhos persistidos, recuperação, edição, pré-visualização, submissão e moderação por outro utilizador.
- Referências aos IDs de empresa, investimento, projeto, cluster, zona e dossier, com ligações nos dois sentidos. ZEE, ZFI e parques reutilizam o cadastro de zonas. O simulador abre o mesmo rascunho autorizado pelo seu ID; não cria um cadastro paralelo. O roteiro continua ligado a `/parcerias`.
- Interesse, atualização/retirada, esclarecimentos, comparação e lista curta. Regras explicáveis com pesos configuráveis: informação insuficiente produz intervalo de pontuação e lacunas; só incumprimento obrigatório comprovado impede a decisão correspondente. A avaliação inicial é documental e declarada pelos participantes, sem certificação automática de perfis.
- Sala privada, mensagens idempotentes, documentos com consentimento/antimalware/integridade, acordo com confirmação separada das partes, responsáveis, marcos, execução e encerramento. Histórico, notificações locais e eventos versionados.
- Proteção de organizações e candidatos, versões concorrentes, transições, isolamento de salas e preservação das propostas antigas na instalação transacional autenticada.
Passaram 46 testes relevantes, com 701 asserções, mais 23 testes de continuidade/privacidade dos rascunhos do simulador, com 176 asserções: 69 testes e 877 asserções nas bases isoladas da porta 55441. TypeScript, compilação e PHPStan dos serviços/controladores revistos também passaram. `browser-qa.json` regista o percurso realizado por três contas sintéticas em QA, do rascunho ao acordo e marco, e ligação de regresso ao projeto. Capturas a 1440 e 390 px verificam computador e telemóvel. As contas e declarações sintéticas servem apenas a verificação; não são propostas reais publicadas na base ativa. Documentos limpos simulados existem somente em testes de permissões; nenhum documento real recebeu confirmação de scan sem evidência.
## Base ativa e aplicação
O código e a compilação estão na cópia local; a extensão da base ativa ainda não foi aplicada. A página `/management/partnerships-install` apresenta o alvo, registos preservados, alterações, prova de restauro e evidência QA da versão do código. A aplicação exige a conta de gestão já autorizada, compara a pré-visualização, bloqueia os registos, migra transacionalmente e regista autor/data/versão. Repetir a instalação não duplica dados. `/parcerias` mantém o comportamento anterior enquanto a extensão não estiver instalada.
O backup atualizado foi restaurado em `partnership_restore_20261007_172202_test:55441`, com comparação integral do conteúdo de 145 tabelas. Inclui as 28 associações territoriais que a leitura final encontrou na base ativa, auditadas pelo utilizador 13 às 12:59:36 UTC. A prova e o dump ficam privados. A ação autenticada para instalar a Bolsa continua necessária por instrução expressa do AGENTS.md: «Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária». Não se criou uma aprovação adicional nem uma conta artificial na base ativa.
## Pendências concretas
- Executar a instalação com uma sessão existente autorizada na base ativa e verificar aí o percurso com utilizadores reais. A sessão QA não autoriza escrever na base ativa.
- CRM/SSO/APIs externos, entrega de emails, IA externa, consulta de registos públicos e assinatura jurídica certificada não estão configurados. O fluxo local não depende deles e não afirma sincronização, certificação ou entrega.
- Fontes das âncoras e clusters continuam com as pendências discriminadas em [../relationships/README.md](../relationships/README.md), incluindo coordenadas e abrangência própria dos clusters. A Bolsa reutiliza relações documentadas e não completa essas lacunas por proximidade.
- O scanner real necessita do serviço antimalware disponível no ambiente para libertar novos ficheiros. Um scan pendente conserva a consulta da proposta e bloqueia apenas o download do ficheiro em questão.
- O teste geral preexistente de download com scan pendente e os erros estáticos globais fora do âmbito continuam discriminados na entrega territorial. A proteção documental não foi reduzida para os fazer passar.
@@ -0,0 +1,20 @@
{
"target": "anchor_restore_20261007_test:55441",
"passed": true,
"partnershipId": 3,
"roomId": 3,
"draftRecovered": true,
"sourceBacklink": true,
"moderation": true,
"interest": true,
"shortlist": true,
"privateMessage": true,
"bilateralAgreement": true,
"milestoneCompleted": true,
"roomClosed": true,
"viewports": [
1440,
390
],
"errors": []
}
+15
View File
@@ -0,0 +1,15 @@
# Contratos internos v1
Não existe entrega externa configurada. Os endpoints públicos são `GET /api/v1/partnerships` e `GET /api/v1/partnerships/source/{type}/{id}`. O primeiro aceita `q`, `sector_id`, `kind`, `partnership_type`, `territory_id`, `origin_type`, `origin_id` e `page`; devolve paginação Laravel de 12 elementos. O segundo devolve apenas propostas publicadas e origens que o visitante pode consultar. Uma origem privada não é divulgada pelo catálogo.
As escritas usam a sessão existente, CSRF e autorização por recurso. Os contratos das rotas estão em `routes/partnerships.php`. Alterações enviam `version`; uma versão desatualizada resulta em 409. Criação de rascunho e mensagens usam `client_key` UUID: repetir a mesma operação devolve o resultado existente; reutilizar a chave com outro conteúdo resulta em 409.
| Agregado | Estados persistidos | Separação |
| --- | --- | --- |
| Publicação | draft, submitted, returned, rejected, published, suspended, archived; pending legado | Renovar recupera o rascunho e exige nova submissão. Editar uma proposta publicada não publica a alteração automaticamente. |
| Candidatura | submitted, clarification, shortlisted, rejected, withdrawn, negotiation | Negociação de um candidato não encerra outros. A retirada pertence ao candidato; avaliação e decisão ao titular da proposta. |
| Sala/acordo | negotiation, agreement_recorded, agreed, execution, closed, cancelled | Registo de termos não confirma as partes. Só a confirmação de ambas permite acompanhar a execução. Encerramento exige os marcos concluídos. Renovação devolve a negociação sem apagar o histórico dos termos. |
Os eventos persistidos em `partnership_events` e `audit_events` têm envelope v1: `event_id`, `event_type`, `event_version`, `source`, `subject_id`, `occurred_at`, `producer_version`, `correlation_id`, `causation_id`, `data_classification`, `aggregate_id`, `aggregate_version`, `publication_version`, `payload`, `external_delivery`. A sequência do agregado é atribuída com bloqueio transacional; não confunde a versão de publicação com a de cada candidatura ou sala. O autor fica no registo do evento e da auditoria. `external_delivery=not_configured` não afirma transmissão ao CRM. Estes envelopes são internos e não constituem um endpoint público para informação privada.
As salas usam documentos do cadastro existente, armazenamento privado, consentimento explícito de partilha e scanner existente. Download exige acesso atual à sala, autorização de partilha, resultado antimalware limpo e integridade do ficheiro. O autor pode revogar a partilha. Gestores de publicação não recebem acesso automático às mensagens ou aos documentos de negociação.
@@ -0,0 +1,22 @@
# Bolsa de Parcerias — matriz de implementação
Especificação: «Bolsas de pareceria Bolsa de Parcerias.docx», versão 1.0, 7 outubro 2026, no acervo de Relatórios. Original, hash e inventário permanecem privados em `storage/app/private/partnership-exchange/`. As instruções do responsável prevalecem sobre as dependências externas e a exigência geral de organização verificada do documento.
| Requisito | Existente | Alteração necessária | Verificação |
| --- | --- | --- | --- |
| Identidade e representação | users, organizations, memberships | Reutilizar sessão; escrita pela conta titular ou representante autorizado; rascunho pessoal permitido | Conta alheia, organizações e papéis |
| Catálogo e propostas antigas | /parcerias, portal_partnerships, moderação | Estender os registos, manter IDs/autores/estados; pesquisa, detalhe, tipos e filtros | Preservação e paginação |
| Dados mestres e origens | opportunities, dossiers, projects, economic_clusters, investment_zones, declarações empresariais | Referências por ID; entradas e ligações nos dois sentidos | Origem pública/privada e identidade |
| Territórios | geo_locations, project_locations, relações de zonas | Várias localizações com fonte e papel; contexto da origem separado de implantação declarada | Hierarquia e ausência de inferência |
| Percurso de publicação | Proposta pending → published | Rascunho, edição, pré-visualização, submissão, devolução/rejeição fundamentada | Concorrência e transições |
| Candidaturas | Pedidos gerais de interesse | Candidaturas próprias da parceria, atualização/retirada, comparação, esclarecimentos e lista curta | Unicidade e isolamento entre candidatos |
| Correspondências | Sem motor da Bolsa | Regras e pesos configuráveis do documento; informação desconhecida separada de incumprimento comprovado | Dados completos/incompletos e explicação |
| Colaboração | investment_messages ligados obrigatoriamente a investment_submissions | Mensagens da sala de parceria, com o mesmo padrão de isolamento e idempotência; documents mestre e scanner existente | Participantes, armazenamento privado e scan |
| Notificações | investment_notifications ligadas a investment_submissions | Caixa local da Bolsa e eventos versionados; sem correio externo obrigatório | Destinatários e leitura |
| Auditoria e CRM | audit_events e CRM interno | Reutilizar auditoria; preparar eventos sem simular sincronização externa | Autor/data/motivo/versão |
| Acordo e acompanhamento | Sem processo próprio | Registo de instrumento/partes/responsáveis/marcos; confirmação das partes distinta de assinatura certificada | Fluxo completo e permissões |
| QA e aplicação | PostgreSQL QA 55441; gestão autenticada | Migração protegida; pré-visualização, backup/restauro e aplicação autenticada | Base ativa intacta e restauro |
As tabelas de mensagens/notificações existentes têm chaves obrigatórias para candidaturas de investimento. Não se criam candidaturas fictícias de investimento para contornar essa integridade. A Bolsa tem entidades transacionais próprias, sem novos cadastros de contas, organizações, documentos ou oportunidades de investimento.
Consulta e preparação de rascunhos não exigem NUIT, empresa constituída, anexos ou verificação. Autenticação serve a propriedade e privacidade; representação exige a membership existente; moderação serve a divulgação pública. Pendências permanecem visíveis. Não há SSO/CRM/IA externos bloqueantes nem emails reais nos testes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+80
View File
@@ -0,0 +1,80 @@
# InvestMoz: projetos âncora e clusters — 7 outubro 2026
Código integrado e compilado, preservando as alterações locais. As 28 associações territoriais de 13 projetos foram verificadas em QA. A leitura final também confirmou essas 28 associações na base ativa, com auditoria de aplicação autenticada pelo utilizador 13 às 12:59:36 UTC de 7 outubro 2026. A nova validação individual foi exercitada exclusivamente em QA. A página /management/relationship-repair apresenta o plano, fontes, motivos, oportunidades preparadas e pendências, usando as permissões de gestão existentes.
## Inventário
Inventariados 66 ficheiros, incluindo 35 DOCX e anexos incorporados, com SHA256 e proveniência. Há 15 âncoras reais, um projeto fictício excluído, oito clusters, 23 dossiers publicados de projetos/clusters, 13 cluster_projects e seis oportunidades genéricas. Não há cadeias nem elos cadastrados. Inventários e fontes extraídas permanecem em storage/app/private/anchor-project-reconciliation/.
O rótulo original dos dossiers refere programação v4, cujo ficheiro não foi encontrado; a programação v5 disponível corrobora as linhas/localizações. O rótulo original foi preservado. projectos.docx fundamenta os componentes preparados. Não foi afirmada autenticidade, vigência jurídica ou análise antimalware. Nenhum documento foi importado ou publicado.
## Projetos: integração das fichas corrigida; associações confirmadas em QA e na base ativa
| Código | Projeto | Localização documentada e papel |
| --- | --- | --- |
| A01 | MozParks — 20 Parques Industriais | Âmbito nacional; sem implantação individual |
| A02 | Linha Ferroviária Norte–Sul | Âmbito nacional; sem traçado documentado |
| A03 | Desenvolvimento Integrado do Corredor de Nacala | Nampula, Cabo Delgado, Niassa, Tete: secundárias; norte da Zambézia: impacto parcial |
| A04 | Crusse Jamali — ZEE de Turismo | Nampula: principal |
| A05 | Linha de Transmissão Namialo–Metoro | Nampula, Cabo Delgado: secundárias |
| A06 | Cadeia de Valor do Arroz por Projecto de Processamento | Tete, Manica, Zambézia, Sofala: secundárias |
| A07 | Parque Têxtil Integrado | Sofala: principal; Manica, Tete: secundárias |
| A08 | ZEE de Mocuba | Zambézia: principal |
| A09 | Cidadela Parlamentar de Mocuba | Zambézia: principal |
| A10 | Mphanda Nkuwa HPP | Tete: principal |
| A11 | Corredor Ferroviário e Portuário Macuse–Moatize | Tete, Zambézia e distrito de Nicoadala: secundárias |
| A12 | Conectividade e Resiliência Costeira — Compacto II | Zambézia: principal |
| A13 | Vilanculos — Região Integrada de Turismo | Inhambane: principal |
| A14 | ZEE de Agronegócio do Limpopo | Gaza: principal |
| A15 | Regadio na Bacia Hidrográfica do Rio Maputo | Maputo Província: principal; distritos Boane, Moamba, Namaacha: impacto |
Para todos faltam coordenadas, posto administrativo e localidade com fonte exata. Os distritos foram associados apenas quando identificados explicitamente; as cidades não foram convertidas em distritos. Não foram inventados pontos. Os estados dos projetos/dossiers foram preservados.
## Clusters: relações existentes apresentadas com navegação nos dois sentidos
| Código | Cluster | Âncoras documentadas |
| --- | --- | --- |
| CLU-01 | Agro-indústria e segurança alimentar | A06, A14, A15 |
| CLU-02 | Energia e industrialização | A01, A05, A08, A10 |
| CLU-03 | Logística, corredores e exportação | A02, A03, A11 |
| CLU-04 | Turismo costeiro e economia azul | A04, A13 |
| CLU-05 | Têxtil, vestuário e algodão | A07 |
| CLU-06 | Mineração e transformação mineral | Sem códigos individuais na fonte |
| CLU-07 | Tecnologia e serviços digitais | Sem códigos individuais na fonte |
| CLU-08 | Financiamento do investimento | Sem códigos individuais na fonte |
As 13 relações reutilizam identificadores existentes e permanecem relações documentais de planeamento pendentes, sem confirmação jurídica. Para todos os clusters falta fonte da área própria de abrangência e elos individualizados de cadeias de valor. Os locais dos membros não definem o perímetro do cluster; nenhum ponto único foi atribuído. As atividades/lacunas continuam consultáveis no dossier. Pertença ao cluster não cria automaticamente associações entre todos os projetos e todas as oportunidades.
Pesquisa, filtros e mapa usam as relações explícitas e a hierarquia territorial. Várias localizações possíveis não produzem um marcador arbitrário da oportunidade. A localização de um projeto ligado é identificada como contexto, sem confirmar implantação da oportunidade.
## Oportunidades preparadas e pendências
158 componentes preparados em privado, com identificador original, texto integral das células, localização textual, custos/horizonte originais e proveniência SHA256/tabela/linha. Não foram cadastrados nem publicados automaticamente.
86 têm códigos explícitos consistentes em A06, A07, A08, A09, A10, A11, A13, A14 e A15. Os restantes 72 têm conflitos nas três primeiras tabelas: legenda A03/âncora A02; legenda A04/âncora A02; legenda A05/âncora A03. Precisam de correção documental; não foram associados ou renumerados.
As seis oportunidades existentes não têm código documental que fundamente associação às âncoras. Nenhuma associação foi inferida por nome/setor. Nos componentes preparados continuam desconhecidos sector_id, promotor confirmado, maturidade, coordenadas, títulos/disponibilidade e interpretação monetária. Estas lacunas não impedem consultar os projetos.
## Backup e situação da aplicação ativa
Backup restaurado em PostgreSQL isolado, porta 55441. Comparadas contagens de 145 tabelas e hashes do conteúdo de 12 tabelas relevantes antes de alterar QA. Prova e dump: storage/app/private/anchor-project-reconciliation/backup-proof.json e backup-20261007/database.dump.
SHA256 do dump: C52865525BE0B227D9EA9B96FD74E563CE7AA547F6BDE31572F9D0FC539E16A6.
A aplicação verifica hash do plano, backup, identidade da base, versões e fontes. Usa transação, bloqueio e auditoria do utilizador autenticado; falhas causam rollback e repetições não criam duplicados.
## Validação territorial individual
`/management/relationship-repair` mostra localização atual e proposta, hierarquia completa disponível, papel, documento, citação e motivo. Valida província → distrito → posto administrativo → localidade, incluindo conflitos de ascendência e registos indisponíveis. Localização principal, secundária e área de impacto são independentes e podem coexistir. Sem coordenadas, uma localização documental continua fundamentada; coordenadas inválidas ou incompatíveis com um polígono territorial disponível são contraditórias. Sem polígonos disponíveis, a inclusão territorial das coordenadas fica explicitamente por verificar.
As linhas são separadas em fundamentadas, pendentes e contraditórias. É possível aplicar uma linha fundamentada individualmente; as restantes conservam os conflitos. Cada aplicação revalida a pré-visualização sob bloqueio e regista utilizador, data, fonte e estado anterior. O histórico permanece visível. Não se deduz a localização de um projeto pela pertença a um cluster ou proximidade a uma zona.
Em QA, duas linhas sintéticas fundamentadas foram aplicadas individualmente; uma pendente e uma contraditória ficaram intactas. O navegador verificou autor/data/fonte e ausência de coordenadas sem bloqueio, em 1440 e 390 px. Evidência: `territorial-browser-qa.json`. Esses locais sintéticos não fazem parte das propostas de correção da base ativa.
A ação autenticada continua necessária para novas correções por regra expressa do AGENTS.md. Iniciar sessão com uma conta existente autorizada em http://localhost:8000/management/relationship-repair; as linhas já aplicadas aparecem no histórico e não devem ser duplicadas. Não publica oportunidades nem promove estados. Inventário ativo final: project_locations=28, opportunity_projects=0, opportunity_clusters=0, cluster_projects=13. Um segundo backup atualizado foi restaurado em `partnership_restore_20261007_172202_test:55441`, comparando integralmente as 145 tabelas, e inclui as 28 associações; prova em `storage/app/private/partnership-exchange/backup-proof.json`.
## Verificação e limites
- 46 testes relevantes passaram, com 701 asserções, exclusivamente em investmoz_test:55441, incluindo a Bolsa: relações, validação territorial individual, quatro níveis territoriais, pesquisa, filtros, mapas, privacidade, ambiguidades, rollback e idempotência. Mais 23 testes do simulador passaram, com 176 asserções.
- TypeScript, compilação de produção e PHPStan dos ficheiros PHP alterados passaram.
- Navegador em QA verificou um projeto sintético completo com localização, coordenadas, mapa, oportunidade e regresso; cluster com dois locais de membros e dois elos; filtros; aplicação autenticada das 28 associações e repetição sem duplicados. Evidência: browser-qa.json e capturas a 1440/390 px. Os mosaicos externos foram bloqueados no ensaio; a disponibilidade do fundo cartográfico externo não foi validada.
- O teste geral preexistente InvestmentUnificationTest::test_attachments_are_private_and_content_changes_are_blocked continua a falhar ao esperar download com análise antimalware pendente. A proteção de downloads foi preservada. A análise estática global contém erros fora dos ficheiros alterados.
@@ -0,0 +1,19 @@
{
"target": "anchor_restore_20261007_test:55441",
"project": "970d942e-329a-48fa-9426-39828b65be6c",
"cluster": "e563883e-4353-4e07-8969-c774fe2d6d5f",
"passed": true,
"publicNavigation": true,
"administrativeHierarchy": true,
"clusterMemberSites": 2,
"clusterSinglePoint": false,
"territorialMap": true,
"chainLinks": true,
"authenticatedPlanApplied": true,
"replayWithoutDuplicates": true,
"viewports": [
1440,
390
],
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

@@ -0,0 +1,14 @@
{
"target": "anchor_restore_20261007_test:55441",
"passed": true,
"individualApplications": 2,
"pendingRetained": 1,
"contradictoryRetained": 1,
"noCoordinatesAccepted": true,
"auditAuthorDateSource": true,
"viewports": [
1440,
390
],
"errors": []
}
@@ -0,0 +1,39 @@
import { Link } from "@inertiajs/react";
import { useEffect, useState } from "react";
export default function PartnershipSearchResults({ query }: { query: string }) {
const [items, setItems] = useState<{ id: number; title: string }[]>([]);
useEffect(() => {
if (!query.trim()) {
setItems([]);
return;
}
const controller = new AbortController();
fetch("/api/v1/partnerships?q=" + encodeURIComponent(query), {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => setItems(data?.data ?? []))
.catch(() => {});
return () => controller.abort();
}, [query]);
if (!items.length) return null;
return (
<section className="my-5 space-y-3 rounded border p-4">
<h2 className="text-xl font-bold">Parcerias nesta pesquisa</h2>
{items.slice(0, 5).map((item) => (
<p key={item.id}>
<Link className="underline" href={"/parcerias/" + item.id}>
{item.title}
</Link>
</p>
))}
<Link
className="underline"
href={"/parcerias?q=" + encodeURIComponent(query)}
>
Ver todas as parcerias correspondentes
</Link>
</section>
);
}
@@ -0,0 +1,52 @@
import { Link } from "@inertiajs/react";
import { useEffect, useState } from "react";
export default function PartnershipSourceLinks({
type,
id,
}: {
type: string;
id: string | number;
}) {
const [data, setData] = useState<{
available: boolean;
items: { id: number; title: string }[];
} | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(
`/api/v1/partnerships/source/${encodeURIComponent(type)}/${encodeURIComponent(String(id))}`,
{
signal: controller.signal,
headers: { Accept: "application/json" },
},
)
.then((r) => (r.ok ? r.json() : null))
.then(setData)
.catch(() => {});
return () => controller.abort();
}, [type, id]);
if (!data?.available) return null;
return (
<section className="my-5 space-y-3 rounded border p-4">
<h2 className="text-xl font-bold">
Parcerias ligadas a este registo
</h2>
{data.items.map((item) => (
<p key={item.id}>
<Link className="underline" href={"/parcerias/" + item.id}>
{item.title}
</Link>
</p>
))}
{!data.items.length && (
<p>Ainda não há parcerias publicadas com esta origem.</p>
)}
<Link
className="underline"
href={`/parcerias/criar?origin_type=${encodeURIComponent(type)}&origin_id=${encodeURIComponent(String(id))}`}
>
Propor parceria a partir deste registo
</Link>
</section>
);
}
+5 -2
View File
@@ -6,6 +6,7 @@
useRef,
useState,
} from 'react';
import PartnershipSourceLinks from './partnership-source-links';
import {
getAutosaveSnapshot,
partitionSimulationDraft,
@@ -1053,6 +1054,7 @@ export function SimulationForm() {
let cancelled = false;
void (async () => {
let local: SimulationDraft = {};
const requestedDraftId = new URLSearchParams(window.location.search).get('draft');
try {
const stored = sessionStorage.getItem(draftKey);
if (stored) local = JSON.parse(stored) as SimulationDraft;
@@ -1063,7 +1065,7 @@ export function SimulationForm() {
}
try {
const response = await fetch(
'/api/v1/simulation-drafts/current',
requestedDraftId ? `/api/v1/simulation-drafts/${encodeURIComponent(requestedDraftId)}` : '/api/v1/simulation-drafts/current',
{
credentials: 'same-origin',
headers: {
@@ -1110,7 +1112,7 @@ export function SimulationForm() {
setSelectedAccountDraftId(
listPayload.data[0]?.id ?? '',
);
if (previewPayload.data) {
if (previewPayload.data && !requestedDraftId) {
setPendingAnonymousDraft(previewPayload.data);
setShowDraftChoice(true);
setSaveStatus(
@@ -2104,6 +2106,7 @@ export function SimulationForm() {
className="space-y-6 rounded-xl border bg-white p-4 sm:p-6"
aria-labelledby="simulator-step-title"
>
{canSavePersonalData && draftVersionNo && draftIdRef.current && <PartnershipSourceLinks type="simulation" id={draftIdRef.current}/>}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2
@@ -0,0 +1,43 @@
import { Link } from '@inertiajs/react';
import InteractiveInvestmentMap from './interactive-investment-map';
export type DocumentedLocation = { id: string; name: string; role: string; source_reference?: string; location_type: string;
path: { id: string; name: string; location_type: string }[];
coordinates: { geometry_id: string; source_reference: string; geometry: { coordinates: [number, number] } }[] };
export type StructureGeography = {
locations: DocumentedLocation[];
members: { id: string; dossier_id: string; title: string; code: string; role: string; validation_status: string; source_reference: string; locations: DocumentedLocation[] }[];
clusters: { id: string; dossier_id: string; title: string; role: string; validation_status: string; source_reference: string }[];
chainLinks: { id: string; origin: string; destination: string; link_type: string }[];
chains?: { id: string; code: string; name: string; stage: string }[];
};
const roles: Record<string,string> = { primary: 'Localização principal', secondary: 'Localização secundária', impact: 'Área de impacto', coverage: 'Área de abrangência documentada' };
export function LocationList({ locations }: { locations: DocumentedLocation[] }) {
return <div className="space-y-4">{locations.map((location, index) => <article key={`${location.id}-${location.role}-${index}`} className="rounded-lg border p-4">
<h3 className="font-bold">{roles[location.role] ?? location.role}: {location.name}</h3>
<dl className="grid gap-2 sm:grid-cols-2">{[['province','Província'],['district','Distrito'],['admin_post','Posto administrativo'],['locality','Localidade']].map(([type,label]) => <div key={type}><dt className="font-semibold">{label}</dt><dd>{location.path.find(node => node.location_type===type)?.name ?? 'Não documentado'}</dd></div>)}</dl>
{location.coordinates.map(point => <p key={point.geometry_id}>Coordenadas documentadas ({location.location_type === 'site' ? 'local do projecto' : 'referência territorial; não representa a implantação'}): {point.geometry.coordinates[1]}, {point.geometry.coordinates[0]} · Fonte: {point.source_reference}</p>)}
{!location.coordinates.length && <p>Coordenadas não documentadas.</p>}
<p className="text-sm">Fonte da relação: {location.source_reference ?? 'Não indicada'}</p>
<Link className="underline" href={`/oportunidades?geo_location_id=${location.id}`}>Pesquisar oportunidades neste território</Link>
</article>)}</div>;
}
export default function StructureGeographyPanel({ geography, cluster = false, dossierId }: { geography: StructureGeography; cluster?: boolean; dossierId: string }) {
const items = cluster ? geography.members.flatMap(member=>member.locations.map(location=>({location,title:member.title,url:`/carteira/${member.dossier_id}`}))) : geography.locations.map(location=>({location,title:location.name,url:`/carteira/${dossierId}`}));
const points = items.flatMap(item=>item.location.location_type === 'site' ? item.location.coordinates.map(point=>({geometry:point.geometry,properties:{id:0,title:item.title,location:`${roles[item.location.role] ?? item.location.role} · ${item.location.name}`,url:item.url}})) : []).map((point,index)=>({...point,properties:{...point.properties,id:index+1}}));
return <section className="space-y-4 rounded-xl border p-5" aria-labelledby={`documented-geography-${dossierId}`}>
<h2 id={`documented-geography-${dossierId}`} className="text-xl font-bold">{cluster ? 'Abrangência do cluster e localizações dos membros' : 'Localização documentada do projecto'}</h2>
<LocationList locations={geography.locations} />
{!geography.locations.length && <p>{cluster ? 'A área própria de abrangência do cluster não está documentada em referências territoriais. As localizações dos membros abaixo não definem o seu perímetro.' : 'Sem associação territorial estruturada. Consulte a descrição de localização no dossier; os níveis desconhecidos permanecem não documentados.'}</p>}
{cluster && <p>O mapa apresenta locais documentados dos membros. Não existe um ponto único atribuído ao cluster.</p>}
<InteractiveInvestmentMap points={points} />
{!points.length && <p>Sem coordenadas documentadas dos locais. Nenhum ponto foi estimado.</p>}
{geography.members.map(member=><article key={member.id} className="space-y-3 border-t pt-4"><h3 className="font-bold"><Link className="underline" href={`/carteira/${member.dossier_id}?from=${dossierId}`}>{member.code} · {member.title}</Link></h3>
<p>Papel: {member.role} · Relação {member.validation_status === 'validated' ? 'validada no cadastro' : 'documental de planeamento; validação pendente'} · Fonte: {member.source_reference}</p><LocationList locations={member.locations}/>{!member.locations.length && <p>Localização estruturada do membro não documentada.</p>}</article>)}
{geography.clusters.map(item=><p key={item.id}>Cluster: <Link className="underline" href={`/carteira/${item.dossier_id}?from=${dossierId}`}>{item.title}</Link> · {item.validation_status === 'validated' ? 'validada no cadastro' : 'relação documental de planeamento; validação pendente'} · Fonte: {item.source_reference}</p>)}
{cluster && <div><h3 className="font-bold">Elos da cadeia de valor cadastrados</h3>{geography.chains?.map(chain=><p key={chain.id}>{chain.code} · {chain.name} · {({input:'Insumos',production:'Produção',processing:'Transformação',logistics:'Logística',market:'Mercado'} as Record<string,string>)[chain.stage] ?? chain.stage}</p>)}{!geography.chains?.length && <p>Sem elos estruturados cadastrados. As actividades e lacunas descritas no dossier continuam disponíveis.</p>}</div>}
{!!geography.chainLinks.length && <div><h3 className="font-bold">Relações entre elos da cadeia de valor</h3>{geography.chainLinks.map(link=><p key={link.id}>{link.origin} → {link.destination} · {link.link_type}</p>)}</div>}
</section>;
}
@@ -1,9 +1,10 @@
import { Link } from "@inertiajs/react";
import { Frame, button, card } from "./shared";
import { businessModalities } from "./business-types";
import PartnershipSourceLinks from '@/components/partnership-source-links';
type Business = {
slug: string; title: string; modality: string; objective?: string | null; stage?: string | null; activity_code?: string | null;
id: string; slug: string; title: string; modality: string; objective?: string | null; stage?: string | null; activity_code?: string | null;
amount_min?: string | null; amount_max?: string | null; available_percentage?: string | null; currency?: string | null;
province?: string | null; district?: string | null; locality?: string | null; sector?: { name: string; slug: string } | null;
};
@@ -11,6 +12,7 @@ type Business = {
export default function BusinessOpportunity({ record, returnUrl = "/negocios", interestUrl = `/negocios/${record.slug}/manifestar-interesse` }: { record: Business; returnUrl?: string; interestUrl?: string }) {
return (
<Frame title={record.title}>
<PartnershipSourceLinks type="business" id={record.id}/>
<Link href={returnUrl} className="underline">Voltar às oportunidades</Link>
<section className={`${card} space-y-4`}>
<p>{businessModalities[record.modality] ?? record.modality}</p>
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import Map from "@/components/interactive-investment-map";
import { TerritoryLevel, type TerritoryOption } from "@/components/investment-territory";
import { MATURITY_LEVELS } from "@/lib/investment-maturity";
import PartnershipSearchResults from '@/components/partnership-search-results';
import { Frame, Field, input, button, card, money, api, groupLabels } from "./shared";
import type { Opportunity, References } from "./shared";
@@ -189,6 +190,7 @@ export default function Catalogue({
<Frame
title={form.data.education ? "Oportunidades na Educação" : "Catálogo de investimento"}
>
<PartnershipSearchResults query={String(filters.q??'')}/>
<nav aria-label="Dimensões do catálogo" className="grid gap-3 sm:grid-cols-4">
<Link className={card} href={dimensionUrl("cluster")}>
Por Cluster
+8
View File
@@ -1,6 +1,8 @@
import { Link, router, usePage } from "@inertiajs/react";
import { useState } from "react";
import InvestmentMapPanel from "@/components/investment-map-panel";
import StructureGeographyPanel from '@/components/structure-geography';
import PartnershipSourceLinks from '@/components/partnership-source-links';
import { Frame, card, button, money, groupLabels } from "./shared";
import type { Opportunity, Context } from "./shared";
export default function Detail({
@@ -8,17 +10,21 @@ export default function Detail({
context: c,
zone_origin,
returnUrl = "/oportunidades",
dossierOrigin,
}: {
record: Opportunity;
context: Context;
zone_origin?: { id: string; name: string } | null;
returnUrl?: string;
dossierOrigin?: { id: string; title: string } | null;
}) {
const { auth } = usePage<{ auth: { user: unknown } }>().props;
const [sharing, setSharing] = useState("");
return (
<Frame title={r.title}>
<PartnershipSourceLinks type="investment" id={r.id}/>
<Link href={returnUrl} className="underline">Voltar à pesquisa</Link>
{dossierOrigin && <p><Link className="underline" href={`/carteira/${dossierOrigin.id}`}>Voltar ao dossier: {dossierOrigin.title}</Link></p>}
<p>
{r.reference} · {r.sector?.name} · {r.province} {r.district}
</p>
@@ -145,6 +151,7 @@ export default function Detail({
</h2>
{c[key].map((entity) => (
<p key={entity.id}>
{entity.dossiers?.map(dossier => <span key={dossier.id}><Link className="underline" href={`/carteira/${dossier.id}`}>Consultar dossier: {dossier.title}</Link><br /></span>)}
<Link
className="underline"
href={
@@ -167,6 +174,7 @@ export default function Detail({
</div>
))}
</section>
{(['projects','clusters'] as const).flatMap(key => c[key].filter(entity => entity.geography && entity.dossiers?.length).map(entity => <StructureGeographyPanel key={`${key}-${entity.id}`} geography={entity.geography!} cluster={key === 'clusters'} dossierId={entity.dossiers![0].id} />))}
<section className={card}>
<h2 className="text-xl font-bold">
Infraestruturas, competências, educação e apoios
@@ -1,5 +1,6 @@
import { Link, router, useForm } from "@inertiajs/react";
import { Frame, Field, input, button, card } from "./shared";
import PartnershipSourceLinks from '@/components/partnership-source-links';
type RecordData = { version: number; payload: Record<string, string> };
type Company = RecordData & { id: string };
@@ -47,6 +48,7 @@ export default function Profile({
</p>
{companies.map((company) => (
<section key={company.id} className={card}>
<PartnershipSourceLinks type="company" id={company.id}/>
<h3 className="text-xl font-bold">{company.payload.name}</h3>
<Editor
key={company.version}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Head, Link, usePage } from "@inertiajs/react";
import type { ReactNode } from "react";
import { PortalMenu } from "@/layouts/portal-navigation";
import type { StructureGeography } from '@/components/structure-geography';
export type Option = {
id: number;
@@ -11,7 +12,7 @@ export type Option = {
requires_description: boolean;
sort_order: number;
};
export type Entity = { id: string; name: string };
export type Entity = { id: string; name: string; dossiers?: { id: string; title: string; source: string }[]; geography?: StructureGeography | null };
export type References = {
zones?: { id: string; name: string; category: string }[];
options: Option[];
+2
View File
@@ -4,6 +4,7 @@ import InvestmentMapPanel from "@/components/investment-map-panel";
import { TerritoryFields, type TerritoryOption } from "@/components/investment-territory";
import { useState } from "react";
import { Frame, Field, input, button, card, api } from "./shared";
import PartnershipSourceLinks from '@/components/partnership-source-links';
import ZoneRelations from "./zone-relations";
import ZoneMap from "./zone-map";
import ZoneAttachments from "./zone-attachments";
@@ -196,6 +197,7 @@ function Editor({
}
return (
<Frame title={z.name}>
{!management&&<PartnershipSourceLinks type="zone" id={z.id}/>}
<p>
{z.code} · {z.category.toUpperCase()} · {z.validation_status} · {z.province}{" "}
{z.district}
@@ -0,0 +1,445 @@
import { Link, useForm } from "@inertiajs/react";
import { Management } from "./shared";
type Plan = {
target: { database: string; port: number };
locations: {
code: string;
project_id: string;
name: string;
role: string;
source_reference: string;
source_quote: string;
reason: string;
existing: boolean;
}[];
memberships: {
cluster_code: string;
project_code: string;
source_reference: string;
source_quote: string;
action: string;
}[];
prepared_opportunities: {
reference: string;
title: string;
project_code: string | null;
location_text: string;
source_reference: string;
identity_status: string;
unknown: string[];
reason: string;
}[];
pending: { code: string; reason: string; name?: string }[];
};
type Territory = {
row_id: string;
validation_hash: string;
status: "grounded" | "pending" | "contradictory";
applied: boolean;
issues: string[];
warnings: string[];
current: {
id: string;
name: string;
role: string;
path: { name: string; type: string }[];
}[];
proposed: {
name: string;
role: string;
path: { name: string; type: string }[];
};
coordinates: { source: string; coordinates: number[]; srid: number }[];
coordinate_checks: {
territory_id: string;
source: string;
compatible: boolean;
}[];
};
type History = {
id: string;
date: string;
actor_id: number;
actor: string | null;
data: {
sources?: {
row_id: string;
source_reference: string;
reason: string;
}[];
};
};
const roles: Record<string, string> = {
primary: "Principal",
secondary: "Secundária",
impact: "Área de impacto",
};
const levels: Record<string, string> = {
country: "País",
province: "Província",
district: "Distrito",
admin_post: "Posto administrativo",
locality: "Localidade",
site: "Implantação",
};
function pathText(path: { name: string; type: string }[]) {
return (
path
.map((node) => `${levels[node.type] ?? node.type}: ${node.name}`)
.join(" → ") || "Hierarquia não indicada"
);
}
function IndividualAssociation({
hash,
item,
}: {
hash: string;
item: Territory;
}) {
const form = useForm({
hash,
row_id: item.row_id,
validation_hash: item.validation_hash,
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
form.transform(() => ({
hash,
row_id: item.row_id,
validation_hash: item.validation_hash,
}));
form.post("/management/relationship-repair", {
preserveScroll: true,
});
}}
>
<button
className="rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50"
disabled={
form.processing ||
item.applied ||
item.status !== "grounded"
}
>
{item.applied
? "Associação aplicada"
: "Aplicar esta associação"}
</button>
{Object.values(form.errors).map((error) => (
<p key={error} role="alert">
{error}
</p>
))}
</form>
);
}
type Unproposed = {
code: string;
current: { name: string; role: string }[];
source: string | null;
location_text: string | null;
reasons: string[];
};
export default function RelationshipRepair({
hash,
plan,
territorial,
history,
unproposed,
}: {
hash: string;
plan: Plan;
territorial: Territory[];
history: History[];
unproposed: Unproposed[];
}) {
const form = useForm({ hash });
return (
<Management title="Associações documentadas de projectos âncora e clusters">
<p>
Pré-visualização para {plan.target.database}:{plan.target.port}.
A execução verifica o backup e o restauro, a versão dos dossiers
e os identificadores. As associações existentes são preservadas.
</p>
<p>
O plano corrige localizações documentadas. As oportunidades
abaixo ficam preparadas para cadastro; não são publicadas nem
ligadas a clusters por pertencerem ao mesmo projecto.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
form.post("/management/relationship-repair");
}}
>
<button
className="rounded bg-emerald-800 px-4 py-3 text-white disabled:opacity-50"
disabled={form.processing}
>
Aplicar associações fundamentadas
</button>
{Object.values(form.errors).map((error) => (
<p key={error} role="alert">
{error}
</p>
))}
</form>
<section className="space-y-4">
<h2 className="text-xl font-bold">
Localizações: {plan.locations.length}
</h2>
<p>
As propostas acrescentam localizações documentadas e
preservam as atuais. Cada projeto pode ter várias
localizações principais, secundárias e de impacto. A
localização não é deduzida de clusters ou da proximidade a
zonas.
</p>
{(["grounded", "pending", "contradictory"] as const).map(
(status) => (
<section
key={status}
className="space-y-4"
aria-label={
status === "grounded"
? "Associações fundamentadas"
: status === "pending"
? "Associações pendentes"
: "Associações contraditórias"
}
>
<h3 className="text-lg font-bold">
{status === "grounded"
? "Fundamentadas"
: status === "pending"
? "Pendentes"
: "Contraditórias"}
:{" "}
{territorial.filter(
(item) => item.status === status,
).length +
(status === "pending"
? unproposed.length
: 0)}
</h3>
{status === "pending" &&
unproposed.map((item) => (
<article
key={item.code}
className="space-y-2 rounded border p-4"
>
<h4 className="font-bold">
{item.code} · Sem associação
territorial individualizada
</h4>
<p>
Localização atual:{" "}
{item.current
.map(
(location) =>
`${location.name} · ${roles[location.role]}`,
)
.join("; ") ||
"Sem localização cadastrada"}
</p>
<p>
Localização documental proposta:{" "}
{item.location_text ??
"Não indicada"}
</p>
<p>
Documento de origem:{" "}
{item.source ?? "Não indicado"}
</p>
{item.reasons.map((reason) => (
<p key={reason}>{reason}</p>
))}
</article>
))}
{territorial.map((validation, index) =>
validation.status !== status ? null : (
<article
key={validation.row_id}
data-row-id={validation.row_id}
className="space-y-3 rounded border p-4"
>
<h4 className="font-bold">
{plan.locations[index].code} →{" "}
{validation.proposed.name} ·{" "}
{roles[validation.proposed.role] ??
validation.proposed.role}
</h4>
<div>
<strong>Localização atual</strong>
{validation.current.length ? (
<ul>
{validation.current.map(
(current, i) => (
<li key={i}>
{current.name} ·{" "}
{roles[
current.role
] ??
current.role}{" "}
—{" "}
{pathText(
current.path,
)}
</li>
),
)}
</ul>
) : (
<p>
Sem localização cadastrada.
</p>
)}
</div>
<p>
<strong>
Localização proposta:
</strong>{" "}
{pathText(validation.proposed.path)}{" "}
· {roles[validation.proposed.role]}
</p>
<p>
Documento de origem:{" "}
{
plan.locations[index]
.source_reference
}
</p>
<blockquote>
{plan.locations[index].source_quote}
</blockquote>
<p>
Motivo:{" "}
{plan.locations[index].reason}
</p>
{validation.coordinates.map(
(point, i) => (
<p key={i}>
Coordenadas (longitude,
latitude):{" "}
{point.coordinates.join(
", ",
)}{" "}
· SRID {point.srid} · Fonte:{" "}
{point.source}
</p>
),
)}
{validation.coordinate_checks.map(
(check, i) => (
<p key={i}>
{check.compatible
? "Coordenadas compatíveis com o limite territorial"
: "Coordenadas incompatíveis com o limite territorial"}{" "}
· Fonte cartográfica:{" "}
{check.source}
</p>
),
)}
{validation.issues.map((issue) => (
<p
key={issue}
className="font-semibold text-red-700"
>
{issue}
</p>
))}
{validation.warnings.map((warning) => (
<p key={warning}>{warning}</p>
))}
<IndividualAssociation
hash={hash}
item={validation}
/>
</article>
),
)}
</section>
),
)}
</section>
<section className="space-y-3">
<h2 className="text-xl font-bold">Histórico de aplicação</h2>
{history.length ? (
history.map((event) => (
<article key={event.id} className="rounded border p-4">
<p>
Autor: {event.actor ?? "Utilizador"} (ID{" "}
{event.actor_id}) · Data: {event.date}
</p>
{event.data.sources?.map((source) => (
<p key={source.row_id}>
Fonte: {source.source_reference} · Motivo:{" "}
{source.reason}
</p>
))}
</article>
))
) : (
<p>Nenhuma associação aplicada por este plano.</p>
)}
</section>
<section className="space-y-4">
<h2 className="text-xl font-bold">
Projectos nos clusters: {plan.memberships.length}
</h2>
{plan.memberships.map((item, index) => (
<article key={index} className="rounded border p-4">
<h3 className="font-bold">
{item.cluster_code} ↔ {item.project_code}
</h3>
<p>
{item.action === "present_existing"
? "Relação existente, agora apresentada como proposta documental pendente"
: "Relação ausente"}
</p>
<p>Fonte: {item.source_reference}</p>
<blockquote>{item.source_quote}</blockquote>
</article>
))}
</section>
<section className="space-y-4">
<h2 className="text-xl font-bold">
Registos de oportunidades preparados:{" "}
{plan.prepared_opportunities.length}
</h2>
{plan.prepared_opportunities.map((item, index) => (
<details key={index} className="rounded border p-4">
<summary>
{item.reference} · {item.title}
</summary>
<p>
Projecto:{" "}
{item.project_code ??
"Identidade em conflito; associação pendente"}
</p>
<p>{item.location_text}</p>
<p>Fonte: {item.source_reference}</p>
<p>{item.reason}</p>
<p>Dados por completar: {item.unknown.join(", ")}.</p>
</details>
))}
</section>
<section>
<h2 className="text-xl font-bold">Pendências concretas</h2>
<ul className="list-disc pl-5">
{plan.pending.map((item, index) => (
<li key={index}>
{item.code}: {item.reason} {item.name}
</li>
))}
</ul>
</section>
<Link href="/carteira" className="underline">
Consultar carteira
</Link>
</Management>
);
}
+2
View File
@@ -53,6 +53,8 @@ export function Management({ title, children }: { title: string; children: React
<Link href="/management/crm/investors">Investidores</Link>
<Link href="/management/projects">Projectos</Link>
<Link href="/management/clusters">Clusters</Link>
<Link href="/management/relationship-repair">Revisão das associações documentadas</Link>
<Link href="/management/partnerships-install">Aplicação da Bolsa de Parcerias</Link>
<Link href="/management/corridors">Corredores</Link>
<Link href="/management/territory-associations">
Localizações dos investimentos
@@ -0,0 +1,179 @@
import { Link, router } from "@inertiajs/react";
import { useState } from "react";
import InteractiveInvestmentMap, {
type InvestmentMapFeature,
} from "@/components/interactive-investment-map";
import {
Frame,
Field,
control,
button,
type Catalogues,
type Partnership,
} from "./shared";
type Result = Pick<
Partnership,
"id" | "title" | "kind" | "partnership_type" | "deadline"
> & { summary: string | null; province: string; sector: string };
export default function Catalogue(
p: Catalogues & {
results: {
data: Result[];
total: number;
prev_page_url: string | null;
next_page_url: string | null;
};
filters: Record<string, string>;
mapPoints: InvestmentMapFeature[];
},
) {
const [filters, setFilters] = useState(p.filters);
const set = (key: string, value: string) =>
setFilters({ ...filters, [key]: value });
return (
<Frame title="Bolsa de Parcerias">
<p className="mb-5">
Explore ofertas e procuras de colaboração. Pode preparar uma
ideia com os dados disponíveis; a publicação passa por
moderação.
</p>
<form
className="grid gap-4 rounded border p-5 md:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
router.get("/parcerias", filters);
}}
>
<Field label="Pesquisar">
<input
className={control}
value={filters.q ?? ""}
onChange={(e) => set("q", e.target.value)}
/>
</Field>
<Field label="Tipo de parceria">
<select
className={control}
value={filters.partnership_type ?? ""}
onChange={(e) =>
set("partnership_type", e.target.value)
}
>
<option value="">Todos</option>
{Object.entries(p.types).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</Field>
<Field label="Oferta ou procura">
<select
className={control}
value={filters.kind ?? ""}
onChange={(e) => set("kind", e.target.value)}
>
<option value="">Ambas</option>
<option value="offering">Oferta</option>
<option value="seeking">Procura</option>
</select>
</Field>
<Field label="Setor">
<select
className={control}
value={filters.sector_id ?? ""}
onChange={(e) => set("sector_id", e.target.value)}
>
<option value="">Todos</option>
{p.sectors.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
<Field label="Território">
<select
className={control}
value={filters.territory_id ?? ""}
onChange={(e) => set("territory_id", e.target.value)}
>
<option value="">Todos</option>
{p.territories.map((x) => (
<option key={x.id} value={x.id}>
{x.name} · {x.location_type}
</option>
))}
</select>
</Field>
<div className="self-end">
<button className={button}>Pesquisar parcerias</button>
</div>
</form>
<p className="my-5">{p.results.total} parcerias publicadas</p>
<div className="grid gap-5 md:grid-cols-2">
{p.results.data.map((item) => (
<article
key={item.id}
className="space-y-3 rounded border p-5"
>
<h2 className="text-xl font-bold">
<Link href={"/parcerias/" + item.id}>
{item.title}
</Link>
</h2>
<p>
{item.partnership_type
? p.types[item.partnership_type]
: "Tipo por completar"}{" "}
· {item.kind === "offering" ? "Oferta" : "Procura"}
</p>
<p>{item.summary}</p>
<p>
{item.sector || "Setor por completar"} ·{" "}
{item.province || "Território na ficha"}
</p>
{item.deadline && <p>Prazo: {item.deadline}</p>}
<Link
href={"/parcerias/" + item.id}
className="underline"
>
Consultar parceria
</Link>
</article>
))}
</div>
{!p.results.data.length && (
<p className="my-8">
Nenhuma parceria corresponde aos filtros.
</p>
)}
<section className="my-6 space-y-3 rounded border p-5">
<h2 className="text-xl font-bold">
Mapa dos resultados desta página
</h2>
<p>
Localizações próprias e contexto documental das origens são
identificados separadamente. Um cluster pode apresentar
vários locais dos membros; estes pontos não confirmam a
implantação da parceria.
</p>
<InteractiveInvestmentMap points={p.mapPoints} />
{!p.mapPoints.length && (
<p>
Sem coordenadas documentadas nos resultados. As fichas e
os filtros territoriais continuam disponíveis.
</p>
)}
</section>
<nav className="mt-6 flex gap-4">
{p.results.prev_page_url && (
<Link href={p.results.prev_page_url}>Anterior</Link>
)}
{p.results.next_page_url && (
<Link href={p.results.next_page_url}>Seguinte</Link>
)}
</nav>
</Frame>
);
}
@@ -0,0 +1,124 @@
import { Link, router } from "@inertiajs/react";
import { Frame, labels, button, type Partnership } from "./shared";
type Props = {
mine: Pick<Partnership, "id" | "title" | "status">[];
interests: {
id: number;
partnership_id: number;
title: string;
status: string;
decision_reason: string | null;
room_id: number | null;
}[];
moderation: Pick<Partnership, "id" | "title" | "status">[];
notifications: {
id: number;
title: string;
href: string;
read_at: string | null;
}[];
};
export default function Dashboard(p: Props) {
return (
<Frame title="O meu painel de parcerias">
<div className="space-y-8">
<section>
<h2 className="mb-4 text-xl font-bold">
Ideias e propostas
</h2>
<Link className={button} href="/parcerias/criar">
Preparar uma parceria
</Link>
{p.mine.map((x) => (
<article className="mt-4 rounded border p-4" key={x.id}>
<Link
className="font-semibold underline"
href={"/parcerias/" + x.id}
>
{x.title || "Ideia sem título"}
</Link>
<p>{labels[x.status] ?? x.status}</p>
<p>
Próxima ação:{" "}
{["draft", "returned", "rejected"].includes(
x.status,
)
? "Completar e pré-visualizar para submissão"
: x.status === "published"
? "Consultar candidatos e esclarecer pendências"
: "Acompanhar a decisão"}
</p>
</article>
))}
</section>
<section>
<h2 className="text-xl font-bold">
As minhas candidaturas
</h2>
{p.interests.map((x) => (
<article className="my-4 rounded border p-4" key={x.id}>
<Link
className="underline"
href={"/parcerias/" + x.partnership_id}
>
{x.title}
</Link>
<p>
{labels[x.status]} · {x.decision_reason}
</p>
{x.room_id && (
<Link
className={button}
href={"/parcerias/salas/" + x.room_id}
>
Abrir conexão privada
</Link>
)}
</article>
))}
</section>
{p.moderation.length > 0 && (
<section>
<h2 className="text-xl font-bold">Fila de moderação</h2>
{p.moderation.map((x) => (
<p key={x.id}>
<Link
className="underline"
href={"/parcerias/" + x.id}
>
{x.title} · {labels[x.status]}
</Link>
</p>
))}
</section>
)}
<section>
<h2 className="text-xl font-bold">
Notificações no portal
</h2>
{p.notifications.map((n) => (
<article className="my-3 rounded border p-3" key={n.id}>
<Link href={n.href} className="underline">
{n.title}
</Link>
{!n.read_at && (
<button
className="ml-4 underline"
onClick={() =>
router.post(
"/parcerias/notificacoes/" +
n.id +
"/ler",
)
}
>
Marcar como lida
</button>
)}
</article>
))}
</section>
</div>
</Frame>
);
}
+628
View File
@@ -0,0 +1,628 @@
import { Link, useForm, usePage } from "@inertiajs/react";
import StructureGeographyPanel, {
type StructureGeography,
} from "@/components/structure-geography";
import {
Frame,
Errors,
Field,
control,
button,
labels,
type Catalogues,
type Interest,
type Partnership,
type Source,
} from "./shared";
type Props = Catalogues & {
record: Partnership;
owner: boolean;
canEdit: boolean;
moderator: boolean;
origins: (Source & { geography: StructureGeography | null })[];
locations: {
id: string;
name: string;
role: string;
source_reference: string;
path: { name: string }[];
}[];
interests: Interest[];
history: {
event_type: string;
actor_id: number;
occurred_at: string;
payload: string;
}[];
};
function Publication({ p }: { p: Props }) {
const form = useForm({ version: p.record.version, action: "", reason: "" });
const action = (value: string) => {
form.transform((data) => ({ ...data, action: value }));
form.post("/parcerias/" + p.record.id + "/estado", {
preserveScroll: true,
});
};
return (
<section className="space-y-4 rounded border p-5">
<h2 className="text-xl font-bold">Pré-visualização e publicação</h2>
<p>
Estado: {labels[p.record.status] ?? p.record.status}.
Verificação da organização, setor e localização permanecem por
confirmar quando não há evidência.
</p>
<Field label="Motivo da ação">
<textarea
className={control}
value={form.data.reason}
onChange={(e) => form.setData("reason", e.target.value)}
/>
</Field>
<Errors errors={form.errors} />
<div className="flex flex-wrap gap-3">
{p.canEdit &&
["draft", "returned", "rejected"].includes(
p.record.status,
) && (
<button
className={button}
disabled={form.processing || !form.data.reason}
onClick={() => action("submit")}
>
Submeter para publicação
</button>
)}
{p.moderator &&
!p.owner &&
["submitted", "pending"].includes(p.record.status) &&
["publish", "return", "reject"].map((value) => (
<button
className={button}
key={value}
disabled={form.processing || !form.data.reason}
onClick={() => action(value)}
>
{
{
publish: "Publicar",
return: "Devolver para correção",
reject: "Rejeitar com fundamento",
}[value]
}
</button>
))}
{p.canEdit &&
["published", "suspended", "archived"].includes(
p.record.status,
) && (
<button
className={button}
disabled={form.processing || !form.data.reason}
onClick={() => action("renew")}
>
Renovar em rascunho
</button>
)}
{p.canEdit &&
["draft", "published", "suspended", "rejected"].includes(
p.record.status,
) && (
<button
className={button}
disabled={form.processing || !form.data.reason}
onClick={() => action("archive")}
>
Arquivar publicação
</button>
)}
{p.canEdit && (
<Link
className="underline"
href={"/parcerias/" + p.record.id + "/editar"}
>
Editar rascunho
</Link>
)}
</div>
</section>
);
}
function InterestForm({
record,
interest,
organizations,
}: {
record: Partnership;
interest?: Interest;
organizations: Catalogues["organizations"];
}) {
const form = useForm({
version: interest?.version ?? null,
organization_id: interest?.organization_id ?? "",
proposal: interest?.proposal ?? "",
payload: {
capacity: interest?.payload.capacity ?? "",
availability: interest?.payload.availability ?? "",
contribution: interest?.payload.contribution ?? "",
conflicts: interest?.payload.conflicts ?? "",
consent: interest?.payload.consent ?? false,
},
});
return (
<form
className="space-y-4 rounded border p-5"
onSubmit={(e) => {
e.preventDefault();
form.post("/parcerias/" + record.id + "/interesses", {
preserveScroll: true,
});
}}
>
<h2 className="text-xl font-bold">
{interest
? "Atualizar manifestação de interesse"
: "Manifestar interesse"}
</h2>
<p>
Declarações são informações comunicadas por si; não atribuem
verificação. Dados desconhecidos podem ser esclarecidos no
processo.
</p>
<Errors errors={form.errors} />
<Field label="Participação">
<select
className={control}
value={form.data.organization_id}
onChange={(e) =>
form.setData("organization_id", e.target.value)
}
>
<option value="">Participação pessoal</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label="Proposta curta">
<textarea
className={control}
value={form.data.proposal}
onChange={(e) => form.setData("proposal", e.target.value)}
/>
</Field>
{(
[
"capacity",
"availability",
"contribution",
"conflicts",
] as const
).map((key) => (
<Field
key={key}
label={
{
capacity: "Capacidade disponível",
availability: "Disponibilidade",
contribution: "Contribuição proposta",
conflicts: "Conflitos de interesse declarados",
}[key]
}
>
<textarea
className={control}
value={form.data.payload[key]}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
[key]: e.target.value,
})
}
/>
</Field>
))}
<label className="block">
<input
type="checkbox"
checked={form.data.payload.consent}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
consent: e.target.checked,
})
}
/>{" "}
Autorizo a partilha desta candidatura com o responsável pela
parceria.
</label>
<button className={button} disabled={form.processing}>
Guardar interesse
</button>
</form>
);
}
function Candidacy({
interest,
owner,
canEdit,
record,
organizations,
}: {
interest: Interest;
owner: boolean;
canEdit: boolean;
record: Partnership;
organizations: Catalogues["organizations"];
}) {
const assessment = Object.fromEntries(
interest.match.criteria.map((c) => [
c.code,
{
state: interest.assessment[c.code]?.state ?? "unknown",
evidence: interest.assessment[c.code]?.evidence ?? "",
},
]),
);
const form = useForm({
version: interest.version,
action: "",
reason: "",
assessment,
});
const act = (action: string) => {
form.transform((data) => ({
...data,
action,
assessment: action === "assess" ? data.assessment : {},
}));
form.post("/parcerias/candidaturas/" + interest.id, {
preserveScroll: true,
});
};
return (
<article className="space-y-4 rounded border p-5">
<h3 className="text-lg font-bold">
Candidatura {interest.id} · {labels[interest.status]}
</h3>
<p>{interest.proposal}</p>
<p>{interest.payload.capacity}</p>
<p>
Disponibilidade:{" "}
{interest.payload.availability || "Por esclarecer"}
</p>
<p>
Contribuição:{" "}
{interest.payload.contribution || "Por esclarecer"}
</p>
<p>
Conflitos declarados:{" "}
{interest.payload.conflicts || "Sem informação"}
</p>
{interest.decision_reason && (
<p>Última decisão: {interest.decision_reason}</p>
)}
<p>
Correspondência: {interest.match.score_min}–
{interest.match.score_max}% · Elegibilidade:{" "}
{labels[interest.match.eligibility]}. O intervalo distingue
dados desconhecidos de incumprimentos; não é uma aprovação.
</p>
<ul className="space-y-2">
{interest.match.criteria.map((c) => (
<li key={c.code}>
{c.label} · peso {c.weight} · {labels[c.state]}
{c.mandatory ? " · Obrigatório" : ""}
{c.evidence && <> · Evidência: {c.evidence}</>}
<p className="text-sm">{c.explanation}</p>
</li>
))}
</ul>
{interest.room_id && (
<Link
className={button}
href={"/parcerias/salas/" + interest.room_id}
>
Abrir conexão privada
</Link>
)}
{owner &&
canEdit &&
["submitted", "clarification", "shortlisted"].includes(
interest.status,
) && (
<>
<details className="space-y-4">
<summary>Registar avaliação fundamentada</summary>
{interest.match.criteria.map((c) => (
<div
className="my-3 grid gap-3 md:grid-cols-2"
key={c.code}
>
<Field label={c.label}>
<select
className={control}
value={
form.data.assessment[c.code]
.state
}
onChange={(e) =>
form.setData("assessment", {
...form.data.assessment,
[c.code]: {
...form.data.assessment[
c.code
],
state: e.target.value,
},
})
}
>
{["unknown", "met", "unmet"].map(
(state) => (
<option
key={state}
value={state}
>
{labels[state]}
</option>
),
)}
</select>
</Field>
<Field label="Referência da evidência">
<input
className={control}
value={
form.data.assessment[c.code]
.evidence
}
onChange={(e) =>
form.setData("assessment", {
...form.data.assessment,
[c.code]: {
...form.data.assessment[
c.code
],
evidence:
e.target.value,
},
})
}
/>
</Field>
</div>
))}
</details>
<Field label="Motivo da decisão ou pedido de esclarecimento">
<textarea
className={control}
value={form.data.reason}
onChange={(e) =>
form.setData("reason", e.target.value)
}
/>
</Field>
<div className="flex flex-wrap gap-3">
{[
"assess",
"clarify",
"shortlist",
"reject",
...(interest.status === "shortlisted"
? ["connect"]
: []),
].map((action) => (
<button
key={action}
className={button}
disabled={
form.processing || !form.data.reason
}
onClick={() => act(action)}
>
{
{
assess: "Guardar avaliação",
clarify: "Pedir esclarecimento",
shortlist:
"Adicionar à lista curta",
reject: "Não selecionar",
connect: "Abrir negociação",
}[action]
}
</button>
))}
</div>
</>
)}
{!owner &&
["submitted", "clarification", "shortlisted"].includes(
interest.status,
) && (
<>
<Field label="Motivo da retirada">
<textarea
className={control}
value={form.data.reason}
onChange={(e) =>
form.setData("reason", e.target.value)
}
/>
</Field>
<button
className={button}
disabled={form.processing || !form.data.reason}
onClick={() => act("withdraw")}
>
Retirar candidatura
</button>
</>
)}
{!owner &&
["submitted", "clarification", "withdrawn"].includes(
interest.status,
) && (
<InterestForm
record={record}
interest={interest}
organizations={organizations}
/>
)}
<Errors errors={form.errors} />
</article>
);
}
export default function Detail(p: Props) {
const { auth } = usePage<{ auth: { user: unknown } }>().props;
return (
<Frame title={p.record.title || "Pré-visualização da ideia"}>
<div className="space-y-6">
<p>
{p.record.partnership_type
? p.types[p.record.partnership_type]
: "Tipo por completar"}{" "}
· {p.record.kind === "offering" ? "Oferta" : "Procura"} ·{" "}
{labels[p.record.status] ?? p.record.status}
</p>
<p className="whitespace-pre-wrap">
{p.record.payload.summary}
</p>
<section className="space-y-3 rounded border p-5">
<h2 className="text-xl font-bold">Oferta e procura</h2>
<p className="whitespace-pre-wrap">{p.record.offer}</p>
<p className="whitespace-pre-wrap">{p.record.seeking}</p>
{p.record.offer === null && (
<p>
O conteúdo detalhado está reservado à conexão
privada.
</p>
)}
<p>{p.record.payload.requirements}</p>
<p>Prazo: {p.record.deadline ?? "Por definir"}</p>
</section>
<section className="space-y-3 rounded border p-5">
<h2 className="text-xl font-bold">
Localizações próprias da parceria
</h2>
{p.locations.length ? (
p.locations.map((l, i) => (
<p key={i}>
{l.path.map((n) => n.name).join(" → ")} ·{" "}
{
{
primary: "Principal",
operating: "Área de atuação",
coverage: "Abrangência",
}[l.role]
}{" "}
· Fonte: {l.source_reference}. Validação
independente pendente.
</p>
))
) : (
<p>Localização própria não indicada.</p>
)}
</section>
{p.origins.map((source, i) => (
<section key={i} className="space-y-3 rounded border p-5">
<h2 className="text-xl font-bold">
Origem:{" "}
<Link className="underline" href={source.href}>
{source.title}
</Link>
</h2>
<p>
As localizações do registo de origem são contexto
documental; não confirmam a implantação da parceria.
</p>
{source.geography && (
<StructureGeographyPanel
geography={source.geography}
cluster={source.type === "cluster"}
dossierId={"partnership-source-" + i}
/>
)}
</section>
))}
{(p.owner || p.moderator) && (
<Publication key={p.record.version} p={p} />
)}
{p.owner && p.interests.length > 0 && (
<section className="overflow-x-auto rounded border p-4">
<h2 className="text-xl font-bold">
Comparação de candidatos
</h2>
<table className="w-full text-left">
<thead>
<tr>
<th>Candidatura</th>
<th>Estado</th>
<th>Correspondência</th>
<th>Pendências</th>
</tr>
</thead>
<tbody>
{p.interests.map((i) => (
<tr key={i.id}>
<td>{i.id}</td>
<td>{labels[i.status]}</td>
<td>
{i.match.score_min}–
{i.match.score_max}%
</td>
<td>
{i.match.criteria
.filter(
(c) =>
c.state === "unknown",
)
.map((c) => c.label)
.join(", ")}
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{p.interests.map((i) => (
<Candidacy
key={i.id + ":" + i.version}
interest={i}
owner={p.owner}
canEdit={p.canEdit}
record={p.record}
organizations={p.organizations}
/>
))}
{!p.owner &&
p.record.status === "published" &&
auth.user &&
!p.interests.length && (
<InterestForm
record={p.record}
organizations={p.organizations}
/>
)}
{!auth.user && p.record.status === "published" && (
<Link className={button} href="/login">
Entrar para manifestar interesse
</Link>
)}
{p.history.length > 0 && (
<details>
<summary>Histórico e decisões</summary>
{p.history.map((h, i) => (
<p key={i}>
{h.occurred_at} · Autor {h.actor_id} ·{" "}
{h.event_type}
</p>
))}
</details>
)}
</div>
</Frame>
);
}
+447
View File
@@ -0,0 +1,447 @@
import { Link, useForm } from "@inertiajs/react";
import { useState } from "react";
import {
Frame,
Field,
Errors,
control,
button,
type Partnership,
type Catalogues,
} from "./shared";
type Location = {
geo_location_id: string;
role: string;
source_reference: string;
};
type Origin = { type: string; target_id: string };
type Props = Catalogues & {
record: Partnership | null;
locations: Location[];
origins: Origin[];
};
function Editor(p: Props) {
const form = useForm({
client_key: crypto.randomUUID(),
version: p.record?.version ?? null,
title: p.record?.title ?? "",
offer: p.record?.offer ?? "",
seeking: p.record?.seeking ?? "",
organization_id: p.record?.organization_id ?? "",
sector_id: p.record?.sector_id?.toString() ?? "",
kind: p.record?.kind ?? "seeking",
partnership_type: p.record?.partnership_type ?? "",
deadline: p.record?.deadline ?? "",
payload: {
summary: p.record?.payload.summary ?? "",
requirements: p.record?.payload.requirements ?? "",
mandatory: p.record?.payload.mandatory ?? ([] as string[]),
confidentiality: p.record?.payload.confidentiality ?? "public",
consent: p.record?.payload.consent ?? false,
},
locations: p.locations.map((x) => ({
geo_location_id: x.geo_location_id,
role: x.role,
source_reference: x.source_reference,
})),
origins: p.origins.map((x) => ({
type: x.type,
target_id: x.target_id,
})),
});
const [place, setPlace] = useState("");
const [role, setRole] = useState("primary");
const [source, setSource] = useState("Declaração do proponente");
const [origin, setOrigin] = useState("");
return (
<Frame title={p.record ? "Editar parceria" : "Preparar parceria"}>
<p className="mb-5">
Guarde uma ideia e retome-a no seu painel. Empresa constituída,
NUIT, documentos e verificação não são exigidos para preparar o
rascunho. As informações desconhecidas ficam pendentes.
</p>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
if (p.record) form.put("/parcerias/" + p.record.id);
else form.post("/parcerias/rascunhos");
}}
>
<Errors errors={form.errors} />
<Field label="Título">
<input
className={control}
value={form.data.title}
onChange={(e) => form.setData("title", e.target.value)}
/>
</Field>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Participação">
<select
className={control}
value={form.data.organization_id}
onChange={(e) =>
form.setData("organization_id", e.target.value)
}
>
<option value="">
Ideia pessoal — sem organização indicada
</option>
{p.organizations.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
<Field label="Oferta ou procura">
<select
className={control}
value={form.data.kind}
onChange={(e) =>
form.setData("kind", e.target.value)
}
>
<option value="seeking">Procura</option>
<option value="offering">Oferta</option>
</select>
</Field>
<Field label="Tipo de parceria">
<select
className={control}
value={form.data.partnership_type}
onChange={(e) =>
form.setData("partnership_type", e.target.value)
}
>
<option value="">Por definir</option>
{Object.entries(p.types).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</Field>
<Field label="Setor">
<select
className={control}
value={form.data.sector_id}
onChange={(e) =>
form.setData("sector_id", e.target.value)
}
>
<option value="">Por definir</option>
{p.sectors.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
</div>
<Field label="Resumo público">
<textarea
className={control}
value={form.data.payload.summary}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
summary: e.target.value,
})
}
/>
</Field>
<Field label="O que oferece">
<textarea
className={control}
value={form.data.offer}
onChange={(e) => form.setData("offer", e.target.value)}
/>
</Field>
<Field label="O que procura">
<textarea
className={control}
value={form.data.seeking}
onChange={(e) =>
form.setData("seeking", e.target.value)
}
/>
</Field>
<Field label="Requisitos e critérios desejáveis">
<textarea
className={control}
value={form.data.payload.requirements}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
requirements: e.target.value,
})
}
/>
</Field>
<fieldset className="space-y-2 rounded border p-4">
<legend>
Critérios obrigatórios expressos da proposta
</legend>
<p>
Marque apenas condições necessárias. Critérios
desejáveis servem a pontuação. Informação desconhecida
exige esclarecimento e não constitui incumprimento
comprovado.
</p>
{Object.entries(p.criteria).map(([k, v]) => (
<label className="block" key={k}>
<input
type="checkbox"
checked={form.data.payload.mandatory.includes(
k,
)}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
mandatory: e.target.checked
? [
...form.data.payload
.mandatory,
k,
]
: form.data.payload.mandatory.filter(
(x) => x !== k,
),
})
}
/>{" "}
{v}
</label>
))}
</fieldset>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Prazo para interesses">
<input
type="date"
className={control}
value={form.data.deadline}
onChange={(e) =>
form.setData("deadline", e.target.value)
}
/>
</Field>
<Field label="Visibilidade do conteúdo detalhado">
<select
className={control}
value={form.data.payload.confidentiality}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
confidentiality: e.target.value,
})
}
>
<option value="public">
Público após moderação
</option>
<option value="private">
Resumo público; detalhes privados
</option>
</select>
</Field>
</div>
<section className="space-y-4 rounded border p-5">
<h2 className="font-bold">Localizações e abrangência</h2>
<p>
A localização de uma origem fica como contexto. Só
acrescente uma localização própria com fonte; não se
deduz por proximidade ou pertença a cluster.
</p>
<div className="grid gap-3 md:grid-cols-3">
<Field label="Localização do cadastro">
<select
className={control}
value={place}
onChange={(e) => setPlace(e.target.value)}
>
<option value="">Escolher território</option>
{p.territories.map((x) => (
<option key={x.id} value={x.id}>
{x.name} · {x.location_type}
</option>
))}
</select>
</Field>
<Field label="Papel">
<select
className={control}
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="primary">Principal</option>
<option value="operating">
Área de atuação
</option>
<option value="coverage">Abrangência</option>
</select>
</Field>
<Field label="Fonte da localização">
<input
className={control}
value={source}
onChange={(e) => setSource(e.target.value)}
/>
</Field>
</div>
<button
type="button"
className={button}
disabled={!place || !source}
onClick={() => {
if (
!form.data.locations.some(
(x) =>
x.geo_location_id === place &&
x.role === role,
)
)
form.setData("locations", [
...form.data.locations,
{
geo_location_id: place,
role,
source_reference: source,
},
]);
}}
>
Acrescentar localização
</button>
{form.data.locations.map((l, i) => (
<p key={i}>
{
p.territories.find(
(t) => t.id === l.geo_location_id,
)?.name
}{" "}
· {l.role} · {l.source_reference}{" "}
<button
type="button"
className="underline"
onClick={() =>
form.setData(
"locations",
form.data.locations.filter(
(_, j) => j !== i,
),
)
}
>
Remover
</button>
</p>
))}
</section>
<section className="space-y-3 rounded border p-5">
<h2 className="font-bold">Registos de origem</h2>
<Field label="Adicionar origem existente">
<select
className={control}
value={origin}
onChange={(e) => setOrigin(e.target.value)}
>
<option value="">
Escolher empresa, investimento, projeto,
cluster, zona ou ficha
</option>
{p.sources?.map((x) => (
<option
key={x.type + ":" + x.target_id}
value={x.type + ":" + x.target_id}
>
{x.title}
</option>
))}
</select>
</Field>
<button
type="button"
className={button}
disabled={!origin}
onClick={() => {
const [type, target_id] = origin.split(":");
if (
!form.data.origins.some(
(x) =>
x.type === type &&
x.target_id === target_id,
)
)
form.setData("origins", [
...form.data.origins,
{ type, target_id },
]);
}}
>
Ligar origem
</button>
{form.data.origins.map((o, i) => (
<p key={i}>
{p.sources?.find(
(x) =>
x.type === o.type &&
x.target_id === o.target_id,
)?.title ?? "Origem documental ligada"}{" "}
<button
type="button"
className="underline"
onClick={() =>
form.setData(
"origins",
form.data.origins.filter(
(_, j) => j !== i,
),
)
}
>
Remover
</button>
</p>
))}
</section>
<label className="block">
<input
type="checkbox"
checked={form.data.payload.consent}
onChange={(e) =>
form.setData("payload", {
...form.data.payload,
consent: e.target.checked,
})
}
/>{" "}
Autorizo submeter o conteúdo indicado para divulgação no
âmbito escolhido. Não confirma verificação ou certificação.
</label>
<div className="flex flex-wrap gap-4">
<button className={button} disabled={form.processing}>
Guardar rascunho
</button>
{p.record && (
<Link
className="underline"
href={"/parcerias/" + p.record.id}
>
Pré-visualizar e submeter
</Link>
)}
</div>
</form>
</Frame>
);
}
export default function Page(p: Props) {
return (
<Editor
key={`${p.record?.id ?? "new"}:${p.record?.version ?? 0}`}
{...p}
/>
);
}
@@ -0,0 +1,74 @@
import { useForm, Link } from "@inertiajs/react";
import { Management } from "../management/shared";
import { Errors, button, labels } from "./shared";
type Props = {
hash: string;
installed: boolean;
target: { database: string; port: number };
records: { id: number; title: string; user_id: number; status: string }[];
backupVerified: boolean;
qaVerified: boolean;
backupDate: string | null;
changes: string[];
};
export default function Installation(p: Props) {
const form = useForm({ hash: p.hash });
return (
<Management title="Aplicação da Bolsa de Parcerias">
<div className="space-y-5">
<p>
Destino: {p.target.database}:{p.target.port}. Estado:{" "}
{p.installed
? "Instalada"
: "Preparada para aplicação autenticada"}
.
</p>
<p>
Backup e restauro:{" "}
{p.backupVerified ? "Comprovados" : "Pendentes"} ·{" "}
{p.backupDate}. QA desta versão:{" "}
{p.qaVerified ? "Comprovada" : "Pendente"}.
</p>
<ul className="list-disc pl-5">
{p.changes.map((change) => (
<li key={change}>{change}</li>
))}
</ul>
<h2 className="text-xl font-bold">
Propostas antigas a preservar: {p.records.length}
</h2>
{p.records.map((record) => (
<p key={record.id}>
{record.id} · {record.title} · Autor {record.user_id} ·{" "}
{labels[record.status] ?? record.status}
</p>
))}
{!p.installed && (
<form
onSubmit={(e) => {
e.preventDefault();
form.post("/management/partnerships-install");
}}
>
<Errors errors={form.errors} />
<button
className={button}
disabled={
form.processing ||
!p.backupVerified ||
!p.qaVerified
}
>
Aplicar módulo preservando propostas
</button>
</form>
)}
{p.installed && (
<Link href="/parcerias/painel" className="underline">
Abrir painel da Bolsa
</Link>
)}
</div>
</Management>
);
}
+505
View File
@@ -0,0 +1,505 @@
import { useForm, usePage, Link, router } from "@inertiajs/react";
import {
Frame,
Field,
Errors,
control,
button,
labels,
type Partnership,
} from "./shared";
type Room = {
id: number;
status: string;
version: number;
agreement: {
title: string;
terms: string;
confirmations: Record<string, { actor_id: number; date: string }>;
legal_signature_status: string;
} | null;
};
type Milestone = {
id: number;
title: string;
responsible_id: number;
due_date: string | null;
status: string;
evidence: string | null;
version: number;
};
type Props = {
room: Room;
partnership: Partnership & { user_id: number };
interest: { id: number; user_id: number };
canWrite: boolean;
messages: {
id: number;
name: string;
author_id: number;
body: string;
created_at: string;
}[];
documents: {
id: number;
owner_id: number;
file_name: string;
shared: boolean;
status: string;
malware_scan_status: string;
}[];
milestones: Milestone[];
};
function MilestoneForm({ p, item }: { p: Props; item?: Milestone }) {
const form = useForm({
title: item?.title ?? "",
responsible_id: item?.responsible_id ?? p.partnership.user_id,
due_date: item?.due_date ?? "",
status: item?.status ?? "pending",
evidence: item?.evidence ?? "",
version: item?.version ?? null,
});
return (
<form
className="space-y-3 rounded border p-4"
onSubmit={(e) => {
e.preventDefault();
if (item)
form.put(
`/parcerias/salas/${p.room.id}/marcos/${item.id}`,
{ preserveScroll: true },
);
else
form.post(`/parcerias/salas/${p.room.id}/marcos`, {
preserveScroll: true,
});
}}
>
<h3 className="font-bold">
{item ? "Atualizar marco" : "Acrescentar marco"}
</h3>
<Field label="Entregável">
<input
className={control}
value={form.data.title}
onChange={(e) => form.setData("title", e.target.value)}
/>
</Field>
<Field label="Responsável">
<select
className={control}
value={form.data.responsible_id}
onChange={(e) =>
form.setData("responsible_id", Number(e.target.value))
}
>
<option value={p.partnership.user_id}>Proponente</option>
<option value={p.interest.user_id}>Candidato</option>
</select>
</Field>
<Field label="Data prevista">
<input
className={control}
type="date"
value={form.data.due_date}
onChange={(e) => form.setData("due_date", e.target.value)}
/>
</Field>
<Field label="Estado">
<select
className={control}
value={form.data.status}
onChange={(e) => form.setData("status", e.target.value)}
>
{["pending", "progress", "completed"].map((x) => (
<option key={x} value={x}>
{x === "pending" ? "Pendente" : labels[x]}
</option>
))}
</select>
</Field>
<Field label="Evidência ou referência do resultado">
<textarea
className={control}
value={form.data.evidence}
onChange={(e) => form.setData("evidence", e.target.value)}
/>
</Field>
<Errors errors={form.errors} />
<button className={button} disabled={form.processing}>
Guardar marco
</button>
</form>
);
}
export default function Page(p: Props) {
return <RoomPage key={p.room.id + ":" + p.room.version} {...p} />;
}
function RoomPage(p: Props) {
const { auth } = usePage<{ auth: { user: { id: number } } }>().props;
const message = useForm({ body: "", client_key: crypto.randomUUID() });
const agreement = useForm({
version: p.room.version,
action: "record",
title: p.room.agreement?.title ?? "",
terms: p.room.agreement?.terms ?? "",
reason: "",
});
const upload = useForm<{ file: File | null; shared: boolean }>({
file: null,
shared: false,
});
const act = (action: string) => {
agreement.transform((data) => ({ ...data, action }));
agreement.post(`/parcerias/salas/${p.room.id}/acordo`, {
preserveScroll: true,
});
};
const mutable =
p.canWrite && !["closed", "cancelled"].includes(p.room.status);
return (
<Frame title={"Conexão privada — " + p.partnership.title}>
<div className="space-y-6">
<Link
className="underline"
href={"/parcerias/" + p.partnership.id}
>
Regressar à parceria
</Link>
<p>
Estado da conexão: {labels[p.room.status]}. Esta sala
pertence apenas às partes autorizadas; não encerra outras
candidaturas.
</p>
<section className="space-y-4 rounded border p-5">
<h2 className="text-xl font-bold">
Mensagens e esclarecimentos
</h2>
{p.messages.map((m) => (
<article className="rounded bg-muted p-4" key={m.id}>
<p className="font-semibold">
{m.name} · {m.created_at}
</p>
<p className="whitespace-pre-wrap">{m.body}</p>
</article>
))}
{mutable && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
message.post(
`/parcerias/salas/${p.room.id}/mensagens`,
{
preserveScroll: true,
onSuccess: () => {
message.setData({
body: "",
client_key: crypto.randomUUID(),
});
},
},
);
}}
>
<Field label="Mensagem privada">
<textarea
className={control}
value={message.data.body}
onChange={(e) =>
message.setData("body", e.target.value)
}
/>
</Field>
<Errors errors={message.errors} />
<button
className={button}
disabled={message.processing}
>
Enviar mensagem
</button>
</form>
)}
</section>
<section className="space-y-4 rounded border p-5">
<h2 className="text-xl font-bold">
Documentos autorizados
</h2>
<p>
O original fica no repositório privado. A partilha exige
autorização explícita; o download exige análise
antimalware limpa e hash correspondente. Anexar não
confirma autenticidade nem vigência.
</p>
{p.documents.map((d) => (
<article key={d.id}>
<p>
{d.file_name} ·{" "}
{d.shared
? "Partilhado com a contraparte"
: "Privado do autor"}{" "}
· Análise: {d.malware_scan_status}
</p>
{mutable && d.owner_id === auth.user.id && (
<button
className="underline"
onClick={() =>
router.post(
`/parcerias/salas/${p.room.id}/documentos/${d.id}/partilha`,
{ shared: !d.shared },
{ preserveScroll: true },
)
}
>
{d.shared
? "Retirar autorização de partilha"
: "Autorizar partilha com a contraparte"}
</button>
)}
{d.status === "active" &&
d.malware_scan_status === "clean" && (
<a
className="underline"
href={`/parcerias/salas/${p.room.id}/documentos/${d.id}`}
>
Descarregar documento
</a>
)}
</article>
))}
{mutable && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
upload.post(
`/parcerias/salas/${p.room.id}/documentos`,
{
forceFormData: true,
preserveScroll: true,
},
);
}}
>
<Field label="Anexar PDF ou imagem">
<input
type="file"
className="w-full min-w-0 max-w-full"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) =>
upload.setData(
"file",
e.target.files?.[0] ?? null,
)
}
/>
</Field>
<label className="block">
<input
type="checkbox"
checked={upload.data.shared}
onChange={(e) =>
upload.setData(
"shared",
e.target.checked,
)
}
/>{" "}
Autorizo a consulta pela contraparte desta sala.
</label>
<Errors errors={upload.errors} />
<button
className={button}
disabled={
upload.processing || !upload.data.file
}
>
Guardar documento privado
</button>
</form>
)}
</section>
<section className="space-y-4 rounded border p-5">
<h2 className="text-xl font-bold">
Acordo e plano conjunto
</h2>
<p>
Registe o instrumento, objetivos, contribuições,
responsabilidades, indicadores, riscos, dependências e
condições de saída. A confirmação no portal regista a
concordância comunicada pelas partes; não certifica
assinatura jurídica ou due diligence.
</p>
{p.room.agreement && (
<>
<h3 className="font-bold">
{p.room.agreement.title}
</h3>
<p className="whitespace-pre-wrap">
{p.room.agreement.terms}
</p>
<p>
Confirmações:{" "}
{Object.entries(p.room.agreement.confirmations)
.map(
([party, c]) =>
`${party}: autor ${c.actor_id}, ${c.date}`,
)
.join("; ") || "Pendentes"}
</p>
</>
)}
{mutable && (
<>
<Field label="Motivo da ação">
<textarea
className={control}
value={agreement.data.reason}
onChange={(e) =>
agreement.setData(
"reason",
e.target.value,
)
}
/>
</Field>
{["negotiation", "agreement_recorded"].includes(
p.room.status,
) && (
<>
<Field label="Título do instrumento">
<input
className={control}
value={agreement.data.title}
onChange={(e) =>
agreement.setData(
"title",
e.target.value,
)
}
/>
</Field>
<Field label="Condições e plano de valor">
<textarea
className={control}
rows={6}
value={agreement.data.terms}
onChange={(e) =>
agreement.setData(
"terms",
e.target.value,
)
}
/>
</Field>
<button
className={button}
disabled={
agreement.processing ||
!agreement.data.reason
}
onClick={() => act("record")}
>
Registar versão do acordo
</button>
</>
)}
<div className="flex flex-wrap gap-3">
{p.room.status === "agreement_recorded" && (
<button
className={button}
disabled={
agreement.processing ||
!agreement.data.reason
}
onClick={() => act("confirm")}
>
Confirmar pela minha parte
</button>
)}
{p.room.status === "agreed" && (
<button
className={button}
disabled={
agreement.processing ||
!agreement.data.reason
}
onClick={() => act("start")}
>
Iniciar acompanhamento
</button>
)}
{p.room.status === "execution" && (
<button
className={button}
disabled={
agreement.processing ||
!agreement.data.reason
}
onClick={() => act("complete")}
>
Concluir após os marcos
</button>
)}
{["agreed", "execution"].includes(
p.room.status,
) && (
<button
className={button}
disabled={
agreement.processing ||
!agreement.data.reason
}
onClick={() => act("renew")}
>
Registar renovação
</button>
)}
<button
className={button}
disabled={
agreement.processing ||
!agreement.data.reason
}
onClick={() => act("cancel")}
>
Cancelar conexão com motivo
</button>
</div>
<Errors errors={agreement.errors} />
</>
)}
</section>
<section className="space-y-4">
<h2 className="text-xl font-bold">Marcos e responsáveis</h2>
<p>
Participação atual:{" "}
{auth.user.id === p.partnership.user_id
? "Proponente"
: auth.user.id === p.interest.user_id
? "Candidato"
: "Representante autorizado"}
.
</p>
{p.milestones.map((m) => (
<article
key={m.id + ":" + m.version}
className="space-y-3"
>
<p>
{m.title} · {m.status === "pending" ? "Pendente" : labels[m.status]} · Prazo:{" "}
{m.due_date ?? "Pendente"} · Responsável ID{" "}
{m.responsible_id}
</p>
{mutable && <MilestoneForm p={p} item={m} />}
</article>
))}
{mutable &&
["agreement_recorded", "agreed", "execution"].includes(
p.room.status,
) && <MilestoneForm p={p} />}
</section>
</div>
</Frame>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { Link } from "@inertiajs/react";
import { cloneElement, isValidElement, useId, type ReactNode } from "react";
import { Layout } from "../portfolio/shared";
export type Option = { id: string | number; name: string };
export type Territory = {
id: string;
name: string;
location_type: string;
parent_id: string | null;
};
export type Source = {
type: string;
target_id: string;
title: string;
href: string;
};
export type Catalogues = {
types: Record<string, string>;
criteria: Record<string, string>;
sectors: Option[];
territories: Territory[];
organizations: Option[];
sources?: Source[];
};
export type Partnership = {
id: number;
title: string;
status: string;
version: number;
kind: string;
partnership_type: string | null;
sector_id: number | null;
organization_id?: string | null;
deadline: string | null;
offer: string | null;
seeking: string | null;
payload: {
summary?: string;
requirements?: string;
mandatory?: string[];
confidentiality?: string;
consent?: boolean;
availability?: string;
};
};
export type Match = {
score_min: number | null;
score_max: number | null;
eligibility: string;
criteria: {
code: string;
label: string;
weight: number;
state: string;
mandatory: boolean;
evidence: string | null;
explanation: string;
}[];
};
export type Interest = {
id: number;
user_id: number;
partnership_id: number;
version: number;
proposal: string;
status: string;
decision_reason: string | null;
organization_id: string | null;
payload: {
capacity?: string;
availability?: string;
contribution?: string;
conflicts?: string;
consent?: boolean;
};
assessment: Record<string, { state: string; evidence?: string }>;
match: Match;
room_id: number | null;
};
export const labels: Record<string, string> = {
draft: "Rascunho",
pending: "Submetida — legado",
submitted: "Submetida",
returned: "Devolvida",
rejected: "Rejeitada",
published: "Publicada",
suspended: "Suspensa",
archived: "Arquivada",
withdrawn: "Retirada",
clarification: "Em esclarecimento",
shortlisted: "Lista curta",
negotiation: "Em negociação",
agreement_recorded: "Acordo registado",
agreed: "Confirmado pelas partes",
execution: "Em execução",
closed: "Concluída",
cancelled: "Cancelada",
progress: "Em curso",
completed: "Concluído",
met: "Comprovadamente cumprido",
unmet: "Comprovadamente incumprido",
unknown: "Informação insuficiente",
};
export const control = "w-full rounded border bg-background p-2";
export const button =
"inline-flex rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50";
export function Frame({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<Layout title={title}>
<nav className="mb-6 flex flex-wrap gap-5 underline">
<Link href="/parcerias">Catálogo</Link>
<Link href="/parcerias/painel">O meu painel</Link>
<Link href="/parcerias/criar">Preparar parceria</Link>
<Link href="/como-investir">Como investir</Link>
<Link href="/simulador-fiscal">
Simulador e ficha de projeto
</Link>
</nav>
{children}
</Layout>
);
}
export function Errors({ errors }: { errors: Record<string, string> }) {
return (
<>
{Object.entries(errors).map(([key, message]) => (
<p role="alert" className="text-red-700" key={key}>
{message}
</p>
))}
</>
);
}
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
const id = useId();
return (
<label className="block space-y-2">
<span id={id} className="font-medium">
{label}
</span>
{isValidElement<{ "aria-labelledby"?: string }>(children)
? cloneElement(children, { "aria-labelledby": id })
: children}
</label>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export const kind = (r: Pick<Dossier, "project_id" | "cluster_id" | "corridor_id
export function Layout({ title, children }: { title: string; children: ReactNode }) {
const { flash } = usePage<{ flash?: { success?: string } }>().props;
return (
<main className="mx-auto max-w-6xl space-y-6 p-4 md:p-8">
<main className="mx-auto w-full min-w-0 max-w-6xl space-y-6 break-words p-4 md:p-8">
<Head title={title} />
<nav className="flex flex-wrap gap-5 text-sm underline" aria-label="Investimento">
<Link href="/">InvestMoz</Link>
+7 -1
View File
@@ -1,6 +1,8 @@
import { Link, useForm, router, usePage } from "@inertiajs/react";
import { useState } from "react";
import InvestmentMapPanel from "@/components/investment-map-panel";
import StructureGeographyPanel, { type StructureGeography } from '@/components/structure-geography';
import PartnershipSourceLinks from '@/components/partnership-source-links';
import { Management } from "../management/shared";
import { Layout, Field, control, button, labels, kind } from "./shared";
import type { Dossier } from "./shared";
@@ -9,6 +11,7 @@ export default function Show(props: Props) {
}
type Props = {
geography: StructureGeography;
existingSubmissions?: { id: string; version: number; updated_at: string }[];
anchorProject?: { code: string; name: string; status: string; budget_amount: string | null; budget_currency: string | null; start_date: string | null; end_date: string | null } | null;
zones?: {
@@ -36,6 +39,7 @@ function Editor({
zones = [],
anchorProject = null,
existingSubmissions = [],
geography,
}: Props) {
const { auth } = usePage<{ auth: { user: unknown } }>().props;
const [creatingManifestation, setCreatingManifestation] = useState(false);
@@ -55,6 +59,8 @@ function Editor({
{kind(record)} · {record.content.code}
</p>
<p className="text-sm">Fonte: {record.source}</p>
{(record.project_id||record.cluster_id)&&<PartnershipSourceLinks type={record.project_id?'project':'cluster'} id={record.project_id??record.cluster_id!}/>}
{(record.project_id || record.cluster_id) && <StructureGeographyPanel geography={geography} cluster={Boolean(record.cluster_id)} dossierId={record.id} />}
{anchorProject && (
<section className="space-y-2 rounded-xl border p-5" aria-labelledby="anchor-project-heading">
<h2 id="anchor-project-heading" className="text-xl font-bold">Referência de projecto âncora</h2>
@@ -148,7 +154,7 @@ function Editor({
<article key={opportunity.id}>
<Link
className="font-semibold underline"
href={`/oportunidades/${opportunity.slug}`}
href={`/oportunidades/${opportunity.slug}?from_dossier=${record.id}`}
>
{opportunity.title}
</Link>
+35
View File
@@ -0,0 +1,35 @@
<?php
use App\Http\Controllers\PartnershipExchangeController as Exchange;
use App\Http\Controllers\PartnershipInstallationController as Installation;
use App\Http\Middleware\PartnershipInstalled;
use App\Http\Middleware\PrivateInvestmentResponse;
use Illuminate\Support\Facades\Route;
// Shared identity; verification of an organization is not a prerequisite for an idea.
Route::middleware(['auth', PartnershipInstalled::class, PrivateInvestmentResponse::class, 'throttle:40,1'])->group(function () {
Route::get('/parcerias/painel', [Exchange::class, 'dashboard']);
Route::get('/parcerias/criar', [Exchange::class, 'edit']);
Route::post('/parcerias/rascunhos', [Exchange::class, 'save']);
Route::get('/parcerias/{id}/editar', [Exchange::class, 'edit'])->whereNumber('id');
Route::put('/parcerias/{id}', [Exchange::class, 'save'])->whereNumber('id');
Route::post('/parcerias/{id}/estado', [Exchange::class, 'transition'])->whereNumber('id');
Route::post('/parcerias/{id}/interesses', [Exchange::class, 'interest'])->whereNumber('id');
Route::post('/parcerias/candidaturas/{id}', [Exchange::class, 'decide'])->whereNumber('id');
Route::get('/parcerias/salas/{id}', [Exchange::class, 'room'])->whereNumber('id');
Route::post('/parcerias/salas/{id}/mensagens', [Exchange::class, 'message'])->whereNumber('id');
Route::post('/parcerias/salas/{id}/acordo', [Exchange::class, 'agreement'])->whereNumber('id');
Route::post('/parcerias/salas/{id}/marcos', [Exchange::class, 'milestone'])->whereNumber('id');
Route::put('/parcerias/salas/{id}/marcos/{milestone}', [Exchange::class, 'milestone'])->whereNumber(['id', 'milestone']);
Route::post('/parcerias/salas/{id}/documentos', [Exchange::class, 'upload'])->whereNumber('id');
Route::get('/parcerias/salas/{id}/documentos/{attachment}', [Exchange::class, 'download'])->whereNumber(['id', 'attachment']);
Route::post('/parcerias/salas/{id}/documentos/{attachment}/partilha', [Exchange::class, 'share'])->whereNumber(['id', 'attachment']);
Route::post('/parcerias/notificacoes/{id}/ler', [Exchange::class, 'notification'])->whereNumber('id');
});
Route::get('/parcerias/{id}', [Exchange::class, 'show'])->whereNumber('id')->middleware([PartnershipInstalled::class, PrivateInvestmentResponse::class]);
Route::get('/api/v1/partnerships', [Exchange::class, 'index'])->middleware(['throttle:api', PartnershipInstalled::class]);
Route::get('/api/v1/partnerships/source/{type}/{id}', [Exchange::class, 'source'])->middleware('throttle:api');
Route::middleware(['auth', 'verified', 'can:manage-portal', PrivateInvestmentResponse::class])->group(function () {
Route::get('/management/partnerships-install', [Installation::class, 'preview']);
Route::post('/management/partnerships-install', [Installation::class, 'apply']);
});
+3
View File
@@ -2,6 +2,7 @@
require __DIR__.'/existing-business.php';
require __DIR__.'/territorial-registry.php';
require __DIR__.'/partnerships.php';
require __DIR__.'/investment-zones.php';
@@ -117,6 +118,8 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
});
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
Route::get('relationship-repair', [\App\Http\Controllers\InvestmentRelationshipRepairController::class, 'preview'])->name('relationship-repair.preview');
Route::post('relationship-repair', [\App\Http\Controllers\InvestmentRelationshipRepairController::class, 'apply'])->name('relationship-repair.apply');
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
@@ -0,0 +1,260 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentRelations;
use App\Services\InvestmentRelationshipRepair;
use App\Services\InvestmentTerritorialValidation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class InvestmentRelationshipTest extends TestCase
{
use RefreshDatabase;
private function fixture(): array
{
$parent = null;
$ids = [];
foreach (['country', 'province', 'district', 'admin_post', 'locality', 'site'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'QA '.$type, 'location_type' => $type, 'parent_id' => $parent,
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
$ids[$type] = $id;
$parent = $id;
}
$project = Project::factory()->create(['code' => 'A99', 'name' => 'QA Anchor', 'status' => 'draft', 'data_classification' => 'internal']);
DB::table('geo_locations')->where('id', $ids['site'])->update(['source_system' => 'QA documented site', 'external_id' => null]);
$dossier = InvestmentDossier::create(['project_id' => $project->id, 'title' => 'QA Anchor dossier', 'source' => 'QA synthetic dossier source',
'content' => ['location' => 'QA province, district and site', 'description' => 'Unique documented component needle'], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA fixture disclosure only']);
$cluster = EconomicCluster::create(['code' => 'QA-CLU', 'name' => 'QA Cluster', 'sector' => 'QA', 'status' => 'planned']);
$clusterDossier = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'QA Cluster dossier', 'source' => 'QA synthetic cluster source',
'content' => ['anchors' => 'A99; QA documented proposal'], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA fixture disclosure only']);
DB::table('cluster_projects')->insert(['id' => (string) Str::uuid(), 'cluster_id' => $cluster->id, 'project_id' => $project->id, 'role' => 'anchor', 'source_system' => 'upgrade-v4']);
DB::table('project_locations')->insert(['id' => (string) Str::uuid(), 'project_id' => $project->id, 'geo_location_id' => $ids['site'], 'role' => 'primary', 'data_classification' => 'public']);
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $ids['site'], 'geometry_type' => 'point', 'data_classification' => 'public',
'geom' => DB::raw('ST_SetSRID(ST_MakePoint(32.5,-25.9),4326)'), 'source_system' => 'QA documented coordinates fixture']);
$sector = Sector::create(['name' => 'QA sector', 'slug' => 'qa-sector', 'is_active' => true]);
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'QA-REL', 'title' => 'QA opportunity', 'slug' => 'qa-rel', 'status' => 'published', 'published_at' => now()->subMinute()]);
DB::table('opportunity_projects')->insert(['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
return compact('ids', 'project', 'dossier', 'cluster', 'clusterDossier', 'opportunity', 'sector');
}
public function test_project_location_and_dossier_navigation_are_bidirectional_without_changing_project_status(): void
{
$f = $this->fixture();
$this->get('/carteira/'.$f['dossier']->id)->assertInertia(fn (Assert $p) => $p->has('geography.locations', 1)->has('geography.locations.0.path', 4)
->where('geography.locations.0.role', 'primary')->where('geography.locations.0.coordinates.0.geometry.coordinates.0', 32.5)->has('opportunities', 1)->has('geography.clusters', 1));
$this->get('/oportunidades/qa-rel?from_dossier='.$f['dossier']->id)->assertInertia(fn (Assert $p) => $p->where('dossierOrigin.id', $f['dossier']->id)
->where('context.projects.0.dossiers.0.id', $f['dossier']->id)->has('context.projects.0.geography.locations', 1));
$this->get('/oportunidades/qa-rel?from_dossier='.$f['clusterDossier']->id)->assertNotFound();
$this->assertSame('draft', $f['project']->fresh()->status);
}
public function test_search_and_all_territorial_levels_follow_explicit_project_relations(): void
{
$f = $this->fixture();
$filters = [];
foreach (['province', 'district', 'admin_post', 'locality'] as $type) {
$filters[$type.'_id'] = $f['ids'][$type];
$this->assertSame([$f['opportunity']->id], app(InvestmentCatalogue::class)->query($filters)->pluck('id')->all());
$this->get('/carteira?'.http_build_query($filters))->assertInertia(fn (Assert $p) => $p->has('records.data', 2));
}
$this->assertSame([$f['opportunity']->id], app(InvestmentCatalogue::class)->query(['q' => 'component needle'])->pluck('id')->all());
$this->assertSame([$f['opportunity']->id], app(InvestmentCatalogue::class)->query(['geo_location_id' => $f['ids']['province']])->pluck('id')->all());
$map = app(InvestmentCatalogue::class)->map($filters);
$this->assertSame(1, $map['georeferenced_count']);
$this->assertSame('linked_project_site', $map['features'][0]['properties']['location_basis']);
}
public function test_cluster_member_locations_do_not_create_opportunity_project_relations_or_a_cluster_point(): void
{
$f = $this->fixture();
DB::table('opportunity_projects')->delete();
DB::table('opportunity_clusters')->insert(['opportunity_id' => $f['opportunity']->id, 'cluster_id' => $f['cluster']->id]);
$this->get('/carteira/'.$f['clusterDossier']->id)->assertInertia(fn (Assert $p) => $p->has('geography.locations', 0)
->has('geography.members', 1)->where('geography.members.0.validation_status', 'pending')->has('geography.members.0.locations', 1)->has('opportunities', 1));
$this->get('/carteira/'.$f['dossier']->id.'?from='.$f['clusterDossier']->id)->assertOk();
$this->assertSame([$f['opportunity']->id], app(InvestmentCatalogue::class)->query(['province_id' => $f['ids']['province']])->pluck('id')->all());
$this->assertSame([], app(InvestmentCatalogue::class)->context($f['opportunity']->id)['projects']->all());
$this->assertSame(0, app(InvestmentCatalogue::class)->map([])['georeferenced_count']);
$this->assertDatabaseCount('opportunity_projects', 0);
}
public function test_private_revoked_and_deleted_relationships_are_not_disclosed(): void
{
$f = $this->fixture();
DB::table('project_locations')->update(['data_classification' => 'confidential']);
DB::table('cluster_projects')->update(['validation_status' => 'revoked']);
$this->assertSame([], app(InvestmentRelations::class)->projectLocations($f['project']->id));
$this->assertSame(0, app(InvestmentRelations::class)->memberships()->count());
$this->assertSame(0, app(InvestmentCatalogue::class)->map([])['georeferenced_count']);
$f['dossier']->update(['publication_status' => 'draft']);
$this->get('/carteira/'.$f['dossier']->id)->assertNotFound();
}
public function test_ambiguous_multiple_sites_do_not_choose_an_arbitrary_opportunity_marker(): void
{
$f = $this->fixture();
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'QA second site', 'location_type' => 'site', 'parent_id' => $f['ids']['locality'], 'data_classification' => 'public']);
DB::table('project_locations')->insert(['id' => (string) Str::uuid(), 'project_id' => $f['project']->id, 'geo_location_id' => $id, 'role' => 'secondary', 'data_classification' => 'public']);
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id, 'geometry_type' => 'point', 'data_classification' => 'public', 'geom' => DB::raw('ST_SetSRID(ST_MakePoint(32.6,-25.8),4326)')]);
$map = app(InvestmentCatalogue::class)->map([]);
$this->assertSame(0, $map['georeferenced_count']);
$this->assertCount(1, $map['unlocated']);
$this->assertCount(2, app(InvestmentRelations::class)->projectLocations($f['project']->id));
}
public function test_repair_requires_authenticated_management_is_transactional_and_replays_without_duplicates(): void
{
$f = $this->fixture();
$directory = storage_path('framework/testing/relationship-repair-'.Str::uuid());
mkdir($directory, 0700, true);
config(['investment.repair_directory' => $directory]);
$dump = $directory.'/synthetic-qa-backup.dump';
file_put_contents($dump, 'synthetic isolated QA proof fixture');
$target = ['database' => DB::connection()->getDatabaseName(), 'port' => 55441];
file_put_contents($directory.'/backup-proof.json', json_encode(['restore_verified' => true, 'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump), 'target' => $target]));
$row = ['project_id' => $f['project']->id, 'code' => 'A99', 'dossier_id' => $f['dossier']->id, 'dossier_version' => 1, 'geo_location_id' => $f['ids']['province'],
'role' => 'secondary', 'source_reference' => $f['dossier']->source, 'source_quote' => $f['dossier']->content['location'], 'reason' => 'QA documented province',
'expected_project_version' => $f['project']->fresh()->version_no, 'data_classification' => 'public'];
file_put_contents($directory.'/preview.json', json_encode(['target' => $target, 'locations' => [$row], 'memberships' => [], 'prepared_opportunities' => [], 'pending' => []]));
$hash = hash_file('sha256', $directory.'/preview.json');
$this->get('/management/relationship-repair')->assertRedirect('/login');
$manager = User::factory()->create();
$this->actingAs($manager)->get('/management/relationship-repair')->assertForbidden();
config(['portal.managers' => [$manager->email]]);
$this->get('/management/relationship-repair')->assertOk();
$this->postJson('/management/relationship-repair', ['hash' => str_repeat('0', 64)])->assertConflict();
$invalid = [...$row, 'expected_project_version' => $row['expected_project_version'] + 7, 'role' => 'impact'];
file_put_contents($directory.'/preview.json', json_encode(['target' => $target, 'locations' => [$row, $invalid], 'memberships' => []]));
$this->postJson('/management/relationship-repair', ['hash' => hash_file('sha256', $directory.'/preview.json')])->assertConflict();
$this->assertDatabaseCount('project_locations', 1);
file_put_contents($directory.'/preview.json', json_encode(['target' => $target, 'locations' => [$row], 'memberships' => [], 'prepared_opportunities' => [], 'pending' => []]));
$hash = hash_file('sha256', $directory.'/preview.json');
$this->postJson('/management/relationship-repair', ['hash' => $hash])->assertOk()->assertJsonPath('locations_created', 1);
$this->postJson('/management/relationship-repair', ['hash' => $hash])->assertOk()->assertJsonPath('replayed', true);
$this->assertDatabaseCount('project_locations', 2);
$this->assertSame('draft', $f['project']->fresh()->status);
$this->assertSame('published', $f['dossier']->fresh()->publication_status);
$changed = [...$row, 'role' => 'impact'];
file_put_contents($directory.'/preview.json', json_encode(['target' => $target, 'locations' => [$changed], 'memberships' => []]));
$this->postJson('/management/relationship-repair', ['hash' => hash_file('sha256', $directory.'/preview.json')])->assertConflict();
$this->assertDatabaseCount('project_locations', 2);
@unlink($directory.'/preview.json');
@unlink($directory.'/backup-proof.json');
@unlink($dump);
@rmdir($directory);
}
private function repairPlan(array $rows): array
{
$directory = storage_path('framework/testing/territorial-repair-'.Str::uuid());
mkdir($directory, 0700, true);
config(['investment.repair_directory' => $directory]);
$dump = $directory.'/qa.dump';
file_put_contents($dump, 'Synthetic QA fixture; not an active backup');
$target = ['database' => DB::connection()->getDatabaseName(), 'port' => 55441];
file_put_contents($directory.'/backup-proof.json', json_encode(['restore_verified' => true, 'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump), 'target' => $target]));
file_put_contents($directory.'/preview.json', json_encode(['target' => $target, 'locations' => $rows, 'memberships' => [], 'pending' => [], 'prepared_opportunities' => []]));
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$this->actingAs($user);
return app(InvestmentRelationshipRepair::class)->preview();
}
private function territorialRow(array $f, string $level = 'province', string $role = 'secondary'): array
{
return ['project_id' => $f['project']->id, 'code' => 'A99', 'dossier_id' => $f['dossier']->id, 'dossier_version' => 1,
'geo_location_id' => $f['ids'][$level], 'role' => $role, 'source_reference' => $f['dossier']->source,
'source_quote' => $f['dossier']->content['location'], 'reason' => 'Explicit synthetic QA document',
'expected_project_version' => $f['project']->fresh()->version_no, 'data_classification' => 'public'];
}
public function test_individual_territories_without_coordinates_apply_sequentially_and_audit_actor_date_and_source(): void
{
$f = $this->fixture();
$preview = $this->repairPlan([$this->territorialRow($f), $this->territorialRow($f, 'district', 'impact')]);
$this->assertSame('grounded', $preview['territorial'][0]['status']);
$this->assertCount(0, $preview['territorial'][0]['coordinates']);
$this->assertCount(1, $preview['territorial'][0]['current']);
$this->get('/management/relationship-repair')->assertInertia(fn (Assert $p) => $p->where('territorial.0.status', 'grounded')->where('territorial.1.proposed.role', 'impact'));
foreach ([0, 1] as $index) {
$fresh = app(InvestmentRelationshipRepair::class)->preview();
$request = ['hash' => $fresh['hash'], 'row_id' => $fresh['territorial'][$index]['row_id'], 'validation_hash' => $fresh['territorial'][$index]['validation_hash']];
$this->postJson('/management/relationship-repair', $request)->assertOk()->assertJsonPath('locations_created', 1);
$this->postJson('/management/relationship-repair', $request)->assertOk()->assertJsonPath('replayed', true);
}
$this->assertDatabaseCount('project_locations', 3);
$history = app(InvestmentRelationshipRepair::class)->preview()['history'];
$this->assertCount(2, $history);
$this->assertSame(auth()->id(), $history[0]['actor_id']);
$this->assertNotEmpty($history[0]['date']);
$this->assertSame($f['dossier']->source, $history[0]['data']['sources'][0]['source_reference']);
$this->assertSame('draft', $f['project']->fresh()->status);
}
public function test_pending_and_contradictory_rows_are_retained_while_grounded_rows_apply(): void
{
$f = $this->fixture();
$good = $this->territorialRow($f);
$pending = [...$this->territorialRow($f, 'district'), 'source_quote' => ''];
$bad = [...$this->territorialRow($f, 'locality', 'impact'), 'province_id' => (string) Str::uuid()];
$preview = $this->repairPlan([$good, $pending, $bad]);
$this->assertSame(['grounded', 'pending', 'contradictory'], array_column($preview['territorial'], 'status'));
foreach ([1, 2] as $index) {
$this->postJson('/management/relationship-repair', ['hash' => $preview['hash'], 'row_id' => $preview['territorial'][$index]['row_id'], 'validation_hash' => $preview['territorial'][$index]['validation_hash']])->assertConflict();
}
$this->postJson('/management/relationship-repair', ['hash' => $preview['hash']])->assertOk()->assertJsonPath('locations_created', 1);
$this->assertDatabaseCount('project_locations', 2);
$this->assertDatabaseMissing('project_locations', ['project_id' => $f['project']->id, 'geo_location_id' => $f['ids']['locality']]);
$this->assertSame('contradictory', app(InvestmentRelationshipRepair::class)->preview()['territorial'][2]['status']);
}
public function test_coordinate_inclusion_and_broken_hierarchy_are_checked_without_inventing_boundaries(): void
{
$f = $this->fixture();
$row = $this->territorialRow($f, 'site');
$validator = app(InvestmentTerritorialValidation::class);
$without = $validator->validate($row);
$this->assertSame('grounded', $without['status']);
$this->assertSame([], $without['coordinate_checks']);
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $f['ids']['province'], 'geometry_type' => 'polygon', 'data_classification' => 'public',
'geom' => DB::raw('ST_MakeEnvelope(32,-26,33,-25,4326)'), 'source_system' => 'Synthetic QA boundary']);
$inside = $validator->validate($row);
$this->assertSame('grounded', $inside['status']);
$this->assertTrue($inside['coordinate_checks'][0]['compatible']);
DB::table('geo_geometries')->where('geo_location_id', $f['ids']['site'])->update(['geom' => DB::raw('ST_SetSRID(ST_MakePoint(35,-25.9),4326)')]);
$outside = $validator->validate($row);
$this->assertSame('contradictory', $outside['status']);
$this->assertFalse($outside['coordinate_checks'][0]['compatible']);
$this->assertSame('contradictory', $validator->validate([...$row, 'coordinates' => [181, -25]])['status']);
DB::table('geo_locations')->where('id', $f['ids']['admin_post'])->update(['deleted_at' => now()]);
$broken = $validator->validate($this->territorialRow($f, 'locality'));
$this->assertSame('contradictory', $broken['status']);
$this->assertStringContainsString('antepassado indisponível', implode(' ', $broken['issues']));
}
public function test_individual_application_rejects_changed_territorial_preview_and_unknown_row(): void
{
$f = $this->fixture();
$preview = $this->repairPlan([$this->territorialRow($f)]);
$this->postJson('/management/relationship-repair', ['hash' => $preview['hash'], 'row_id' => str_repeat('a', 64), 'validation_hash' => $preview['territorial'][0]['validation_hash']])->assertUnprocessable();
DB::table('geo_locations')->where('id', $f['ids']['province'])->update(['name' => 'Changed QA territory']);
$this->postJson('/management/relationship-repair', ['hash' => $preview['hash'], 'row_id' => $preview['territorial'][0]['row_id'], 'validation_hash' => $preview['territorial'][0]['validation_hash']])->assertConflict();
$this->assertDatabaseCount('project_locations', 1);
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
namespace Tests\Feature;
use App\Models\InvestmentDossier;
use App\Models\Project;
use App\Models\User;
use App\Services\PartnershipExchange;
use App\Services\PartnershipInstallation;
use App\Services\PartnershipMatching;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class PartnershipExchangeTest extends TestCase
{
use RefreshDatabase;
private function draft(User $owner, array $extra = []): object
{
return app(PartnershipExchange::class)->save($owner, array_replace_recursive(['client_key' => (string) Str::uuid(), 'title' => 'QA partnership', 'kind' => 'seeking', 'partnership_type' => 'joint_venture',
'offer' => 'QA documented capacity', 'seeking' => 'QA technical contribution', 'payload' => ['summary' => 'QA summary', 'consent' => true, 'mandatory' => ['capacity']], 'origins' => [], 'locations' => []], $extra));
}
private function publish(User $owner): array
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$p = $this->draft($owner);
$p = app(PartnershipExchange::class)->transition($owner, $p->id, $p->version, 'submit', 'QA submission');
$p = app(PartnershipExchange::class)->transition($manager, $p->id, $p->version, 'publish', 'QA reviewed disclosure');
return [$p, $manager];
}
private function candidature(User $candidate, object $p): object
{
return app(PartnershipExchange::class)->interest($candidate, $p->id, ['proposal' => 'QA proposal', 'payload' => ['consent' => true, 'capacity' => 'Declared capacity, not certified']]);
}
public function test_complete_local_flow_from_personal_draft_to_agreement_and_completed_milestone(): void
{
$owner = User::factory()->create(['email_verified_at' => null]);
$candidate = User::factory()->create(['email_verified_at' => null]);
[$p, $manager] = $this->publish($owner);
$this->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonPath('record.status', 'published');
$i = $this->candidature($candidate, $p);
$other = $this->candidature(User::factory()->create(), $p);
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'shortlist', 'Potential match; unknown data to clarify');
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'connect', 'Private negotiation authorized');
$room = DB::table('partnership_rooms')->where('interest_id', $i->id)->first();
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 1, 'action' => 'record', 'title' => 'QA work plan', 'terms' => 'Parties, objectives, contributions, risks and exit conditions', 'reason' => 'QA instrument recorded'])->assertOk();
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 2, 'action' => 'confirm', 'reason' => 'Proponent confirmation'])->assertOk();
$this->actingAs($candidate)->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 3, 'action' => 'confirm', 'reason' => 'Candidate confirmation'])->assertOk();
$this->assertDatabaseHas('partnership_rooms', ['id' => $room->id, 'status' => 'agreed']);
$this->postJson('/parcerias/salas/'.$room->id.'/marcos', ['title' => 'Pilot deliverable', 'responsible_id' => $candidate->id, 'status' => 'pending'])->assertOk();
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 4, 'action' => 'start', 'reason' => 'Start pilot'])->assertOk();
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 5, 'action' => 'complete', 'reason' => 'Premature completion'])->assertConflict();
$milestone = DB::table('partnership_milestones')->where('room_id', $room->id)->first();
$this->putJson('/parcerias/salas/'.$room->id.'/marcos/'.$milestone->id, ['version' => 1, 'title' => 'Pilot deliverable', 'responsible_id' => $candidate->id, 'status' => 'completed', 'evidence' => 'QA evidence reference'])->assertOk();
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 5, 'action' => 'complete', 'reason' => 'Deliverables complete'])->assertOk();
$this->assertDatabaseHas('partnership_rooms', ['id' => $room->id, 'status' => 'closed']);
$this->assertDatabaseHas('partnership_interests', ['id' => $other->id, 'status' => 'submitted']);
$this->assertDatabaseHas('portal_partnerships', ['id' => $p->id, 'status' => 'published']);
$this->assertNotEmpty(DB::table('audit_events')->where('source_system', 'partnership-exchange')->get());
$this->assertSame('not_verified', json_decode(DB::table('partnership_rooms')->where('id', $room->id)->value('agreement'), true)['legal_signature_status']);
}
public function test_preserves_legacy_proposal_and_rejects_self_moderation_and_concurrent_updates(): void
{
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$owner->email, $manager->email]]);
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $owner->id, 'title' => 'Legacy', 'sector' => 'Original sector', 'province' => 'Original province', 'offer' => 'Original offer', 'seeking' => 'Original request', 'status' => 'pending']);
$this->actingAs($owner)->postJson('/parcerias/'.$id.'/estado', ['version' => 1, 'action' => 'publish', 'reason' => 'Self moderation'])->assertForbidden();
$this->actingAs($manager)->postJson('/parcerias/'.$id.'/estado', ['version' => 1, 'action' => 'publish', 'reason' => 'Legacy reviewed'])->assertOk();
$this->assertDatabaseHas('portal_partnerships', ['id' => $id, 'user_id' => $owner->id, 'sector' => 'Original sector', 'province' => 'Original province', 'offer' => 'Original offer']);
$this->postJson('/parcerias/'.$id.'/estado', ['version' => 1, 'action' => 'return', 'reason' => 'Stale'])->assertConflict();
}
public function test_draft_recovery_and_idempotency_do_not_require_organization_or_documents(): void
{
$owner = User::factory()->create(['email_verified_at' => null]);
$input = ['client_key' => (string) Str::uuid(), 'title' => '', 'kind' => 'seeking', 'payload' => [], 'origins' => [], 'locations' => []];
$this->actingAs($owner)->postJson('/parcerias/rascunhos', $input)->assertOk();
$this->postJson('/parcerias/rascunhos', $input)->assertOk();
$this->postJson('/parcerias/rascunhos', [...$input, 'title' => 'Different bytes'])->assertConflict();
$this->assertDatabaseCount('portal_partnerships', 1);
$p = DB::table('portal_partnerships')->first();
$this->get('/parcerias/'.$p->id.'/editar')->assertOk();
$this->postJson('/parcerias/'.$p->id.'/estado', ['version' => $p->version, 'action' => 'submit', 'reason' => 'Still incomplete'])->assertUnprocessable();
$this->actingAs(User::factory()->create())->get('/parcerias/'.$p->id.'/editar')->assertForbidden();
}
public function test_competing_candidates_and_moderator_cannot_enter_private_room_or_read_other_interests(): void
{
$owner = User::factory()->create();
$candidate = User::factory()->create();
[$p, $moderator] = $this->publish($owner);
$i = $this->candidature($candidate, $p);
$outsider = User::factory()->create();
$this->candidature($outsider, $p);
$this->actingAs($outsider)->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonCount(1, 'interests');
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'shortlist', 'QA shortlist');
app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'connect', 'QA connect');
$room = DB::table('partnership_rooms')->first();
$this->getJson('/parcerias/salas/'.$room->id)->assertForbidden();
$this->actingAs($moderator)->getJson('/parcerias/salas/'.$room->id)->assertForbidden();
$this->getJson('/parcerias/'.$p->id)->assertJsonCount(0, 'interests');
$this->actingAs($candidate)->postJson('/parcerias/candidaturas/'.$i->id, ['version' => $i->version + 1, 'action' => 'assess', 'reason' => 'Self assessment', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'Claim']]])->assertForbidden();
$key = (string) Str::uuid();
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', ['client_key' => $key, 'body' => 'Private QA message'])->assertOk();
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', ['client_key' => $key, 'body' => 'Private QA message'])->assertOk();
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', ['client_key' => $key, 'body' => 'Changed'])->assertConflict();
$this->assertDatabaseCount('partnership_messages', 1);
}
public function test_matching_keeps_unknown_information_separate_from_proven_failure_and_desirable_criteria(): void
{
$owner = User::factory()->create();
[$p] = $this->publish($owner);
$i = $this->candidature(User::factory()->create(), $p);
$engine = app(PartnershipMatching::class);
$unknown = $engine->explain($p, $i);
$this->assertSame('unknown', $unknown['eligibility']);
$this->assertSame(100.0, $unknown['score_max']);
$failed = $engine->explain($p, $i, ['capacity' => ['state' => 'unmet', 'evidence' => 'QA documented inability']]);
$this->assertSame('unmet', $failed['eligibility']);
$desirable = $engine->explain($p, $i, ['impact' => ['state' => 'unmet', 'evidence' => 'QA reference']]);
$this->assertSame('unknown', $desirable['eligibility']);
$this->assertFalse($desirable['recommendation_is_approval']);
$all = [];
foreach (array_keys(config('partnerships.criteria')) as $code) {
$all[$code] = ['state' => 'met', 'evidence' => 'Synthetic QA source for '.$code];
}
$this->assertSame(100.0, $engine->explain($p, $i, $all)['score_min']);
}
public function test_document_sharing_reuses_private_repository_and_scan_controls(): void
{
Queue::fake();
Storage::fake('local');
$owner = User::factory()->create();
$candidate = User::factory()->create();
[$p] = $this->publish($owner);
$i = $this->candidature($candidate, $p);
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'shortlist', 'QA shortlist');
app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'connect', 'QA room');
$room = DB::table('partnership_rooms')->first();
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/documentos', ['file' => UploadedFile::fake()->create('qa.pdf', 1, 'application/pdf'), 'shared' => false])->assertOk();
$link = DB::table('partnership_documents')->first();
$this->actingAs($candidate)->getJson('/parcerias/salas/'.$room->id)->assertJsonCount(0, 'documents');
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertNotFound();
$this->postJson('/parcerias/salas/'.$room->id.'/documentos/'.$link->id.'/partilha', ['shared' => true])->assertForbidden();
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/documentos/'.$link->id.'/partilha', ['shared' => true])->assertOk();
$this->actingAs($candidate);
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertNotFound();
$document = DB::table('documents')->where('id', $link->document_id)->first();
DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $document->sha256]);
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertOk();
Storage::disk('local')->put($document->storage_key, 'Changed bytes');
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertNotFound();
}
public function test_organization_representation_blocks_viewer_writes_and_revoked_authors(): void
{
$owner = User::factory()->create();
$representative = User::factory()->create();
$viewer = User::factory()->create();
$organization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $organization, 'name' => 'Synthetic QA organization', 'organization_type' => 'private']);
foreach ([[$owner, 'manager'], [$representative, 'officer'], [$viewer, 'viewer']] as [$user, $role]) {
DB::table('memberships')->insert(['user_id' => $user->id, 'organization_id' => $organization, 'role' => $role]);
}
$p = $this->draft($owner, ['organization_id' => $organization]);
$this->actingAs($viewer)->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonPath('canEdit', false);
$this->postJson('/parcerias/'.$p->id.'/estado', ['version' => $p->version, 'action' => 'submit', 'reason' => 'Viewer attempt'])->assertForbidden();
$this->actingAs($representative)->get('/parcerias/'.$p->id.'/editar')->assertOk();
DB::table('memberships')->where('user_id', $owner->id)->update(['deleted_at' => now()]);
$this->actingAs($owner)->get('/parcerias/'.$p->id.'/editar')->assertForbidden();
$this->getJson('/parcerias/'.$p->id)->assertNotFound();
}
public function test_hierarchical_filters_and_source_links_use_only_explicit_documented_relations(): void
{
$parent = null;
$ids = [];
foreach (['country', 'province', 'district', 'admin_post', 'locality'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'QA '.$type, 'location_type' => $type, 'parent_id' => $parent,
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
$ids[$type] = $id;
$parent = $id;
}
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$project = Project::factory()->create();
$dossier = InvestmentDossier::create(['project_id' => $project->id, 'title' => 'QA documented anchor', 'source' => 'Synthetic QA source', 'content' => ['location' => 'QA locality'], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA fixture disclosure only']);
DB::table('project_locations')->insert(['project_id' => $project->id, 'geo_location_id' => $ids['locality'], 'role' => 'primary', 'data_classification' => 'public']);
$p = $this->draft($owner, ['origins' => [['type' => 'project', 'target_id' => $project->id]]]);
$p = app(PartnershipExchange::class)->transition($owner, $p->id, $p->version, 'submit', 'QA submission');
$p = app(PartnershipExchange::class)->transition($manager, $p->id, $p->version, 'publish', 'QA source checked');
foreach (['province', 'district', 'admin_post', 'locality'] as $level) {
$this->getJson('/api/v1/partnerships?territory_id='.$ids[$level])->assertOk()->assertJsonPath('data.0.id', $p->id)->assertJsonPath('total', 1);
}
$this->getJson('/api/v1/partnerships/source/project/'.$project->id)->assertOk()->assertJsonPath('items.0.id', $p->id);
$this->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonPath('origins.0.href', '/carteira/'.$dossier->id);
$this->assertDatabaseCount('partnership_locations', 0);
$this->assertDatabaseCount('opportunity_projects', 0);
}
public function test_authenticated_installation_preserves_legacy_rows_and_is_repeatable_in_qa(): void
{
$migration = require database_path('migrations/2026_10_07_160000_extend_internal_partnership_exchange.php');
$migration->down();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $manager->id, 'title' => 'Legacy before migration', 'sector' => 'Original', 'province' => 'Original', 'offer' => 'Retained', 'seeking' => 'Retained', 'status' => 'pending']);
$dir = storage_path('framework/testing/partnership-install-'.Str::uuid());
mkdir($dir, 0700, true);
config(['partnerships.proof_directory' => $dir]);
$dump = $dir.'/synthetic-unit-fixture.dump';
file_put_contents($dump, 'Isolated synthetic unit proof, not active backup');
file_put_contents($dir.'/backup-proof.json', json_encode(['restore_verified' => true, 'target' => ['database' => DB::connection()->getDatabaseName(), 'port' => 55441], 'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump)]));
$installer = app(PartnershipInstallation::class);
file_put_contents($dir.'/qa-evidence.json', json_encode(['passed' => true, 'code_hash' => $installer->fingerprint(), 'database' => DB::connection()->getDatabaseName(), 'port' => 55441]));
$hash = $installer->preview()['hash'];
$this->actingAs($manager)->post('/management/partnerships-install', ['hash' => str_repeat('0', 64)])->assertConflict();
$this->post('/management/partnerships-install', ['hash' => $hash])->assertRedirect();
$this->assertDatabaseHas('portal_partnerships', ['id' => $id, 'user_id' => $manager->id, 'title' => 'Legacy before migration', 'status' => 'pending', 'offer' => 'Retained']);
$this->post('/management/partnerships-install', ['hash' => $hash])->assertRedirect();
$this->assertSame(1, DB::table('audit_events')->where('action', 'partnership_module_installed')->count());
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
require dirname(__DIR__).'/vendor/autoload.php';
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db = Illuminate\Support\Facades\DB::connection();
$report = $db->transaction(function () use ($db) {
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
return [
'generated_at' => date(DATE_ATOM),
'target' => $db->selectOne('select current_database() as database, inet_server_port() as port'),
'projects' => $db->table('projects')->orderBy('code')->get(),
'dossiers' => $db->table('investment_dossiers')->orderBy('title')->get(),
'clusters' => $db->table('economic_clusters')->orderBy('code')->get(),
'cluster_projects' => $db->table('cluster_projects')->get(),
'opportunity_clusters' => $db->table('opportunity_clusters')->get(),
'value_chains' => $db->table('value_chains')->get(),
'value_chain_links' => $db->table('value_chain_links')->get(),
'project_locations' => $db->table('project_locations')->get(),
'opportunity_projects' => $db->table('opportunity_projects')->get(),
'opportunities' => $db->table('opportunities')->get(),
'profiles' => $db->table('opportunity_profiles')->get(),
'locations' => $db->table('geo_locations')->get(['id','name','location_type','parent_id','source_system','external_id','data_classification','deleted_at']),
'geometries' => $db->table('geo_geometries')->select(['id','geo_location_id','geometry_type','source_system','external_id','data_classification','deleted_at'])->selectRaw('ST_AsGeoJSON(geom) as geometry')->get(),
];
});
$directory = storage_path('app/private/anchor-project-reconciliation');
if (! is_dir($directory)) mkdir($directory, 0700, true);
$file = $directory.'/inventory-'.date('Ymd-His').'.json';
file_put_contents($file, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
echo $file.PHP_EOL;
+35
View File
@@ -0,0 +1,35 @@
import hashlib, json, pathlib, zipfile, xml.etree.ElementTree as ET
root = pathlib.Path(r'C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\portal do investidor')
out = pathlib.Path('storage/app/private/anchor-project-reconciliation')
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
inventory, extracts = [], []
for file in sorted(root.rglob('*')):
if not file.is_file(): continue
record = {'path':str(file), 'bytes':file.stat().st_size, 'analysis':'not_analysed'}
try:
data = file.read_bytes()
record['sha256'] = hashlib.sha256(data).hexdigest()
if zipfile.is_zipfile(file):
with zipfile.ZipFile(file) as archive:
record['attachments'] = [{'path':e.filename,'bytes':e.file_size,'sha256':hashlib.sha256(archive.read(e)).hexdigest()} for e in archive.infolist() if '/media/' in e.filename or '/embeddings/' in e.filename]
if file.suffix.lower()=='.docx':
tree=ET.fromstring(archive.read('word/document.xml'))
paragraphs=[''.join(p.itertext()) for p in []]
paragraphs=[''.join(t.text or '' for t in p.findall('.//w:t',ns)) for p in tree.findall('.//w:p',ns)]
text='\n'.join(paragraphs)
tables=[[[ '\n'.join(''.join(t.text or '' for t in p.findall('.//w:t',ns)) for p in c.findall('./w:p',ns)) for c in r.findall('./w:tc',ns)] for r in table.findall('./w:tr',ns)] for table in tree.findall('.//w:body/w:tbl',ns)]
extracts.append({'path':str(file),'sha256':record['sha256'],'paragraphs':paragraphs,'tables':tables})
record['analysis']='text_extracted; relevance_pending'
except Exception as error:
record['error']=str(error)
inventory.append(record)
out.mkdir(parents=True,exist_ok=True)
(out/'source-files.json').write_text(json.dumps(inventory,ensure_ascii=False,indent=2),encoding='utf8')
(out/'source-texts.json').write_text(json.dumps(extracts,ensure_ascii=False,indent=2),encoding='utf8')
for doc in extracts:
if 'Matriz detalhada' in doc['path'] or 'v5 programacao' in doc['path'] or doc['path'].endswith('projectos.docx'):
print(doc['path'])
relevant=[(i,p) for i,p in enumerate(doc['paragraphs']) if any(s in p for s in ['A01','A03','A04','A09','6.6','6.1','oportunidades complementares','Oportunidades complementares'])]
for i,p in relevant[:24]: print(i, p[:450])
print('Files:',len(inventory),'DOCX:',len(extracts))
+37
View File
@@ -0,0 +1,37 @@
"""Inventory the accessible documentary collection before selecting form sources."""
from pathlib import Path
import hashlib, json, zipfile
from xml.etree import ElementTree as ET
roots = [Path(r'C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo'), Path(r'C:\Users\macbook\.codex\attachments')]
out = Path('storage/app/private/virtual-desk')
out.mkdir(parents=True, exist_ok=True)
inventory = []
for root in roots:
for path in sorted(root.rglob('*')):
if not path.is_file():
continue
try:
raw = path.read_bytes()
item = dict(path=str(path), size=len(raw), sha256=hashlib.sha256(raw).hexdigest(), analysis='not_analysed', attachments=[])
if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive:
item['attachments'] = [dict(identifier=n, size=len(archive.read(n)), sha256=hashlib.sha256(archive.read(n)).hexdigest()) for n in archive.namelist() if '/embeddings/' in n or '/media/' in n]
inventory.append(item)
except OSError as error:
inventory.append(dict(path=str(path), availability='unreadable', reason=str(error)))
(out/'source-inventory.json').write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding='utf8')
# Selection starts only after the complete inventory was persisted.
selected=[]
for item in inventory:
path=Path(item['path'])
if path.suffix.lower()=='.docx' and not path.name.startswith('~$'):
with zipfile.ZipFile(path) as archive:
root=ET.fromstring(archive.read('word/document.xml'))
text='\n'.join(''.join(p.itertext()) for p in root.findall('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'))
if any(word in path.name.lower() for word in ['balcão','balcao','formul','apiex']) or ('APIEX' in text and ('FORMULÁRIO' in text or 'Formulário' in text)):
target=out/(item['sha256']+'.txt')
target.write_text(text, encoding='utf8')
selected.append(dict(**item, extraction=str(target), form_status='source_review_pending'))
(out/'selected-sources.json').write_text(json.dumps(selected, ensure_ascii=False, indent=2), encoding='utf8')
print(json.dumps(dict(files=len(inventory), selected=[dict(path=i['path'], extraction=i['extraction']) for i in selected]), ensure_ascii=False))
@@ -0,0 +1,49 @@
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('node:fs');const assert=require('node:assert/strict');
const dir='storage/app/private/anchor-project-reconciliation/';
const fixture=JSON.parse(fs.readFileSync(dir+'qa-browser-fixture.json'));
const base='http://127.0.0.1:18086';
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
try {
const context=await browser.newContext();
await context.route('**/*',route=>route.request().url().startsWith(base)||route.request().url().startsWith('data:')?route.continue():route.abort());
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
const response=await page.goto(base+'/carteira/'+fixture.project_dossier,{waitUntil:'networkidle'});
assert.equal(response.headers()['x-investmoz-qa-target'],fixture.target);assert.equal(response.status(),200);
await page.getByRole('heading',{name:'Localização documentada do projecto',exact:true}).waitFor();
for(const text of ['QA province','QA district','QA admin_post','QA locality','32.5'])assert((await page.locator('body').innerText()).includes(text),text);
await page.getByRole('link',{name:'Synthetic QA linked opportunity',exact:true}).click();
await page.getByRole('link',{name:'Voltar ao dossier: Synthetic QA anchor 1',exact:true}).waitFor();
await page.getByRole('link',{name:'Voltar ao dossier: Synthetic QA anchor 1',exact:true}).click();
await page.getByRole('heading',{name:'Localização documentada do projecto',exact:true}).waitFor();
await page.getByRole('link',{name:'Synthetic QA multi-site cluster',exact:true}).first().click();
const geography=page.locator(`[aria-labelledby="documented-geography-${fixture.cluster_dossier}"]`);
await geography.getByText('Não existe um ponto único atribuído ao cluster.',{exact:false}).waitFor();
assert((await geography.innerText()).includes('2 investimentos com coordenadas disponíveis.'));
assert((await geography.innerText()).includes('QA production'));
assert((await geography.innerText()).includes('QA processing'));
assert.equal(await geography.getByRole('link',{name:/QA-ANCHOR-/}).count(),2);
for(const width of [1440,390]){
await page.setViewportSize({width,height:1000});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
await page.screenshot({path:`docs/investmoz/relationships/cluster-${width}.png`,fullPage:true});
}
const map=await context.request.get(base+'/api/v1/investment/map?province_id='+fixture.province_id);assert.equal(map.headers()['x-investmoz-qa-target'],fixture.target);
const data=await map.json();assert.equal(data.matched_count,1);assert.equal(data.georeferenced_count,1);assert.equal(data.features[0].properties.location_basis,'linked_project_site');
const detail=await context.request.get(base+'/api/v1/investment/opportunities/'+fixture.opportunity_slug);
assert(detail.ok());const json=await detail.json();assert.equal(json.context.projects.length,1);assert.equal(json.context.clusters.length,1);
await page.goto(base+'/login',{waitUntil:'networkidle'});
await page.locator('input[type=email]').fill('relationship-qa@example.invalid');await page.locator('input[type=password]').fill('QA-Relationship-2026!');
await page.locator('button[type=submit]').click();await page.waitForURL(url=>!url.pathname.startsWith('/login'));
await page.goto(base+'/management/relationship-repair',{waitUntil:'networkidle'});
await page.getByRole('heading',{name:'Localizações: 28',exact:true}).waitFor();
await page.getByRole('button',{name:'Aplicar associações fundamentadas',exact:true}).click();
await page.getByText(/Associações documentadas aplicadas\.|Este plano já foi aplicado; nenhum duplicado criado\./).waitFor();
await page.getByRole('button',{name:'Aplicar associações fundamentadas',exact:true}).click();
await page.getByText('Este plano já foi aplicado; nenhum duplicado criado.',{exact:true}).waitFor();
assert.deepEqual(errors,[]);
fs.writeFileSync('docs/investmoz/relationships/browser-qa.json',JSON.stringify({target:fixture.target,project:fixture.project_dossier,cluster:fixture.cluster_dossier,passed:true,publicNavigation:true,administrativeHierarchy:true,clusterMemberSites:2,clusterSinglePoint:false,territorialMap:true,chainLinks:true,authenticatedPlanApplied:true,replayWithoutDuplicates:true,viewports:[1440,390],errors},null,2));
console.log('QA browser passed: public project/cluster/opportunity navigation, hierarchy, two member sites, chains, filters, authenticated 28-location repair and idempotent replay.');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+47
View File
@@ -0,0 +1,47 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$active = Illuminate\Support\Facades\DB::connection();
$identity = $active->selectOne('select current_database() as database, inet_server_port() as port');
if ($identity->database !== 'investemoz' || (int) $identity->port !== 5432) throw new RuntimeException('Unexpected active backup target.');
$directory = storage_path('app/private/partnership-exchange/backup-'.date('Ymd-His'));
mkdir($directory, 0700, true);
$dump = $directory.'/database.dump';
$qaName = 'partnership_restore_'.date('Ymd_His').'_test';
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
$run = function (array $command, array $environment = []) {
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, [...getenv(), ...$environment]);
if (! is_resource($process)) throw new RuntimeException('Backup process unavailable.');
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]); fclose($pipes[1]);
$errors = stream_get_contents($pipes[2]); fclose($pipes[2]);
if (proc_close($process) !== 0) throw new RuntimeException('Backup/restore command failed: '.$errors);
return $output;
};
$proof = $active->transaction(function () use ($active, $run, $bin, $qaName, $dump, $identity) {
$active->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$snapshot = $active->selectOne('select pg_export_snapshot() as snapshot')->snapshot;
$run([$bin.'pg_dump.exe', '-h', $active->getConfig('host'), '-p', '5432', '-U', $active->getConfig('username'), '-d', 'investemoz', '-Fc', '--snapshot='.$snapshot, '-f', $dump], ['PGPASSWORD' => $active->getConfig('password')]);
$run([$bin.'createdb.exe', '-h', '127.0.0.1', '-p', '55441', '-U', 'postgres', $qaName], ['PGPASSWORD' => '']);
$run([$bin.'pg_restore.exe', '-h', '127.0.0.1', '-p', '55441', '-U', 'postgres', '-d', $qaName, '--exit-on-error', '--no-owner', '--no-privileges', $dump], ['PGPASSWORD' => '']);
config(['database.connections.partnership_restore' => [...config('database.connections.pgsql'), 'host' => '127.0.0.1', 'port' => 55441, 'database' => $qaName, 'username' => 'postgres', 'password' => '', 'url' => null]]);
$restore = Illuminate\Support\Facades\DB::connection('partnership_restore');
$restored = $restore->selectOne('select current_database() as database, inet_server_port() as port');
if ($restored->database !== $qaName || ! str_ends_with($qaName, '_test') || (int) $restored->port !== 55441) throw new RuntimeException('QA identity rejected.');
$counts = []; $hashes = [];
foreach ($active->select("select tablename from pg_tables where schemaname='public' order by tablename") as $table) {
$name = $table->tablename;
$quoted = '"'.str_replace('"', '""', $name).'"';
$rows = fn ($connection) => array_map(fn ($row) => $row->value, $connection->select('select row_to_json(t)::text as value from public.'.$quoted.' as t order by row_to_json(t)::text'));
$a = $rows($active); $b = $rows($restore);
if ($a !== $b) throw new RuntimeException('Restored contents differ: '.$name);
$counts[$name] = count($a); $hashes[$name] = hash('sha256', implode("\n", $a));
}
return ['restore_verified' => true, 'verified_at' => now()->toIso8601String(), 'target' => $identity, 'restored_target' => $restored,
'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump), 'table_counts' => $counts, 'table_hashes' => $hashes];
});
file_put_contents(storage_path('app/private/partnership-exchange/backup-proof.json'), json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$audit = $active->table('audit_events')->where('source_system', 'documented-relations-repair')->get(['actor_user_id', 'created_at', 'action']);
echo json_encode(['restore_verified' => true, 'tables' => count($proof['table_counts']), 'project_locations' => $proof['table_counts']['project_locations'], 'restored_target' => $proof['restored_target'], 'relationship_audit' => $audit], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
+86
View File
@@ -0,0 +1,86 @@
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('node:fs');const assert=require('node:assert/strict');
const base='http://127.0.0.1:18086';
const fixture=JSON.parse(fs.readFileSync('storage/app/private/anchor-project-reconciliation/qa-browser-fixture.json'));
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
const errors=[];
async function login(role){const context=await browser.newContext();await context.route('**/*',r=>r.request().url().startsWith(base)?r.continue():r.abort());const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(base+'/login');await page.locator('input[type=email]').fill(`partnership-qa-${role}@example.invalid`);await page.locator('input[type=password]').fill('QA-Partnership-2026!');await page.locator('button[type=submit]').click();await page.waitForURL(url=>!url.pathname.startsWith('/login'));return page;}
try{
const owner=await login('owner'),candidate=await login('candidate'),moderator=await login('moderator');
const title='QA browser partnership '+Date.now();
await owner.goto(base+'/carteira/'+fixture.project_dossier,{waitUntil:'networkidle'});
await owner.getByRole('link',{name:'Propor parceria a partir deste registo',exact:true}).click();
await owner.getByLabel('Título',{exact:true}).fill(title);
await owner.getByLabel('Tipo de parceria',{exact:true}).selectOption('joint_venture');
await owner.getByLabel('Resumo público',{exact:true}).fill('QA partnership: source references and local workflow');
await owner.getByLabel('O que oferece',{exact:true}).fill('Synthetic QA production capacity');
await owner.getByLabel('O que procura',{exact:true}).fill('Synthetic QA technology partner');
await owner.getByLabel(/Autorizo submeter/).check();
await owner.getByRole('button',{name:'Guardar rascunho',exact:true}).click();
await owner.waitForURL(/\/parcerias\/\d+\/editar$/);
const partnershipId=Number(new URL(owner.url()).pathname.split('/')[2]);
await owner.reload();assert.equal(await owner.getByLabel('Título',{exact:true}).inputValue(),title);
await owner.getByRole('link',{name:'Pré-visualizar e submeter',exact:true}).click();
await owner.getByLabel('Motivo da ação',{exact:true}).fill('QA source checked and disclosure requested');
await owner.getByRole('button',{name:'Submeter para publicação',exact:true}).click();
await owner.getByText('Estado: Submetida.',{exact:false}).waitFor();
await moderator.goto(base+'/parcerias/'+partnershipId);
await moderator.getByLabel('Motivo da ação',{exact:true}).fill('QA reviewed public content; no organization verification presumed');
await moderator.getByRole('button',{name:'Publicar',exact:true}).click();
await moderator.getByText('Estado: Publicada.',{exact:false}).waitFor();
await candidate.goto(base+'/parcerias/'+partnershipId);
await candidate.getByLabel('Proposta curta',{exact:true}).fill('QA candidate contribution');
await candidate.getByLabel('Capacidade disponível',{exact:true}).fill('Declared synthetic QA capacity');
await candidate.getByLabel(/Autorizo a partilha desta candidatura/).check();
await candidate.getByRole('button',{name:'Guardar interesse',exact:true}).click();
await candidate.getByRole('heading',{name:/Candidatura \d+/}).waitFor();
await owner.reload();
await owner.getByLabel('Motivo da decisão ou pedido de esclarecimento',{exact:true}).fill('QA shortlist; unknown evidence remains pending');
await owner.getByRole('button',{name:'Adicionar à lista curta',exact:true}).click();
await owner.getByRole('heading',{name:/Candidatura \d+ · Lista curta/}).waitFor();
await owner.getByLabel('Motivo da decisão ou pedido de esclarecimento',{exact:true}).fill('QA private negotiation authorized');
await owner.getByRole('button',{name:'Abrir negociação',exact:true}).click();
await owner.getByRole('link',{name:'Abrir conexão privada',exact:true}).click();
const roomId=Number(new URL(owner.url()).pathname.split('/').pop());
await owner.getByLabel('Mensagem privada',{exact:true}).fill('QA private discussion');
await owner.getByRole('button',{name:'Enviar mensagem',exact:true}).click();
await owner.getByText('QA private discussion',{exact:true}).waitFor();
await owner.getByLabel('Título do instrumento',{exact:true}).fill('QA pilot agreement');
await owner.getByLabel('Condições e plano de valor',{exact:true}).fill('Parties, deliverables, responsibilities, risk and exit conditions. Synthetic QA only.');
await owner.getByLabel('Motivo da ação',{exact:true}).fill('QA terms recorded');
await owner.getByRole('button',{name:'Registar versão do acordo',exact:true}).click();
await owner.getByRole('button',{name:'Confirmar pela minha parte',exact:true}).waitFor();
await owner.getByLabel('Motivo da ação',{exact:true}).fill('QA proponent acknowledgement');
await owner.getByRole('button',{name:'Confirmar pela minha parte',exact:true}).click();
await candidate.goto(base+'/parcerias/salas/'+roomId);
await candidate.getByText('QA private discussion',{exact:true}).waitFor();
await candidate.getByLabel('Motivo da ação',{exact:true}).fill('QA candidate acknowledgement');
await candidate.getByRole('button',{name:'Confirmar pela minha parte',exact:true}).click();
await candidate.getByRole('button',{name:'Iniciar acompanhamento',exact:true}).waitFor();
await owner.reload();
await owner.getByLabel('Entregável',{exact:true}).fill('QA pilot delivery');
await owner.getByRole('button',{name:'Guardar marco',exact:true}).click();
await owner.getByRole('heading',{name:'Atualizar marco',exact:true}).waitFor();
await owner.getByLabel('Motivo da ação',{exact:true}).fill('QA start monitoring');
await owner.getByRole('button',{name:'Iniciar acompanhamento',exact:true}).click();
await owner.getByText('Estado da conexão: Em execução.',{exact:false}).waitFor();
const update=owner.getByRole('heading',{name:'Atualizar marco',exact:true}).locator('..');
await update.getByLabel('Estado',{exact:true}).selectOption('completed');
await update.getByLabel('Evidência ou referência do resultado',{exact:true}).fill('Synthetic QA deliverable evidence');
await update.getByRole('button',{name:'Guardar marco',exact:true}).click();
await owner.getByText('QA pilot delivery · Concluído · Prazo:',{exact:false}).waitFor();
assert.equal(await update.getByLabel('Estado',{exact:true}).inputValue(),'completed');
for(const width of [1440,390]){await owner.setViewportSize({width,height:1000});assert(await owner.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await owner.screenshot({path:`docs/investmoz/partnerships/room-${width}.png`,fullPage:true});}
await owner.getByLabel('Motivo da ação',{exact:true}).fill('QA all milestones completed');
await owner.getByRole('button',{name:'Concluir após os marcos',exact:true}).click();
await owner.getByText('Estado da conexão: Concluída.',{exact:false}).waitFor();
await owner.goto(base+'/carteira/'+fixture.project_dossier,{waitUntil:'networkidle'});
await owner.getByRole('link',{name:title,exact:true}).waitFor();
await candidate.goto(base+'/parcerias?q='+encodeURIComponent(title));
await candidate.getByRole('link',{name:title,exact:true}).waitFor();
assert.deepEqual(errors,[]);
fs.writeFileSync('docs/investmoz/partnerships/browser-qa.json',JSON.stringify({target:'anchor_restore_20261007_test:55441',passed:true,partnershipId,roomId,draftRecovered:true,sourceBacklink:true,moderation:true,interest:true,shortlist:true,privateMessage:true,bilateralAgreement:true,milestoneCompleted:true,roomClosed:true,viewports:[1440,390],errors},null,2));
console.log('QA partnership browser passed: real forms, draft recovery, moderation, interest, shortlist, private room, bilateral agreement, milestone and source backlink.');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+25
View File
@@ -0,0 +1,25 @@
<?php
// Read-only against the active database. Evidence is written only to private files.
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$directory = storage_path('app/private/partnership-exchange');
$tests = json_decode(ltrim(file_get_contents($directory.'/qa-tests.json'), "\xEF\xBB\xBF"), true, 512, JSON_THROW_ON_ERROR);
$checks = json_decode(ltrim(file_get_contents($directory.'/qa-checks.json'), "\xEF\xBB\xBF"), true, 512, JSON_THROW_ON_ERROR);
$browser = json_decode(file_get_contents(base_path('docs/investmoz/partnerships/browser-qa.json')), true, 512, JSON_THROW_ON_ERROR);
if (($tests['result'] ?? '') !== 'passed' || ($tests['passed'] ?? 0) < 46 || ! ($browser['passed'] ?? false)
|| ($browser['target'] ?? '') !== 'anchor_restore_20261007_test:55441' || ! ($checks['typescript'] ?? false) || ! ($checks['build'] ?? false)) {
throw new RuntimeException('QA evidence is incomplete.');
}
$installer = app(App\Services\PartnershipInstallation::class);
$proof = json_decode(file_get_contents($directory.'/backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
if (! ($proof['restore_verified'] ?? false) || hash_file('sha256', $proof['dump_path']) !== $proof['sha256']) {
throw new RuntimeException('Verified restore proof or dump unavailable.');
}
$evidence = ['passed' => true, 'code_hash' => $installer->fingerprint(), 'database' => 'anchor_restore_20261007_test', 'port' => 55441,
'tests' => $tests, 'checks' => $checks, 'browser' => $browser, 'generated_at' => now()->toIso8601String()];
file_put_contents($directory.'/qa-evidence.json', json_encode($evidence, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$preview = $installer->preview();
echo json_encode(['target' => $preview['target'], 'installed' => $preview['installed'], 'preserved_records' => count($preview['records']),
'backupVerified' => $preview['backupVerified'], 'qaVerified' => $preview['qaVerified'], 'code_hash' => $evidence['code_hash']], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
+22
View File
@@ -0,0 +1,22 @@
<?php
foreach (['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'anchor_restore_20261007_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'MAIL_MAILER' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
require dirname(__DIR__).'/vendor/autoload.php';
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$identity = Illuminate\Support\Facades\DB::selectOne('select current_database() as database, inet_server_port() as port');
if ($identity->database !== 'anchor_restore_20261007_test' || (int) $identity->port !== 55441) {
throw new RuntimeException('QA database identity rejected');
}
if (! Illuminate\Support\Facades\Schema::hasTable('partnership_interests')) {
Illuminate\Support\Facades\Artisan::call('migrate', ['--path' => 'database/migrations/2026_10_07_160000_extend_internal_partnership_exchange.php', '--force' => true]);
}
foreach (['owner', 'candidate', 'moderator'] as $role) {
$user = App\Models\User::firstOrCreate(['email' => 'partnership-qa-'.$role.'@example.invalid'], ['name' => 'Synthetic QA partnership '.$role, 'password' => Illuminate\Support\Facades\Hash::make('QA-Partnership-2026!')]);
$user->forceFill(['email_verified_at' => $role === 'moderator' ? now() : null])->save();
}
echo "Partnership migration and synthetic users prepared exclusively in isolated restored QA.\n";
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs';
import crypto from 'node:crypto';
const dir='storage/app/private/anchor-project-reconciliation/';
const inventory=JSON.parse(fs.readFileSync(dir+fs.readdirSync(dir).filter(f=>f.startsWith('inventory')).sort().at(-1),'utf8'));
const texts=JSON.parse(fs.readFileSync(dir+'source-texts.json','utf8'));
const normalize=s=>s.normalize('NFD').replace(/\p{Diacritic}/gu,'').toLowerCase().trim();
const provincePlan={A03:[['Nampula','secondary'],['Cabo Delgado','secondary'],['Niassa','secondary'],['Tete','secondary'],['Zambezia','impact']],A04:[['Nampula','primary']],A05:[['Nampula','secondary'],['Cabo Delgado','secondary']],A06:[['Tete','secondary'],['Manica','secondary'],['Zambezia','secondary'],['Sofala','secondary']],A07:[['Sofala','primary'],['Manica','secondary'],['Tete','secondary']],A08:[['Zambezia','primary']],A09:[['Zambezia','primary']],A10:[['Tete','primary']],A11:[['Tete','secondary'],['Zambezia','secondary']],A12:[['Zambezia','primary']],A13:[['Inhambane','primary']],A14:[['Gaza','primary']],A15:[['Maputo Província','primary']]};
const plan={schema:1,target:inventory.target,inventory_file:fs.readdirSync(dir).filter(f=>f.startsWith('inventory')).sort().at(-1),created_at:new Date().toISOString(),locations:[],memberships:[],prepared_opportunities:[],pending:[]};
for(const project of inventory.projects.filter(p=>/^A\d{2}$/.test(p.code))){
const dossier=inventory.dossiers.find(d=>d.project_id===project.id&&!d.deleted_at);
const content=JSON.parse(dossier.content);
for(const [name,role]of provincePlan[project.code]??[]){
const nodes=inventory.locations.filter(l=>l.location_type==='province'&&!l.deleted_at&&normalize(l.name)===normalize(name));
if(nodes.length!==1){plan.pending.push({code:project.code,reason:'Identidade territorial ambígua',name});continue;}
const location=nodes[0];
plan.locations.push({project_id:project.id,code:project.code,dossier_id:dossier.id,dossier_version:dossier.version_no,geo_location_id:location.id,name:location.name,role,source_reference:dossier.source,source_quote:content.location,reason:role==='impact'?'Referência parcial ao Norte da Zambézia; não representa cobertura integral da província':'Província explicitamente identificada na localização do dossier autorizado',expected_project_version:project.version_no,data_classification:dossier.publication_status==='published'?'public':'internal',existing:inventory.project_locations.some(l=>!l.deleted_at&&l.project_id===project.id&&l.geo_location_id===location.id&&l.role===role)});
}
if(['A01','A02'].includes(project.code))plan.pending.push({code:project.code,reason:'Âmbito nacional sem implantação individual ou traçado documentado; não atribuir um ponto único.'});
plan.pending.push({code:project.code,reason:'Coordenadas, posto administrativo e localidade sem fonte exacta. Cidade/zona não convertida automaticamente em distrito.'});
}
// Districts expressly called districts in the source; city names and ambiguous settlements remain text.
for(const [code,province,districts,role]of [['A15','Maputo Província',['Boane','Moamba','Namaacha'],'impact'],['A11','Zambezia',['Nicoadala'],'secondary']]){
const p=inventory.projects.find(p=>p.code===code),d=inventory.dossiers.find(d=>d.project_id===p.id),base=plan.locations.find(l=>l.code===code&&normalize(l.name)===normalize(province));
for(const name of districts){const nodes=inventory.locations.filter(l=>l.location_type==='district'&&l.parent_id===base.geo_location_id&&!l.deleted_at&&normalize(l.name)===normalize(name));if(nodes.length!==1){plan.pending.push({code,reason:'Distrito sem correspondência exacta e única',name});continue;}plan.locations.push({...base,geo_location_id:nodes[0].id,name:nodes[0].name,role,reason:'Distrito explicitamente identificado na fonte; sem inferir posto, localidade ou coordenadas.',existing:inventory.project_locations.some(l=>!l.deleted_at&&l.project_id===p.id&&l.geo_location_id===nodes[0].id&&l.role===role)});}
}
for(const cluster of inventory.clusters){
const dossier=inventory.dossiers.find(d=>d.cluster_id===cluster.id&&!d.deleted_at),content=JSON.parse(dossier.content);
for(const code of [...new Set(content.anchors.match(/\bA\d{2}\b/g)??[])]){
const project=inventory.projects.find(p=>p.code===code&&!p.deleted_at);if(!project){plan.pending.push({code:cluster.code,project:code,reason:'Projecto citado sem cadastro'});continue;}
const existing=inventory.cluster_projects.find(l=>l.project_id===project.id&&l.cluster_id===cluster.id&&!l.deleted_at);
plan.memberships.push({cluster_id:cluster.id,cluster_code:cluster.code,project_id:project.id,project_code:code,role:'anchor',existing_id:existing?.id??null,existing_version:existing?.version_no??0,dossier_id:dossier.id,dossier_version:dossier.version_no,source_reference:dossier.source,source_quote:content.anchors,reason:'Código exacto do projecto citado no dossier do cluster; relação de planeamento, sem confirmar pertença jurídica',action:existing?'present_existing':'insert_missing'});
}
plan.pending.push({code:cluster.code,reason:'Área própria de abrangência não individualizada na fonte; locais dos membros não definem o perímetro do cluster.'});
if(!inventory.value_chains.some(v=>v.cluster_id===cluster.id&&!v.deleted_at))plan.pending.push({code:cluster.code,reason:'Sem elos cadastrados em value_chains. Actividades e lacunas do dossier permanecem consultáveis; não foram convertidas em cadeias confirmadas.'});
}
const source=texts.find(t=>t.path.endsWith('projectos.docx'));
for(const [index,table]of source.tables.entries()){
if(!table[0].includes('Localização'))continue;
const anchor=table[1]?.[0]?.trim();
const conflict=index<3; // caption/project identifier conflict documented in original, never silently renumbered.
for(const [rowIndex,row]of table.entries()){
if(rowIndex<2||!row[0]?.trim())continue;
const original=row[0].trim(),project=inventory.projects.find(p=>p.code===anchor),ref=original;
const existing=inventory.opportunities.find(o=>o.reference===ref);
plan.prepared_opportunities.push({reference:ref,source_identifier:row[0],title:row[1],summary:row[3],description:row.map((cell,i)=>`${table[0][i]}: ${cell}`).join('\n'),location_text:row[4],cost_text:row[5],horizon_text:row[6],source_reference:`${source.path}; SHA256 ${source.sha256}; tabela ${index+1}, linha ${rowIndex+1}`,source_sha256:source.sha256,source_table:index+1,source_row:rowIndex+1,source_cells:row,status:'draft',project_id:conflict?null:project?.id??null,project_code:conflict?null:anchor,existing_id:existing?.id??null,identity_status:conflict?'conflict_caption_anchor_code':'explicit_source_code',unknown:['sector_id','promotor confirmado','maturidade','coordenadas','títulos e disponibilidade','interpretação monetária'],reason:conflict?'Código da âncora e legenda da tabela incompatíveis com os dossiers; não associar nem renumerar':'Componente identificado por código exacto na tabela da âncora; preparado sem publicar, sem associação automática aos clusters'});
}
}
plan.pending.push({code:'opportunity_projects',reason:'As seis oportunidades existentes não têm código documental que as associe às âncoras. Nenhuma associação inferida por nome ou sector.'});
const data=JSON.stringify(plan,null,2);fs.writeFileSync(dir+'preview.json',data);fs.writeFileSync(dir+'preview.sha256',crypto.createHash('sha256').update(data).digest('hex'));
console.log(JSON.stringify({locations:plan.locations.length,projectsWithLocations:new Set(plan.locations.map(l=>l.code)).size,memberships:plan.memberships.length,existingMemberships:plan.memberships.filter(l=>l.existing_id).length,prepared:plan.prepared_opportunities.length,identityConflicts:plan.prepared_opportunities.filter(o=>o.identity_status.startsWith('conflict')).length,pending:plan.pending.length}));
+27
View File
@@ -0,0 +1,27 @@
<?php
// QA-only HTTP harness: fixed isolated restored database; never use artisan serve for this test.
$root = dirname(__DIR__);
foreach (['APP_ENV'=>'testing','APP_DEBUG'=>'true','DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>'55441',
'DB_DATABASE'=>'anchor_restore_20261007_test','DB_USERNAME'=>'postgres','DB_PASSWORD'=>'','DB_URL'=>'',
'QUEUE_CONNECTION'=>'sync','SESSION_DRIVER'=>'file','SESSION_COOKIE'=>'anchor_relationship_qa','CACHE_STORE'=>'array',
'PORTAL_MANAGER_EMAILS'=>'relationship-qa@example.invalid,partnership-qa-moderator@example.invalid','MAIL_MAILER'=>'array'] as $key=>$value) {
putenv($key.'='.$value); $_ENV[$key]=$value; $_SERVER[$key]=$value;
}
$path=rawurldecode(parse_url($_SERVER['REQUEST_URI']??'/',PHP_URL_PATH)?:'/');
if ($path!=='/' && is_file($root.'/public'.$path)) return false;
require_once $root.'/vendor/autoload.php';
$app=require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$connection=config('database.connections.'.config('database.default'));
if (($connection['database']??null)!=='anchor_restore_20261007_test' || (string)($connection['port']??'')!=='55441'
|| ($connection['host']??null)!=='127.0.0.1' || !in_array($connection['url']??null,[null,''],true)) {http_response_code(503);exit('QA target rejected');}
$identity=Illuminate\Support\Facades\DB::selectOne('select current_database() as database, inet_server_port() as port');
if ($identity->database!=='anchor_restore_20261007_test'||(int)$identity->port!==55441){http_response_code(503);exit('QA connected identity rejected');}
$planDirectory = getenv('RELATIONSHIP_QA_PLAN') === 'territorial' ? 'qa-territorial-preview' : 'qa-preview';
config(['inertia.ssr.enabled'=>false,'session.files'=>$root.'/storage/framework/anchor-qa-sessions',
'investment.repair_directory'=>$root.'/storage/app/private/anchor-project-reconciliation/'.$planDirectory]);
if(!is_dir(config('session.files')))mkdir(config('session.files'),0700,true);
Illuminate\Support\Facades\Vite::useHotFile($root.'/storage/framework/anchor-qa-hot-file');
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
$app->handleRequest(Illuminate\Http\Request::capture());
+60
View File
@@ -0,0 +1,60 @@
<?php
foreach (['APP_ENV'=>'testing','DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>'55441','DB_DATABASE'=>'anchor_restore_20261007_test','DB_USERNAME'=>'postgres','DB_PASSWORD'=>'','DB_URL'=>''] as $key=>$value) {
putenv($key.'='.$value); $_ENV[$key]=$value; $_SERVER[$key]=$value;
}
require dirname(__DIR__).'/vendor/autoload.php';
$app=require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db=Illuminate\Support\Facades\DB::connection();
if ($db->getDatabaseName()!=='anchor_restore_20261007_test'||(string)$db->getConfig('port')!=='55441'||$db->getConfig('host')!=='127.0.0.1')throw new RuntimeException('QA target rejected before seed.');
$identity=$db->selectOne('select current_database() as database,inet_server_port() as port');
if($identity->database!=='anchor_restore_20261007_test'||(int)$identity->port!==55441)throw new RuntimeException('QA runtime identity rejected.');
$uuid=fn()=>(string)Illuminate\Support\Str::uuid();
$manifest=$db->transaction(function()use($db,$uuid){
$user=App\Models\User::firstOrCreate(['email'=>'relationship-qa@example.invalid'],['name'=>'Synthetic QA manager','email_verified_at'=>now(),'password'=>Illuminate\Support\Facades\Hash::make('QA-Relationship-2026!')]);
$user->forceFill(['email_verified_at'=>now()])->save();
$sector=App\Models\Sector::firstOrCreate(['slug'=>'qa-relationship'],['name'=>'Synthetic QA relationship sector','is_active'=>true]);
$parent=null;$ids=[];
foreach(['country','province','district','admin_post','locality']as$type){
$external='qa-relationship-'.$type;
$node=$db->table('geo_locations')->where('source_system','cartografia-workbook')->where('code',$external)->first();
$id=$node?->id??$uuid();
if(!$node)$db->table('geo_locations')->insert(['id'=>$id,'code'=>$external,'name'=>'QA '.$type,'location_type'=>$type,'parent_id'=>$parent,'data_classification'=>'public',
'source_system'=>'cartografia-workbook','external_id'=>'964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$external]);
$ids[$type]=$id;$parent=$id;
}
$projects=[];$dossiers=[];
foreach([1,2]as$index){
$project=App\Models\Project::firstOrCreate(['code'=>'QA-ANCHOR-'.$index],['name'=>'Synthetic QA anchor '.$index,'source_system'=>'qa-relationship','status'=>'planned','data_classification'=>'public']);
$dossier=App\Models\InvestmentDossier::firstOrCreate(['project_id'=>$project->id],['title'=>$project->name,'source'=>'Synthetic browser QA fixture; no operational coordinates',
'content'=>['location'=>'QA province / district / admin_post / locality','description'=>'Synthetic project with documented QA fixture site'],'publication_status'=>'published','published_at'=>now(),
'approval_basis'=>'document_owner','approval_evidence'=>'Synthetic QA disclosure fixture only']);
$site=$db->table('geo_locations')->where('code','qa-anchor-site-'.$index)->first();$siteId=$site?->id??$uuid();
if(!$site)$db->table('geo_locations')->insert(['id'=>$siteId,'code'=>'qa-anchor-site-'.$index,'name'=>'QA documented site '.$index,'location_type'=>'site','parent_id'=>$parent,'data_classification'=>'public','source_system'=>'qa-relationship']);
if(!$db->table('project_locations')->where('project_id',$project->id)->where('geo_location_id',$siteId)->exists())$db->table('project_locations')->insert(['id'=>$uuid(),'project_id'=>$project->id,'geo_location_id'=>$siteId,'role'=>$index===1?'primary':'secondary','data_classification'=>'public','source_system'=>'qa-relationship']);
if(!$db->table('geo_geometries')->where('geo_location_id',$siteId)->exists())$db->table('geo_geometries')->insert(['id'=>$uuid(),'geo_location_id'=>$siteId,'geometry_type'=>'point','data_classification'=>'public','source_system'=>'synthetic QA source',
'geom'=>Illuminate\Support\Facades\DB::raw('ST_SetSRID(ST_MakePoint('.(32.4+$index/10).','.(-25.8-$index/10).'),4326)')]);
$projects[]=$project;$dossiers[]=$dossier;
}
$cluster=App\Models\EconomicCluster::firstOrCreate(['code'=>'QA-REL-CLU'],['name'=>'Synthetic QA multi-site cluster','sector'=>'QA','status'=>'planned','data_classification'=>'public']);
$clusterDossier=App\Models\InvestmentDossier::firstOrCreate(['cluster_id'=>$cluster->id],['title'=>$cluster->name,'source'=>'Synthetic QA member relationship source',
'content'=>['anchors'=>'QA-ANCHOR-1, QA-ANCHOR-2; coverage pending','gaps'=>'Synthetic QA chain gaps'],'publication_status'=>'published','published_at'=>now(),
'approval_basis'=>'document_owner','approval_evidence'=>'Synthetic QA fixture disclosure only']);
foreach($projects as$project)if(!$db->table('cluster_projects')->where('cluster_id',$cluster->id)->where('project_id',$project->id)->exists())$db->table('cluster_projects')->insert(['id'=>$uuid(),'cluster_id'=>$cluster->id,'project_id'=>$project->id,'role'=>'anchor','data_classification'=>'public','source_system'=>'qa-relationship','source_reference'=>'Synthetic documented QA membership','validation_status'=>'pending']);
$production=App\Models\ValueChain::firstOrCreate(['cluster_id'=>$cluster->id,'code'=>'QA-PRODUCTION'],['name'=>'QA production','stage'=>'production','data_classification'=>'public']);
$processing=App\Models\ValueChain::firstOrCreate(['cluster_id'=>$cluster->id,'code'=>'QA-PROCESSING'],['name'=>'QA processing','stage'=>'processing','data_classification'=>'public']);
if(!$db->table('value_chain_links')->where('from_value_chain_id',$production->id)->where('to_value_chain_id',$processing->id)->exists())$db->table('value_chain_links')->insert(['id'=>$uuid(),'from_value_chain_id'=>$production->id,'to_value_chain_id'=>$processing->id,'link_type'=>'supplies','data_classification'=>'public']);
$opportunity=App\Models\Opportunity::firstOrCreate(['reference'=>'QA-ANCHOR-OPPORTUNITY'],['sector_id'=>$sector->id,'title'=>'Synthetic QA linked opportunity','slug'=>'qa-anchor-opportunity','summary'=>'Synthetic browser QA fixture','status'=>'published','published_at'=>now()]);
$db->table('opportunity_projects')->insertOrIgnore(['opportunity_id'=>$opportunity->id,'project_id'=>$projects[0]->id]);
$db->table('opportunity_clusters')->insertOrIgnore(['opportunity_id'=>$opportunity->id,'cluster_id'=>$cluster->id]);
return ['target'=>'anchor_restore_20261007_test:55441','project_dossier'=>$dossiers[0]->id,'second_project'=>$projects[1]->id,'cluster_dossier'=>$clusterDossier->id,'opportunity_slug'=>$opportunity->slug,'opportunity_id'=>$opportunity->id,'province_id'=>$ids['province']];
});
$directory=storage_path('app/private/anchor-project-reconciliation');
if(!is_dir($directory.'/qa-preview'))mkdir($directory.'/qa-preview',0700,true);
$plan=json_decode(file_get_contents($directory.'/preview.json'),true);$plan['target']=['database'=>'anchor_restore_20261007_test','port'=>55441];
file_put_contents($directory.'/qa-preview/preview.json',json_encode($plan,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
$proof=json_decode(file_get_contents($directory.'/backup-proof.json'),true);$proof['target']=$plan['target'];
file_put_contents($directory.'/qa-preview/backup-proof.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
file_put_contents($directory.'/qa-browser-fixture.json',json_encode($manifest,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo json_encode($manifest).PHP_EOL;
+43
View File
@@ -0,0 +1,43 @@
const { chromium } = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const assert = require('node:assert/strict');
const base = 'http://127.0.0.1:18087';
(async () => {
const browser = await chromium.launch({headless:true, executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
try {
const context = await browser.newContext();
await context.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(base+'/login');
await page.locator('input[type=email]').fill('relationship-qa@example.invalid');
await page.locator('input[type=password]').fill('QA-Relationship-2026!');
await page.locator('button[type=submit]').click();
await page.waitForURL(url => !url.pathname.startsWith('/login'));
const response = await page.goto(base+'/management/relationship-repair', {waitUntil:'networkidle'});
assert.equal(response.headers()['x-investmoz-qa-target'], 'anchor_restore_20261007_test:55441');
await page.getByRole('heading',{name:'Fundamentadas: 2',exact:true}).waitFor();
await page.getByRole('heading',{name:'Pendentes: 1',exact:true}).waitFor();
await page.getByRole('heading',{name:'Contraditórias: 1',exact:true}).waitFor();
const grounded = page.getByRole('region',{name:'Associações fundamentadas'});
for (const text of ['Localização atual','Localização proposta','Documento de origem','Motivo','Sem coordenadas']) assert((await grounded.innerText()).includes(text));
await grounded.getByRole('button',{name:'Aplicar esta associação',exact:true}).first().click();
await grounded.getByRole('button',{name:'Associação aplicada',exact:true}).waitFor();
assert.equal(await grounded.getByRole('button',{name:'Aplicar esta associação',exact:true}).count(),1);
await grounded.getByRole('button',{name:'Aplicar esta associação',exact:true}).click();
await page.waitForFunction(()=>document.querySelectorAll('[data-row-id] button').length===4 && [...document.querySelectorAll('[data-row-id] button')].filter(button=>button.textContent==='Associação aplicada').length===2);
assert(await page.getByRole('region',{name:'Associações pendentes'}).getByRole('button').isDisabled());
assert(await page.getByRole('region',{name:'Associações contraditórias'}).getByRole('button').isDisabled());
assert((await page.locator('body').innerText()).includes('Autor:'));
assert((await page.locator('body').innerText()).includes('Data:'));
for (const width of [1440,390]) {
await page.setViewportSize({width,height:1000});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
await page.screenshot({path:`docs/investmoz/relationships/territorial-${width}.png`,fullPage:true});
}
assert.deepEqual(errors,[]);
fs.writeFileSync('docs/investmoz/relationships/territorial-browser-qa.json',JSON.stringify({target:'anchor_restore_20261007_test:55441',passed:true,individualApplications:2,pendingRetained:1,contradictoryRetained:1,noCoordinatesAccepted:true,auditAuthorDateSource:true,viewports:[1440,390],errors},null,2));
console.log('QA territorial browser passed: two individual applications, conflicts retained, author/date/source, desktop/mobile.');
} finally { await browser.close(); }
})().catch(error=>{console.error(error);process.exitCode=1;});
+47
View File
@@ -0,0 +1,47 @@
<?php
// Synthetic preview only, fixed isolated restored QA database; never writes active data.
foreach (['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'anchor_restore_20261007_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => ''] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
require dirname(__DIR__).'/vendor/autoload.php';
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$identity = Illuminate\Support\Facades\DB::selectOne('select current_database() as database, inet_server_port() as port');
if ($identity->database !== 'anchor_restore_20261007_test' || (int) $identity->port !== 55441) {
throw new RuntimeException('Isolated QA required');
}
$root = storage_path('app/private/anchor-project-reconciliation');
$fixture = json_decode(file_get_contents($root.'/qa-browser-fixture.json'), true, 512, JSON_THROW_ON_ERROR);
$dossier = Illuminate\Support\Facades\DB::table('investment_dossiers')->where('id', $fixture['project_dossier'])->first();
$project = Illuminate\Support\Facades\DB::table('projects')->where('id', $dossier->project_id)->first();
if (! str_starts_with($project->code, 'QA-')) {
throw new RuntimeException('Synthetic project required');
}
$site = Illuminate\Support\Facades\DB::table('project_locations')->where('project_id', $project->id)->where('role', 'primary')->first();
$nodes = [];
$id = $site->geo_location_id;
while ($id) {
$node = Illuminate\Support\Facades\DB::table('geo_locations')->where('id', $id)->first();
$nodes[$node->location_type] = $node;
$id = $node->parent_id;
}
$base = ['project_id' => $project->id, 'code' => $project->code, 'dossier_id' => $dossier->id, 'dossier_version' => $dossier->version_no,
'source_reference' => $dossier->source, 'source_quote' => json_decode($dossier->content, true)['location'],
'reason' => 'Synthetic QA individual territorial validation', 'expected_project_version' => $project->version_no, 'data_classification' => 'public'];
$rows = [];
foreach ([['province', 'secondary'], ['district', 'impact'], ['admin_post', 'secondary'], ['locality', 'impact']] as [$type, $role]) {
$rows[] = [...$base, 'geo_location_id' => $nodes[$type]->id, 'name' => $nodes[$type]->name, 'role' => $role, 'existing' => false];
}
$rows[2]['source_quote'] = '';
$rows[3]['province_id'] = (string) Illuminate\Support\Str::uuid();
$directory = $root.'/qa-territorial-preview';
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
file_put_contents($directory.'/preview.json', json_encode(['target' => ['database' => $identity->database, 'port' => (int) $identity->port],
'locations' => $rows, 'memberships' => [], 'prepared_opportunities' => [], 'pending' => []], JSON_THROW_ON_ERROR));
copy($root.'/qa-preview/backup-proof.json', $directory.'/backup-proof.json');
echo "Synthetic isolated QA territorial preview prepared.\n";
@@ -0,0 +1,41 @@
<?php
require dirname(__DIR__).'/vendor/autoload.php';
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$active = Illuminate\Support\Facades\DB::connection();
if ($active->getDatabaseName() !== 'investemoz' || (string) $active->getConfig('port') !== '5432') throw new RuntimeException('Unexpected active target.');
config(['database.connections.anchor_restore' => [...config('database.connections.pgsql'), 'host' => '127.0.0.1', 'port' => '55441',
'database' => 'anchor_restore_20261007_test', 'username' => 'postgres', 'password' => '', 'url' => null]]);
$restore = Illuminate\Support\Facades\DB::connection('anchor_restore');
$identity = $restore->selectOne('select current_database() as database, inet_server_port() as port');
if ($identity->database !== 'anchor_restore_20261007_test' || (int) $identity->port !== 55441) throw new RuntimeException('Restore verification requires isolated QA.');
$report = $active->transaction(function () use ($active, $restore) {
$active->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$counts = [];
foreach ($active->select("select tablename from pg_tables where schemaname='public' order by tablename") as $table) {
$name = $table->tablename;
$a = $active->table($name)->count();
$b = $restore->table($name)->count();
if ($a !== $b) throw new RuntimeException('Restore count differs for '.$name);
$counts[$name] = $a;
}
$hashes = [];
foreach (['projects','economic_clusters','investment_dossiers','project_locations','cluster_projects','opportunity_projects','opportunity_clusters','value_chains','value_chain_links','opportunities','geo_locations','geo_geometries'] as $table) {
$hash = function ($db) use ($table) {
$rows = array_map(fn ($row) => $row->value, $db->select('select row_to_json(t)::text as value from public.'.$table.' as t'));
sort($rows, SORT_STRING);
return hash('sha256', implode("\n", $rows));
};
$a = $hash($active);
if ($a !== $hash($restore)) throw new RuntimeException('Restore data differs for '.$table);
$hashes[$table] = $a;
}
return ['table_counts' => $counts, 'relation_table_hashes' => $hashes];
});
$directory = storage_path('app/private/anchor-project-reconciliation');
$dump = $directory.'/backup-20261007/database.dump';
$proof = [...$report, 'restore_verified' => true, 'verified_at' => date(DATE_ATOM), 'target' => ['database'=>'investemoz','port'=>5432],
'restored_target' => ['database'=>$identity->database,'port'=>$identity->port], 'dump_path'=>$dump,'sha256'=>hash_file('sha256',$dump)];
file_put_contents($directory.'/backup-proof.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo 'Restore verified: '.count($report['table_counts']).' table counts and '.count($report['relation_table_hashes']).' content hashes.'.PHP_EOL;