This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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> */
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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])]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
@@ -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;});
|
||||
@@ -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;
|
||||
@@ -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;});
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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}));
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
@@ -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;});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user