150 lines
9.8 KiB
PHP
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,
|
|
]))];
|
|
}
|
|
}
|