*/ public function read(User $actor, Opportunity $record): array { $access = app(PortalProfileAccess::class); $access->authorize($actor, 'view', $record); $payload = app(OpportunityManagement::class)->values($record)['canonical'] ?? []; $context = $record->workflow_context ?? []; $data = [...app(OpportunityCanonicalMetadata::class)->project($payload, $context['canonical_items'] ?? []), 'id' => $record->id, 'opportunity_code' => $record->reference, 'opportunity_category' => $record->opportunity_category, 'status' => $access->state($record), 'version' => (int) $record->management_version, 'is_deleted' => $record->trashed(), 'deleted_at' => $record->deleted_at?->toIso8601String(), ...$this->rootMetadata($record)]; foreach (['submitted' => 'submission', 'approved' => 'approval', 'published' => 'publication', 'archived' => 'archive'] as $prefix => $key) { $data[$prefix.'_by'] = $context[$key]['actor_id'] ?? null; $data[$prefix.'_at'] = $context[$key]['at'] ?? null; } $completion = app(OpportunityCompleteness::class)->calculate($payload); $data['completion_percentage'] = $completion['percentage']; $errors = []; try { app(OpportunityCanonicalContract::class)->validateSubmission($record); } catch (ValidationException $failure) { $errors = $failure->errors(); } catch (HttpExceptionInterface $failure) { $errors = ['canonical.resources' => ['Recurso indisponível para validação; estado HTTP '.$failure->getStatusCode().'. Preserve o original e resolva a operação afetada.']]; } $data['classification'] = app(OpportunityClassification::class)->listing($actor, $record); $data['validation_result'] = ['errors' => $errors, 'warnings' => [], 'rule' => OpportunityCanonicalContract::VERSION, 'evaluated_at' => now()->toIso8601String(), 'persisted_submission_hash' => $context['submission']['hash'] ?? null, 'external_decision' => 'not_inferred']; $mutations = []; foreach (DB::table('audit_events')->where('source_system', 'opportunity-resources')->where('after_data->opportunity_id', $record->id)->orderBy('occurred_at')->orderBy('id')->get(['actor_user_id', 'action', 'occurred_at', 'after_data']) as $event) { $detail = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); $mutations[$detail['resource_id']][] = ['actor' => $event->actor_user_id, 'action' => $event->action, 'at' => $event->occurred_at, 'reason' => $detail['reason'] ?? null]; } $references = []; foreach (OpportunityResources::KINDS as $kind) { $references[$kind] = $payload[$kind] ?? []; $rows = []; if (app(OpportunityResources::class)->installed()) { foreach (app(OpportunityResources::class)->listing($record->id, $kind) as $row) { $rows[$row->id] = $row; } } $data[$kind] = array_map(function (string $id) use ($rows, $kind, $mutations): array { $row = $rows[$id] ?? null; if ($row === null) { return ['id' => $id, 'availability' => 'unavailable']; } $value = [...$row->payload, 'id' => $row->id, 'version' => (int) $row->version, 'is_deleted' => $row->deleted_at !== null, 'deleted_at' => $row->deleted_at, 'created_by' => $row->created_by, 'created_at' => $row->created_at, 'updated_at' => $row->updated_at]; $events = $mutations[$id] ?? []; $last = $events ? $events[array_key_last($events)] : null; $deletion = $row->deleted_at && ($last['action'] ?? '') === 'delete' ? $last : null; $value['updated_by'] = $last['actor'] ?? ((int) $row->version === 1 ? $row->created_by : null); $value['deleted_by'] = $deletion['actor'] ?? null; $value['delete_reason'] = $deletion && $deletion['reason'] ? Crypt::decryptString($deletion['reason']) : null; $value['mutation_metadata_source'] = $last ? 'existing_audit_events' : 'creation_record_only'; if ($kind !== 'locations') { $value['original_name'] = $row->file?->file_name; $value['mime_type'] = $row->file?->media_type; $value['sha256'] = $row->file?->sha256; $value['scan_recorded_status'] = $row->file?->malware_scan_status; } return $value; }, $references[$kind]); } return ['adapter_version' => self::VERSION, 'source_schema_sha256' => hash_file('sha256', resource_path('data/investmoz-opportunity-modular.schema.json')), 'data' => $data, 'resource_references' => $references, 'completion' => $completion, 'materiality' => $context['materiality'] ?? null, 'review_conditions' => array_values($context['conditions'] ?? []), 'deleted_inline_items' => array_values($context['canonical_items']['deleted'] ?? []), 'validation_result' => ['scope' => 'current_payload_validation_and_recorded_submission_metadata', 'submission_hash' => $context['submission']['hash'] ?? null, 'submitted_content_revision' => $context['submission']['content_revision'] ?? null, 'current_content_revision' => $context['content_revision'] ?? 1, 'revalidated_on_read' => true, 'external_decision' => 'not_inferred'], 'publication' => $record->publicationPresentation(), 'compatibility' => ['normative_response_complete' => false, 'opportunity_actor_sector_ids' => 'existing bigint, not converted to UUID', 'modular_decision' => 'Functional approval of four modular contracts and SLA remains pending; legacy integration did not approve them. Historical snapshot annotations are preserved, not current approvals.', 'opportunity_code' => 'existing reference preserved; no INV-MZ code invented', 'states' => 'existing workflow values preserved; no implicit stage equivalence', 'inline_modules' => 'management_draft canonical JSON with audited revisions; no parallel specialized tables', 'state_correspondence' => ['draft' => 'draft', 'needs_information' => 'returned', 'submitted' => 'submitted', 'approved' => 'approved', 'published' => 'published', 'suspended' => 'suspended', 'archived' => 'archived', 'rejected' => 'rejected'], 'unmapped_review_states' => 'in_review/information_pending preserve parallel gates; no single normative stage inferred', 'metadata_gaps' => ['Legacy inline creation metadata can be unknown; first observation is not creation. Ambiguous changed collection rows get new identities with previous identities retained as deleted.', 'Existing bigint IDs remain explicit compatibility differences; no renumbering.']]]; } /** * Recover actors from existing audited versions; an imported creator must never be inferred. * * @return array */ private function rootMetadata(Opportunity $record): array { $events = []; foreach (DB::table('audit_events')->whereIn('source_system', ['opportunity-management', 'portal-profiles-v1']) ->where('after_data->record_id', $record->id)->get(['source_system', 'actor_user_id', 'action', 'after_data']) as $event) { $detail = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); $version = $detail['version'] ?? $detail['after']['version'] ?? null; if (! is_int($version)) { continue; } $events[$version] = ['actor' => $event->actor_user_id, 'action' => $event->action, 'reason' => $detail['reason'] ?? null, 'encrypted_reason' => $event->source_system === 'opportunity-management']; } ksort($events); $creation = null; $deletion = null; foreach ($events as $event) { if ($event['action'] === 'draft_created') { $creation = $event; } if ($event['action'] === 'delete') { $deletion = $event; } } $latest = $events[(int) $record->management_version] ?? null; $deletion = $record->trashed() ? $deletion : null; return ['created_by' => $creation['actor'] ?? null, 'created_at' => $record->created_at?->toIso8601String(), 'updated_by' => $latest['actor'] ?? null, 'updated_at' => $record->updated_at?->toIso8601String(), 'deleted_by' => $deletion['actor'] ?? null, 'delete_reason' => $deletion && $deletion['reason'] ? ($deletion['encrypted_reason'] ? Crypt::decryptString($deletion['reason']) : $deletion['reason']) : null, 'mutation_metadata_source' => 'existing_audit_events_and_record_timestamps', 'mutation_metadata_gaps' => array_values(array_filter([ $creation === null ? 'No recorded creation actor; imported ownership does not establish creation.' : null, $latest === null ? 'No audit actor matching the current management version; no updater inferred.' : null, ]))]; } }