*/ public function values(Opportunity $record): array { $base = $record->only(self::FIELDS); $draft = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : []; return [...$base, ...($this->closed($record) ? $draft : array_intersect_key($draft, array_flip(self::MATURITY_FIELDS)))]; } 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 $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,8'], 'maturity_stage' => ['nullable', Rule::in(array_keys(OpportunityMaturity::STAGES))], 'maturity_catalogue_version' => ['nullable', Rule::in([OpportunityMaturity::VERSION, OpportunityMaturity::LEGACY])], '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(); $data = app(OpportunityMaturity::class)->normalize($data); 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 = $this->values($existing); foreach (array_intersect_key($data, array_flip([...self::FIELDS, ...self::MATURITY_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, ...self::MATURITY_FIELDS])); 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) { if (in_array($key, ['maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) { continue; } $record->{$key} = $value; } if (isset($changes['maturity_catalogue_version'])) { // The existing draft JSON holds the versioned catalogue; legacy columns stay intact. $previous = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : []; $record->management_draft = json_encode([...$previous, ...array_intersect_key($changes, array_flip(self::MATURITY_FIELDS))], JSON_THROW_ON_ERROR); } } $record->save(); $this->audit($record, $actor, $before, $id ? 'draft_updated' : 'draft_created', $data['reason']); return $record; }); } /** @param array $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 $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)]); } }