Files
Investidor-V2/app/Services/OpportunityCanonicalResponse.php
T
nala 6a76aac80f
tests / ci (push) Canceled after 0s
versao compilada 1
2026-10-16 09:17:28 +02:00

150 lines
9.8 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Read adapter only: preserve persisted identifiers and make unresolved metadata explicit. */
class OpportunityCanonicalResponse
{
public const VERSION = 'opportunity-response-existing-ids-20261011-4';
/** @return array<string,mixed> */
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<string,mixed>
*/
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,
]))];
}
}