288 lines
19 KiB
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]);
|
|
}
|
|
}
|
|
}
|