hasVerifiedEmail()) { return false; } $docs = app(DocumentManagementAccess::class); return ! app(PortalProfileAccess::class)->has($actor, 'auditor') && ($docs->full($actor) || $docs->editorial($actor)); } public function guard(?User $actor): void { abort_unless($this->available(), 404); abort_unless($this->access($actor), 403, 'Use a autorização editorial já atribuída.'); } public function references(User $actor): array { $this->guard($actor); $editable = app(DocumentManagementAccess::class)->documents($actor)->select('id'); return ['regimes' => config('procurement.regimes'), 'acts' => config('procurement.acts'), 'organizations' => DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']), 'sectors' => DB::table('sectors')->orderBy('name')->get(['id', 'name']), 'territories' => app(InvestmentGeography::class)->references(true)->orderBy('name')->get(['id', 'name', 'location_type']), 'opportunities' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->orderBy('title')->get(['id', 'title']), 'projects' => DB::table('projects')->whereNull('deleted_at')->whereIn('data_classification', ['internal', 'public'])->orderBy('name')->get(['id', 'name']), 'documents' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at') ->where(fn ($q) => $q->whereIn('d.id', $editable)->orWhere(fn ($p) => $p->where('r.published', true)->where('d.data_classification', 'public'))) ->orderBy('r.title')->get(['r.id', 'r.title', 'r.published', 'd.malware_scan_status'])]; } public function record(string $id): object { abort_unless($this->available(), 404); return DB::table('portal_tenders')->where('id', $id)->firstOrFail(); } public function save(User $actor, ?string $id, array $input): string { $this->guard($actor); $v = Validator::make($input, ['version' => 'required|integer|min:0', 'reason' => 'required|string|min:20|max:1000', 'draft' => 'required|array']); $v->validate(); $draft = $this->normalize($actor, $input['draft']); return DB::transaction(function () use ($actor, $id, $input, $draft) { $old = $id ? DB::table('portal_tenders')->where('id', $id)->lockForUpdate()->firstOrFail() : null; abort_unless((int) ($old->version ?? 0) === (int) $input['version'], 409, 'A versão mudou; recupere o concurso antes de guardar.'); $id ??= (string) Str::uuid(); $version = ($old->version ?? 0) + 1; if ($old) { DB::table('portal_tenders')->where('id', $id)->update(['draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'version' => $version, 'updated_at' => now()]); } else { DB::table('portal_tenders')->insert(['id' => $id, 'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'version' => $version, 'created_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]); } $this->version($actor, $id, $version, 'draft', $draft, $input['reason'], false); return $id; }); } private function normalize(User $actor, array $input): array { $rules = array_fill_keys(self::TEXT, 'nullable|string|max:12000'); $rules = [...$rules, 'regime' => 'nullable|in:state,external,private,other', 'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_id' => 'nullable|integer|exists:opportunities,id', 'project_id' => 'nullable|uuid|exists:projects,id', 'sector_id' => 'nullable|integer|exists:sectors,id', 'territory_id' => 'nullable|uuid|exists:geo_locations,id', 'contact_authorized' => 'boolean', 'minimum_days' => 'nullable|integer|min:0|max:3650', 'lots' => 'array|max:100', 'lots.*' => 'array:code,description,quantities', 'lots.*.code' => 'required|string|max:80|distinct', 'lots.*.description' => 'required|string|max:3000', 'lots.*.quantities' => 'nullable|string|max:1500', 'attachments' => 'array|max:100', 'attachments.*' => 'array:resource_id,public,purpose', 'attachments.*.resource_id' => 'required|integer|exists:portal_resources,id', 'attachments.*.public' => 'required|boolean', 'attachments.*.purpose' => 'required|string|max:150']; foreach (['origin_url', 'delivery_url', 'rule_source'] as $url) { $rules[$url] = 'nullable|url:http,https|max:2000'; } $data = Validator::make($input, $rules)->validate(); if (! empty($data['opportunity_id'])) { abort_unless(app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->whereKey($data['opportunity_id'])->exists(), 403); } if (! empty($data['project_id'])) { abort_unless(DB::table('projects')->where('id', $data['project_id'])->whereNull('deleted_at')->whereIn('data_classification', ['internal', 'public'])->exists(), 422); } foreach ($data['attachments'] ?? [] as $attachment) { $resource = DB::table('portal_resources')->where('id', $attachment['resource_id'])->firstOrFail(); abort_unless($this->publicDocuments()->where('r.id', $resource->id)->exists() || app(DocumentManagementAccess::class)->documents($actor)->where('id', $resource->document_id)->exists(), 403); } return $data; } public function preview(User $actor, string $id, string $act): array { $this->guard($actor); $record = $this->record($id); abort_unless(array_key_exists($act, config('procurement.acts')), 422); $p = json_decode($record->draft, true, 512, JSON_THROW_ON_ERROR); Validator::make($p, array_fill_keys(['title', 'reference', 'organization_id', 'modality', 'object', 'regime', 'instrument', 'edition', 'rule_source', 'origin_published_at', 'deadline', 'delivery_channel', 'timezone'], 'required'))->validate(); Validator::make($p, ['timezone' => 'timezone', 'object_kind' => 'nullable|in:works,goods,services,mixed,other', 'guarantee_status' => 'nullable|in:required,not_required,unknown'])->validate(); Validator::make($p, ['public_reason' => 'required|string|min:20|max:1000'])->validate(); $dates = []; foreach (['origin_published_at', 'clarifications_at', 'deadline', 'opening_at'] as $field) { if (empty($p[$field])) { continue; } $validator = Validator::make([$field => $p[$field]], [$field => 'date_format:Y-m-d\TH:i']); $validator->validate(); $dates[$field] = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $p[$field], $p['timezone']); } $this->require($dates['deadline']->greaterThan($dates['origin_published_at']), 'deadline', 'Entrega anterior à publicação de origem.'); if (isset($dates['clarifications_at'])) { $this->require($dates['clarifications_at']->betweenIncluded($dates['origin_published_at'], $dates['deadline']), 'clarifications_at', 'Esclarecimentos fora do período de publicação/entrega.'); } if (isset($dates['opening_at'])) { $this->require($dates['opening_at']->greaterThanOrEqualTo($dates['deadline']), 'opening_at', 'A abertura indicada é anterior à entrega.'); } if (($p['guarantee_status'] ?? '') === 'required') { $this->require(! empty($p['guarantees']), 'guarantees', 'Descreva a garantia documentada exigida.'); } if (isset($p['minimum_days'])) { foreach (['minimum_source', 'minimum_edition', 'minimum_modality', 'minimum_day_basis'] as $field) { $this->require(! empty($p[$field]), $field, 'Identifique fonte, edição, modalidade e contagem do prazo mínimo.'); } $this->require($p['minimum_modality'] === $p['modality'], 'minimum_modality', 'A regra indicada pertence a outra modalidade.'); $earliest = $dates['origin_published_at']; if ($p['minimum_day_basis'] === 'business') { $this->require(! empty($p['minimum_calendar_source']), 'minimum_calendar_source', 'Identifique a fonte do calendário de dias úteis e feriados.'); $weekdays = array_filter(array_map('trim', explode(',', (string) ($p['minimum_working_days'] ?? '')))); $this->require(count($weekdays) > 0 && ! array_diff($weekdays, ['1', '2', '3', '4', '5', '6', '7']), 'minimum_working_days', 'Indique os dias de trabalho documentados (1=segunda, 7=domingo).'); $holidays = array_values(array_filter(preg_split('/[\s,;]+/', (string) ($p['minimum_holidays'] ?? '')))); Validator::make(['dates' => $holidays], ['dates' => 'array|max:1000', 'dates.*' => 'date_format:Y-m-d'])->validate(); for ($left = (int) $p['minimum_days'],$iterations = 0; $left > 0; $iterations++) { $this->require($iterations < 36500, 'minimum_days', 'Calendário insuficiente para calcular o prazo indicado.'); $earliest = $earliest->addDay(); if (in_array((string) $earliest->isoWeekday(), $weekdays, true) && ! in_array($earliest->format('Y-m-d'), $holidays, true)) { $left--; } } } else { $this->require($p['minimum_day_basis'] === 'calendar', 'minimum_day_basis', 'Identifique dias corridos ou calendário de dias úteis documentado.'); $earliest = $earliest->addDays((int) $p['minimum_days']); } $this->require($dates['deadline']->greaterThanOrEqualTo($earliest), 'deadline', 'Prazo inferior ao mínimo documentado para esta modalidade/edição/calendário.'); } $previous = $record->published_version_id ? $this->publishedVersion($record->published_version_id) : null; $this->require($previous ? $act !== 'initial' : $act === 'initial', 'act', $previous ? 'O anúncio inicial já existe; publique um acto associado.' : 'Publique primeiro o anúncio inicial.'); if ($previous) { $before = $previous['payload']; if (($before['deadline'] ?? '') !== $p['deadline'] || ($before['timezone'] ?? '') !== $p['timezone']) { $this->require($act === 'extension', 'act', 'Uma mudança do prazo exige acto de prorrogação, com data anterior e nova.'); } if ($act === 'extension') { $this->require($dates['deadline']->greaterThan(CarbonImmutable::parse($before['deadline'], $before['timezone'])), 'deadline', 'A prorrogação deve avançar o prazo anterior.'); $p['deadline_previous'] = $before['deadline']; $p['timezone_previous'] = $before['timezone']; } $p['extended'] = ($before['extended'] ?? false) || $act === 'extension'; $p['procedural_state'] = $before['procedural_state'] ?? null; } if ($act === 'award') { $p['procedural_state'] = 'results'; } if ($act === 'cancellation') { $p['procedural_state'] = 'cancelled'; } if (in_array($act, ['award', 'cancellation', 'other'], true)) { $this->require(! empty($p['result']), 'result', 'Descreva o acto ou resultado documentado.'); } $p['organization_name'] = DB::table('organizations')->where('id', $p['organization_id'])->whereNull('deleted_at')->value('name'); $this->require((bool) $p['organization_name'], 'organization_id', 'Entidade eliminada; recupere a referência.'); $p['sector_name'] = empty($p['sector_id']) ? null : DB::table('sectors')->where('id', $p['sector_id'])->value('name'); $p['territory_name'] = empty($p['territory_id']) ? null : app(InvestmentGeography::class)->references(true)->where('id', $p['territory_id'])->value('name'); if (! empty($p['territory_id'])) { $this->require((bool) $p['territory_name'], 'territory_id', 'Referência territorial indisponível.'); } unset($p['internal_notes']); if (! ($p['contact_authorized'] ?? false)) { unset($p['public_contact']); } $attachments = []; foreach ($p['attachments'] ?? [] as $item) { if (! $item['public']) { continue; } $doc = $this->publicDocuments()->where('r.id', $item['resource_id'])->first(['r.id', 'r.title', 'd.sha256', 'd.id as document_id']); $this->require((bool) $doc, 'attachments', 'Anexo público exige publicação autorizada, scan limpo e direitos sem restrição na biblioteca.'); $attachments[] = [...$item, 'title' => $doc->title, 'sha256' => trim($doc->sha256), 'document_id' => $doc->document_id]; } $p['attachments'] = $attachments; if (! empty($p['opportunity_id'])) { $o = Opportunity::published()->whereKey($p['opportunity_id'])->first(['id', 'title', 'slug']); $p['opportunity'] = $o ? ['title' => $o->title, 'url' => '/oportunidades/'.$o->slug] : null; } if (! empty($p['project_id'])) { $p['project_name'] = DB::table('projects')->where('id', $p['project_id'])->whereNull('deleted_at')->where('data_classification', 'public')->value('name'); } unset($p['opportunity_id'],$p['project_id']); $plan = ['id' => $id, 'version' => $record->version, 'act' => $act, 'payload' => $p]; $plan['hash'] = hash_hmac('sha256', json_encode($plan, JSON_THROW_ON_ERROR), config('app.key')); return $plan; } public function publish(User $actor, string $id, array $input): void { $this->guard($actor); Validator::make($input, ['hash' => 'required|string|size:64', 'act' => 'required|string', 'reason' => 'required|string|min:20|max:1000'])->validate(); DB::transaction(function () use ($actor, $id, $input) { $record = DB::table('portal_tenders')->where('id', $id)->lockForUpdate()->firstOrFail(); $plan = $this->preview($actor, $id, $input['act']); abort_unless(hash_equals($plan['hash'], $input['hash']), 409, 'Prévia alterada; reveja o conteúdo antes de publicar.'); abort_unless($input['reason'] === $plan['payload']['public_reason'], 409, 'O motivo público deve coincidir com a prévia.'); $version = $record->version + 1; $vid = $this->version($actor, $id, $version, $input['act'], $plan['payload'], $input['reason'], true); DB::table('portal_tenders')->where('id', $id)->update(['published_version_id' => $vid, 'version' => $version, 'updated_at' => now()]); }); } private function version(User $actor, string $id, int $version, string $act, array $payload, string $reason, bool $published): string { $vid = (string) Str::uuid(); DB::table('portal_tender_versions')->insert(['id' => $vid, 'tender_id' => $id, 'version' => $version, 'act' => $act, 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'reason' => $reason, 'actor_id' => $actor->id, 'published_at' => $published ? now() : null, 'created_at' => now()]); DB::table('audit_events')->insert(['source_system' => 'procurement', 'entity_type' => 'portal_tenders', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $published ? 'notice_published' : 'tender_draft_saved', 'data_classification' => 'internal', 'after_data' => json_encode(['version_id' => $vid, 'act' => $act, 'reason' => $reason], JSON_THROW_ON_ERROR)]); return $vid; } public function publicDocuments(): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')->where('d.data_classification', 'public')->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256') ->where(fn ($q) => $q->whereNull('d.source_system')->orWhereNotIn('d.source_system', ['opportunity-resources', 'opportunity-image-derivatives'])) ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_documents as od')->whereColumn('od.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(od.payload,'{}'::jsonb),'library_source')")) ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_images as oi')->whereColumn('oi.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(oi.payload,'{}'::jsonb),'library_source')")); } public function publishedVersion(string $id): array { $v = DB::table('portal_tender_versions')->where('id', $id)->whereNotNull('published_at')->firstOrFail(); $p = json_decode($v->payload, true, 512, JSON_THROW_ON_ERROR); foreach ($p['attachments'] ?? [] as $i => $item) { $p['attachments'][$i]['available'] = $this->publicDocuments()->where('r.id', $item['resource_id'])->where('d.id', $item['document_id'])->where('d.sha256', $item['sha256'])->exists(); } return ['id' => $v->id, 'version' => $v->version, 'act' => $v->act, 'payload' => $p, 'published_at' => $v->published_at, 'reason' => $v->reason]; } public function state(array $version): string { $p = $version['payload']; if (in_array($p['procedural_state'] ?? '', ['cancelled', 'results'], true)) { return $p['procedural_state']; } if (CarbonImmutable::parse($p['deadline'], $p['timezone'])->isPast()) { return 'deadline_ended'; } return ($p['extended'] ?? false) ? 'extended' : 'open'; } private function require(bool $condition, string $field, string $message): void { if (! $condition) { throw ValidationException::withMessages([$field => $message]); } } }