Implementa qualificacao documental com requisitos e analises por versao
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\DocumentaryQualificationWorkflow;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DocumentaryQualificationController extends Controller
|
||||
{
|
||||
private function access(Request $request, int $id): Requisition
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $opening): Response
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$filters = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||
$sets = QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->get(['id', 'revision', 'reason', 'created_at']);
|
||||
$latest = $sets->first() ? QualificationSet::query()->findOrFail($sets->first()->id) : null;
|
||||
$selected = isset($filters['version']) ? QualificationSet::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filters['version']) : $latest;
|
||||
$proposals = $opening->proposals($requisition);
|
||||
$basis = $opening->basis($proposals);
|
||||
$current = $selected && $selected->id === $latest?->id && $selected->basis_hash === $basis;
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$query = QualificationAssessment::query()->where('qualification_set_id', $selected->id ?? 0);
|
||||
$recordIds = (clone $query)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id');
|
||||
$records = QualificationAssessment::query()->whereIn('id', $recordIds)->orderBy('quotation_id')->orderBy('analyst_id')->get();
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/qualification', [
|
||||
'requisition' => $requisition->only(['id', 'number', 'justification']), 'sets' => $sets, 'selected' => $selected,
|
||||
'latestId' => $latest->id ?? 0, 'latestCriteria' => $latest->criteria ?? [], 'basisHash' => $basis, 'isCurrent' => (bool) $current,
|
||||
'proposals' => $proposals,
|
||||
'closedForChanges' => $closed,
|
||||
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
|
||||
'canDefine' => $open && $proposals !== [] && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
||||
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function define(Request $request, Requisition $requisition, DocumentaryQualificationWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate([
|
||||
'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000',
|
||||
'criteria' => 'required|array|min:1|max:30', 'criteria.*' => 'required|array:label,category,required',
|
||||
'criteria.*.label' => 'required|string|min:3|max:500', 'criteria.*.category' => 'required|in:legal,financial,fiscal,technical,administrative', 'criteria.*.required' => 'required|boolean',
|
||||
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
|
||||
]);
|
||||
$workflow->define($request->user(), $requisition, $data);
|
||||
|
||||
return to_route('qualifications.show', $requisition);
|
||||
}
|
||||
|
||||
public function assess(Request $request, QualificationSet $set, DocumentaryQualificationWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request, $set->requisition_id);
|
||||
$data = $request->validate([
|
||||
'previous_id' => 'required|integer|min:0', 'quotation_id' => 'required|integer', 'reason' => 'required|string|min:10|max:4000',
|
||||
'answers' => 'required|array|min:1|max:30', 'answers.*' => 'required|array:criterion_id,result,evidence,reason',
|
||||
'answers.*.criterion_id' => 'required|integer|distinct', 'answers.*.result' => 'required|in:pending,met,not_met,not_applicable',
|
||||
'answers.*.evidence' => 'nullable|string|max:1000', 'answers.*.reason' => 'nullable|string|max:4000',
|
||||
'snapshot' => 'prohibited', 'analyst_id' => 'prohibited', 'tenant_id' => 'prohibited', 'revision' => 'prohibited',
|
||||
]);
|
||||
$data['answers'] = array_map(fn ($answer) => [...$answer, 'criterion_id' => (int) $answer['criterion_id']], $data['answers']);
|
||||
$workflow->assess($request->user(), $set, $data);
|
||||
|
||||
return to_route('qualifications.show', $set->requisition_id);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchasePlanItem;
|
||||
use App\Models\PurchaseRevision;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\TechnicalReview;
|
||||
@@ -36,6 +37,10 @@ class PurchaseProcessController extends Controller
|
||||
$base = '/requisicoes/'.$requisition->id;
|
||||
$stages = [];
|
||||
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
|
||||
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
|
||||
if ($qualification) {
|
||||
$documents[] = ['label' => 'Qualificação documental · Requisitos v'.$qualification->revision, 'kind' => 'Qualificação documental', 'href' => $base.'/qualificacao'];
|
||||
}
|
||||
$opening = $purchases ? ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
|
||||
if ($opening) {
|
||||
$documents[] = ['label' => 'Acta de abertura · Versão '.$opening->revision, 'kind' => 'Abertura', 'href' => $base.'/abertura'];
|
||||
@@ -103,7 +108,7 @@ class PurchaseProcessController extends Controller
|
||||
}
|
||||
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
|
||||
if ($purchases) {
|
||||
$actions = [...$actions, 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
|
||||
$actions = [...$actions, 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
|
||||
}
|
||||
if ($inventory) {
|
||||
$actions[] = 'receipt.confirmed';
|
||||
@@ -118,7 +123,7 @@ class PurchaseProcessController extends Controller
|
||||
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
|
||||
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
|
||||
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
|
||||
'future' => ['Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
|
||||
'future' => ['Decisão formal de admissão ou exclusão', 'Relatório de recomendação em etapas'],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QualificationAssessment extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['answers' => 'array', 'snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property list<array{id:int,label:string,category:string,required:bool}> $criteria
|
||||
* @property array<string,mixed> $snapshot
|
||||
*/
|
||||
class QualificationSet extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['criteria' => 'array', 'snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ class ConflictGuard
|
||||
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
|
||||
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
|
||||
'opening.recorded',
|
||||
'qualification.defined', 'qualification.assessed',
|
||||
'equipment.technical_approved', 'equipment.technical_rejected',
|
||||
'invoice.created', 'invoice.validated',
|
||||
'payment.registered', 'payment.confirmed', 'payment.rejected',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\QuotationItem;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DocumentaryQualificationWorkflow
|
||||
{
|
||||
private function invalid(string $field, string $message): never
|
||||
{
|
||||
throw ValidationException::withMessages([$field => $message]);
|
||||
}
|
||||
|
||||
/** Call after the company/actor locks; coordinates with award and quote transactions. */
|
||||
private function process(User $actor, int $requisitionId): Requisition
|
||||
{
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($requisitionId);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
|
||||
$this->invalid('qualification', 'Registe a análise num pedido aprovado, antes da adjudicação. As versões anteriores continuam disponíveis.');
|
||||
}
|
||||
app(ConflictGuard::class)->assertParticipation($actor, $request);
|
||||
$quotes = Quotation::query()->where('requisition_id', $request->id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
|
||||
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
|
||||
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function define(User $actor, Requisition $request, array $data): QualificationSet
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $request, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
|
||||
$request = $this->process($actor, $request->id);
|
||||
$latest = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
||||
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
|
||||
$this->invalid('previous_id', 'Os requisitos mudaram. Actualize a página antes de registar outra versão.');
|
||||
}
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
$proposals = $opening->proposals($request);
|
||||
if ($proposals === [] || $opening->basis($proposals) !== $data['basis_hash']) {
|
||||
$this->invalid('basis_hash', 'As propostas mudaram ou ainda não existem. Confira a base actual.');
|
||||
}
|
||||
$criteria = [];
|
||||
$labels = [];
|
||||
foreach (array_values($data['criteria']) as $index => $criterion) {
|
||||
$label = trim($criterion['label']);
|
||||
$key = mb_strtolower($label);
|
||||
if (mb_strlen($label) < 3 || in_array($key, $labels, true) || ! in_array($criterion['category'], ['legal', 'financial', 'fiscal', 'technical', 'administrative'])) {
|
||||
$this->invalid('criteria', 'Defina requisitos distintos, com descrição e categoria válidas.');
|
||||
}
|
||||
$labels[] = $key;
|
||||
$criteria[] = ['id' => $index + 1, 'label' => $label, 'category' => $criterion['category'], 'required' => (bool) $criterion['required']];
|
||||
}
|
||||
if ($criteria === []) {
|
||||
$this->invalid('criteria', 'Defina pelo menos um requisito.');
|
||||
}
|
||||
$set = QualificationSet::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'criteria' => $criteria, 'snapshot' => ['requisition' => $request->only(['id', 'number', 'justification']), 'author' => $actor->only(['id', 'name']), 'proposals' => $proposals], 'basis_hash' => $data['basis_hash'], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'qualification.defined', $request, null, ['set_id' => $set->id, 'revision' => $set->revision, 'previous_id' => $latest?->id]);
|
||||
|
||||
return $set;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function assess(User $actor, QualificationSet $set, array $data): QualificationAssessment
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $set, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$request = $this->process($actor, $set->requisition_id);
|
||||
abort_if($actor->id === $request->requester_id, 403);
|
||||
$set = QualificationSet::query()->lockForUpdate()->findOrFail($set->id);
|
||||
$latestSet = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->firstOrFail();
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
if ($set->id !== $latestSet->id || $set->basis_hash !== $opening->basis($opening->proposals($request))) {
|
||||
$this->invalid('qualification', 'Os requisitos ou a base mudaram. As análises anteriores são históricas; registe uma versão actual dos requisitos.');
|
||||
}
|
||||
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
|
||||
app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor);
|
||||
$latest = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('quotation_id', $quote->id)->where('analyst_id', $actor->id)->latest('revision')->first();
|
||||
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
|
||||
$this->invalid('previous_id', 'A sua análise mudou. Não é permitido substituir versões ou análises de outro utilizador.');
|
||||
}
|
||||
$expected = array_column($set->criteria, 'id');
|
||||
$received = array_column($data['answers'], 'criterion_id');
|
||||
sort($expected);
|
||||
sort($received);
|
||||
if ($expected !== $received) {
|
||||
$this->invalid('answers', 'Registe uma resposta para cada requisito desta versão, sem repetições.');
|
||||
}
|
||||
$answers = [];
|
||||
foreach ($data['answers'] as $answer) {
|
||||
$result = $answer['result'];
|
||||
$reason = trim($answer['reason'] ?? '');
|
||||
$evidence = trim($answer['evidence'] ?? '');
|
||||
if (! in_array($result, ['pending', 'met', 'not_met', 'not_applicable']) || ($result !== 'pending' && mb_strlen($reason) < 10) || (in_array($result, ['met', 'not_met']) && mb_strlen($evidence) < 3)) {
|
||||
$this->invalid('answers', 'Resultados preenchidos exigem fundamento; cumprido e não cumprido exigem também referência de evidência.');
|
||||
}
|
||||
$answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason];
|
||||
}
|
||||
$assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => $actor->only(['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]);
|
||||
|
||||
return $assessment;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user