Files
Investidor-V2/app/Services/Procurement.php

288 lines
19 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Publication of notices; never receives bids or changes the linked investment. */
final class Procurement
{
public const TEXT = ['title', 'reference', 'responsible_unit', 'modality', 'object', 'object_kind', 'quantities', 'financier', 'instrument', 'edition', 'rule_source', 'financing_agreement', 'eligibility', 'requirements', 'proposal_validity', 'guarantee_status', 'guarantees', 'delivery_channel', 'delivery_address', 'delivery_url', 'public_contact', 'dissemination', 'origin_url', 'origin_published_at', 'clarifications_at', 'deadline', 'opening_at', 'timezone', 'minimum_source', 'minimum_edition', 'minimum_modality', 'minimum_day_basis', 'result', 'internal_notes', 'public_reason', 'minimum_working_days', 'minimum_holidays', 'minimum_calendar_source'];
public function available(): bool
{
return config('procurement.enabled') && Schema::hasTable('portal_tenders');
}
public function access(?User $actor): bool
{
if (! $actor || ! $actor->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]);
}
}
}