feat: integrate opportunity draft management with audited lifecycle
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Opportunities\Enums\OpportunityStatus;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityManagementInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OpportunityManagementController extends Controller
|
||||
{
|
||||
public function installation(Request $request, OpportunityManagementInstallation $service): JsonResponse|Response
|
||||
{
|
||||
return $request->expectsJson() ? response()->json($service->preview()) : Inertia::render('management/opportunity-install', $service->preview());
|
||||
}
|
||||
|
||||
public function applyInstallation(Request $request, OpportunityManagementInstallation $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
|
||||
$service->apply($request->user(), $data['hash']);
|
||||
|
||||
return back()->with('success', 'Gestão instalada; nenhuma oportunidade foi submetida ou publicada.');
|
||||
}
|
||||
|
||||
public function index(Request $request, OpportunityManagement $service): Response
|
||||
{
|
||||
if (! $service->installed()) {
|
||||
return app(PortalManagementController::class)->catalog($request, 'opportunities');
|
||||
}
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
|
||||
$query = Opportunity::query();
|
||||
if ($filters['deleted'] ?? false) {
|
||||
$query->onlyTrashed();
|
||||
}
|
||||
if ($filters['q'] ?? null) {
|
||||
$query->where(fn ($q) => $q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%'));
|
||||
}
|
||||
if ($filters['status'] ?? null) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
if ($filters['category'] ?? null) {
|
||||
$query->where('opportunity_category', $filters['category']);
|
||||
}
|
||||
if ($filters['sector_id'] ?? null) {
|
||||
$query->where('sector_id', $filters['sector_id']);
|
||||
}
|
||||
if ($filters['geography_scope'] ?? null) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
|
||||
return Inertia::render('management/opportunities', ['records' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'counts' => Opportunity::selectRaw('status,count(*) AS total')->groupBy('status')->pluck('total', 'status'), 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => Opportunity::where('geography_scope', 'unspecified')->count(), 'sectors' => Sector::orderBy('name')->get(['id', 'name'])]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
|
||||
{
|
||||
if (! $service->installed()) {
|
||||
return app(PortalManagementController::class)->edit('opportunities', $id);
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
|
||||
$values = $record ? ($record->management_draft ? json_decode($record->management_draft, true) : $record->only(OpportunityManagement::FIELDS)) : [];
|
||||
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
|
||||
|
||||
return Inertia::render('management/opportunity-draft', ['record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view'), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES,
|
||||
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
|
||||
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
|
||||
}
|
||||
|
||||
public function save(Request $request, OpportunityManagement $service, ?int $id = null): JsonResponse
|
||||
{
|
||||
$record = $service->save($request->user(), $request->all(), $id);
|
||||
if (! $id) {
|
||||
$request->session()->flash('success', 'Rascunho guardado no servidor. Pode continuar o preenchimento; não foi submetido.');
|
||||
}
|
||||
|
||||
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
|
||||
}
|
||||
|
||||
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
|
||||
{
|
||||
abort_unless($service->installed(), 503);
|
||||
$record = $service->lifecycle($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['id' => $record->id, 'message' => $request->input('action') === 'restore' ? 'Restaurado como rascunho; nova revisão necessária antes de publicar.' : 'Exclusão lógica/arquivo registado; histórico e relações preservados.']);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Models\User;
|
||||
use App\Services\InterestWorkflow;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -257,6 +258,9 @@ class PortalManagementController extends Controller
|
||||
|
||||
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
|
||||
{
|
||||
if ($resource === 'opportunities' && app(OpportunityManagement::class)->installed()) {
|
||||
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
|
||||
}
|
||||
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
|
||||
$record = $id ? $model::findOrFail($id) : new $model;
|
||||
$rules = $resource === 'sectors' ? [
|
||||
|
||||
@@ -11,11 +11,20 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
/**
|
||||
* @property string|null $primary_territory_id
|
||||
* @property int $territory_version
|
||||
* @property int $management_version
|
||||
* @property string|null $management_draft
|
||||
* @property string|null $management_creation_key
|
||||
* @property string|null $first_submitted_at
|
||||
* @property bool $management_closed_once
|
||||
* @property string|null $opportunity_category
|
||||
* @property string|null $estimated_investment_currency
|
||||
*/
|
||||
class Opportunity extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once'];
|
||||
|
||||
protected $fillable = [
|
||||
'sector_id', 'reference', 'title', 'slug', 'summary', 'description',
|
||||
'province', 'district', 'promoter_name', 'promoter_type',
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class OpportunityManagement
|
||||
{
|
||||
public const CATEGORIES = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise', 'concession', 'financing', 'supply', 'other'];
|
||||
|
||||
public const FIELDS = ['title', 'summary', 'description', 'sector_id', 'promoter_name', 'promoter_type', 'maturity_level', 'maturity_label', 'financing_modality', 'expected_impact', 'estimated_investment_amount', 'estimated_investment_currency', 'opportunity_category', 'primary_territory_id', 'province', 'district', 'geography_scope'];
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return Schema::hasColumn('opportunities', 'management_version');
|
||||
}
|
||||
|
||||
public function etag(Opportunity $record): string
|
||||
{
|
||||
return hash('sha256', json_encode($record->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function closed(Opportunity $record): bool
|
||||
{
|
||||
return $record->first_submitted_at !== null || $record->management_closed_once || ! in_array($record->status, ['draft'], true);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, array $input, ?int $id = null): Opportunity
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
abort_unless($this->installed(), 503, 'Instalação autenticada ainda necessária.');
|
||||
$data = Validator::make($input, [
|
||||
'etag' => [$id ? 'required' : 'nullable', 'string', 'size:64'],
|
||||
'creation_key' => [$id ? 'nullable' : 'required', 'uuid'],
|
||||
'title' => ['nullable', 'string', 'max:150'], 'summary' => ['nullable', 'string', 'max:500'],
|
||||
'description' => ['nullable', 'string', 'max:50000'], 'promoter_name' => ['nullable', 'string', 'max:200'],
|
||||
'promoter_type' => ['nullable', Rule::in(['public', 'private', 'ppp', 'cooperative', 'association', 'community', 'other', $id ? Opportunity::withTrashed()->findOrFail($id)->promoter_type : null])],
|
||||
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
|
||||
'financing_modality' => ['nullable', 'string', 'max:120'],
|
||||
'expected_impact' => ['nullable', 'string', 'max:10000'],
|
||||
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
|
||||
'opportunity_category' => ['nullable', Rule::in(self::CATEGORIES)],
|
||||
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
|
||||
'estimated_investment_currency' => ['nullable', 'in:MZN,USD,EUR,ZAR'],
|
||||
'reason' => ['required', 'string', 'min:20', 'max:1000'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $id) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
|
||||
if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) {
|
||||
$snapshot = $existing->management_draft ? json_decode($existing->management_draft, true) : $existing->only(self::FIELDS);
|
||||
foreach (array_intersect_key($data, array_flip(self::FIELDS)) as $key => $value) {
|
||||
if (($snapshot[$key] ?? null) != $value) {
|
||||
throw ValidationException::withMessages(['creation_key' => 'Esta chave já foi utilizada com dados diferentes.']);
|
||||
}
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
|
||||
if ($id) {
|
||||
$this->check($record, $data['etag']);
|
||||
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
|
||||
}
|
||||
$before = $record->getAttributes();
|
||||
$changes = array_intersect_key($data, array_flip(self::FIELDS));
|
||||
if (array_key_exists('maturity_level', $changes)) {
|
||||
$labels = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
|
||||
$changes['maturity_label'] = $labels[$changes['maturity_level'] ?? 0] ?? null;
|
||||
}
|
||||
if (array_intersect_key($data, InvestmentGeography::FIELDS) !== []) {
|
||||
$path = app(InvestmentGeography::class)->selection($data, true);
|
||||
$changes['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
$changes['province'] = $path[0]->name ?? null;
|
||||
$changes['district'] = $path[1]->name ?? null;
|
||||
$changes['geography_scope'] = $path === [] ? 'unspecified' : 'specific';
|
||||
}
|
||||
if (! $id) {
|
||||
$record->reference = 'INV-MZ-GEN-'.random_int(1000000000000000, 9999999999999999);
|
||||
$record->slug = strtolower($record->reference);
|
||||
$record->status = 'draft';
|
||||
$record->management_creation_key = $data['creation_key'];
|
||||
$record->management_version = 1;
|
||||
$record->estimated_investment_currency = null;
|
||||
} else {
|
||||
$record->management_version++;
|
||||
}
|
||||
if ($id && $this->closed($record)) {
|
||||
$record->management_closed_once = true;
|
||||
$previous = $record->management_draft ? json_decode($record->management_draft, true) : $record->only(self::FIELDS);
|
||||
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
foreach ($changes as $key => $value) {
|
||||
$record->{$key} = $value;
|
||||
}
|
||||
}
|
||||
$record->save();
|
||||
$this->audit($record, $actor, $before, $id ? 'draft_updated' : 'draft_created', $data['reason']);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function lifecycle(User $actor, int $id, array $input): Opportunity
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
$data = Validator::make($input, ['etag' => ['required', 'string', 'size:64'], 'action' => ['required', 'in:delete,restore'], 'reason' => ['required', 'string', 'min:20', 'max:1000'], 'confirm' => ['required', 'accepted']])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data) {
|
||||
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
$this->check($record, $data['etag']);
|
||||
$before = $record->getAttributes();
|
||||
if ($data['action'] === 'restore') {
|
||||
abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.');
|
||||
if ($record->sector_id) {
|
||||
Validator::make(['sector_id' => $record->sector_id], ['sector_id' => [Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)]])->validate();
|
||||
}
|
||||
$record->deleted_at = null;
|
||||
$record->status = 'draft';
|
||||
$record->published_at = null;
|
||||
if ($this->closed($record) && ! $record->management_draft) {
|
||||
$record->management_draft = json_encode($record->only(self::FIELDS), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
} else {
|
||||
abort_if($record->trashed() || $record->status === 'archived', 409, 'Registo já eliminado/arquivado.');
|
||||
if ($this->closed($record)) {
|
||||
$record->management_closed_once = true;
|
||||
$record->status = 'archived';
|
||||
} else {
|
||||
$record->deleted_at = now();
|
||||
}
|
||||
}
|
||||
$record->management_version++;
|
||||
$record->save();
|
||||
$this->audit($record, $actor, $before, $data['action'], $data['reason']);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
private function check(Opportunity $record, string $etag): void
|
||||
{
|
||||
if (! hash_equals($this->etag($record), $etag)) {
|
||||
throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['O registo mudou. Os dados preenchidos foram preservados; abra a versão atual noutra aba antes de reconciliar.']]], 409));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $before */
|
||||
private function audit(Opportunity $record, User $actor, array $before, string $action, string $reason): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'opportunity-management', 'entity_type' => 'opportunities', 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
|
||||
'before_data' => json_encode(['record_id' => $record->id, 'snapshot' => Crypt::encryptString(json_encode($before, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR),
|
||||
'after_data' => json_encode(['record_id' => $record->id, 'version' => $record->management_version, 'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString(json_encode($record->getAttributes(), JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OpportunityManagementInstallation
|
||||
{
|
||||
public const MIGRATION = '2026_10_09_020000_extend_opportunity_management';
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$files = [__FILE__, app_path('Models/Opportunity.php'), app_path('Services/OpportunityManagement.php'), app_path('Http/Controllers/OpportunityManagementController.php'), app_path('Http/Controllers/PortalManagementController.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunities.tsx')];
|
||||
|
||||
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(): array
|
||||
{
|
||||
$path = storage_path('app/private/investmoz-v2/opportunity-management-activation-proof.json');
|
||||
$proof = is_file($path) ? json_decode(file_get_contents($path) ?: '{}', true) : [];
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$code = $this->fingerprint();
|
||||
$ready = ($proof['restore_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && ($proof['reversal_verified'] ?? false) && ($proof['qa_passed'] ?? false) && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null);
|
||||
$installed = app(OpportunityManagement::class)->installed();
|
||||
|
||||
return ['installed' => $installed, 'ready' => $ready, 'target' => $identity, 'code_hash' => $code, 'hash' => hash('sha256', json_encode([$identity, $code, $ready, $installed], JSON_THROW_ON_ERROR)), 'migration' => self::MIGRATION, 'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Permitir setor, título e moeda ausentes em rascunhos; nenhum preenchimento fictício.', 'Acrescentar versão, categoria opcional, rascunho de revisão, chave idempotente e marca de primeira submissão à entidade existente.', 'Impedir eliminação física de versões submetidas; preservar relações e classificações.', 'Nenhuma submissão, aprovação ou publicação automática.'], 'rollback' => 'Down apenas antes de novos eventos/rascunhos incompletos. Depois preservar e reconciliar dados; restauro integral requer janela autorizada e reconciliação de alterações posteriores.'];
|
||||
}
|
||||
|
||||
public function apply(User $actor, string $hash): void
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
DB::transaction(function () use ($actor, $hash) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
|
||||
$preview = $this->preview();
|
||||
abort_unless(hash_equals($preview['hash'], $hash), 409, 'Plano mudou; reveja.');
|
||||
if ($preview['installed']) {
|
||||
return;
|
||||
}
|
||||
abort_unless($preview['ready'], 422, 'Backup/restauro, QA, compatibilidade e reversão desta versão necessários.');
|
||||
config(['opportunity_management.authenticated_installation' => true]);
|
||||
try {
|
||||
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]);
|
||||
abort_unless($exit === 0, 500, 'Migração falhou.');
|
||||
} finally {
|
||||
config(['opportunity_management.authenticated_installation' => false]);
|
||||
}
|
||||
DB::table('audit_events')->insert(['source_system' => 'opportunity-management-install', 'entity_type' => 'installation', 'action' => 'opportunity_management_installed', 'actor_user_id' => $actor->id, 'after_data' => json_encode(['code_hash' => $preview['code_hash'], 'migration' => self::MIGRATION], JSON_THROW_ON_ERROR)]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')) {
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
throw_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, RuntimeException::class, 'Isolated QA required');
|
||||
} else {
|
||||
throw_unless(config('opportunity_management.authenticated_installation') === true, RuntimeException::class, 'Authenticated installation required');
|
||||
}
|
||||
DB::statement('ALTER TABLE opportunities ALTER COLUMN sector_id DROP NOT NULL, ALTER COLUMN title DROP NOT NULL, ALTER COLUMN estimated_investment_currency DROP NOT NULL');
|
||||
DB::statement('ALTER TABLE opportunities ADD COLUMN management_version bigint NOT NULL DEFAULT 1 CHECK(management_version>0), ADD COLUMN opportunity_category varchar(40), ADD COLUMN management_draft jsonb, ADD COLUMN management_creation_key uuid UNIQUE, ADD COLUMN first_submitted_at timestamptz, ADD COLUMN management_closed_once boolean NOT NULL DEFAULT false');
|
||||
DB::unprepared("CREATE OR REPLACE FUNCTION guard_opportunity_physical_delete() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF OLD.first_submitted_at IS NOT NULL OR OLD.management_closed_once OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Physical deletion forbidden after submission'; END IF; RETURN OLD; END; \$\$; CREATE TRIGGER opportunity_no_physical_delete BEFORE DELETE ON opportunities FOR EACH ROW EXECUTE FUNCTION guard_opportunity_physical_delete()");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw_if(DB::table('audit_events')->where('source_system', 'opportunity-management')->exists(), RuntimeException::class, 'Preserve management history; rollback requires reconciled restore');
|
||||
throw_if(DB::table('opportunities')->whereNull('sector_id')->orWhereNull('title')->orWhereNull('estimated_investment_currency')->exists(), RuntimeException::class, 'Incomplete drafts must be preserved');
|
||||
DB::unprepared('DROP TRIGGER opportunity_no_physical_delete ON opportunities; DROP FUNCTION guard_opportunity_physical_delete()');
|
||||
DB::statement('ALTER TABLE opportunities DROP COLUMN management_version, DROP COLUMN opportunity_category, DROP COLUMN management_draft, DROP COLUMN management_creation_key, DROP COLUMN first_submitted_at, DROP COLUMN management_closed_once, ALTER COLUMN sector_id SET NOT NULL, ALTER COLUMN title SET NOT NULL, ALTER COLUMN estimated_investment_currency SET NOT NULL');
|
||||
}
|
||||
};
|
||||
@@ -207,3 +207,13 @@ Todos mantêm estado editorial `published` e inclusão no filtro público do cat
|
||||
**Preservação:** hashes das dez tabelas comparadas com o backup coincidem, incluindo IDs/dados das seis oportunidades, pedidos, recibos, mensagens e relações. Os **118 eventos históricos anteriores** permanecem presentes e inalterados; foram acrescentados seis eventos de classificação. Os dois eventos do ensaio QA são excluídos da comparação histórica, pois não pertencem à base ativa.
|
||||
|
||||
**Associações separadas:** zero relações oportunidade–projeto e oportunidade–cluster continuam presentes, tal como antes. As **28 localizações de projetos e 13 relações cluster–projeto** permanecem iguais ao backup. Classificar origem não aplica as correspondências ambíguas. Evidência privada: `opportunity-origin-active-confirmation.json`; leitor `tools/opportunity-origin-active-confirmation.php`. Nenhuma escrita ativa nesta confirmação. As restantes pendências independentes mantêm-se.
|
||||
|
||||
### Gestão de Oportunidades no Dashboard — primeiro incremento em QA
|
||||
|
||||
[Fonte, matriz reconciliada e plano de ativação](gestao-oportunidades-dashboard.md). DOCX libertado normalmente e lido integralmente; SHA-256 `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`, 14 958 parágrafos e esquema JSON com 110 definições. Consulta interna autorizada, especificação técnica, nenhum uso em cálculos ou publicação documental. `AGENTS.md` preservado.
|
||||
|
||||
**Implementado/QA:** gestão integrada, lista/pesquisa/filtros/contagens, criar/ver/editar, rascunho incompleto no servidor e recuperação em nova sessão, erros por campo, exclusão lógica/arquivo/restauro com motivo e confirmação, ETag/versão, replay sem duplicação, snapshots cifrados anterior/novo. Revisão de versão fechada preserva valores públicos; publicação pelo editor genérico fica bloqueada após instalação. SQL documental não executado; IDs, seis classificações reais, relações e histórico preservados.
|
||||
|
||||
**Evidência:** 72 testes/1 164 asserções, TypeScript/PHPStan/build e percurso desktop/móvel passaram. Backup/restauro de 288 tabelas/cinco ficheiros e reversão antes de uso comprovados em `desk_restore_20261009_060826_test:55441`. Plano único `/management/opportunity-management-install`, válido até 10/10/2026 06:08:36 UTC; aplicação exclusivamente autenticada. Leitura ativa em 09/10/2026 06:13:34 UTC: migration ausente, zero eventos desta gestão e seis classificações reais mantidas. **Preparado, não aplicado na base ativa.**
|
||||
|
||||
**Pendentes da especificação:** catálogos completos/dropdowns pesquisáveis, maturidade de oito etapas, wizard/autosave, imagens/documentos/multilocalização completa, validação final, segregação de papéis, revisão/submissão/aprovação/publicação e perfis especializados de investimento/imobiliário/direitos fundiários/transações. Matriz por secção no relatório; CRUD não certifica REQ01–REQ17/DT01–DT15 integrais. Cobertura do inventário, questões jurídicas, ClamD real e primeiro pedido real continuam separados.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Gestão de Oportunidades integrada no Dashboard
|
||||
|
||||
Entrega de 9 de outubro de 2026. **Primeiro incremento implementado e validado em QA; não instalado na base ativa. A especificação integral não está concluída.**
|
||||
|
||||
## Fonte e reconciliação
|
||||
|
||||
Documento indicado: `C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\prompt\Formularios\novos formularios\Especificação do Formulário de Cadastro de Oportunidades de Investimento — InvestMoz.docx`. Título interno: «Especificação do Formulário de Cadastro de Oportunidades — InvestMoz». SHA-256: `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`; 320 004 bytes; modificação observada em 09/10/2026 05:31:13 UTC.
|
||||
|
||||
O bloqueio inicial, com a janela correspondente aberta no Word, foi libertado normalmente pelo utilizador. A leitura posterior foi bem-sucedida, sem terminar processos ou contornar o bloqueio. Extração de todos os 14 958 elementos `w:p`, incluindo tabelas: 14 849 com texto e 2 950 textos distintos por igualdade exata. Não há objetos incorporados ou media no pacote. A extração integral mantém todas as ocorrências; o índice único é auxiliar. O esquema JSON da secção 26 foi analisado e parseado, com 110 definições. Texto, índice e metadados privados em `storage/app/private/investmoz-v2/opportunity-form-*`.
|
||||
|
||||
Disponibilidade: consulta interna autorizada pelo pedido. Situação documental: especificação de software, não diploma jurídico. Utilização em cálculos: nenhuma. Não houve importação/publicação do DOCX nem declaração de scan. `AGENTS.md` foi lido integralmente; aceitação das fontes oficiais e seis classificações reais preservadas.
|
||||
|
||||
O cadastro anterior usava `Opportunity`, setores, territórios e relações existentes, mas exigia título/setor/moeda para guardar e permitia publicação pelo editor genérico. O incremento reutiliza essas entidades, dashboard, gate `manage-portal`, `audit_events` e geografia. Não executa SQL documental: mantém IDs bigint existentes em vez dos UUID dos exemplos. Referências técnicas novas são geradas pelo servidor; título, setor e moeda desconhecidos permanecem nulos.
|
||||
|
||||
## Matriz de cobertura
|
||||
|
||||
| Fonte / requisito | Estado | Implementação e lacuna |
|
||||
| --- | --- | --- |
|
||||
| §8/8.1 — rascunho vs exigências finais | Implementado para guardar; parcial global | Rascunho incompleto persistido; não declara validação final, submissão, aprovação ou publicação. Contrato JSON final não impede guardar. |
|
||||
| §9 — gestão integrada | Implementado no escopo autorizado | Lista, pesquisa, filtros por estado/categoria/setor/âmbito, paginação e contagens; Nova, Ver, Editar, Excluir/Arquivar, Restaurar. Indicadores avançados e papéis adicionais pendentes. |
|
||||
| §10 — núcleo/catálogos | Parcial | Campos legados preservados, categoria opcional e seleção territorial. Valor histórico `privado` conservado. Catálogos completos, dropdowns pesquisáveis e maturidade de oito etapas pendentes; não remapear automaticamente as seis etapas antigas. |
|
||||
| §11 — multilocalização | Parcial existente | Território principal e hierarquia reutilizados; contexto consultável. Formulário de até 50 localizações, papéis, distribuição e geometrias/evidência por localização pendente. Não inventa coordenadas. |
|
||||
| §12 — mercado/financiamento/calendário | Parcial | Montante decimal validado, campos existentes conservados. Estruturas completas e coerência cruzada por categoria pendentes; não calcula resultados financeiros/fiscais. |
|
||||
| §13 — documentos/visibilidade | Parcial existente | Infraestrutura preservada, sem publicação automática. Integração completa de documentos, idiomas e pré-visualização exata de visibilidade pendente. |
|
||||
| §15 — guardar/recuperar/editar | Implementado no incremento | Confirmação visível com foco, erros por campo, preservação e bloqueio durante operação, recuperação numa nova sessão pelo servidor. Wizard, autosave, validação final e comparação de duplicados documentais pendentes. |
|
||||
| §15.2 — ciclo de exclusão | Implementado para oportunidade | Motivo 20–1000 caracteres e confirmação; draft eliminado logicamente, versão fechada arquivada, restauração autorizada como draft sem republicar. Trigger proíbe eliminação física após fecho/submissão. Ciclo individual de coleções/anexos pendente. |
|
||||
| §15.3 — concorrência/idempotência | Implementado nos endpoints novos | Versão crescente, ETag do estado completo, bloqueio transacional e conflito HTTP 409; UUID de criação evita duplicação por replay. Token no payload e cabeçalho ETag na resposta. Contrato integral de todos os endpoints documentais pendente. |
|
||||
| §16 — imagens | Pendente | Pipeline completo, direitos, MIME, EXIF, capa e versões. ClamD real separado; scanner simulado não valida ficheiros reais. |
|
||||
| §17/19 — persistência/versões | Parcial | Mesmos IDs; alterações de versão fechada em `management_draft`, sem alterar ficha pública. Snapshots anterior/novo cifrados na auditoria existente. Fluxo completo de revisão pendente. |
|
||||
| §20 — permissões/workflow | Parcial | Gate existente no servidor, sem conceder novos papéis. Editor genérico deixa de publicar após instalação. Segregação de papéis e workflow integral pendentes. Revisão interna não é aprovação institucional. |
|
||||
| §21 — DT01–DT15 | Parcial | QA cobre rascunho, concorrência, permissões, replay, exclusão/arquivo/restauro e preservação. Critérios de imagens, validação final, publicação e coleções completas continuam abertos. |
|
||||
| §22 — REQ01–REQ17 | Parcial global | Cobertura por grupos acima; requisitos dos módulos finais continuam pendentes. Não declarar Go/No-Go integral por CRUD. |
|
||||
| §24–28 — categorias especializadas | Pendente além da seleção | Investimento, imobiliário, direitos fundiários, venda de negócio, parceria, franquia, concessão, financiamento, fornecimento e outros selecionáveis. Perfis, coleções e validações específicas exigem incrementos com reutilização dos módulos existentes. |
|
||||
| Relações e fontes oficiais | Preservado | Consulta do contexto existente; nenhum par inferido por nome, setor ou proximidade. Editor de evidência por ligação permanece na frente territorial. |
|
||||
|
||||
## QA e preservação
|
||||
|
||||
Base isolada `desk_restore_20261009_060826_test:55441`, restaurada do backup ativo. Guardas recusam CLI de testes/migration na base ativa. Restauro verificado de **288 tabelas e cinco ficheiros**. Migração e reversão antes de novos eventos ensaiadas; hashes das 288 tabelas coincidem após reversão.
|
||||
|
||||
**72 testes, 1 164 asserções passaram**, cobrindo gestão, origem, geografia, relações, zonas, portfólio e unificação. TypeScript, PHPStan e build passaram. Navegador: dashboard → gestão → novo rascunho incompleto → guardar → erro de montante junto ao campo → correção → nova sessão móvel sem armazenamento local → recuperação no servidor → exclusão lógica → restauração. Desktop e largura 390 px sem overflow; sem erros de execução observados. Dados de teste apenas na cópia QA.
|
||||
|
||||
Casos automatizados adicionais: revisão publicada preserva valores públicos; API não expõe proposta privada; arquivo/restauro sem republicação; eliminação física rejeitada; conflito de versão; acessos negados; replay sem duplicação; instalação recusada sem prova válida.
|
||||
|
||||
Após navegador: oportunidades originais, relações, favoritos, pedidos, recibos, mensagens, tarefas, localizações e auditoria anterior preservados; seis classificações reais conservadas. Evidência privada: `opportunity-management-compatibility.json`, `opportunity-management-preservation.json`, `opportunity-management-browser.json`, `opportunity-management-backup-proof.json` e capturas desktop/móvel. Histórico visível mostra autor/data/ação; snapshots anterior/novo e motivos permanecem cifrados na auditoria restrita. Interface detalhada de comparação desses snapshots pendente.
|
||||
|
||||
## Plano único de ativação
|
||||
|
||||
Abrir com conta autorizada [a página de instalação](http://127.0.0.1:8000/management/opportunity-management-install), rever plano/backup/destino, assinalar confirmação e executar **Aplicar plano de gestão**. **Não executado pelo Codex.** Até instalar, lista/editor serve o cadastro anterior, pois o esquema novo está ausente.
|
||||
|
||||
Migration única `2026_10_09_020000_extend_opportunity_management`: permitir nulos em título/setor/moeda para drafts; acrescentar versão, categoria, proposta, chave idempotente, primeira submissão e marca histórica de fecho; instalar guarda de eliminação física. Não altera valores de negócio, classificações ou relações existentes. A página confere hash do plano/código, identidade, integridade do backup/restauro e QA desta versão, validade de 24 h; exige autenticação/permissão e audita aplicação.
|
||||
|
||||
Backup verificado em **09/10/2026 06:08:36 UTC**, válido para esta pré-visualização até **10/10/2026 06:08:36 UTC**. Expiração ou mudança de código exige nova preparação. Leitura ativa em **06:13:34 UTC**: migration ausente, zero eventos desta gestão, IDs 1–6 `real`; nenhuma escrita ativa nesta entrega.
|
||||
|
||||
Reversão direta `down` apenas antes de novos eventos ou drafts incompletos. Depois de uso, preservar/reconciliar dados e preparar reversão autorizada. Restauro integral exige janela e reconciliação de escritas posteriores; ensaio em cópia não autoriza sobrescrever a base ativa.
|
||||
|
||||
Pendências separadas: cobertura do inventário, questões jurídicas específicas, ClamD real, referências institucionais concretas e primeiro pedido real no Balcão. Nenhuma foi concluída por estes testes.
|
||||
@@ -52,7 +52,11 @@ export default function Dashboard({ summary }: { summary: Summary | null }) {
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Catálogo</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Link href="/management/opportunities">
|
||||
Gestão de Oportunidades
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
Management,
|
||||
Pagination,
|
||||
button,
|
||||
control,
|
||||
states,
|
||||
type Paginated,
|
||||
} from './shared';
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
reference: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
opportunity_category: string | null;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
export default function Opportunities({
|
||||
records,
|
||||
filters,
|
||||
counts,
|
||||
categories,
|
||||
sectors,
|
||||
geographyReviewCount,
|
||||
}: {
|
||||
records: Paginated<Entry>;
|
||||
filters: {
|
||||
q?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
deleted?: boolean;
|
||||
sector_id?: string;
|
||||
geography_scope?: string;
|
||||
};
|
||||
counts: Record<string, number>;
|
||||
categories: string[];
|
||||
sectors: { id: number; name: string }[];
|
||||
geographyReviewCount: number;
|
||||
}) {
|
||||
const form = useForm({
|
||||
q: filters.q ?? '',
|
||||
status: filters.status ?? '',
|
||||
category: filters.category ?? '',
|
||||
deleted: Boolean(filters.deleted),
|
||||
sector_id: String(filters.sector_id ?? ''),
|
||||
geography_scope: filters.geography_scope ?? '',
|
||||
});
|
||||
return (
|
||||
<Management title="Gestão de Oportunidades">
|
||||
<Link className={button} href="/management/opportunities/create">
|
||||
Nova oportunidade
|
||||
</Link>
|
||||
<p>
|
||||
{geographyReviewCount} registos com âmbito por indicar;
|
||||
referências existentes permanecem aceites.
|
||||
</p>
|
||||
<section
|
||||
aria-label="Indicadores autorizados"
|
||||
className="grid gap-3 sm:grid-cols-3"
|
||||
>
|
||||
{[
|
||||
'draft',
|
||||
'submitted',
|
||||
'returned',
|
||||
'approved',
|
||||
'published',
|
||||
'suspended',
|
||||
'archived',
|
||||
].map((s) => (
|
||||
<Link
|
||||
className="rounded border p-3"
|
||||
key={s}
|
||||
href={`/management/opportunities?status=${s}`}
|
||||
>
|
||||
{states[s] ?? s}: {counts[s] ?? 0}
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
<form
|
||||
className="flex flex-wrap gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/management/opportunities');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label="Pesquisa global"
|
||||
placeholder="Título ou referência"
|
||||
className={control}
|
||||
value={form.data.q}
|
||||
onChange={(e) => form.setData('q', e.target.value)}
|
||||
/>
|
||||
<select
|
||||
aria-label="Estado"
|
||||
className={control}
|
||||
value={form.data.status}
|
||||
onChange={(e) => form.setData('status', e.target.value)}
|
||||
>
|
||||
<option value="">Todos os estados</option>
|
||||
{Object.keys(counts).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{states[s] ?? s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Categoria"
|
||||
className={control}
|
||||
value={form.data.category}
|
||||
onChange={(e) => form.setData('category', e.target.value)}
|
||||
>
|
||||
<option value="">Todas as categorias</option>
|
||||
{categories.map((s) => (
|
||||
<option key={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.deleted}
|
||||
onChange={(e) =>
|
||||
form.setData('deleted', e.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Rascunhos eliminados logicamente
|
||||
</label>
|
||||
<select
|
||||
aria-label="Sector"
|
||||
className={control}
|
||||
value={form.data.sector_id}
|
||||
onChange={(e) => form.setData('sector_id', e.target.value)}
|
||||
>
|
||||
<option value="">Todos os sectores</option>
|
||||
{sectors.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Âmbito geográfico"
|
||||
className={control}
|
||||
value={form.data.geography_scope}
|
||||
onChange={(e) =>
|
||||
form.setData('geography_scope', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os âmbitos</option>
|
||||
{[
|
||||
'unspecified',
|
||||
'specific',
|
||||
'multiple',
|
||||
'national',
|
||||
'digital',
|
||||
].map((s) => (
|
||||
<option key={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className={button} disabled={form.processing}>
|
||||
Pesquisar
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-3">
|
||||
{records.data.map((r) => (
|
||||
<article key={r.id} className="rounded border p-4">
|
||||
<h2 className="font-semibold">
|
||||
{r.title ?? 'Rascunho sem título'}
|
||||
</h2>
|
||||
<p>
|
||||
ID {r.id} · {r.reference} ·{' '}
|
||||
{r.opportunity_category ?? 'Categoria por indicar'}{' '}
|
||||
·{' '}
|
||||
{r.deleted_at
|
||||
? 'Eliminado logicamente'
|
||||
: (states[r.status] ?? r.status)}
|
||||
</p>
|
||||
<p>Atualizado: {r.updated_at}</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link
|
||||
href={`/management/opportunities/${r.id}/view`}
|
||||
>
|
||||
Ver
|
||||
</Link>
|
||||
<Link
|
||||
href={`/management/opportunities/${r.id}/edit`}
|
||||
>
|
||||
{r.deleted_at || r.status === 'archived'
|
||||
? 'Restaurar'
|
||||
: r.status === 'draft'
|
||||
? 'Continuar preenchimento'
|
||||
: 'Editar nova versão'}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{!records.data.length && <p>Nenhum registo encontrado.</p>}
|
||||
<Pagination page={records} />
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
TerritoryLevel,
|
||||
type TerritoryOption,
|
||||
} from '@/components/investment-territory';
|
||||
import { Management, Field, button, control } from './shared';
|
||||
type Values = Record<string, string | number | null>;
|
||||
type Entry = {
|
||||
id: number;
|
||||
reference: string;
|
||||
status: string;
|
||||
deleted_at: string | null;
|
||||
management_version: number;
|
||||
};
|
||||
export default function OpportunityDraft({
|
||||
record,
|
||||
values,
|
||||
etag,
|
||||
readOnly,
|
||||
closedVersion,
|
||||
classification,
|
||||
sectors,
|
||||
categories,
|
||||
history,
|
||||
territoryPath,
|
||||
}: {
|
||||
record: Entry | null;
|
||||
values: Values;
|
||||
etag: string | null;
|
||||
readOnly: boolean;
|
||||
closedVersion: boolean;
|
||||
classification: string;
|
||||
sectors: { id: number; name: string }[];
|
||||
categories: string[];
|
||||
territoryPath: TerritoryOption[];
|
||||
history: {
|
||||
id: string;
|
||||
action: string;
|
||||
actor_user_id: number;
|
||||
occurred_at: string;
|
||||
}[];
|
||||
}) {
|
||||
const [data, setData] = useState<Values>({
|
||||
...values,
|
||||
creation_key: crypto.randomUUID(),
|
||||
});
|
||||
const [busy, setBusy] = useState(false),
|
||||
[errors, setErrors] = useState<Record<string, string>>({}),
|
||||
[message, setMessage] = useState(''),
|
||||
[reason, setReason] = useState(''),
|
||||
[confirm, setConfirm] = useState(false),
|
||||
[token, setToken] = useState(etag);
|
||||
useEffect(() => setToken(etag), [etag]);
|
||||
const [path, setPath] = useState(territoryPath);
|
||||
const unavailable = Boolean(
|
||||
record?.deleted_at || record?.status === 'archived',
|
||||
);
|
||||
const set = (key: string, value: string) =>
|
||||
setData((d) => ({ ...d, [key]: value }));
|
||||
async function send(action: 'save' | 'delete' | 'restore') {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
setMessage('');
|
||||
try {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='));
|
||||
const url =
|
||||
action === 'save'
|
||||
? record
|
||||
? `/management/opportunities/${record.id}/draft`
|
||||
: '/management/opportunities/drafts'
|
||||
: `/management/opportunities/${record!.id}/lifecycle`;
|
||||
const response = await fetch(url, {
|
||||
method: action === 'save' && record ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': cookie
|
||||
? decodeURIComponent(cookie.slice(11))
|
||||
: '',
|
||||
},
|
||||
body: JSON.stringify(
|
||||
action === 'save'
|
||||
? { ...data, etag: token, reason }
|
||||
: { etag: token, action, reason, confirm },
|
||||
),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setErrors(
|
||||
Object.fromEntries(
|
||||
Object.entries(result.errors ?? {}).map(([k, v]) => [
|
||||
k,
|
||||
Array.isArray(v) ? v.join(' ') : String(v),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setMessage(
|
||||
result.message ??
|
||||
'Operação falhou; os dados preenchidos foram preservados.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMessage(result.message);
|
||||
if (result.etag) setToken(result.etag);
|
||||
if (!record)
|
||||
router.visit(`/management/opportunities/${result.id}/edit`);
|
||||
else if (action !== 'save') router.reload();
|
||||
} catch {
|
||||
setMessage(
|
||||
'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
requestAnimationFrame(() =>
|
||||
document.getElementById('opportunity-result')?.focus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Management
|
||||
title={
|
||||
record
|
||||
? `${readOnly ? 'Ver' : 'Editar'} oportunidade ${record.reference}`
|
||||
: 'Nova oportunidade'
|
||||
}
|
||||
>
|
||||
<Link href="/management/opportunities">Voltar à gestão</Link>
|
||||
<p>
|
||||
Classificação de origem: {classification}. Esta classificação
|
||||
não cria associações territoriais.
|
||||
</p>
|
||||
{closedVersion && (
|
||||
<p>
|
||||
Versão anterior fechada. As alterações são guardadas numa
|
||||
nova versão de rascunho; a ficha publicada permanece
|
||||
intacta. Este incremento não submete, aprova ou publica.
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
id="opportunity-result"
|
||||
tabIndex={-1}
|
||||
role={Object.keys(errors).length ? 'alert' : 'status'}
|
||||
className="rounded border p-3"
|
||||
>
|
||||
{message}
|
||||
{errors.etag && (
|
||||
<p>
|
||||
{errors.etag}{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`/management/opportunities/${record?.id}/view`}
|
||||
>
|
||||
Abrir versão atual
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="max-w-3xl space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void send('save');
|
||||
}}
|
||||
>
|
||||
<fieldset
|
||||
disabled={busy || readOnly || unavailable}
|
||||
className="space-y-4"
|
||||
>
|
||||
<section aria-label="Localização registada">
|
||||
<p>
|
||||
Hierarquia do cadastro territorial existente.
|
||||
Alterar esta seleção não cria relações com
|
||||
projetos/clusters.
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
'province_id',
|
||||
'district_id',
|
||||
'admin_post_id',
|
||||
'locality_id',
|
||||
] as const
|
||||
).map((key, index) => (
|
||||
<TerritoryLevel
|
||||
key={`${key}-${path[index - 1]?.id ?? ''}`}
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint="/management/territory-options"
|
||||
fieldError={errors[key]}
|
||||
onChange={(value) => {
|
||||
const next = [
|
||||
...path.slice(0, index),
|
||||
...(value ? [value] : []),
|
||||
];
|
||||
setPath(next);
|
||||
setData((previous) => ({
|
||||
...previous,
|
||||
province_id: next[0]?.id ?? '',
|
||||
district_id: next[1]?.id ?? '',
|
||||
admin_post_id: next[2]?.id ?? '',
|
||||
locality_id: next[3]?.id ?? '',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<Field
|
||||
label="Categoria"
|
||||
error={errors.opportunity_category}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.opportunity_category ?? '')}
|
||||
onChange={(e) =>
|
||||
set('opportunity_category', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{Object.entries({
|
||||
title: 'Título oficial',
|
||||
summary: 'Resumo',
|
||||
description: 'Descrição',
|
||||
promoter_name: 'Promotor',
|
||||
}).map(([key, label]) => (
|
||||
<Field key={key} label={label} error={errors[key]}>
|
||||
{key === 'description' || key === 'summary' ? (
|
||||
<textarea
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Sector" error={errors.sector_id}>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.sector_id ?? '')}
|
||||
onChange={(e) => set('sector_id', e.target.value)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{sectors.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Tipo de promotor"
|
||||
error={errors.promoter_type}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.promoter_type ?? '')}
|
||||
onChange={(e) =>
|
||||
set('promoter_type', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{values.promoter_type &&
|
||||
![
|
||||
'public',
|
||||
'private',
|
||||
'ppp',
|
||||
'cooperative',
|
||||
'association',
|
||||
'community',
|
||||
'other',
|
||||
].includes(String(values.promoter_type)) && (
|
||||
<option
|
||||
value={String(values.promoter_type)}
|
||||
>
|
||||
{String(values.promoter_type)} (valor
|
||||
existente)
|
||||
</option>
|
||||
)}
|
||||
{[
|
||||
'public',
|
||||
'private',
|
||||
'ppp',
|
||||
'cooperative',
|
||||
'association',
|
||||
'community',
|
||||
'other',
|
||||
].map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Maturidade existente"
|
||||
error={errors.maturity_level}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.maturity_level ?? '')}
|
||||
onChange={(e) =>
|
||||
set('maturity_level', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{[
|
||||
'Ideia identificada',
|
||||
'Pré-estudo disponível',
|
||||
'Estudo de viabilidade em curso',
|
||||
'Projecto estruturado',
|
||||
'Pronto para investimento',
|
||||
'Em negociação ou implementação',
|
||||
].map((c, index) => (
|
||||
<option key={c} value={index + 1}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Modalidade de financiamento existente"
|
||||
error={errors.financing_modality}
|
||||
>
|
||||
<input
|
||||
className={control}
|
||||
value={String(data.financing_modality ?? '')}
|
||||
onChange={(e) =>
|
||||
set('financing_modality', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Impacto esperado"
|
||||
error={errors.expected_impact}
|
||||
>
|
||||
<textarea
|
||||
className={control}
|
||||
value={String(data.expected_impact ?? '')}
|
||||
onChange={(e) =>
|
||||
set('expected_impact', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{(data.opportunity_category === 'investment_project' ||
|
||||
(!data.opportunity_category &&
|
||||
data.estimated_investment_amount != null)) && (
|
||||
<>
|
||||
<Field
|
||||
label="Investimento estimado"
|
||||
error={errors.estimated_investment_amount}
|
||||
>
|
||||
<input
|
||||
className={control}
|
||||
value={String(
|
||||
data.estimated_investment_amount ?? '',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
'estimated_investment_amount',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Moeda"
|
||||
error={errors.estimated_investment_currency}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(
|
||||
data.estimated_investment_currency ??
|
||||
'',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
'estimated_investment_currency',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{['MZN', 'USD', 'EUR', 'ZAR'].map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{data.opportunity_category &&
|
||||
data.opportunity_category !== 'investment_project' && (
|
||||
<p>
|
||||
O módulo especializado desta categoria permanece
|
||||
pendente. Pode guardar a identificação
|
||||
incompleta; não será submetida nem publicada.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Field
|
||||
label="Motivo da alteração"
|
||||
error={errors.reason}
|
||||
>
|
||||
<textarea
|
||||
className={control}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Field>
|
||||
{!unavailable && (
|
||||
<button className={button} disabled={busy}>
|
||||
Guardar rascunho
|
||||
</button>
|
||||
)}
|
||||
{record && (
|
||||
<>
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirm}
|
||||
onChange={(e) =>
|
||||
setConfirm(e.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Confirmo a{' '}
|
||||
{unavailable
|
||||
? 'restauração'
|
||||
: 'exclusão lógica/arquivo'}
|
||||
, preservando histórico e relações
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy || !confirm}
|
||||
onClick={() =>
|
||||
void send(
|
||||
unavailable ? 'restore' : 'delete',
|
||||
)
|
||||
}
|
||||
>
|
||||
{unavailable
|
||||
? 'Restaurar'
|
||||
: closedVersion
|
||||
? 'Arquivar'
|
||||
: 'Excluir rascunho'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
{record && (
|
||||
<Link href={`/management/opportunities/${record.id}/context`}>
|
||||
Consultar projetos, clusters e relações existentes
|
||||
</Link>
|
||||
)}
|
||||
<section aria-label="Histórico de gestão">
|
||||
<h2>Histórico</h2>
|
||||
{history.length ? (
|
||||
history.map((e) => (
|
||||
<p key={e.id}>
|
||||
{e.action} · conta {e.actor_user_id} ·{' '}
|
||||
{e.occurred_at}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p>Sem eventos deste incremento.</p>
|
||||
)}
|
||||
</section>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { Management, button } from './shared';
|
||||
export default function Install({
|
||||
installed,
|
||||
ready,
|
||||
target,
|
||||
hash,
|
||||
changes,
|
||||
migration,
|
||||
backupDate,
|
||||
rollback,
|
||||
}: {
|
||||
installed: boolean;
|
||||
ready: boolean;
|
||||
target: { database: string; port: number };
|
||||
hash: string;
|
||||
changes: string[];
|
||||
migration: string;
|
||||
backupDate: string | null;
|
||||
rollback: string;
|
||||
}) {
|
||||
const form = useForm({ hash, confirm: false });
|
||||
return (
|
||||
<Management title="Ativação da Gestão de Oportunidades">
|
||||
<p>
|
||||
Base: {target.database}:{target.port} ·{' '}
|
||||
{installed ? 'Instalado' : 'Ainda não aplicado'}
|
||||
</p>
|
||||
<ul>
|
||||
{changes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>Migração: {migration}</p>
|
||||
<p>Backup/restauro: {backupDate ?? 'Prova ainda por preparar'}</p>
|
||||
<p>{rollback}</p>
|
||||
<p>
|
||||
Este plano ativa apenas o primeiro incremento; workflow
|
||||
completo, imagens e módulos especializados permanecem no
|
||||
backlog.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/management/opportunity-management-install');
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.confirm}
|
||||
onChange={(e) =>
|
||||
form.setData('confirm', e.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Confirmo a aplicação deste plano
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
installed ||
|
||||
!ready ||
|
||||
!form.data.confirm ||
|
||||
form.processing
|
||||
}
|
||||
>
|
||||
Aplicar plano de gestão
|
||||
</button>
|
||||
{!ready && (
|
||||
<p>
|
||||
Aplicação indisponível até comprovar QA,
|
||||
backup/restauro, compatibilidade e reversão da versão
|
||||
atual.
|
||||
</p>
|
||||
)}
|
||||
{Object.values(form.errors).map((e) => (
|
||||
<p role="alert" key={e}>
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export const states: Record<string, string> = {
|
||||
draft: 'Rascunho',
|
||||
published: 'Publicado',
|
||||
archived: 'Arquivado',
|
||||
returned: 'Devolvida',
|
||||
approved: 'Aprovada internamente',
|
||||
suspended: 'Suspensa',
|
||||
};
|
||||
|
||||
export function Management({
|
||||
@@ -57,7 +60,7 @@ export function Management({
|
||||
>
|
||||
<Link href="/management/interests">Pedidos de interesse</Link>
|
||||
<Link href="/management/sectors">Sectores</Link>
|
||||
<Link href="/management/opportunities">Oportunidades</Link>
|
||||
<Link href="/management/opportunities">Gestão de Oportunidades</Link>
|
||||
<Link href="/management/opportunity-origin">
|
||||
Revisão da origem das oportunidades
|
||||
</Link>
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Http\Controllers\InvestmentStructureController;
|
||||
use App\Http\Controllers\InvestmentStructureRelationController;
|
||||
use App\Http\Controllers\InvestmentTerritoryController;
|
||||
use App\Http\Controllers\InvestorWorkspaceController;
|
||||
use App\Http\Controllers\OpportunityManagementController;
|
||||
use App\Http\Controllers\OpportunityOriginReviewController;
|
||||
use App\Http\Controllers\PortalManagementController;
|
||||
use App\Http\Controllers\PortalServicesController;
|
||||
@@ -104,6 +105,15 @@ Route::middleware(['auth', 'verified', 'can:manage-portal', AiEnabled::class])->
|
||||
Route::get('/api/v1/investment/territory-options', [PublicGeographyController::class, 'options'])->middleware('throttle:api');
|
||||
|
||||
Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management')->name('management.')->group(function () {
|
||||
Route::get('opportunity-management-install', [OpportunityManagementController::class, 'installation']);
|
||||
Route::post('opportunity-management-install', [OpportunityManagementController::class, 'applyInstallation']);
|
||||
Route::get('opportunities', [OpportunityManagementController::class, 'index']);
|
||||
Route::get('opportunities/create', [OpportunityManagementController::class, 'edit']);
|
||||
Route::get('opportunities/{id}/edit', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
|
||||
Route::get('opportunities/{id}/view', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
|
||||
Route::post('opportunities/drafts', [OpportunityManagementController::class, 'save']);
|
||||
Route::put('opportunities/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
|
||||
Route::post('opportunities/{id}/lifecycle', [OpportunityManagementController::class, 'lifecycle'])->whereNumber('id');
|
||||
Route::get('opportunities/{id}/coordinates', [InvestmentCoordinatesController::class, 'show'])->whereNumber('id');
|
||||
Route::post('opportunities/{id}/coordinates', [InvestmentCoordinatesController::class, 'save'])->whereNumber('id');
|
||||
Route::get('territory-options', [InvestmentTerritoryController::class, 'options'])->middleware('throttle:api');
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Database\Seeders\InvestmentAreaSeeder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -74,9 +75,9 @@ class InvestmentTerritoryTest extends TestCase
|
||||
$this->assertCount(2, $service->context($opportunity->id)['primary_territory_path']);
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->post('/management/opportunities', ['sector_id' => $sector->id, 'reference' => 'SYNTHETIC-CREATE', 'slug' => 'synthetic-create', 'title' => 'Synthetic create',
|
||||
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD', 'province_id' => $province, 'district_id' => $district])->assertRedirect();
|
||||
$this->assertDatabaseHas('opportunities', ['reference' => 'SYNTHETIC-CREATE', 'primary_territory_id' => $district, 'territory_version' => 1]);
|
||||
$created = $this->actingAs($manager)->postJson('/management/opportunities/drafts', ['sector_id' => $sector->id, 'creation_key' => (string) Str::uuid(), 'reason' => 'Cadastro territorial de rascunho apenas em QA', 'title' => 'Synthetic create',
|
||||
'estimated_investment_currency' => 'USD', 'province_id' => $province, 'district_id' => $district])->assertCreated()->json();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $created['id'], 'primary_territory_id' => $district]);
|
||||
$this->post('/management/clusters', ['code' => 'GEO-CREATE', 'name' => 'Synthetic cluster', 'sector' => 'Synthetic', 'status' => 'planned', 'province_id' => $province, 'district_id' => $district])->assertRedirect();
|
||||
$this->assertDatabaseHas('economic_clusters', ['code' => 'GEO-CREATE', 'primary_territory_id' => $district]);
|
||||
$manager->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
|
||||
@@ -283,11 +284,12 @@ class InvestmentTerritoryTest extends TestCase
|
||||
$province = $this->location('province', $country);
|
||||
$sector = Sector::create(['name' => 'Synthetic edit', 'slug' => 'synthetic-edit', 'is_active' => true]);
|
||||
$record = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Synthetic edit', 'status' => 'draft']);
|
||||
$this->actingAs($manager)->put('/management/opportunities/'.$record->id, [
|
||||
$this->actingAs($manager)->putJson('/management/opportunities/'.$record->id.'/draft', [
|
||||
'sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Synthetic edit',
|
||||
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD',
|
||||
'province_id' => $province, 'territory_version' => 0,
|
||||
])->assertRedirect();
|
||||
'etag' => app(OpportunityManagement::class)->etag($record->fresh()), 'reason' => 'Atualização territorial do rascunho em QA',
|
||||
])->assertOk();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'primary_territory_id' => $province]);
|
||||
$this->assertSame($province, $record->fresh()->primaryTerritory->id);
|
||||
$this->get('/management/opportunities/'.$record->id.'/edit')->assertOk();
|
||||
@@ -299,9 +301,10 @@ class InvestmentTerritoryTest extends TestCase
|
||||
$payload = ['sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Must not persist',
|
||||
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD',
|
||||
'province_id' => $province, 'district_id' => $district, 'territory_version' => 1];
|
||||
$this->putJson('/management/opportunities/'.$record->id, $payload)->assertUnprocessable()->assertJsonValidationErrors('district_id');
|
||||
$etag = app(OpportunityManagement::class)->etag($record->fresh());
|
||||
$this->putJson('/management/opportunities/'.$record->id.'/draft', [...$payload, 'etag' => $etag, 'reason' => 'Validação de hierarquia no rascunho QA'])->assertUnprocessable()->assertJsonValidationErrors('district_id');
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'title' => 'Synthetic edit', 'primary_territory_id' => $province]);
|
||||
$this->putJson('/management/opportunities/'.$record->id, [...$payload, 'district_id' => null, 'territory_version' => 0])->assertConflict();
|
||||
$this->putJson('/management/opportunities/'.$record->id.'/draft', [...$payload, 'district_id' => null, 'etag' => str_repeat('0', 64), 'reason' => 'Teste de conflito de edição territorial QA'])->assertConflict();
|
||||
}
|
||||
|
||||
public function test_database_rejects_a_cycle_wrong_parent_and_duplicate_source_code(): void
|
||||
@@ -348,10 +351,10 @@ class InvestmentTerritoryTest extends TestCase
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->from('/management/opportunities/create')
|
||||
->post('/management/opportunities', [])->assertSessionHasErrors('reference');
|
||||
->post('/management/opportunities', [])->assertSessionHasErrors('status');
|
||||
$this->withCookie(config('session.cookie'), session()->getId())->withCredentials();
|
||||
$this->getJson('/management/territory-options?type=province')->assertOk()->assertSessionHasErrors('reference');
|
||||
$this->get('/management/opportunities/create')->assertInertia(fn (AssertableInertia $page) => $page->has('errors.reference'));
|
||||
$this->getJson('/management/territory-options?type=province')->assertOk()->assertSessionHasErrors('status');
|
||||
$this->get('/management/opportunities/create')->assertInertia(fn (AssertableInertia $page) => $page->has('errors.status'));
|
||||
}
|
||||
|
||||
public function test_investment_areas_are_idempotent_and_preserve_legacy_sectors(): void
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OpportunityManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_installation_requires_management_permission_and_current_proofs(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())->getJson('/management/opportunity-management-install')->assertForbidden();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$migration = require database_path('migrations/2026_10_09_020000_extend_opportunity_management.php');
|
||||
$migration->down();
|
||||
$preview = $this->actingAs($manager)->getJson('/management/opportunity-management-install')->assertOk()->assertJsonPath('installed', false)->assertJsonPath('ready', false)->json();
|
||||
$this->postJson('/management/opportunity-management-install', ['hash' => $preview['hash'], 'confirm' => true])->assertUnprocessable();
|
||||
$this->assertFalse(app(OpportunityManagement::class)->installed());
|
||||
}
|
||||
|
||||
public function test_incomplete_draft_recovers_and_soft_delete_restore_preserves_identity(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$payload = ['creation_key' => (string) Str::uuid(), 'reason' => 'Criação de rascunho incompleto em QA'];
|
||||
$result = $this->actingAs($manager)->postJson('/management/opportunities/drafts', $payload)->assertCreated()->json();
|
||||
$id = $result['id'];
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $id, 'title' => null, 'sector_id' => null, 'estimated_investment_currency' => null, 'status' => 'draft']);
|
||||
$this->postJson('/management/opportunities/drafts', $payload)->assertCreated()->assertJsonPath('id', $id);
|
||||
$this->assertSame(1, Opportunity::count());
|
||||
$this->postJson('/management/opportunities/drafts', [...$payload, 'title' => 'Different data'])->assertUnprocessable();
|
||||
$this->get('/management/opportunities/'.$id.'/edit')->assertOk();
|
||||
$update = $this->putJson('/management/opportunities/'.$id.'/draft', ['etag' => $result['etag'], 'title' => 'QA incomplete saved title', 'reason' => 'Edição de identificação incompleta QA'])->assertOk()->json();
|
||||
$this->putJson('/management/opportunities/'.$id.'/draft', ['etag' => $result['etag'], 'title' => 'Lost update', 'reason' => 'Tentativa concorrente para teste QA'])->assertConflict()->assertJsonValidationErrors('etag');
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $id, 'title' => 'QA incomplete saved title']);
|
||||
$request = ['etag' => $update['etag'], 'action' => 'delete', 'reason' => 'Exclusão lógica autorizada apenas em QA', 'confirm' => true];
|
||||
$this->postJson('/management/opportunities/'.$id.'/lifecycle', [...$request, 'confirm' => false])->assertUnprocessable();
|
||||
$this->postJson('/management/opportunities/'.$id.'/lifecycle', $request)->assertOk();
|
||||
$this->assertSoftDeleted('opportunities', ['id' => $id]);
|
||||
$this->get('/management/opportunities/'.$id.'/edit')->assertOk();
|
||||
$record = Opportunity::withTrashed()->findOrFail($id);
|
||||
$this->postJson('/management/opportunities/'.$id.'/lifecycle', [...$request, 'action' => 'restore', 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $id, 'status' => 'draft', 'deleted_at' => null]);
|
||||
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'opportunity-management')->count());
|
||||
$this->actingAs(User::factory()->create())->get('/management/opportunities')->assertForbidden();
|
||||
$this->postJson('/management/opportunities/drafts', $payload)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_closed_version_edits_do_not_change_public_values_classification_or_relations(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$sector = Sector::create(['name' => 'QA sector', 'slug' => 'qa-management', 'is_active' => true]);
|
||||
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'INV-MZ-QA-001', 'title' => 'Published QA original', 'promoter_type' => 'privado', 'slug' => 'qa-management', 'status' => 'published', 'published_at' => now()->subMinute()]);
|
||||
$project = Project::factory()->create();
|
||||
DB::table('opportunity_projects')->insert(['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'opportunity-origin-review', 'entity_type' => 'opportunity_origin', 'action' => 'origin_classification_decided', 'actor_user_id' => $manager->id, 'after_data' => json_encode(['opportunity_id' => $opportunity->id, 'classification' => 'real', 'reason' => 'QA classification retained'])]);
|
||||
$service = app(OpportunityManagement::class);
|
||||
$update = $this->actingAs($manager)->putJson('/management/opportunities/'.$opportunity->id.'/draft', ['etag' => $service->etag($opportunity->fresh()), 'promoter_type' => 'privado', 'title' => 'QA new unpublished version', 'reason' => 'Nova versão em rascunho para teste QA'])->assertOk()->json();
|
||||
$this->assertSame('Published QA original', $opportunity->fresh()->title);
|
||||
$this->assertSame('QA new unpublished version', json_decode($opportunity->fresh()->management_draft, true)['title']);
|
||||
$this->getJson('/api/v1/opportunities/qa-management')->assertOk()->assertJsonMissingPath('data.management_draft')->assertJsonPath('data.title', 'Published QA original');
|
||||
$this->postJson('/management/opportunities/'.$opportunity->id.'/lifecycle', ['etag' => $update['etag'], 'action' => 'delete', 'reason' => 'Arquivo explícito de versão submetida QA', 'confirm' => true])->assertOk();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $opportunity->id, 'status' => 'archived', 'deleted_at' => null]);
|
||||
$this->assertTrue((bool) $opportunity->fresh()->management_closed_once);
|
||||
$this->assertNull($opportunity->fresh()->first_submitted_at);
|
||||
$this->assertDatabaseHas('opportunity_projects', ['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
|
||||
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count());
|
||||
$this->putJson('/management/opportunities/'.$opportunity->id, ['status' => 'published'])->assertUnprocessable();
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
$this->postJson('/management/opportunities/'.$opportunity->id.'/lifecycle', ['etag' => $service->etag($opportunity->fresh()), 'action' => 'restore', 'reason' => 'Reabertura autorizada com revisão somente QA', 'confirm' => true])->assertOk();
|
||||
$this->assertSame('draft', $opportunity->fresh()->status);
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
try {
|
||||
DB::transaction(fn () => DB::table('opportunities')->where('id', $opportunity->id)->delete());
|
||||
$this->fail('Physical deletion after a closed version was accepted');
|
||||
} catch (QueryException $error) {
|
||||
$this->assertStringContainsString('Physical deletion forbidden', $error->getMessage());
|
||||
}
|
||||
$this->assertDatabaseHas('opportunity_projects', ['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Models\InterestRequest;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
@@ -113,24 +114,19 @@ class PortalManagementTest extends TestCase
|
||||
$this->get('/management/sectors/'.$sector->id.'/edit')->assertOk();
|
||||
}
|
||||
|
||||
public function test_opportunities_can_be_published_and_withdrawn_without_losing_money_precision(): void
|
||||
public function test_draft_money_precision_is_preserved_without_editorial_publication_bypass(): void
|
||||
{
|
||||
$this->actingAs($this->manager());
|
||||
$data = ['sector_id' => $this->sector()->id, 'reference' => 'INV-TEST', 'title' => 'Solar',
|
||||
'slug' => 'solar', 'status' => 'draft', 'is_featured' => true, 'maturity_level' => 4,
|
||||
'estimated_investment_amount' => '1234567890123456.78', 'estimated_investment_currency' => 'MZN'];
|
||||
$this->post('/management/opportunities', $data)->assertRedirect('/management/opportunities');
|
||||
$this->postJson('/management/opportunities/drafts', [...$data, 'creation_key' => (string) Str::uuid(), 'reason' => 'Rascunho financeiro incompleto em QA'])->assertCreated();
|
||||
$opportunity = Opportunity::firstOrFail();
|
||||
$this->getJson('/api/v1/opportunities/solar')->assertNotFound();
|
||||
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'published'])->assertRedirect();
|
||||
$this->getJson('/api/v1/opportunities/solar')->assertOk()
|
||||
->assertJsonPath('data.estimated_investment_amount', '1234567890123456.78')
|
||||
->assertJsonPath('data.maturity_label', 'Projecto estruturado');
|
||||
$this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 1);
|
||||
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'archived'])->assertRedirect();
|
||||
$this->getJson('/api/v1/opportunities/solar')->assertNotFound();
|
||||
$this->assertSame('1234567890123456.78', $opportunity->estimated_investment_amount);
|
||||
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'published'])->assertSessionHasErrors('status');
|
||||
$this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 0);
|
||||
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'estimated_investment_amount' => '-1'])->assertSessionHasErrors('estimated_investment_amount');
|
||||
$this->putJson('/management/opportunities/'.$opportunity->id.'/draft', [...$data, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'reason' => 'Correção financeira testada apenas em QA', 'estimated_investment_amount' => '-1'])->assertUnprocessable()->assertJsonValidationErrors('estimated_investment_amount');
|
||||
}
|
||||
|
||||
public function test_review_tracks_the_authenticated_reviewer_and_rejects_invalid_states(): void
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
// Protected active reader only; no application kernel or migrations.
|
||||
require __DIR__.'/investmoz-v2-territory-audit.php';
|
||||
$dir = dirname(__DIR__).'/storage/app/private/investmoz-v2';
|
||||
$pdo->exec('BEGIN TRANSACTION READ ONLY');
|
||||
$identity = $pdo->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC);
|
||||
$installed = (bool) $pdo->query("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='opportunities' AND column_name='management_version')")->fetchColumn();
|
||||
$migrationApplied = (int) $pdo->query("SELECT count(*) FROM migrations WHERE migration='2026_10_09_020000_extend_opportunity_management'")->fetchColumn();
|
||||
$classifications = $pdo->query("SELECT DISTINCT ON (after_data->>'opportunity_id') after_data->>'opportunity_id' AS id,after_data->>'classification' AS classification FROM audit_events WHERE source_system='opportunity-origin-review' ORDER BY after_data->>'opportunity_id',occurred_at DESC,id DESC")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$auditCount = (int) $pdo->query("SELECT count(*) FROM audit_events WHERE source_system IN ('opportunity-management','opportunity-management-install')")->fetchColumn();
|
||||
$pdo->exec('ROLLBACK');
|
||||
$result = ['captured_at' => gmdate('c'), 'identity' => $identity, 'installed' => $installed, 'migration_applied' => $migrationApplied, 'classifications' => $classifications, 'management_events' => $auditCount, 'active_writes' => 0];
|
||||
file_put_contents($dir.'/opportunity-management-active-read-only.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($result).PHP_EOL;
|
||||
@@ -0,0 +1,25 @@
|
||||
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const fs=require('node:fs'),assert=require('node:assert/strict');
|
||||
const base='http://127.0.0.1:18095',dir='storage/app/private/investmoz-v2/';
|
||||
(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 session(viewport){const context=await browser.newContext({viewport});await context.route('**/*',r=>r.request().url().startsWith(base)||r.request().url().startsWith('data:')?r.continue():r.abort());const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(base+'/login');await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');await page.getByRole('button',{name:'Log in',exact:true}).click();await page.waitForURL('**/dashboard');return {context,page};}
|
||||
const desktop=await session({width:1280,height:900}),page=desktop.page;
|
||||
await page.getByRole('link',{name:'Gestão de Oportunidades',exact:true}).click();await page.getByRole('link',{name:'Nova oportunidade',exact:true}).click();
|
||||
await page.getByLabel('Motivo da alteração').fill('Criação incompleta autorizada somente em QA');
|
||||
await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.waitForURL(/\/management\/opportunities\/\d+\/edit$/);
|
||||
const id=Number(page.url().match(/(\d+)\/edit$/)[1]);assert(id>6);
|
||||
await page.getByLabel('Título oficial').fill('QA browser opportunity recovered from server');await page.getByLabel('Categoria',{exact:true}).selectOption('investment_project');
|
||||
await page.getByLabel('Investimento estimado').fill('-1');await page.getByLabel('Motivo da alteração').fill('Edição financeira inválida para verificar erros QA');
|
||||
await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.getByText('The estimated investment amount field format is invalid.').waitFor().catch(async()=>{await page.locator('[role="alert"]').first().waitFor();});
|
||||
assert.equal(await page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');
|
||||
await page.getByLabel('Investimento estimado').fill('1000.25');await page.getByLabel('Moeda',{exact:true}).selectOption('MZN');await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.getByText('Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.',{exact:true}).waitFor();
|
||||
await page.screenshot({path:dir+'opportunity-management-desktop.png',fullPage:true});await desktop.context.close();
|
||||
const mobile=await session({width:390,height:844});await mobile.page.goto(base+`/management/opportunities/${id}/edit`);
|
||||
await mobile.page.evaluate(()=>localStorage.clear());await mobile.page.reload();assert.equal(await mobile.page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');assert.equal(await mobile.page.getByLabel('Investimento estimado').inputValue(),'1000.25');
|
||||
await mobile.page.screenshot({path:dir+'opportunity-management-mobile.png',fullPage:true});assert(await mobile.page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
|
||||
await mobile.page.getByLabel('Motivo da alteração').fill('Exclusão lógica do rascunho de demonstração QA');await mobile.page.getByRole('checkbox',{name:/Confirmo a exclusão/}).check();await mobile.page.getByRole('button',{name:'Excluir rascunho',exact:true}).click();await mobile.page.getByRole('button',{name:'Restaurar',exact:true}).waitFor();
|
||||
await mobile.page.getByLabel('Motivo da alteração').fill('Restauração autorizada do rascunho somente QA');await mobile.page.getByRole('checkbox',{name:/Confirmo a restauração/}).check();await mobile.page.getByRole('button',{name:'Restaurar',exact:true}).click();await mobile.page.getByRole('button',{name:'Excluir rascunho',exact:true}).waitFor();assert.equal(await mobile.page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');
|
||||
assert.deepEqual(errors,[]);const proof={recorded_at:new Date().toISOString(),id,desktop:true,mobile:true,new_session_recovery:true,field_errors:true,soft_delete_restore:true,errors,active_writes:0};fs.writeFileSync(dir+'opportunity-management-browser.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));await browser.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagementInstallation;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$dir = $root.'/storage/app/private/investmoz-v2';
|
||||
$backup = json_decode(file_get_contents($dir.'/opportunity-management-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $backup['restored_target'];
|
||||
if (! $backup['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('QA target required');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'true', 'APP_URL' => 'http://127.0.0.1:18095', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'opportunity_management_qa', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Refusing non-QA target');
|
||||
}
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
$sessions = $dir.'/qa-opportunity-management-sessions';
|
||||
if (! is_dir($sessions)) {
|
||||
mkdir($sessions, 0700, true);
|
||||
}
|
||||
config(['session.files' => $sessions]);
|
||||
Vite::useHotFile($root.'/storage/framework/qa-opportunity-management-hot-absent');
|
||||
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'prepare') {
|
||||
$migration = require $root.'/database/migrations/'.OpportunityManagementInstallation::MIGRATION.'.php';
|
||||
$migration->up();
|
||||
$oldColumns = array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1'));
|
||||
$oldColumns = array_diff($oldColumns, ['management_version', 'opportunity_category', 'management_draft', 'management_creation_key', 'first_submitted_at', 'management_closed_once']);
|
||||
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldColumns));
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM (SELECT '.$projection.' FROM opportunities) t ORDER BY row_to_json(t)::text');
|
||||
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $backup['table_hashes']['"public"."opportunities"']) {
|
||||
throw new RuntimeException('Existing opportunity values changed');
|
||||
}
|
||||
$migration->down();
|
||||
foreach ($backup['table_hashes'] as $table => $hash) {
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$table.' t ORDER BY row_to_json(t)::text');
|
||||
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $hash) {
|
||||
throw new RuntimeException('Restore/rollback comparison failed: '.$table);
|
||||
}
|
||||
}
|
||||
Artisan::call('migrate', ['--path' => 'database/migrations/'.OpportunityManagementInstallation::MIGRATION.'.php', '--force' => true]);
|
||||
$user = User::updateOrCreate(['email' => 'opportunity-manager@example.invalid'], ['name' => 'QA opportunity manager', 'password' => Hash::make('Opportunity-QA-2026!')]);
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$proof = ['target' => $target, 'code_hash' => app(OpportunityManagementInstallation::class)->fingerprint(), 'compatibility_verified' => true, 'reversal_verified' => true, 'tables_compared' => count($backup['table_hashes']), 'active_writes' => 0];
|
||||
file_put_contents($dir.'/opportunity-management-compatibility.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof).PHP_EOL;
|
||||
exit;
|
||||
}
|
||||
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'verify') {
|
||||
$columns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1')), ['management_version', 'opportunity_category', 'management_draft', 'management_creation_key', 'first_submitted_at', 'management_closed_once']);
|
||||
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $columns));
|
||||
$checks = [];
|
||||
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
|
||||
$key = '"public"."'.$table.'"';
|
||||
$query = $table === 'opportunities' ? '(SELECT '.$projection.' FROM opportunities WHERE management_creation_key IS NULL)' : ($table === 'audit_events' ? "(SELECT * FROM audit_events WHERE source_system IS DISTINCT FROM 'opportunity-management')" : $key);
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$query.' t ORDER BY row_to_json(t)::text');
|
||||
$checks[$table] = hash('sha256', implode("\n", array_column($rows, 'row'))) === $backup['table_hashes'][$key];
|
||||
if (! $checks[$table]) {
|
||||
throw new RuntimeException('Baseline data changed: '.$table);
|
||||
}
|
||||
}
|
||||
$browser = json_decode(file_get_contents($dir.'/opportunity-management-browser.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$draft = DB::table('opportunities')->where('id', $browser['id'])->first();
|
||||
if ($draft->status !== 'draft' || $draft->deleted_at !== null) {
|
||||
throw new RuntimeException('Browser cycle not restored');
|
||||
}
|
||||
$proof = ['target' => $target, 'code_hash' => app(OpportunityManagementInstallation::class)->fingerprint(), 'preserved' => $checks, 'browser_draft_id' => $draft->id, 'browser_draft_restored' => true, 'origin_classifications' => DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count(), 'active_writes' => 0];
|
||||
file_put_contents($dir.'/opportunity-management-preservation.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($proof).PHP_EOL;
|
||||
exit;
|
||||
}
|
||||
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||
$asset = realpath($root.'/public'.$path);
|
||||
$public = realpath($root.'/public');
|
||||
if ($path !== '/' && $asset && $public && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset)) {
|
||||
return false;
|
||||
}
|
||||
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
||||
$app->handleRequest(Request::capture());
|
||||
Reference in New Issue
Block a user