Implementa recomendação formal com adopção e conferência independente
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\PurchasingRole;
|
||||||
|
use App\Models\PurchaseAward;
|
||||||
|
use App\Models\RecommendationAdoption;
|
||||||
|
use App\Models\RecommendationAttempt;
|
||||||
|
use App\Models\RecommendationPolicy;
|
||||||
|
use App\Models\RecommendationReview;
|
||||||
|
use App\Models\Requisition;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ConflictGuard;
|
||||||
|
use App\Services\FormalAdmission;
|
||||||
|
use App\Services\FormalRecommendation;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class FormalRecommendationController 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 policy(Request $request): Response
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||||
|
Inertia::encryptHistory();
|
||||||
|
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
|
||||||
|
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(Request $request, FormalRecommendation $workflow): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
|
||||||
|
$data['enabled'] = (bool) $data['enabled'];
|
||||||
|
$workflow->policy($request->user(), $data);
|
||||||
|
|
||||||
|
return to_route('recommendations.policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Requisition $requisition, FormalRecommendation $workflow): Response
|
||||||
|
{
|
||||||
|
$requisition = $this->access($request, $requisition->id);
|
||||||
|
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||||
|
$adoption = $workflow->adoption($requisition);
|
||||||
|
$selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
|
||||||
|
$current = $selected && $selected->id === $adoption?->id;
|
||||||
|
$basis = $workflow->basis($requisition);
|
||||||
|
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first();
|
||||||
|
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||||
|
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||||
|
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString();
|
||||||
|
$history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) {
|
||||||
|
$fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
|
||||||
|
|
||||||
|
return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(),
|
||||||
|
'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()];
|
||||||
|
});
|
||||||
|
Inertia::encryptHistory();
|
||||||
|
$response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
|
||||||
|
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
|
||||||
|
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $basis['ready'] && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id])->toResponse($request);
|
||||||
|
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adopt(Request $request, Requisition $requisition, FormalRecommendation $workflow): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->access($request, $requisition->id);
|
||||||
|
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
|
||||||
|
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||||
|
|
||||||
|
return to_route('recommendations.show', $requisition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function propose(Request $request, Requisition $requisition, FormalRecommendation $workflow): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->access($request, $requisition->id);
|
||||||
|
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
|
||||||
|
$workflow->propose($request->user(), $requisition, $data);
|
||||||
|
|
||||||
|
return to_route('recommendations.show', $requisition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function review(Request $request, RecommendationAttempt $attempt, FormalRecommendation $workflow): RedirectResponse
|
||||||
|
{
|
||||||
|
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||||
|
$this->access($request, $adoption->requisition_id);
|
||||||
|
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
|
||||||
|
$workflow->review($request->user(), $attempt, $data['verdict'], $data['reason']);
|
||||||
|
|
||||||
|
return to_route('recommendations.show', $adoption->requisition_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function print(Request $request, RecommendationAttempt $attempt, FormalRecommendation $workflow): Response
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($request, $attempt, $workflow) {
|
||||||
|
if (DB::connection()->getDriverName() === 'pgsql') {
|
||||||
|
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||||
|
}
|
||||||
|
$actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id);
|
||||||
|
$request->setUserResolver(fn () => $actor);
|
||||||
|
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||||
|
$requisition = $this->access($request, $adoption->requisition_id);
|
||||||
|
$basis = $workflow->basis($requisition);
|
||||||
|
$currentAdoption = $workflow->adoption($requisition);
|
||||||
|
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->firstOrFail();
|
||||||
|
|
||||||
|
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]);
|
||||||
|
|
||||||
|
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,16 @@ use App\Models\AuditLog;
|
|||||||
use App\Models\AwardCancellation;
|
use App\Models\AwardCancellation;
|
||||||
use App\Models\PurchaseAward;
|
use App\Models\PurchaseAward;
|
||||||
use App\Models\PurchaseOrder;
|
use App\Models\PurchaseOrder;
|
||||||
|
use App\Models\RecommendationAttempt;
|
||||||
use App\Models\Requisition;
|
use App\Models\Requisition;
|
||||||
|
use App\Services\FormalRecommendation;
|
||||||
use App\Services\ProposalComparison;
|
use App\Services\ProposalComparison;
|
||||||
use App\Services\PurchaseWorkflow;
|
use App\Services\PurchaseWorkflow;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -30,10 +33,25 @@ class PurchaseController extends Controller
|
|||||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||||
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
|
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
|
||||||
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
|
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
|
||||||
|
$recommendation = app(FormalRecommendation::class);
|
||||||
|
$adoption = $recommendation->adoption($requisition);
|
||||||
|
$candidate = null;
|
||||||
|
if ($adoption) {
|
||||||
|
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||||
|
if ($latest) {
|
||||||
|
try {
|
||||||
|
$recommendation->selection($requisition, $latest->quotation_id);
|
||||||
|
$candidate = $latest->quotation_id;
|
||||||
|
} catch (ValidationException) {
|
||||||
|
// The POST revalidates again; stale recommendations cannot enable the form.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('purchases/award', [
|
return Inertia::render('purchases/award', [
|
||||||
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
|
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
|
||||||
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id,
|
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
|
||||||
|
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
|
||||||
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
|
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
|
||||||
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
||||||
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
|
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class PurchaseProcessController extends Controller
|
|||||||
if ($purchases) {
|
if ($purchases) {
|
||||||
$documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio'];
|
$documents[] = ['label' => 'Relatório de conferência', 'kind' => 'Relatório de consulta', 'href' => $base.'/relatorio'];
|
||||||
$documents[] = ['label' => 'Regime, decisões e conferências', 'kind' => 'Admissão de propostas', 'href' => $base.'/admissao'];
|
$documents[] = ['label' => 'Regime, decisões e conferências', 'kind' => 'Admissão de propostas', 'href' => $base.'/admissao'];
|
||||||
|
$documents[] = ['label' => 'Regime, tentativas e conferências', 'kind' => 'Recomendação', 'href' => $base.'/recomendacao'];
|
||||||
}
|
}
|
||||||
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
|
$qualification = $purchases ? QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
|
||||||
if ($qualification) {
|
if ($qualification) {
|
||||||
@@ -112,7 +113,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'];
|
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
|
||||||
if ($purchases) {
|
if ($purchases) {
|
||||||
$actions = [...$actions, 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
|
$actions = [...$actions, 'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed', 'admission.adopted', 'admission.proposed', 'admission.reviewed', 'qualification.defined', 'qualification.assessed', 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
|
||||||
}
|
}
|
||||||
if ($inventory) {
|
if ($inventory) {
|
||||||
$actions[] = 'receipt.confirmed';
|
$actions[] = 'receipt.confirmed';
|
||||||
@@ -127,7 +128,7 @@ class PurchaseProcessController extends Controller
|
|||||||
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
|
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
|
||||||
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
|
'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']),
|
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
|
||||||
'future' => ['Relatório de recomendação em etapas'],
|
'future' => [],
|
||||||
])->toResponse($request);
|
])->toResponse($request);
|
||||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
* @property list<array<string, mixed>> $decision_matrix
|
* @property list<array<string, mixed>> $decision_matrix
|
||||||
* @property array<string,mixed>|null $investment_snapshot
|
* @property array<string,mixed>|null $investment_snapshot
|
||||||
* @property array<string,mixed>|null $admission_snapshot
|
* @property array<string,mixed>|null $admission_snapshot
|
||||||
|
* @property array<string,mixed>|null $recommendation_snapshot
|
||||||
*/
|
*/
|
||||||
class PurchaseAward extends Model
|
class PurchaseAward extends Model
|
||||||
{
|
{
|
||||||
@@ -27,7 +28,7 @@ class PurchaseAward extends Model
|
|||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
|
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'admission_snapshot' => 'array', 'recommendation_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<PurchaseAwardApproval, $this> */
|
/** @return HasMany<PurchaseAwardApproval, $this> */
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/** @property array{roles:list<string>,required_count:int,policy_revision:int} $snapshot */
|
||||||
|
class RecommendationAdoption extends Model
|
||||||
|
{
|
||||||
|
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['snapshot' => 'array'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/** @property array<string,mixed> $snapshot */
|
||||||
|
class RecommendationAttempt extends Model
|
||||||
|
{
|
||||||
|
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['snapshot' => 'array'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/** @property list<string> $roles */
|
||||||
|
class RecommendationPolicy extends Model
|
||||||
|
{
|
||||||
|
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['roles' => 'array', 'enabled' => 'boolean'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class RecommendationReview extends Model
|
||||||
|
{
|
||||||
|
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['snapshot' => 'array'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ class ConflictGuard
|
|||||||
'opening.recorded',
|
'opening.recorded',
|
||||||
'qualification.defined', 'qualification.assessed',
|
'qualification.defined', 'qualification.assessed',
|
||||||
'admission.adopted', 'admission.proposed', 'admission.reviewed',
|
'admission.adopted', 'admission.proposed', 'admission.reviewed',
|
||||||
|
'recommendation.adopted', 'recommendation.proposed', 'recommendation.reviewed',
|
||||||
'equipment.technical_approved', 'equipment.technical_rejected',
|
'equipment.technical_approved', 'equipment.technical_rejected',
|
||||||
'invoice.created', 'invoice.validated',
|
'invoice.created', 'invoice.validated',
|
||||||
'payment.registered', 'payment.confirmed', 'payment.rejected',
|
'payment.registered', 'payment.confirmed', 'payment.rejected',
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\PurchasingRole;
|
||||||
|
use App\Models\AdmissionDecision;
|
||||||
|
use App\Models\AdmissionReview;
|
||||||
|
use App\Models\ProposalEvaluation;
|
||||||
|
use App\Models\PurchaseAward;
|
||||||
|
use App\Models\PurchaseOrder;
|
||||||
|
use App\Models\Quotation;
|
||||||
|
use App\Models\QuotationItem;
|
||||||
|
use App\Models\RecommendationAdoption;
|
||||||
|
use App\Models\RecommendationAttempt;
|
||||||
|
use App\Models\RecommendationPolicy;
|
||||||
|
use App\Models\RecommendationReview;
|
||||||
|
use App\Models\Requisition;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class FormalRecommendation
|
||||||
|
{
|
||||||
|
private function invalid(string $message): never
|
||||||
|
{
|
||||||
|
throw ValidationException::withMessages(['recommendation' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adoption(Requisition $request): ?RecommendationAdoption
|
||||||
|
{
|
||||||
|
return RecommendationAdoption::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function process(User $actor, int $id): Requisition
|
||||||
|
{
|
||||||
|
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
|
||||||
|
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
|
||||||
|
$this->invalid('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.');
|
||||||
|
}
|
||||||
|
app(ConflictGuard::class)->assertParticipation($actor, $request);
|
||||||
|
$quotes = Quotation::query()->where('requisition_id', $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();
|
||||||
|
ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $data */
|
||||||
|
public function policy(User $actor, array $data): RecommendationPolicy
|
||||||
|
{
|
||||||
|
if (! is_bool($data['enabled']) || ! is_array($data['roles']) || $data['roles'] === [] || count(array_unique($data['roles'])) !== count($data['roles']) || array_diff($data['roles'], ['finance', 'department_head']) !== [] || filter_var($data['required_count'], FILTER_VALIDATE_INT) === false || $data['required_count'] < 1 || $data['required_count'] > 50 || mb_strlen(trim($data['reason'])) < 10) {
|
||||||
|
$this->invalid('Defina perfis, quantidade de conferentes e fundamento próprios da recomendação.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($actor, $data) {
|
||||||
|
app(ConflictGuard::class)->lockCompany($actor);
|
||||||
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin]);
|
||||||
|
$latest = RecommendationPolicy::query()->latest('revision')->first();
|
||||||
|
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
|
||||||
|
$this->invalid('A política mudou. Actualize antes de criar outra versão.');
|
||||||
|
}
|
||||||
|
$policy = RecommendationPolicy::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'enabled' => $data['enabled'], 'roles' => array_values($data['roles']), 'required_count' => $data['required_count'], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||||
|
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.policy', null, null, $policy->toArray());
|
||||||
|
|
||||||
|
return $policy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adopt(User $actor, Requisition $request, int $policyId, int $previousId, string $reason): RecommendationAdoption
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($actor, $request, $policyId, $previousId, $reason) {
|
||||||
|
app(ConflictGuard::class)->lockCompany($actor);
|
||||||
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
|
||||||
|
$request = $this->process($actor, $request->id);
|
||||||
|
if (! app(FormalAdmission::class)->adoption($request)) {
|
||||||
|
$this->invalid('Adopte expressamente o regime de admissão antes da recomendação.');
|
||||||
|
}
|
||||||
|
$previous = $this->adoption($request);
|
||||||
|
$policy = RecommendationPolicy::query()->latest('revision')->first();
|
||||||
|
if (! $policy || ! $policy->enabled || $policy->id !== $policyId || ($previous->id ?? 0) !== $previousId || $previous?->recommendation_policy_id === $policy->id || mb_strlen(trim($reason)) < 10) {
|
||||||
|
$this->invalid('Seleccione a política activa, a adopção actual e um fundamento próprio.');
|
||||||
|
}
|
||||||
|
$adoption = RecommendationAdoption::query()->create(['tenant_id' => $actor->tenant_id, 'requisition_id' => $request->id, 'recommendation_policy_id' => $policy->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'snapshot' => ['roles' => $policy->roles, 'required_count' => $policy->required_count, 'policy_revision' => $policy->revision], 'reason' => $reason, 'created_at' => now()]);
|
||||||
|
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.adopted', $request, null, ['adoption' => $adoption->toArray(), 'previous_id' => $previous?->id]);
|
||||||
|
|
||||||
|
return $adoption;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{hash:string,payload:array{admission_adoption:array<string,mixed>|null,comparison_hash:string,ready:bool,top_ids:list<int>,rows:list<array<string,mixed>>,admissions:list<array<string,mixed>>},ready:bool,top_ids:list<int>} */
|
||||||
|
public function basis(Requisition $request): array
|
||||||
|
{
|
||||||
|
$comparison = app(ProposalComparison::class)->inspect($request);
|
||||||
|
$admission = app(FormalAdmission::class);
|
||||||
|
$adoption = $admission->adoption($request);
|
||||||
|
$rows = [];
|
||||||
|
$admissions = [];
|
||||||
|
foreach ($comparison['rows'] as $row) {
|
||||||
|
// Freeze the decision inputs without supplier contacts or private documents.
|
||||||
|
$rows[] = [...array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'payment_terms', 'weights', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor', 'items', 'evaluations', 'qualified_evaluations'])), 'supplier_name' => $row['supplier']['name'] ?? 'Fornecedor indisponível'];
|
||||||
|
$decision = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id ?? 0)->where('quotation_id', $row['id'])->latest('revision')->first();
|
||||||
|
$admissions[] = ['quotation_id' => $row['id'], 'basis' => $admission->basis($request, $row['id']), 'decision' => $decision?->toArray(), 'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id ?? 0)->orderBy('id')->get()->toArray()];
|
||||||
|
}
|
||||||
|
$payload = ['admission_adoption' => $adoption?->toArray(), 'comparison_hash' => $comparison['fingerprint'], 'ready' => $comparison['ready'], 'top_ids' => $comparison['top_ids'], 'rows' => $rows, 'admissions' => $admissions];
|
||||||
|
|
||||||
|
return ['hash' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'payload' => $payload, 'ready' => $adoption !== null && $comparison['ready'], 'top_ids' => $comparison['top_ids']];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(RecommendationAttempt $attempt, RecommendationAdoption $adoption): string
|
||||||
|
{
|
||||||
|
$reviews = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->get();
|
||||||
|
if ($reviews->contains('verdict', 'returned')) {
|
||||||
|
return 'returned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reviews->where('verdict', 'approved')->count() >= $adoption->snapshot['required_count'] ? 'confirmed' : 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $data */
|
||||||
|
public function propose(User $actor, Requisition $request, array $data): RecommendationAttempt
|
||||||
|
{
|
||||||
|
if (mb_strlen(trim($data['reason'])) < 10) {
|
||||||
|
$this->invalid('Fundamente a recomendação, incluindo a escolha em caso de empate.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
abort_if($actor->id === $request->requester_id, 403);
|
||||||
|
$adoption = $this->adoption($request);
|
||||||
|
if (! $adoption) {
|
||||||
|
$this->invalid('Adopte expressamente o regime de recomendação.');
|
||||||
|
}
|
||||||
|
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
|
||||||
|
$basis = $this->basis($request);
|
||||||
|
if (! $basis['ready'] || $basis['hash'] !== $data['basis_hash'] || ! in_array($quote->id, $basis['top_ids'], true)) {
|
||||||
|
$this->invalid('Exige-se matriz pronta, admissões confirmadas e escolha fundamentada entre as maiores pontuações da base actual.');
|
||||||
|
}
|
||||||
|
$previous = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||||
|
if ((int) $data['previous_id'] !== ($previous->id ?? 0)) {
|
||||||
|
$this->invalid('A tentativa mudou. Actualize antes de registar outra revisão.');
|
||||||
|
}
|
||||||
|
$attempt = RecommendationAttempt::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_adoption_id' => $adoption->id, 'quotation_id' => $quote->id, 'created_by' => $actor->id, 'revision' => ($previous->revision ?? 0) + 1, 'basis_hash' => $basis['hash'], 'snapshot' => ['basis' => $basis['payload'], 'proponent' => $actor->only(['id', 'name']), 'policy' => $adoption->snapshot], 'reason' => $data['reason'], 'created_at' => now()]);
|
||||||
|
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.proposed', $request, null, ['attempt' => $attempt->toArray(), 'previous_id' => $previous?->id]);
|
||||||
|
|
||||||
|
return $attempt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
|
||||||
|
{
|
||||||
|
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
|
||||||
|
{
|
||||||
|
if (! in_array($verdict, ['approved', 'returned'], true) || mb_strlen(trim($reason)) < 10) {
|
||||||
|
$this->invalid('Registe uma conferência válida e fundamentada.');
|
||||||
|
}
|
||||||
|
DB::transaction(function () use ($actor, $attempt, $verdict, $reason) {
|
||||||
|
app(ConflictGuard::class)->lockCompany($actor);
|
||||||
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||||
|
$original = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||||
|
$request = $this->process($actor, $original->requisition_id);
|
||||||
|
$adoption = $this->adoption($request);
|
||||||
|
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403);
|
||||||
|
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||||
|
$basis = $this->basis($request);
|
||||||
|
if ($attempt->recommendation_adoption_id !== $adoption->id || $latest?->id !== $attempt->id || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash) {
|
||||||
|
$this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.');
|
||||||
|
}
|
||||||
|
$existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->first();
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing->verdict !== $verdict || $existing->reason !== $reason) {
|
||||||
|
$this->invalid('A conferência anterior é imutável.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->status($attempt, $adoption) !== 'pending') {
|
||||||
|
$this->invalid('A tentativa já foi concluída ou devolvida.');
|
||||||
|
}
|
||||||
|
if ($verdict === 'approved') {
|
||||||
|
foreach (RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('verdict', 'approved')->get() as $review) {
|
||||||
|
$reviewer = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($review->reviewed_by);
|
||||||
|
if (! $reviewer || ! $this->eligible($reviewer, $request, $adoption, $attempt)) {
|
||||||
|
$this->invalid('Um conferente anterior deixou de ser elegível. Devolva e prepare outra tentativa.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
|
||||||
|
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
public function selection(Requisition $request, int $quoteId): ?array
|
||||||
|
{
|
||||||
|
$adoption = $this->adoption($request);
|
||||||
|
if (! $adoption) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$attempt = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||||
|
$basis = $this->basis($request);
|
||||||
|
if (! $attempt || $attempt->quotation_id !== $quoteId || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash || $this->status($attempt, $adoption) !== 'confirmed') {
|
||||||
|
$this->invalid('A adjudicação exige a proposta da recomendação confirmada na base actual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['adoption_id' => $adoption->id, 'policy_id' => $adoption->recommendation_policy_id, 'attempt_id' => $attempt->id, 'quotation_id' => $quoteId, 'basis_hash' => $attempt->basis_hash, 'review_ids' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->pluck('id')->all()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ class ProcurementReviewReport
|
|||||||
'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows],
|
'comparison' => ['ready' => $comparison['ready'], 'top_count' => $comparison['ready'] ? count($comparison['top_ids']) : 0, 'rows' => $rows],
|
||||||
'qualification' => $qualification,
|
'qualification' => $qualification,
|
||||||
'admission' => app(FormalAdmission::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']),
|
'admission' => app(FormalAdmission::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']),
|
||||||
|
'recommendation' => app(FormalRecommendation::class)->adoption($request)?->only(['id', 'revision', 'snapshot', 'reason']),
|
||||||
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
|
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
|
||||||
'award' => $award?->only(['id', 'status', 'total_minor']),
|
'award' => $award?->only(['id', 'status', 'total_minor']),
|
||||||
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
|
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class PurchaseWorkflow
|
|||||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
|
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
|
||||||
$result = $this->checkComparison($user, $requisition, $quote);
|
$result = $this->checkComparison($user, $requisition, $quote);
|
||||||
$admission = app(FormalAdmission::class)->selection($requisition, $quote->id);
|
$admission = app(FormalAdmission::class)->selection($requisition, $quote->id);
|
||||||
|
$recommendation = app(FormalRecommendation::class)->selection($requisition, $quote->id);
|
||||||
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
|
$investment = app(InvestmentWorkflow::class)->check($requisition, $quote);
|
||||||
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
|
app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor);
|
||||||
$award = PurchaseAward::query()->create([
|
$award = PurchaseAward::query()->create([
|
||||||
@@ -49,6 +50,7 @@ class PurchaseWorkflow
|
|||||||
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
|
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
|
||||||
'investment_snapshot' => $investment,
|
'investment_snapshot' => $investment,
|
||||||
'admission_snapshot' => $admission,
|
'admission_snapshot' => $admission,
|
||||||
|
'recommendation_snapshot' => $recommendation,
|
||||||
]);
|
]);
|
||||||
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
|
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists());
|
||||||
foreach ($roles as $position => $role) {
|
foreach ($roles as $position => $role) {
|
||||||
@@ -152,6 +154,7 @@ class PurchaseWorkflow
|
|||||||
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
|
'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
|
||||||
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
|
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
|
||||||
'admission' => $award->admission_snapshot,
|
'admission' => $award->admission_snapshot,
|
||||||
|
'recommendation' => $award->recommendation_snapshot,
|
||||||
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
||||||
'final_approvals' => $award->approvals()->get()->toArray(),
|
'final_approvals' => $award->approvals()->get()->toArray(),
|
||||||
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
|
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
|
||||||
@@ -186,6 +189,9 @@ class PurchaseWorkflow
|
|||||||
if (app(FormalAdmission::class)->selection($requisition, $quote->id) !== $award->admission_snapshot) {
|
if (app(FormalAdmission::class)->selection($requisition, $quote->id) !== $award->admission_snapshot) {
|
||||||
throw ValidationException::withMessages(['admission' => 'A base de admissão já não corresponde à adjudicação. Não se emite com outra base.']);
|
throw ValidationException::withMessages(['admission' => 'A base de admissão já não corresponde à adjudicação. Não se emite com outra base.']);
|
||||||
}
|
}
|
||||||
|
if (app(FormalRecommendation::class)->selection($requisition, $quote->id) !== $award->recommendation_snapshot) {
|
||||||
|
throw ValidationException::withMessages(['recommendation' => 'A recomendação já não corresponde à base da adjudicação. Prepare a revisão autorizada antes de emitir.']);
|
||||||
|
}
|
||||||
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
|
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
|
||||||
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
|
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
|
||||||
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
|
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('recommendation_policies', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||||
|
$table->unsignedInteger('revision');
|
||||||
|
$table->boolean('enabled');
|
||||||
|
$table->json('roles');
|
||||||
|
$table->unsignedInteger('required_count');
|
||||||
|
$table->text('reason');
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
$table->unique(['tenant_id', 'revision']);
|
||||||
|
});
|
||||||
|
Schema::create('recommendation_adoptions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('recommendation_policy_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||||
|
$table->unsignedInteger('revision');
|
||||||
|
$table->json('snapshot');
|
||||||
|
$table->text('reason');
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
$table->unique(['requisition_id', 'revision']);
|
||||||
|
});
|
||||||
|
Schema::create('recommendation_attempts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('recommendation_adoption_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('quotation_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||||
|
$table->unsignedInteger('revision');
|
||||||
|
$table->string('basis_hash', 64);
|
||||||
|
$table->json('snapshot');
|
||||||
|
$table->text('reason');
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
$table->unique(['recommendation_adoption_id', 'revision'], 'recommendation_attempt_version_unique');
|
||||||
|
});
|
||||||
|
Schema::create('recommendation_reviews', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('recommendation_attempt_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete();
|
||||||
|
$table->string('verdict');
|
||||||
|
$table->text('reason');
|
||||||
|
$table->json('snapshot');
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
$table->unique(['recommendation_attempt_id', 'reviewed_by']);
|
||||||
|
});
|
||||||
|
Schema::table('purchase_awards', fn (Blueprint $table) => $table->json('recommendation_snapshot')->nullable());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new LogicException('Preservar políticas, adopções, decisões e conferências de recomendação.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,9 +5,10 @@ planeamento acrescenta itens por empresa, responsáveis, marcos e datas
|
|||||||
previstas/realizadas, com ligação posterior às requisições. O registo documental
|
previstas/realizadas, com ligação posterior às requisições. O registo documental
|
||||||
opcional da abertura e actas por versão também foi implementado. Seguiram-se
|
opcional da abertura e actas por versão também foi implementado. Seguiram-se
|
||||||
qualificação documental, relatório de conferência e admissão/exclusão por
|
qualificação documental, relatório de conferência e admissão/exclusão por
|
||||||
adopção expressa (`d020be5`). A recomendação formal está proposta para validação
|
adopção expressa (`d020be5`). O âmbito de recomendação formal foi depois
|
||||||
em `docs/proposta-recomendacao.md`, com demonstração em
|
validado e implementado com política própria, adopção opcional e conferência
|
||||||
`/proposta/recomendacao`; não está activa na base de negócio. Avaliação em
|
independente antes da adjudicação, sem activar políticas reais. A demonstração
|
||||||
|
`/proposta/recomendacao` continua separada do circuito autenticado. Avaliação em
|
||||||
novas modalidades continua por implementar. Estes avanços não importam os
|
novas modalidades continua por implementar. Estes avanços não importam os
|
||||||
processos históricos nem aprovam as regras de cancelamento de ordens emitidas.
|
processos históricos nem aprovam as regras de cancelamento de ordens emitidas.
|
||||||
|
|
||||||
@@ -73,8 +74,8 @@ comportamento dos processos existentes.
|
|||||||
|
|
||||||
A visão do processo, o planeamento e os marcos, a abertura, a qualificação
|
A visão do processo, o planeamento e os marcos, a abertura, a qualificação
|
||||||
documental e a admissão/exclusão já foram entregues como incrementos próprios.
|
documental e a admissão/exclusão já foram entregues como incrementos próprios.
|
||||||
O próximo circuito proposto é a recomendação formal antes da adjudicação,
|
A recomendação formal antes da adjudicação também foi entregue após validação,
|
||||||
sujeita à validação de âmbito e sem alterar a comparação ponderada existente.
|
sem alterar a comparação ponderada existente.
|
||||||
Novas modalidades, métodos de classificação, correcções de preços e dispensas
|
Novas modalidades, métodos de classificação, correcções de preços e dispensas
|
||||||
continuam a exigir definição explícita.
|
continuam a exigir definição explícita.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# Proposta de âmbito para recomendação antes da adjudicação
|
# Proposta de âmbito para recomendação antes da adjudicação
|
||||||
|
|
||||||
Estado: proposta para validação após a entrega de admissão/exclusão em
|
Estado: âmbito validado pelo utilizador e implementado em 28 de Outubro de
|
||||||
`d020be5`. A demonstração `/proposta/recomendacao` não implementa uma nova
|
2026, após a entrega de admissão/exclusão em `d020be5`. O circuito real é
|
||||||
decisão na base de negócio. Data: 28 de Outubro de 2026.
|
opcional por empresa e por processo. A demonstração `/proposta/recomendacao`
|
||||||
|
continua separada dos registos autenticados; nenhuma política real foi
|
||||||
|
publicada nesta entrega.
|
||||||
|
|
||||||
## Resultado preparado para revisão
|
## Demonstração preparada antes da validação
|
||||||
|
|
||||||
Uma proposta navegável, com cabeçalho, rodapé e menus partilhados do Sistema
|
Uma proposta navegável, com cabeçalho, rodapé e menus partilhados do Sistema
|
||||||
de Gestão de Compras (SGC), permite simular:
|
de Gestão de Compras (SGC), permite simular:
|
||||||
@@ -25,7 +27,7 @@ página não autentica outra pessoa e não demonstra segregação efectiva. As
|
|||||||
pontuações ilustram decisões de interface; não constituem um novo algoritmo
|
pontuações ilustram decisões de interface; não constituem um novo algoritmo
|
||||||
de comparação. Não se reutilizam nomes ou valores dos exemplos históricos.
|
de comparação. Não se reutilizam nomes ou valores dos exemplos históricos.
|
||||||
|
|
||||||
## Circuito inicial proposto para o produto
|
## Circuito inicial validado para o produto
|
||||||
|
|
||||||
1. Administração da empresa activa uma política de recomendação versionada,
|
1. Administração da empresa activa uma política de recomendação versionada,
|
||||||
com fundamento, perfis autorizados e quantidade de pessoas distintas.
|
com fundamento, perfis autorizados e quantidade de pessoas distintas.
|
||||||
@@ -98,7 +100,7 @@ decisões empresariais continuam a pertencer a cada aderente.
|
|||||||
- Tornar a recomendação obrigatória em todos os processos ou activar
|
- Tornar a recomendação obrigatória em todos os processos ou activar
|
||||||
políticas reais por conta das empresas durante o desenvolvimento.
|
políticas reais por conta das empresas durante o desenvolvimento.
|
||||||
|
|
||||||
## Verificações exigidas para a futura implementação
|
## Verificações da implementação
|
||||||
|
|
||||||
Isolamento e privacidade, identidade relida no servidor, autoria, segregação,
|
Isolamento e privacidade, identidade relida no servidor, autoria, segregação,
|
||||||
perfil/departamento revogado, quórum sem duplicações, conflitos, matriz
|
perfil/departamento revogado, quórum sem duplicações, conflitos, matriz
|
||||||
@@ -108,20 +110,24 @@ rollback de auditoria, vínculo à adjudicação/emissão e concorrência
|
|||||||
PostgreSQL com processos independentes. Uma demonstração local não substitui
|
PostgreSQL com processos independentes. Uma demonstração local não substitui
|
||||||
estas verificações do circuito autenticado.
|
estas verificações do circuito autenticado.
|
||||||
|
|
||||||
## Validação necessária antes de implementar o circuito real
|
## Validação recebida e activação
|
||||||
|
|
||||||
Validar **recomendação manual por Compras, conferências independentes em
|
O utilizador validou **recomendação manual por Compras, conferências independentes em
|
||||||
quantidade/perfis definidos pela empresa, regime opcional por adopção
|
quantidade/perfis definidos pela empresa, regime opcional por adopção
|
||||||
expressa após admissão e vínculo à adjudicação da proposta recomendada**.
|
expressa após admissão e vínculo à adjudicação da proposta recomendada**.
|
||||||
|
|
||||||
A orientação de continuidade determina: «definir com a empresa o próximo
|
A Administração pode configurar `/politica-recomendacao`; Compras adopta a
|
||||||
âmbito pendente antes de o implementar», em
|
versão activa em `/requisicoes/{requisition}/recomendacao`, após adopção do
|
||||||
`docs/orientacoes-continuidade.md`. A validação anterior autorizou admissão
|
regime de admissão e antes da adjudicação. Perfis e quantidade são escolhas
|
||||||
e exclusão; não definiu uma etapa de recomendação com estes efeitos. A
|
expressas da empresa. O limite de 50 no formulário é técnico, sem quórum
|
||||||
implementação do circuito real aguarda a validação deste âmbito. As
|
empresarial predefinido. A empresa deve assegurar pessoas elegíveis em número
|
||||||
alterações locais dos três documentos de continuidade ficam preservadas.
|
suficiente. Consultar `docs/validacao-tecnica.md` para resultados e limitações.
|
||||||
|
|
||||||
## Verificações desta preparação
|
A validação é do âmbito do produto; não publica políticas por conta das
|
||||||
|
aderentes. As alterações locais dos três documentos de continuidade foram
|
||||||
|
preservadas.
|
||||||
|
|
||||||
|
## Histórico: verificações da preparação da demonstração
|
||||||
|
|
||||||
Passaram os 18 testes de entrada e consulta pública, com 290 asserções,
|
Passaram os 18 testes de entrada e consulta pública, com 290 asserções,
|
||||||
PHPStan, Pint, TypeScript, build e lint/formatação do âmbito. O acesso HTTP
|
PHPStan, Pint, TypeScript, build e lint/formatação do âmbito. O acesso HTTP
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"tested_at_utc": "2026-10-28T18:42:31+00:00",
|
||||||
|
"separate_database": true,
|
||||||
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"name": "same_person",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 1,
|
||||||
|
"state": "pending",
|
||||||
|
"attempts": 1,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "distinct_people",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 2,
|
||||||
|
"state": "confirmed",
|
||||||
|
"attempts": 1,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "opposing_reviews",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 1,
|
||||||
|
"state": "pending",
|
||||||
|
"attempts": 1,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "revoked_role",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 0,
|
||||||
|
"state": "pending",
|
||||||
|
"attempts": 1,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "changed_basis",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 0,
|
||||||
|
"state": "pending",
|
||||||
|
"attempts": 1,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new_adoption",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 0,
|
||||||
|
"state": "pending",
|
||||||
|
"attempts": 1,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "competing_attempts",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 0,
|
||||||
|
"state": "pending",
|
||||||
|
"attempts": 2,
|
||||||
|
"awards": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recommendation_versus_award",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"reviews": 2,
|
||||||
|
"state": "confirmed",
|
||||||
|
"attempts": 2,
|
||||||
|
"awards": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passed": true,
|
||||||
|
"test_database_dropped": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"verified_at_utc": "2026-10-28T18:45:44+00:00",
|
||||||
|
"migration": "2026_10_28_000020_create_formal_recommendations",
|
||||||
|
"batch": 21,
|
||||||
|
"backup_created": true,
|
||||||
|
"backup_bytes": 326150,
|
||||||
|
"restore_verified": true,
|
||||||
|
"restored_tables": 61,
|
||||||
|
"restore_database_dropped": true,
|
||||||
|
"business_records_unchanged": true,
|
||||||
|
"existing_constraints_preserved": true,
|
||||||
|
"env_unchanged": true,
|
||||||
|
"recommendation_policies_count": 0,
|
||||||
|
"recommendation_adoptions_count": 0,
|
||||||
|
"operational_validation": false
|
||||||
|
}
|
||||||
@@ -1,5 +1,103 @@
|
|||||||
# Validação técnica do SGC — 28 de Outubro de 2026
|
# Validação técnica do SGC — 28 de Outubro de 2026
|
||||||
|
|
||||||
|
## Incremento: recomendação formal antes da adjudicação
|
||||||
|
|
||||||
|
O âmbito de `docs/proposta-recomendacao.md` foi validado pelo utilizador e
|
||||||
|
implementado. A Administração configura uma política própria de recomendação
|
||||||
|
em `/politica-recomendacao`, com perfis Finanças e/ou chefia do departamento,
|
||||||
|
quantidade de pessoas distintas, activação e fundamento expressos. Não há
|
||||||
|
perfis ou quantidade inicialmente seleccionados. O máximo de 50 confirmações
|
||||||
|
é um limite técnico e não define um quórum empresarial.
|
||||||
|
|
||||||
|
Compras adopta a versão activa em `/requisicoes/{requisition}/recomendacao`,
|
||||||
|
num pedido aprovado que já adoptou o regime de admissão, antes de adjudicar.
|
||||||
|
Sem essa adopção, mantém-se o circuito existente. Uma versão geral nova ou
|
||||||
|
desactivada não reescreve processos; a desactivação impede novas adopções.
|
||||||
|
Trocar a versão aplicável exige adopção expressa e novas tentativas e
|
||||||
|
conferências. Uma política de admissão não autoriza, por si, a recomendação.
|
||||||
|
|
||||||
|
A recomendação exige matriz pronta, admissões confirmadas na base actual,
|
||||||
|
três fornecedores distintos elegíveis e duas avaliações por proposta, sem
|
||||||
|
alteração do cálculo ponderado existente. Compras, diferente do solicitante,
|
||||||
|
escolhe expressamente entre as maiores pontuações e fundamenta, incluindo em
|
||||||
|
empate. Não há desempate automático, dispensa do mínimo, escolha abaixo da
|
||||||
|
maior pontuação ou autorização para corrigir preços.
|
||||||
|
|
||||||
|
Cada tentativa preserva matriz, propostas/linhas, avaliações, referências e
|
||||||
|
bases das admissões/requisitos/análises, conferências, política e proponente.
|
||||||
|
Os dados de contacto ou documentos privados dos fornecedores não entram na
|
||||||
|
matriz preservada. Uma nova tentativa é imutável e não herda confirmações.
|
||||||
|
Uma devolução encerra a tentativa sem excluir o concorrente ou cancelar o
|
||||||
|
processo. Conferências repetidas idênticas são idempotentes e contam uma vez.
|
||||||
|
Identidades, empresa, perfil, departamento, segregação e conflitos são
|
||||||
|
revalidados dentro da transacção, após o bloqueio da empresa. As confirmações
|
||||||
|
anteriores são revalidadas até concluir o quórum; a história concluída conserva
|
||||||
|
a elegibilidade verificada na sua data, sem autorizar futuras operações com
|
||||||
|
perfil revogado. Uma pessoa não conta duas vezes após mudar de perfil.
|
||||||
|
|
||||||
|
Nos processos com adopção, a adjudicação só selecciona a mesma proposta de
|
||||||
|
uma recomendação confirmada na base actual. A referência e a base ficam
|
||||||
|
preservadas na adjudicação e na ordem; são revalidadas antes de aprovar/emitir.
|
||||||
|
Uma alteração de avaliação ou uma nova admissão pode invalidar a recomendação
|
||||||
|
mesmo mantendo preços e pontuações. Não se reescrevem adjudicações/ordens
|
||||||
|
anteriores. Existindo adjudicação, a adopção e as tentativas/conferências
|
||||||
|
ficam fechadas; alterações exigem o circuito de revisão já suportado, quando
|
||||||
|
autorizado. A repetição de uma emissão concluída devolve a mesma ordem.
|
||||||
|
Recomendar não movimenta orçamento nem substitui as aprovações do valor final,
|
||||||
|
regras de investimento, conflitos, facturas ou pagamentos.
|
||||||
|
|
||||||
|
As páginas usam o cabeçalho, rodapé, menus e identificação Sistema de Gestão
|
||||||
|
de Compras (SGC). Comparação, adjudicação e processo completo ligam ao novo
|
||||||
|
circuito; o processo apresenta os eventos de auditoria. O relatório de
|
||||||
|
conferência assinala a adopção e remete para o relatório preservado da
|
||||||
|
recomendação. A impressão autenticada em `/recomendacoes/{attempt}/imprimir`
|
||||||
|
mostra matriz/base originais, candidata, fundamentos, conferências, devoluções
|
||||||
|
e tentativas anteriores da mesma adopção, distinguindo dados históricos da
|
||||||
|
situação actual. As restantes adopções são consultáveis na página do processo.
|
||||||
|
O conteúdo é escapado e a resposta é `private, no-store`. Em PostgreSQL, a
|
||||||
|
impressão usa `REPEATABLE READ, READ ONLY`, relendo o operador; não cria
|
||||||
|
auditoria nem outros registos. Não é uma assinatura digital ou certificação
|
||||||
|
das evidências. A revisão visual da paginação continua pendente.
|
||||||
|
|
||||||
|
Resultado da bateria completa: **498 testes aprovados, 2 ignorados, 4141
|
||||||
|
asserções**. Inclui **17 cenários de recomendação**, com percurso genuíno até
|
||||||
|
adjudicação/emissão, empate e escolha da proposta empatada, escolha abaixo
|
||||||
|
da maior pontuação, matriz incompleta, admissão pendente/nova, mudanças de
|
||||||
|
base, devolução, repetição, perfis/departamentos revogados, mesma identidade
|
||||||
|
em perfis diferentes, política preservada, circuito sem adopção, segregação,
|
||||||
|
isolamento, relatório histórico sem escrita, escape de conteúdo e rollback
|
||||||
|
de auditoria de tentativa/conferência.
|
||||||
|
|
||||||
|
Passaram **8 cenários PostgreSQL**, com processos PHP e ligações independentes
|
||||||
|
e dois bloqueios simultâneos observados em cada cenário: mesma pessoa,
|
||||||
|
pessoas distintas, respostas opostas, perfil revogado, avaliação alterada,
|
||||||
|
nova adopção durante a espera, tentativas concorrentes e nova recomendação
|
||||||
|
versus adjudicação. Conferiram-se quórum, versões, auditoria e vínculo à
|
||||||
|
tentativa; a base de teste foi eliminada. O resultado está em
|
||||||
|
`docs/recomendacao-concorrencia-postgresql.json`. Não se afirma que foram
|
||||||
|
observadas todas as ordens possíveis de escalonamento ou testada corrida de
|
||||||
|
impressão/emissão nesta bateria.
|
||||||
|
|
||||||
|
Migração `000020`, lote **21**, aplicada em `compras_dev` após backup privado
|
||||||
|
de **326150 bytes**, verificado por restauro numa base separada e eliminada
|
||||||
|
depois da conferência. As **61 tabelas anteriores**, os registos de negócio
|
||||||
|
por projecção das colunas originais, restrições existentes, histórico de
|
||||||
|
migrações e `.env` foram preservados. São quatro tabelas novas e um campo
|
||||||
|
JSON anulável na adjudicação. As quatro tabelas reais de recomendação estão
|
||||||
|
vazias; não houve publicação de políticas, seeds ou compras reais. O relatório
|
||||||
|
sanitizado está em `docs/recomendacao-migracao-000020.json`; backup e manifesto
|
||||||
|
continuam privados e fora do Git. O rollback destrutivo automático é impedido.
|
||||||
|
|
||||||
|
Pint, PHPStan, TypeScript, build e lint/formatação do âmbito passaram. A
|
||||||
|
formatação global de `docs/compatibilidade.md`, alteração local alheia, continua
|
||||||
|
pendente e fora do commit, juntamente com as alterações de desenvolvimento e
|
||||||
|
continuidade preservadas. O navegador voltou a devolver `The browser access
|
||||||
|
host is unavailable`: permanecem pendentes revisão visual pública, autenticada,
|
||||||
|
de impressão e interacções no navegador. Validação operacional integral das
|
||||||
|
empresas, novas modalidades/métodos de avaliação, dispensas, comunicação
|
||||||
|
externa, cancelamento de ordens emitidas, estornos/devoluções e agregação por
|
||||||
|
projecto/fraccionamento não são declarados concluídos por esta entrega.
|
||||||
|
|
||||||
## Preparação: proposta de recomendação formal
|
## Preparação: proposta de recomendação formal
|
||||||
|
|
||||||
Após `d020be5`, foi preparada `/proposta/recomendacao`, ligada ao menu
|
Após `d020be5`, foi preparada `/proposta/recomendacao`, ligada ao menu
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ const groups: { label: string; entries: Entry[] }[] = [
|
|||||||
demo: '/proposta/admissao',
|
demo: '/proposta/admissao',
|
||||||
roles: ['admin'],
|
roles: ['admin'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Política de recomendação',
|
||||||
|
path: '/politica-recomendacao',
|
||||||
|
demo: '',
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Props = {
|
|||||||
canDecide: boolean;
|
canDecide: boolean;
|
||||||
canIssue: boolean;
|
canIssue: boolean;
|
||||||
order: { id: number; number: string } | null;
|
order: { id: number; number: string } | null;
|
||||||
|
recommendation: { adopted: boolean; quotation_id: number | null };
|
||||||
};
|
};
|
||||||
export default function Award({
|
export default function Award({
|
||||||
requisition,
|
requisition,
|
||||||
@@ -28,6 +29,7 @@ export default function Award({
|
|||||||
canDecide,
|
canDecide,
|
||||||
canIssue,
|
canIssue,
|
||||||
order,
|
order,
|
||||||
|
recommendation,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const selection = useForm({
|
const selection = useForm({
|
||||||
quotation_id: '',
|
quotation_id: '',
|
||||||
@@ -127,6 +129,19 @@ export default function Award({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
<p className="text-sm">
|
||||||
|
{recommendation.adopted
|
||||||
|
? recommendation.quotation_id
|
||||||
|
? 'Regime de recomendação adoptado: a selecção deve coincidir com a proposta recomendada.'
|
||||||
|
: 'Conclua uma recomendação actual antes de seleccionar a adjudicação.'
|
||||||
|
: 'Sem adopção do regime de recomendação: mantém-se o circuito existente.'}{' '}
|
||||||
|
<Link
|
||||||
|
className="underline"
|
||||||
|
href={`/requisicoes/${requisition.id}/recomendacao`}
|
||||||
|
>
|
||||||
|
Consultar recomendação
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
{canSelect && (
|
{canSelect && (
|
||||||
<Panel
|
<Panel
|
||||||
title="Seleccionar proposta"
|
title="Seleccionar proposta"
|
||||||
@@ -160,8 +175,14 @@ export default function Award({
|
|||||||
Seleccione uma proposta no topo
|
Seleccione uma proposta no topo
|
||||||
</option>
|
</option>
|
||||||
{comparison.rows
|
{comparison.rows
|
||||||
.filter((q) =>
|
.filter(
|
||||||
comparison.top_ids.includes(q.id),
|
(q) =>
|
||||||
|
comparison.top_ids.includes(
|
||||||
|
q.id,
|
||||||
|
) &&
|
||||||
|
(!recommendation.adopted ||
|
||||||
|
recommendation.quotation_id ===
|
||||||
|
q.id),
|
||||||
)
|
)
|
||||||
.map((q) => (
|
.map((q) => (
|
||||||
<option key={q.id} value={q.id}>
|
<option key={q.id} value={q.id}>
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ const events: Record<string, string> = {
|
|||||||
'admission.adopted': 'Política de admissão adoptada expressamente',
|
'admission.adopted': 'Política de admissão adoptada expressamente',
|
||||||
'admission.proposed': 'Tentativa de decisão de admissão registada',
|
'admission.proposed': 'Tentativa de decisão de admissão registada',
|
||||||
'admission.reviewed': 'Conferência independente de admissão registada',
|
'admission.reviewed': 'Conferência independente de admissão registada',
|
||||||
|
'recommendation.adopted': 'Política de recomendação adoptada expressamente',
|
||||||
|
'recommendation.proposed': 'Tentativa de recomendação registada',
|
||||||
|
'recommendation.reviewed':
|
||||||
|
'Conferência independente da recomendação registada',
|
||||||
'opening.recorded': 'Versão da acta de abertura registada',
|
'opening.recorded': 'Versão da acta de abertura registada',
|
||||||
'requisition.created': 'Requisição criada',
|
'requisition.created': 'Requisição criada',
|
||||||
'requisition.updated': 'Requisição actualizada',
|
'requisition.updated': 'Requisição actualizada',
|
||||||
@@ -276,16 +280,18 @@ export default function PurchaseProcess({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
{future.length > 0 && (
|
||||||
title="Etapas previstas no roteiro"
|
<Panel
|
||||||
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
|
title="Etapas previstas no roteiro"
|
||||||
>
|
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
|
||||||
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
|
>
|
||||||
{future.map((stage) => (
|
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
|
||||||
<li key={stage}>{stage}</li>
|
{future.map((stage) => (
|
||||||
))}
|
<li key={stage}>{stage}</li>
|
||||||
</ul>
|
))}
|
||||||
</Panel>
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
|
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { date, roleLabels } from '@/types/procurement';
|
||||||
|
|
||||||
|
type Policy = {
|
||||||
|
id: number;
|
||||||
|
revision: number;
|
||||||
|
enabled: boolean;
|
||||||
|
roles: string[];
|
||||||
|
required_count: number;
|
||||||
|
reason: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
type Props = {
|
||||||
|
latest: Policy | null;
|
||||||
|
policies: {
|
||||||
|
data: Policy[];
|
||||||
|
prev_page_url: string | null;
|
||||||
|
next_page_url: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default function RecommendationPolicy({ latest, policies }: Props) {
|
||||||
|
return (
|
||||||
|
<PolicyForm key={latest?.id ?? 0} latest={latest} policies={policies} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyForm({ latest, policies }: Props) {
|
||||||
|
const form = useForm({
|
||||||
|
previous_id: latest?.id ?? 0,
|
||||||
|
enabled: false,
|
||||||
|
roles: [] as string[],
|
||||||
|
required_count: '',
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Política de recomendação" />
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="Configuração da empresa"
|
||||||
|
title="Política de recomendação"
|
||||||
|
description="Sistema de Gestão de Compras (SGC) · Configuração da empresa"
|
||||||
|
/>
|
||||||
|
<Panel title="Nova versão da política">
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Defina os perfis e a quantidade de pessoas distintas.
|
||||||
|
Activar permite novas adopções expressas pelos
|
||||||
|
processos; desactivar impede novas adopções. As versões
|
||||||
|
já adoptadas mantêm-se. O limite técnico de 50
|
||||||
|
confirmações não define uma regra da empresa.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.post('/politica-recomendacao');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormErrors errors={form.errors} />
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.data.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setData('enabled', e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Activar para novas adopções
|
||||||
|
</Label>
|
||||||
|
<fieldset className="space-y-2">
|
||||||
|
<legend className="mb-2 text-sm font-medium">
|
||||||
|
Perfis autorizados a conferir
|
||||||
|
</legend>
|
||||||
|
{['finance', 'department_head'].map((role) => (
|
||||||
|
<Label
|
||||||
|
key={role}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.data.roles.includes(role)}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setData(
|
||||||
|
'roles',
|
||||||
|
e.target.checked
|
||||||
|
? [...form.data.roles, role]
|
||||||
|
: form.data.roles.filter(
|
||||||
|
(r) => r !== role,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{roleLabels[role] ?? role}
|
||||||
|
{role === 'department_head' &&
|
||||||
|
' do departamento do processo'}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="required-count">
|
||||||
|
Quantidade de pessoas distintas exigida
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="required-count"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
required
|
||||||
|
value={form.data.required_count}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setData(
|
||||||
|
'required_count',
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="policy-reason">
|
||||||
|
Fundamento desta versão
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="policy-reason"
|
||||||
|
className="min-h-24 w-full rounded-md border p-3"
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
maxLength={4000}
|
||||||
|
value={form.data.reason}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setData('reason', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button disabled={form.processing}>
|
||||||
|
Registar versão {latest ? latest.revision + 1 : 1}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Versões preservadas">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{policies.data.length === 0 && (
|
||||||
|
<p>A empresa ainda não definiu uma política.</p>
|
||||||
|
)}
|
||||||
|
{policies.data.map((p) => (
|
||||||
|
<article
|
||||||
|
key={p.id}
|
||||||
|
className="rounded-md border p-4"
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
Versão {p.revision} ·{' '}
|
||||||
|
{p.enabled ? 'Activada' : 'Desactivada'} ·{' '}
|
||||||
|
{date(p.created_at)}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{p.required_count} pessoa(s) ·{' '}
|
||||||
|
{p.roles
|
||||||
|
.map((r) => roleLabels[r] ?? r)
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{p.reason}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{policies.prev_page_url && (
|
||||||
|
<Link href={policies.prev_page_url}>
|
||||||
|
Anteriores
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{policies.next_page_url && (
|
||||||
|
<Link href={policies.next_page_url}>
|
||||||
|
Seguintes
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||||
|
import {
|
||||||
|
FormErrors,
|
||||||
|
PageTitle,
|
||||||
|
Panel,
|
||||||
|
controlClass,
|
||||||
|
} from '@/components/procurement';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { date, roleLabels } from '@/types/procurement';
|
||||||
|
|
||||||
|
type Adoption = {
|
||||||
|
id: number;
|
||||||
|
revision: number;
|
||||||
|
recommendation_policy_id: number;
|
||||||
|
reason: string;
|
||||||
|
snapshot: {
|
||||||
|
roles: string[];
|
||||||
|
required_count: number;
|
||||||
|
policy_revision: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type Row = {
|
||||||
|
id: number;
|
||||||
|
reference: string;
|
||||||
|
supplier_name: string;
|
||||||
|
currency: string;
|
||||||
|
total_minor: number;
|
||||||
|
score_minor: number | null;
|
||||||
|
eligible: boolean;
|
||||||
|
ineligibility_reasons: string[];
|
||||||
|
evaluation_count: number;
|
||||||
|
};
|
||||||
|
type Review = {
|
||||||
|
id: number;
|
||||||
|
verdict: string;
|
||||||
|
reason: string;
|
||||||
|
created_at: string;
|
||||||
|
snapshot: { name: string; purchasing_role: string };
|
||||||
|
};
|
||||||
|
type Attempt = {
|
||||||
|
id: number;
|
||||||
|
revision: number;
|
||||||
|
quotation_id: number;
|
||||||
|
reason: string;
|
||||||
|
status: string;
|
||||||
|
current: boolean;
|
||||||
|
canReview: boolean;
|
||||||
|
created_at: string;
|
||||||
|
basis_hash: string;
|
||||||
|
snapshot: { proponent: { name: string }; basis: { rows: Row[] } };
|
||||||
|
reviews: Review[];
|
||||||
|
};
|
||||||
|
type Props = {
|
||||||
|
requisition: { id: number; number: string };
|
||||||
|
policy: { id: number; revision: number; enabled: boolean } | null;
|
||||||
|
adoption: Adoption | null;
|
||||||
|
selected: Adoption | null;
|
||||||
|
adoptions: Adoption[];
|
||||||
|
hasAdmission: boolean;
|
||||||
|
basis: { hash: string; ready: boolean; top_ids: number[]; rows: Row[] };
|
||||||
|
previousId: number;
|
||||||
|
history: {
|
||||||
|
data: Attempt[];
|
||||||
|
prev_page_url: string | null;
|
||||||
|
next_page_url: string | null;
|
||||||
|
};
|
||||||
|
canAdopt: boolean;
|
||||||
|
canPropose: boolean;
|
||||||
|
closed: boolean;
|
||||||
|
};
|
||||||
|
const statuses: Record<string, string> = {
|
||||||
|
pending: 'A aguardar conferência',
|
||||||
|
confirmed: 'Recomendação confirmada',
|
||||||
|
returned: 'Tentativa devolvida',
|
||||||
|
};
|
||||||
|
const amount = (minor: number) =>
|
||||||
|
(minor / 100).toLocaleString('pt-PT', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
function Reason({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={id}>Fundamento</Label>
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
maxLength={4000}
|
||||||
|
className="min-h-24 w-full rounded-lg border p-3 text-sm"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Adopt({
|
||||||
|
requisition,
|
||||||
|
policy,
|
||||||
|
adoption,
|
||||||
|
hasAdmission,
|
||||||
|
}: Pick<Props, 'requisition' | 'policy' | 'adoption' | 'hasAdmission'>) {
|
||||||
|
const form = useForm({
|
||||||
|
policy_id: policy?.id ?? 0,
|
||||||
|
previous_id: adoption?.id ?? 0,
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
if (!hasAdmission)
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
É necessário adoptar expressamente o regime de admissão antes da
|
||||||
|
recomendação.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (!policy?.enabled)
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
A empresa ainda não disponibilizou uma política activa de
|
||||||
|
recomendação.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (adoption?.recommendation_policy_id === policy.id)
|
||||||
|
return <p>O processo já adoptou a versão activa.</p>;
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.post(
|
||||||
|
`/requisicoes/${requisition.id}/recomendacao/adoptar`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormErrors errors={form.errors} />
|
||||||
|
<p>
|
||||||
|
Adoptar expressamente a política de recomendação versão{' '}
|
||||||
|
{policy.revision}. Uma nova adopção exige novas tentativas e
|
||||||
|
conferências.
|
||||||
|
</p>
|
||||||
|
<Reason
|
||||||
|
id="recommendation-adopt-reason"
|
||||||
|
value={form.data.reason}
|
||||||
|
onChange={(v) => form.setData('reason', v)}
|
||||||
|
/>
|
||||||
|
<Button disabled={form.processing}>
|
||||||
|
Adoptar política neste processo
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Propose({
|
||||||
|
requisition,
|
||||||
|
basis,
|
||||||
|
previousId,
|
||||||
|
}: Pick<Props, 'requisition' | 'basis' | 'previousId'>) {
|
||||||
|
const form = useForm({
|
||||||
|
previous_id: previousId,
|
||||||
|
basis_hash: basis.hash,
|
||||||
|
quotation_id: '',
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.post(
|
||||||
|
`/requisicoes/${requisition.id}/recomendacao/tentativas`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormErrors errors={form.errors} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recommendation-candidate">
|
||||||
|
Proposta a recomendar
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="recommendation-candidate"
|
||||||
|
className={controlClass}
|
||||||
|
required
|
||||||
|
value={form.data.quotation_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setData('quotation_id', e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Seleccione expressamente</option>
|
||||||
|
{basis.rows
|
||||||
|
.filter((row) => basis.top_ids.includes(row.id))
|
||||||
|
.map((row) => (
|
||||||
|
<option key={row.id} value={row.id}>
|
||||||
|
{row.supplier_name} · {row.reference}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
A escolha deve pertencer às maiores pontuações. Em empate,
|
||||||
|
fundamente a escolha entre as empatadas. Uma nova tentativa
|
||||||
|
preserva a anterior e começa sem conferências.
|
||||||
|
</p>
|
||||||
|
<Reason
|
||||||
|
id="recommendation-propose-reason"
|
||||||
|
value={form.data.reason}
|
||||||
|
onChange={(v) => form.setData('reason', v)}
|
||||||
|
/>
|
||||||
|
<Button disabled={form.processing}>
|
||||||
|
Registar nova tentativa de recomendação
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Confer({ attempt }: { attempt: Attempt }) {
|
||||||
|
const form = useForm({ verdict: '', reason: '' });
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-4 space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.post(`/recomendacoes/${attempt.id}/conferir`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormErrors errors={form.errors} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`recommendation-review-${attempt.id}`}>
|
||||||
|
Conferência independente
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id={`recommendation-review-${attempt.id}`}
|
||||||
|
className={controlClass}
|
||||||
|
required
|
||||||
|
value={form.data.verdict}
|
||||||
|
onChange={(e) => form.setData('verdict', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Seleccione</option>
|
||||||
|
<option value="approved">Confirmar a recomendação</option>
|
||||||
|
<option value="returned">Devolver esta tentativa</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Reason
|
||||||
|
id={`recommendation-review-reason-${attempt.id}`}
|
||||||
|
value={form.data.reason}
|
||||||
|
onChange={(v) => form.setData('reason', v)}
|
||||||
|
/>
|
||||||
|
<Button disabled={form.processing}>Registar conferência</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Matrix({ rows, topIds }: { rows: Row[]; topIds?: number[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2">Fornecedor e proposta</th>
|
||||||
|
<th className="p-2">Total</th>
|
||||||
|
<th className="p-2">Avaliações elegíveis</th>
|
||||||
|
<th className="p-2">Pontuação / 10</th>
|
||||||
|
<th className="p-2">Situação</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b">
|
||||||
|
<td className="p-2">
|
||||||
|
{row.supplier_name} · {row.reference}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 whitespace-nowrap">
|
||||||
|
{amount(row.total_minor)} {row.currency}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{row.evaluation_count}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{row.score_minor === null
|
||||||
|
? 'Por concluir'
|
||||||
|
: amount(row.score_minor)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{row.eligible
|
||||||
|
? 'Elegível na comparação'
|
||||||
|
: row.ineligibility_reasons.join(' ')}
|
||||||
|
{topIds?.includes(row.id) &&
|
||||||
|
' · Maior pontuação'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default function Recommendation(props: Props) {
|
||||||
|
const {
|
||||||
|
requisition,
|
||||||
|
policy,
|
||||||
|
adoption,
|
||||||
|
selected,
|
||||||
|
adoptions,
|
||||||
|
basis,
|
||||||
|
previousId,
|
||||||
|
history,
|
||||||
|
canAdopt,
|
||||||
|
canPropose,
|
||||||
|
closed,
|
||||||
|
hasAdmission,
|
||||||
|
} = props;
|
||||||
|
const base = `/requisicoes/${requisition.id}`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title={`Recomendação · ${requisition.number}`} />
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||||||
|
<PageTitle
|
||||||
|
eyebrow="Compras"
|
||||||
|
title={`Recomendação · ${requisition.number}`}
|
||||||
|
description="Sistema de Gestão de Compras (SGC) · Decisão fundamentada antes da adjudicação"
|
||||||
|
/>
|
||||||
|
<nav className="flex flex-wrap gap-4 text-sm underline">
|
||||||
|
<Link href={`${base}/processo`}>Processo completo</Link>
|
||||||
|
<Link href={`${base}/admissao`}>Admissão</Link>
|
||||||
|
<Link href={`${base}/cotacoes`}>Comparação</Link>
|
||||||
|
<Link href={`${base}/adjudicacao`}>Adjudicação</Link>
|
||||||
|
</nav>
|
||||||
|
<Panel title="Regime do processo">
|
||||||
|
<p className="mb-4">
|
||||||
|
{adoption
|
||||||
|
? `Adopção ${adoption.revision} · política ${adoption.snapshot.policy_revision} · ${adoption.snapshot.required_count} pessoas distintas · ${adoption.snapshot.roles.map((r) => roleLabels[r] ?? r).join(', ')}.`
|
||||||
|
: 'Sem adopção do regime de recomendação: mantém-se o circuito existente.'}
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
A recomendação não autoriza compra nem substitui
|
||||||
|
orçamento ou aprovações do valor final. Conserva o
|
||||||
|
cálculo ponderado e os mínimos existentes de três
|
||||||
|
fornecedores elegíveis e duas avaliações por proposta.
|
||||||
|
</p>
|
||||||
|
{closed && (
|
||||||
|
<p>
|
||||||
|
Existindo adjudicação, o processo está fechado para
|
||||||
|
novas adopções, tentativas e conferências. Uma base
|
||||||
|
alterada exige o circuito de revisão autorizado.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{canAdopt && (
|
||||||
|
<Adopt
|
||||||
|
key={`${policy?.id}-${adoption?.id}`}
|
||||||
|
requisition={requisition}
|
||||||
|
policy={policy}
|
||||||
|
adoption={adoption}
|
||||||
|
hasAdmission={hasAdmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Comparação actual">
|
||||||
|
<p className="mb-4 text-sm">
|
||||||
|
{basis.ready
|
||||||
|
? 'Matriz pronta para recomendação fundamentada.'
|
||||||
|
: 'Resolva a admissão ou os impedimentos da comparação antes de recomendar.'}{' '}
|
||||||
|
Os valores desta matriz são os actuais; cada tentativa
|
||||||
|
conserva a sua própria base.
|
||||||
|
</p>
|
||||||
|
<Matrix
|
||||||
|
rows={basis.rows}
|
||||||
|
topIds={basis.ready ? basis.top_ids : []}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
{adoptions.length > 0 && (
|
||||||
|
<Panel title="Histórico de adopções">
|
||||||
|
<Label htmlFor="recommendation-adoption-version">
|
||||||
|
Versão consultada
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="recommendation-adoption-version"
|
||||||
|
className={`${controlClass} mt-2`}
|
||||||
|
value={selected?.id ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
router.get(`${base}/recomendacao`, {
|
||||||
|
version: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{adoptions.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
Adopção {a.revision} · política{' '}
|
||||||
|
{a.snapshot.policy_revision}
|
||||||
|
{a.id === adoption?.id
|
||||||
|
? ' · actual'
|
||||||
|
: ' · histórica'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-3 text-sm whitespace-pre-wrap">
|
||||||
|
{selected?.reason}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
{canPropose && (
|
||||||
|
<Panel title="Proposta manual por Compras">
|
||||||
|
<Propose
|
||||||
|
key={`${selected?.id}-${previousId}-${basis.hash}`}
|
||||||
|
requisition={requisition}
|
||||||
|
basis={basis}
|
||||||
|
previousId={previousId}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
<Panel title="Tentativas e conferências">
|
||||||
|
<div className="space-y-5">
|
||||||
|
{history.data.length === 0 && (
|
||||||
|
<p>Sem tentativas nesta adopção.</p>
|
||||||
|
)}
|
||||||
|
{history.data.map((attempt) => (
|
||||||
|
<article
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
key={attempt.id}
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
Tentativa {attempt.revision} ·{' '}
|
||||||
|
{
|
||||||
|
attempt.snapshot.basis.rows.find(
|
||||||
|
(row) =>
|
||||||
|
row.id === attempt.quotation_id,
|
||||||
|
)?.supplier_name
|
||||||
|
}{' '}
|
||||||
|
· {statuses[attempt.status]}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{date(attempt.created_at)} ·{' '}
|
||||||
|
{attempt.snapshot.proponent.name}
|
||||||
|
{!attempt.current &&
|
||||||
|
' · histórica ou base desactualizada; não utilizável para adjudicar'}
|
||||||
|
</p>
|
||||||
|
<p className="my-4 whitespace-pre-wrap">
|
||||||
|
{attempt.reason}
|
||||||
|
</p>
|
||||||
|
<details className="mb-4 text-sm">
|
||||||
|
<summary className="cursor-pointer font-medium">
|
||||||
|
Matriz preservada nesta tentativa
|
||||||
|
</summary>
|
||||||
|
<Matrix
|
||||||
|
rows={attempt.snapshot.basis.rows}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs break-all">
|
||||||
|
Base: {attempt.basis_hash}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
{attempt.reviews.map((review) => (
|
||||||
|
<div
|
||||||
|
key={review.id}
|
||||||
|
className="mt-3 rounded-lg bg-muted p-3 text-sm"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{review.snapshot.name} ·{' '}
|
||||||
|
{roleLabels[
|
||||||
|
review.snapshot.purchasing_role
|
||||||
|
] ??
|
||||||
|
review.snapshot
|
||||||
|
.purchasing_role}{' '}
|
||||||
|
·{' '}
|
||||||
|
{review.verdict === 'approved'
|
||||||
|
? 'Confirmou'
|
||||||
|
: 'Devolveu'}{' '}
|
||||||
|
· {date(review.created_at)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap">
|
||||||
|
{review.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{attempt.canReview && (
|
||||||
|
<Confer attempt={attempt} />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
className="mt-4 inline-block text-sm underline"
|
||||||
|
href={`/recomendacoes/${attempt.id}/imprimir`}
|
||||||
|
>
|
||||||
|
Consultar relatório para impressão
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Uma devolução encerra a tentativa sem excluir o
|
||||||
|
concorrente ou cancelar o processo. As conferências
|
||||||
|
não são transferidas para outra tentativa.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{history.prev_page_url && (
|
||||||
|
<Link href={history.prev_page_url}>
|
||||||
|
Anteriores
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{history.next_page_url && (
|
||||||
|
<Link href={history.next_page_url}>
|
||||||
|
Seguintes
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ type Report = {
|
|||||||
requester: string;
|
requester: string;
|
||||||
};
|
};
|
||||||
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
|
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
|
||||||
|
recommendation: { id: number; revision: number } | null;
|
||||||
admission: {
|
admission: {
|
||||||
id: number;
|
id: number;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -169,6 +170,17 @@ export default function ReviewReport({ report }: { report: Report }) {
|
|||||||
a qualificação documental são apresentadas separadamente.
|
a qualificação documental são apresentadas separadamente.
|
||||||
</div>
|
</div>
|
||||||
<Panel title="Identificação do pedido">
|
<Panel title="Identificação do pedido">
|
||||||
|
<p className="mb-4 text-sm">
|
||||||
|
{report.recommendation
|
||||||
|
? 'Regime de recomendação adoptado. A adjudicação exige recomendação confirmada e actual.'
|
||||||
|
: 'Sem adopção do regime de recomendação.'}{' '}
|
||||||
|
<Link
|
||||||
|
className="underline print:hidden"
|
||||||
|
href={`/requisicoes/${r.id}/recomendacao`}
|
||||||
|
>
|
||||||
|
Consultar recomendação e relatório preservado
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
<p className="mb-4 text-sm">
|
<p className="mb-4 text-sm">
|
||||||
{report.admission
|
{report.admission
|
||||||
? `Regime de admissão adoptado: política ${report.admission.snapshot.policy_revision}, ${report.admission.snapshot.required_count} confirmações independentes. A elegibilidade da comparação inclui admissão confirmada na base actual.`
|
? `Regime de admissão adoptado: política ${report.admission.snapshot.policy_revision}, ${report.admission.snapshot.required_count} confirmações independentes. A elegibilidade da comparação inclui admissão confirmada na base actual.`
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ export default function Comparison({
|
|||||||
>
|
>
|
||||||
Admissão e exclusão de propostas
|
Admissão e exclusão de propostas
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-sm font-medium underline"
|
||||||
|
href={`/requisicoes/${requisition.id}/recomendacao`}
|
||||||
|
>
|
||||||
|
Recomendação antes da adjudicação
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
className="text-sm font-medium underline"
|
className="text-sm font-medium underline"
|
||||||
href={`/requisicoes/${requisition.id}/relatorio`}
|
href={`/requisicoes/${requisition.id}/relatorio`}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Recomendação · {{ $requisition->number }} · Sistema de Gestão de Compras (SGC)</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 14mm; }
|
||||||
|
body { font: 12px Arial, sans-serif; color: #111; margin: 24px; }
|
||||||
|
header { border-bottom: 3px solid #ed1c24; margin-bottom: 18px; padding-bottom: 12px; }
|
||||||
|
h1 { font-size: 21px; } h2 { margin-top: 22px; font-size: 16px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
|
th, td { border: 1px solid #bbb; padding: 7px; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||||
|
th { background: #eee; } thead { display: table-header-group; }
|
||||||
|
tr, article { break-inside: avoid; } p { overflow-wrap: anywhere; }
|
||||||
|
.reason { white-space: pre-wrap; } .notice { border: 1px solid #999; padding: 12px; }
|
||||||
|
article { border-left: 3px solid #ddd; padding-left: 12px; margin: 14px 0; }
|
||||||
|
footer { border-top: 1px solid #999; margin-top: 24px; padding-top: 10px; }
|
||||||
|
@media print { .controls { display: none; } body { margin: 0; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="controls"><a href="{{ route('recommendations.show', $requisition) }}">Voltar ao processo</a> · <button onclick="window.print()">Imprimir</button></nav>
|
||||||
|
<header><strong>Sistema de Gestão de Compras (SGC)</strong><h1>Recomendação antes da adjudicação · {{ $requisition->number }}</h1><p>Adopção {{ $adoption->revision }} · política {{ $adoption->snapshot['policy_revision'] }} · tentativa {{ $attempt->revision }}</p></header>
|
||||||
|
@php
|
||||||
|
$states = ['pending' => 'A aguardar conferência', 'confirmed' => 'Recomendação confirmada', 'returned' => 'Tentativa devolvida'];
|
||||||
|
$rows = $attempt->snapshot['basis']['rows'];
|
||||||
|
$candidate = collect($rows)->firstWhere('id', $attempt->quotation_id);
|
||||||
|
@endphp
|
||||||
|
<p class="notice"><strong>{{ $states[$state] }} · {{ $current ? 'Base actual na consulta' : 'Histórica ou base desactualizada: não utilizável para adjudicar' }}</strong><br>Recomendação não autoriza compra, não substitui aprovações do valor final e não constitui assinatura digital ou certificação de evidências.</p>
|
||||||
|
<p>Consulta por {{ $generatedBy }} em {{ $generatedAt->format('d/m/Y H:i:s') }} · Africa/Johannesburg (UTC+2). A situação actual pode mudar após esta consulta.</p>
|
||||||
|
<h2>Proposta recomendada</h2><p><strong>{{ $candidate['supplier_name'] ?? 'Fornecedor indisponível' }}</strong> · {{ $candidate['reference'] ?? $attempt->quotation_id }}</p>
|
||||||
|
<p>Proponente: {{ $attempt->snapshot['proponent']['name'] }} · Registo: {{ $attempt->created_at }} UTC</p><p class="reason">{{ $attempt->reason }}</p>
|
||||||
|
<p>Política preservada: {{ $adoption->snapshot['required_count'] }} pessoas distintas; {{ implode(', ', array_map(fn ($role) => \App\Enums\PurchasingRole::from($role)->label(), $adoption->snapshot['roles'])) }}.</p>
|
||||||
|
<p>Base preservada: {{ $attempt->basis_hash }}</p>
|
||||||
|
<h2>Matriz preservada na tentativa</h2>
|
||||||
|
<table><thead><tr><th>Fornecedor e proposta</th><th>Total</th><th>Avaliações elegíveis</th><th>Pontuação / 10</th><th>Situação na base preservada</th></tr></thead><tbody>
|
||||||
|
@foreach($rows as $row)
|
||||||
|
<tr><td>{{ $row['supplier_name'] }} · {{ $row['reference'] }}</td><td>{{ number_format($row['total_minor'] / 100, 2, ',', ' ') }} {{ $row['currency'] }}</td><td>{{ $row['evaluation_count'] }}</td><td>{{ $row['score_minor'] === null ? 'Por concluir' : number_format($row['score_minor'] / 100, 2, ',', ' ') }}</td><td>{{ $row['eligible'] ? 'Elegível, com admissão confirmada' : implode(' ', $row['ineligibility_reasons']) }}{{ in_array($row['id'], $attempt->snapshot['basis']['top_ids'], true) ? ' · Maior pontuação' : '' }}</td></tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody></table>
|
||||||
|
<h2>Referências de admissão preservadas</h2>
|
||||||
|
@foreach($attempt->snapshot['basis']['admissions'] as $admission)
|
||||||
|
<p>Proposta #{{ $admission['quotation_id'] }} · requisitos #{{ $admission['basis']['set_id'] ?? 'não disponíveis' }} · decisão #{{ $admission['decision']['id'] ?? 'não disponível' }} · conferências {{ implode(', ', array_column($admission['reviews'], 'id')) ?: 'sem registos' }}.</p>
|
||||||
|
@endforeach
|
||||||
|
<h2>Conferências desta tentativa</h2>
|
||||||
|
@forelse($reviews as $review)
|
||||||
|
<article><strong>{{ $review->snapshot['name'] }} · {{ \App\Enums\PurchasingRole::from($review->snapshot['purchasing_role'])->label() }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }}</strong><p>{{ $review->created_at }} UTC</p><p class="reason">{{ $review->reason }}</p></article>
|
||||||
|
@empty<p>Sem conferências registadas.</p>@endforelse
|
||||||
|
<h2>Histórico desta adopção até à tentativa impressa</h2>
|
||||||
|
@forelse($history as $entry)
|
||||||
|
<article><strong>Tentativa {{ $entry['attempt']->revision }} · proposta #{{ $entry['attempt']->quotation_id }} · {{ $states[$entry['state']] }} · histórica</strong><p class="reason">{{ $entry['attempt']->reason }}</p>
|
||||||
|
@foreach($entry['reviews'] as $review)<p class="reason">{{ $review->snapshot['name'] }} · {{ $review->verdict === 'approved' ? 'Confirmou' : 'Devolveu' }} · {{ $review->created_at }} UTC · {{ $review->reason }}</p>@endforeach</article>
|
||||||
|
@empty<p>Sem tentativas anteriores nesta adopção.</p>@endforelse
|
||||||
|
<footer>Sistema de Gestão de Compras (SGC) · Registos preservados para conferência. Outras adopções são consultáveis no histórico autenticado do processo.</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\DocumentaryQualificationController;
|
|||||||
use App\Http\Controllers\EquipmentController;
|
use App\Http\Controllers\EquipmentController;
|
||||||
use App\Http\Controllers\ExternalPaymentController;
|
use App\Http\Controllers\ExternalPaymentController;
|
||||||
use App\Http\Controllers\FormalAdmissionController;
|
use App\Http\Controllers\FormalAdmissionController;
|
||||||
|
use App\Http\Controllers\FormalRecommendationController;
|
||||||
use App\Http\Controllers\GoodsReceiptController;
|
use App\Http\Controllers\GoodsReceiptController;
|
||||||
use App\Http\Controllers\InvestmentPolicyController;
|
use App\Http\Controllers\InvestmentPolicyController;
|
||||||
use App\Http\Controllers\InvoiceController;
|
use App\Http\Controllers\InvoiceController;
|
||||||
@@ -36,6 +37,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||||
|
|
||||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
||||||
|
Route::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy');
|
||||||
|
Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']);
|
||||||
|
Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show');
|
||||||
|
Route::post('requisicoes/{requisition}/recomendacao/adoptar', [FormalRecommendationController::class, 'adopt']);
|
||||||
|
Route::post('requisicoes/{requisition}/recomendacao/tentativas', [FormalRecommendationController::class, 'propose']);
|
||||||
|
Route::post('recomendacoes/{attempt}/conferir', [FormalRecommendationController::class, 'review']);
|
||||||
|
Route::get('recomendacoes/{attempt}/imprimir', [FormalRecommendationController::class, 'print'])->name('recommendations.print');
|
||||||
Route::get('politica-admissao', [FormalAdmissionController::class, 'policy'])->name('admissions.policy');
|
Route::get('politica-admissao', [FormalAdmissionController::class, 'policy'])->name('admissions.policy');
|
||||||
Route::post('politica-admissao', [FormalAdmissionController::class, 'configure']);
|
Route::post('politica-admissao', [FormalAdmissionController::class, 'configure']);
|
||||||
Route::get('requisicoes/{requisition}/admissao', [FormalAdmissionController::class, 'show'])->name('admissions.show');
|
Route::get('requisicoes/{requisition}/admissao', [FormalAdmissionController::class, 'show'])->name('admissions.show');
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\RecommendationAttempt;
|
||||||
|
use App\Models\Requisition;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FormalRecommendation;
|
||||||
|
use App\Services\PurchaseWorkflow;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||||
|
|
||||||
|
require __DIR__.'/../../vendor/autoload.php';
|
||||||
|
$database = $argv[1] ?? '';
|
||||||
|
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||||
|
throw new LogicException('Base de teste inválida.');
|
||||||
|
}
|
||||||
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
$app->make(Kernel::class)->bootstrap();
|
||||||
|
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
|
||||||
|
throw new LogicException('Worker não isolado.');
|
||||||
|
}
|
||||||
|
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_recommendation_'.$argv[4]]);
|
||||||
|
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||||
|
$actor = User::query()->findOrFail($argv[2]);
|
||||||
|
auth()->guard()->setUser($actor);
|
||||||
|
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
try {
|
||||||
|
if (($payload['action'] ?? 'review') === 'propose') {
|
||||||
|
app(FormalRecommendation::class)->propose($actor, Requisition::query()->findOrFail($payload['requisition_id']), $payload['data']);
|
||||||
|
} elseif ($payload['action'] === 'award') {
|
||||||
|
app(PurchaseWorkflow::class)->select($actor, Requisition::query()->findOrFail($payload['requisition_id']), $payload['data']);
|
||||||
|
} else {
|
||||||
|
app(FormalRecommendation::class)->review($actor, RecommendationAttempt::query()->findOrFail($argv[3]), $payload['verdict'], 'Conferência exclusivamente fictícia');
|
||||||
|
}
|
||||||
|
$status = 200;
|
||||||
|
} catch (ValidationException) {
|
||||||
|
$status = 422;
|
||||||
|
} catch (HttpExceptionInterface $exception) {
|
||||||
|
$status = $exception->getStatusCode();
|
||||||
|
}
|
||||||
|
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PurchaseAward;
|
||||||
|
use App\Models\RecommendationAttempt;
|
||||||
|
use App\Models\RecommendationReview;
|
||||||
|
use App\Services\FormalRecommendation;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
require __DIR__.'/../../vendor/autoload.php';
|
||||||
|
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
$app->make(Kernel::class)->bootstrap();
|
||||||
|
require __DIR__.'/../Support/RecommendationFixture.php';
|
||||||
|
$original = DB::connection();
|
||||||
|
if ($original->getDriverName() !== 'pgsql') {
|
||||||
|
throw new LogicException('Exige PostgreSQL.');
|
||||||
|
}
|
||||||
|
$config = $original->getConfig();
|
||||||
|
$admin = $original->getPdo();
|
||||||
|
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||||
|
$created = false;
|
||||||
|
$processes = [];
|
||||||
|
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
|
||||||
|
try {
|
||||||
|
$admin->exec('CREATE DATABASE "'.$database.'"');
|
||||||
|
$created = true;
|
||||||
|
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
|
||||||
|
DB::purge('pgsql');
|
||||||
|
DB::setDefaultConnection('pgsql');
|
||||||
|
Schema::clearResolvedInstance('db.schema');
|
||||||
|
app()->forgetInstance('db.schema');
|
||||||
|
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
|
||||||
|
throw new LogicException('Migração fora do isolamento recusada.');
|
||||||
|
}
|
||||||
|
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
|
||||||
|
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
|
||||||
|
foreach (['same_person', 'distinct_people', 'opposing_reviews', 'revoked_role', 'changed_basis', 'new_adoption', 'competing_attempts', 'recommendation_versus_award'] as $scenario) {
|
||||||
|
$f = recommendationFixture(2);
|
||||||
|
$workflow = app(FormalRecommendation::class);
|
||||||
|
$decision = $workflow->propose($f['buyer'], $f['r'], $f['proposal']);
|
||||||
|
if ($scenario === 'recommendation_versus_award') {
|
||||||
|
foreach ([$f['reviewer'], $f['other_reviewer']] as $reviewer) {
|
||||||
|
auth()->guard()->setUser($reviewer);
|
||||||
|
$workflow->review($reviewer, $decision, 'approved', 'Confirmação fictícia antes da corrida');
|
||||||
|
}
|
||||||
|
auth()->guard()->setUser($f['buyer']);
|
||||||
|
}
|
||||||
|
$beforeReviews = RecommendationReview::query()->count();
|
||||||
|
$connection = DB::connection();
|
||||||
|
$connection->beginTransaction();
|
||||||
|
$connection->table('tenants')->where('id', $f['tenant']->id)->lockForUpdate()->first();
|
||||||
|
$processes = [];
|
||||||
|
foreach (['A', 'B'] as $index => $suffix) {
|
||||||
|
$actor = $scenario === 'distinct_people' && $index === 1 ? $f['other_reviewer'] : $f['reviewer'];
|
||||||
|
$verdict = $scenario === 'opposing_reviews' && $index === 1 ? 'returned' : 'approved';
|
||||||
|
$payload = ['action' => 'review', 'verdict' => $verdict];
|
||||||
|
if (in_array($scenario, ['competing_attempts', 'recommendation_versus_award'], true)) {
|
||||||
|
$actor = $f['buyer'];
|
||||||
|
$award = $scenario === 'recommendation_versus_award' && $index === 1;
|
||||||
|
$payload = ['action' => $award ? 'award' : 'propose', 'requisition_id' => $f['r']->id, 'data' => $award ? $f['selection'] : [...$f['proposal'], 'previous_id' => $decision->id]];
|
||||||
|
}
|
||||||
|
$process = new Process([PHP_BINARY, __DIR__.'/recommendation-worker.php', $database, (string) $actor->id, (string) $decision->id, $suffix], base_path(), $workerEnv);
|
||||||
|
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||||
|
$process->setTimeout(40);
|
||||||
|
$process->start();
|
||||||
|
$processes[] = $process;
|
||||||
|
}
|
||||||
|
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_recommendation_A','sgc_recommendation_B') AND wait_event_type = 'Lock'");
|
||||||
|
$deadline = microtime(true) + 20;
|
||||||
|
do {
|
||||||
|
$statement->execute([$database]);
|
||||||
|
$waits = (int) $statement->fetchColumn();
|
||||||
|
if ($waits === 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
if (! $process->isRunning()) {
|
||||||
|
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usleep(20000);
|
||||||
|
} while (microtime(true) < $deadline);
|
||||||
|
if ($waits !== 2) {
|
||||||
|
throw new RuntimeException('Sem sobreposição dos dois bloqueios.');
|
||||||
|
}
|
||||||
|
if ($scenario === 'revoked_role') {
|
||||||
|
$connection->table('users')->where('id', $f['reviewer']->id)->update(['purchasing_role' => 'requester']);
|
||||||
|
}
|
||||||
|
if ($scenario === 'changed_basis') {
|
||||||
|
$connection->table('proposal_evaluations')->where('quotation_id', $f['quote']->id)->update(['comment' => 'Alteração fictícia durante a espera']);
|
||||||
|
}
|
||||||
|
if ($scenario === 'new_adoption') {
|
||||||
|
auth()->guard()->setUser($f['admin']);
|
||||||
|
$policy = $workflow->policy($f['admin'], [...$f['policy_data'], 'previous_id' => $f['policy']->id]);
|
||||||
|
auth()->guard()->setUser($f['buyer']);
|
||||||
|
$workflow->adopt($f['buyer'], $f['r'], $policy->id, $f['adoption']->id, 'Nova adopção enquanto os conferentes aguardam');
|
||||||
|
}
|
||||||
|
$connection->commit();
|
||||||
|
$results = [];
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
$process->wait();
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
throw new RuntimeException('Worker falhou: '.$process->getErrorOutput());
|
||||||
|
}
|
||||||
|
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
$statuses = array_column($results, 'status');
|
||||||
|
sort($statuses);
|
||||||
|
$expected = match ($scenario) {
|
||||||
|
'revoked_role' => [403, 403], 'changed_basis', 'new_adoption' => [422, 422], 'opposing_reviews', 'competing_attempts', 'recommendation_versus_award' => [200, 422], default => [200, 200]
|
||||||
|
};
|
||||||
|
auth()->guard()->setUser($f['buyer']);
|
||||||
|
$reviews = RecommendationReview::query()->count();
|
||||||
|
$expectedReviews = match ($scenario) {
|
||||||
|
'distinct_people' => 2, 'same_person', 'opposing_reviews' => 1, default => 0
|
||||||
|
};
|
||||||
|
$expectedReviews += $beforeReviews;
|
||||||
|
if ($statuses !== $expected || $reviews !== $expectedReviews || AuditLog::query()->where('action', 'recommendation.reviewed')->count() !== $reviews || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||||
|
throw new RuntimeException('Resultado concorrente inconsistente: '.json_encode([$scenario, $statuses, $reviews], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
$state = $workflow->status($decision, $f['adoption']);
|
||||||
|
$attempts = RecommendationAttempt::query()->count();
|
||||||
|
$awards = PurchaseAward::query()->count();
|
||||||
|
$newAttempt = $scenario === 'competing_attempts' || ($scenario === 'recommendation_versus_award' && $results[0]['status'] === 200);
|
||||||
|
if (($scenario === 'distinct_people' && $state !== 'confirmed') || ($scenario === 'same_person' && $state !== 'pending') || $attempts !== ($newAttempt ? 2 : 1) || $awards !== ($scenario === 'recommendation_versus_award' && ! $newAttempt ? 1 : 0)) {
|
||||||
|
throw new RuntimeException('Quórum ou tentativa alterados indevidamente.');
|
||||||
|
}
|
||||||
|
if ($awards && PurchaseAward::query()->sole()->recommendation_snapshot['attempt_id'] !== $decision->id) {
|
||||||
|
throw new RuntimeException('Adjudicação ligada à tentativa errada.');
|
||||||
|
}
|
||||||
|
if (AuditLog::query()->where('action', 'recommendation.proposed')->count() !== $attempts || AuditLog::query()->where('action', 'award.created')->count() !== $awards) {
|
||||||
|
throw new RuntimeException('Auditoria de tentativa/adjudicação inconsistente.');
|
||||||
|
}
|
||||||
|
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'reviews' => $reviews, 'state' => $state, 'attempts' => $attempts, 'awards' => $awards];
|
||||||
|
}
|
||||||
|
$report['passed'] = true;
|
||||||
|
} finally {
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
if ($process->isRunning()) {
|
||||||
|
$process->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($connection) && $connection->transactionLevel() > 0) {
|
||||||
|
$connection->rollBack();
|
||||||
|
}
|
||||||
|
DB::disconnect('pgsql');
|
||||||
|
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||||
|
$admin->exec('DROP DATABASE "'.$database.'"');
|
||||||
|
$report['test_database_dropped'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_put_contents(base_path('docs/recomendacao-concorrencia-postgresql.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||||
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PurchasingRole;
|
||||||
|
use App\Models\AdmissionDecision;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\BudgetEntry;
|
||||||
|
use App\Models\ConflictDeclaration;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\ProposalEvaluation;
|
||||||
|
use App\Models\PurchaseAward;
|
||||||
|
use App\Models\PurchaseOrder;
|
||||||
|
use App\Models\RecommendationAdoption;
|
||||||
|
use App\Models\RecommendationAttempt;
|
||||||
|
use App\Models\RecommendationPolicy;
|
||||||
|
use App\Models\RecommendationReview;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FormalAdmission;
|
||||||
|
use App\Services\FormalRecommendation;
|
||||||
|
use App\Services\ProposalComparison;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
|
require_once __DIR__.'/../Support/RecommendationFixture.php';
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->f = recommendationFixture();
|
||||||
|
$this->url = '/requisicoes/'.$this->f['r']->id.'/recomendacao';
|
||||||
|
$this->awardUrl = '/requisicoes/'.$this->f['r']->id.'/adjudicacao';
|
||||||
|
$this->actingAs($this->f['buyer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
function recommendationAttempt(object $test, array $changes = []): RecommendationAttempt
|
||||||
|
{
|
||||||
|
$test->actingAs($test->f['buyer'])->post($test->url.'/tentativas', [...$test->f['proposal'], ...$changes])->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
return RecommendationAttempt::query()->latest('id')->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendationConfer(object $test, RecommendationAttempt $attempt, ?User $actor = null, string $verdict = 'approved'): void
|
||||||
|
{
|
||||||
|
$test->actingAs($actor ?? $test->f['reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => $verdict, 'reason' => 'Conferência de recomendação fictícia'])->assertSessionHasNoErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('company recommendation requires quorum and binds genuine award and issued order', function () {
|
||||||
|
$before = app(ProposalComparison::class)->inspect($this->f['r']);
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
$this->get($this->awardUrl)->assertInertia(fn (Assert $p) => $p->where('canSelect', false));
|
||||||
|
$this->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('pending')->and(BudgetEntry::query()->count())->toBe(0);
|
||||||
|
recommendationConfer($this, $attempt, $this->f['head']);
|
||||||
|
expect(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
|
||||||
|
$award = PurchaseAward::query()->sole();
|
||||||
|
expect($award->recommendation_snapshot['attempt_id'])->toBe($attempt->id)->and($award->recommendation_snapshot['review_ids'])->toHaveCount(2);
|
||||||
|
$this->actingAs($this->f['head'])->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||||
|
$this->actingAs($this->f['buyer'])->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
|
||||||
|
$order = PurchaseOrder::query()->sole();
|
||||||
|
expect($order->snapshot['recommendation'])->toBe($award->recommendation_snapshot);
|
||||||
|
$this->f['quotes'][0]->update(['reference' => 'Alteração posterior à emissão']);
|
||||||
|
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
|
||||||
|
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->refresh()->snapshot['quotation']['reference'])->toBe('PROP-TEST');
|
||||||
|
$this->post($this->url.'/tentativas', [...$this->f['proposal'], 'previous_id' => $attempt->id])->assertSessionHasErrors('recommendation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendation retries count one person once and returned attempt cannot authorize award', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
expect(RecommendationReview::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'recommendation.reviewed')->count())->toBe(1);
|
||||||
|
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Outra resposta para a mesma pessoa'])->assertSessionHasErrors('recommendation');
|
||||||
|
recommendationConfer($this, $attempt, $this->f['other_reviewer'], 'returned');
|
||||||
|
expect(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('returned')->and(AdmissionDecision::query()->where('outcome', 'admit')->count())->toBe(3);
|
||||||
|
$next = recommendationAttempt($this, ['previous_id' => $attempt->id]);
|
||||||
|
expect($next->revision)->toBe(2)->and(RecommendationReview::query()->where('recommendation_attempt_id', $next->id)->count())->toBe(0);
|
||||||
|
$this->post($this->url.'/tentativas', $this->f['proposal'])->assertSessionHasErrors('recommendation');
|
||||||
|
$this->actingAs($this->f['head'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de tentativa anterior'])->assertSessionHasErrors('recommendation');
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendation rejects lower score incomplete matrix and stale admission', function (string $case) {
|
||||||
|
if ($case === 'incomplete') {
|
||||||
|
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->where('evaluator_id', $this->f['analyst']->id)->delete();
|
||||||
|
} elseif ($case === 'admission') {
|
||||||
|
$old = AdmissionDecision::query()->where('quotation_id', $this->f['quotes'][0]->id)->latest('id')->firstOrFail();
|
||||||
|
app(FormalAdmission::class)->propose($this->f['buyer'], $this->f['r'], ['quotation_id' => $old->quotation_id, 'previous_id' => $old->id, 'basis_hash' => $old->basis_hash, 'outcome' => 'admit', 'reason' => 'Nova tentativa de admissão sem confirmação']);
|
||||||
|
}
|
||||||
|
$data = [...$this->f['proposal'], 'basis_hash' => app(FormalRecommendation::class)->basis($this->f['r'])['hash']];
|
||||||
|
if ($case === 'lower') {
|
||||||
|
$data['quotation_id'] = $this->f['quotes'][2]->id;
|
||||||
|
}
|
||||||
|
$this->post($this->url.'/tentativas', $data)->assertSessionHasErrors('recommendation');
|
||||||
|
expect(RecommendationAttempt::query()->count())->toBe(0);
|
||||||
|
})->with(['lower', 'incomplete', 'admission']);
|
||||||
|
|
||||||
|
test('tie permits reasoned recommendation and award only of the chosen tied proposal', function () {
|
||||||
|
// Only evaluations change, preserving documentary admission basis.
|
||||||
|
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][0]->id)->update(['quality' => '0', 'history' => '0', 'payment' => '0']);
|
||||||
|
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][1]->id)->update(['quality' => '5', 'history' => '5', 'payment' => '2.5']);
|
||||||
|
// Existing weights yield 5.50 for each of the first two proposals, without changing received prices.
|
||||||
|
$this->f['quotes'][2]->evaluations()->update(['quality' => '0', 'history' => '0', 'payment' => '0']);
|
||||||
|
$basis = app(FormalRecommendation::class)->basis($this->f['r']);
|
||||||
|
expect($basis['ready'])->toBeTrue()->and($basis['top_ids'])->toHaveCount(2)->toContain($this->f['quotes'][0]->id, $this->f['quotes'][1]->id);
|
||||||
|
$attempt = recommendationAttempt($this, ['basis_hash' => $basis['hash'], 'quotation_id' => $this->f['quotes'][1]->id, 'reason' => 'Escolha fundamentada da segunda proposta empatada']);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
|
||||||
|
$this->post($this->awardUrl, [...$this->f['selection'], 'quotation_id' => $this->f['quotes'][1]->id])->assertSessionHasNoErrors();
|
||||||
|
expect(PurchaseAward::query()->sole()->quotation_id)->toBe($this->f['quotes'][1]->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changed matrix invalidates pending reviews and blocks emission after award', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
recommendationConfer($this, $attempt, $this->f['head']);
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
|
||||||
|
$award = PurchaseAward::query()->sole();
|
||||||
|
$this->actingAs($this->f['head'])->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||||
|
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][2]->id)->update(['comment' => 'Análise alterada antes da emissão']);
|
||||||
|
$this->actingAs($this->f['buyer'])->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('recommendation');
|
||||||
|
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changed evaluation requires a new recommendation with no inherited approvals', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
ProposalEvaluation::query()->where('quotation_id', $this->f['quotes'][1]->id)->update(['comment' => 'Nova fundamentação da avaliação']);
|
||||||
|
$this->actingAs($this->f['other_reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência sobre uma base anterior'])->assertSessionHasErrors('recommendation');
|
||||||
|
$next = recommendationAttempt($this, ['previous_id' => $attempt->id, 'basis_hash' => app(FormalRecommendation::class)->basis($this->f['r'])['hash']]);
|
||||||
|
expect($next->revision)->toBe(2)->and(RecommendationReview::query()->where('recommendation_attempt_id', $next->id)->count())->toBe(0)->and(RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new confirmed admission invalidates recommendation even with identical scores and prices', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
|
||||||
|
$before = app(ProposalComparison::class)->inspect($this->f['r']);
|
||||||
|
$this->actingAs($this->f['buyer']);
|
||||||
|
$admission = app(FormalAdmission::class);
|
||||||
|
$old = AdmissionDecision::query()->where('quotation_id', $this->f['quotes'][1]->id)->latest('id')->firstOrFail();
|
||||||
|
$new = $admission->propose($this->f['buyer'], $this->f['r'], ['quotation_id' => $old->quotation_id, 'previous_id' => $old->id, 'basis_hash' => $old->basis_hash, 'outcome' => 'admit', 'reason' => 'Reapreciação de admissão fundamentada']);
|
||||||
|
$this->actingAs($this->f['analyst']);
|
||||||
|
$admission->review($this->f['analyst'], $new, 'approved', 'Nova conferência de admissão fictícia');
|
||||||
|
expect(app(ProposalComparison::class)->inspect($this->f['r']))->toBe($before);
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
|
||||||
|
expect(PurchaseAward::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one identity cannot count again after role change and review audit failure rolls back', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::DepartmentHead, 'department_id' => $this->f['r']->department_id])->save();
|
||||||
|
recommendationConfer($this, $attempt, $this->f['reviewer']->fresh());
|
||||||
|
expect(RecommendationReview::query()->count())->toBe(1)->and(RecommendationReview::query()->sole()->snapshot['purchasing_role'])->toBe('finance');
|
||||||
|
$this->actingAs($this->f['head']);
|
||||||
|
DB::statement("CREATE TRIGGER fail_recommendation_review BEFORE INSERT ON audit_logs WHEN NEW.action = 'recommendation.reviewed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
|
||||||
|
try {
|
||||||
|
app(FormalRecommendation::class)->review($this->f['head'], $attempt, 'approved', 'Confirmação que deve ser revertida');
|
||||||
|
$this->fail('Missing conference rollback');
|
||||||
|
} catch (QueryException) {
|
||||||
|
expect(RecommendationReview::query()->count())->toBe(1)->and(app(FormalRecommendation::class)->status($attempt, $this->f['adoption']))->toBe('pending');
|
||||||
|
} finally {
|
||||||
|
DB::statement('DROP TRIGGER fail_recommendation_review');
|
||||||
|
}
|
||||||
|
$other = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra chefia']);
|
||||||
|
$this->f['reviewer']->forceFill(['department_id' => $other->id])->save();
|
||||||
|
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferente anterior mudou de departamento'])->assertSessionHasErrors('recommendation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current and earlier reviewer eligibility is revalidated while completed history remains', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||||
|
$this->actingAs($this->f['other_reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Primeiro conferente perdeu o perfil'])->assertSessionHasErrors('recommendation');
|
||||||
|
$this->f['other_reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||||
|
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'returned', 'reason' => 'Perfil revogado durante a sessão'])->assertForbidden();
|
||||||
|
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||||
|
recommendationConfer($this, $attempt, $this->f['head']);
|
||||||
|
$this->f['reviewer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||||
|
expect(app(FormalRecommendation::class)->selection($this->f['r'], $attempt->quotation_id)['review_ids'])->toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proponent requester and wrong department cannot confirm recommendations', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
$this->f['buyer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||||
|
$this->actingAs($this->f['buyer']->fresh())->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Auto confirmação indevida'])->assertForbidden();
|
||||||
|
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||||
|
$this->actingAs($this->f['issuer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Solicitante não pode conferir'])->assertForbidden();
|
||||||
|
$other = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Departamento distinto']);
|
||||||
|
$this->f['head']->forceFill(['department_id' => $other->id])->save();
|
||||||
|
$this->actingAs($this->f['head'])->get($this->url)->assertNotFound();
|
||||||
|
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Chefia de outro departamento'])->assertNotFound();
|
||||||
|
expect(RecommendationReview::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy changes preserve adoption until explicit new version and reset recommendations', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
recommendationConfer($this, $attempt);
|
||||||
|
$workflow = app(FormalRecommendation::class);
|
||||||
|
$this->actingAs($this->f['admin']);
|
||||||
|
$disabled = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $this->f['policy']->id, 'required_count' => 1, 'enabled' => false]);
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->url.'/adoptar', ['previous_id' => $this->f['adoption']->id, 'policy_id' => $disabled->id, 'reason' => 'Adopção com política desactivada'])->assertSessionHasErrors('recommendation');
|
||||||
|
recommendationConfer($this, $attempt, $this->f['other_reviewer']);
|
||||||
|
expect($workflow->status($attempt, $this->f['adoption']))->toBe('confirmed')->and($this->f['adoption']->refresh()->snapshot['required_count'])->toBe(2);
|
||||||
|
$this->actingAs($this->f['admin']);
|
||||||
|
$new = $workflow->policy($this->f['admin'], [...$this->f['policy_data'], 'previous_id' => $disabled->id, 'required_count' => 1]);
|
||||||
|
$this->actingAs($this->f['buyer']);
|
||||||
|
$workflow->adopt($this->f['buyer'], $this->f['r'], $new->id, $this->f['adoption']->id, 'Outra versão expressamente adoptada');
|
||||||
|
$this->post($this->awardUrl, $this->f['selection'])->assertSessionHasErrors('recommendation');
|
||||||
|
expect(RecommendationAttempt::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy is admin only explicit and independent from admission and absent adoption keeps legacy circuit', function () {
|
||||||
|
$this->post('/politica-recomendacao', $this->f['policy_data'])->assertForbidden();
|
||||||
|
$this->actingAs($this->f['admin'])->post('/politica-recomendacao', ['previous_id' => $this->f['policy']->id, 'enabled' => true, 'roles' => [], 'reason' => 'Sem valores definidos pela empresa'])->assertSessionHasErrors(['roles', 'required_count']);
|
||||||
|
$this->post('/politica-recomendacao', $this->f['policy_data'])->assertSessionHasErrors('recommendation');
|
||||||
|
$this->get('/politica-recomendacao')->assertInertia(fn (Assert $p) => $p->component('purchases/recommendation-policy')->has('policies.data', 1));
|
||||||
|
// Remove only synthetic adoption from the isolated test DB to exercise the legacy path.
|
||||||
|
DB::table('recommendation_adoptions')->delete();
|
||||||
|
$this->actingAs($this->f['buyer'])->post($this->awardUrl, $this->f['selection'])->assertSessionHasNoErrors();
|
||||||
|
$award = PurchaseAward::query()->sole();
|
||||||
|
expect($award->recommendation_snapshot)->toBeNull();
|
||||||
|
$this->post($this->url.'/adoptar', ['policy_id' => $this->f['policy']->id, 'previous_id' => 0, 'reason' => 'Adopção retroactiva impedida'])->assertSessionHasErrors('recommendation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendation requires prior formal admission adoption and no implicit policy', function () {
|
||||||
|
// All fixture rows belong only to this in-memory database.
|
||||||
|
DB::table('recommendation_adoptions')->delete();
|
||||||
|
DB::table('admission_reviews')->delete();
|
||||||
|
DB::table('admission_decisions')->delete();
|
||||||
|
DB::table('admission_adoptions')->delete();
|
||||||
|
$this->post($this->url.'/adoptar', ['policy_id' => $this->f['policy']->id, 'previous_id' => 0, 'reason' => 'Sem adopção formal de admissão'])->assertSessionHasErrors('recommendation');
|
||||||
|
expect(RecommendationAdoption::query()->count())->toBe(0)->and(RecommendationPolicy::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('report and history are restricted read only preserve old basis and escape user text', function () {
|
||||||
|
$attempt = recommendationAttempt($this, ['reason' => '<script>alert("ficticio")</script>Fundamento fictício']);
|
||||||
|
recommendationConfer($this, $attempt, null, 'returned');
|
||||||
|
$next = recommendationAttempt($this, ['previous_id' => $attempt->id]);
|
||||||
|
$before = AuditLog::query()->count();
|
||||||
|
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $p) => $p->component('purchases/recommendation')->where('history.data.0.id', $next->id)->where('history.data.1.status', 'returned')->where('basis.ready', true));
|
||||||
|
$this->get('/recomendacoes/'.$next->id.'/imprimir')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertSee('Sistema de Gestão de Compras (SGC)')->assertSee('Histórico desta adopção')->assertSee('<script>', false)->assertDontSee('<script>alert', false);
|
||||||
|
$this->f['quotes'][0]->update(['reference' => 'Referência actual diferente']);
|
||||||
|
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertOk()->assertSee('Histórica ou base desactualizada')->assertSee('PROP-TEST')->assertDontSee('Referência actual diferente');
|
||||||
|
expect(AuditLog::query()->count())->toBe($before);
|
||||||
|
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
|
||||||
|
foreach ([$this->f['issuer'], $this->f['receiver']] as $reader) {
|
||||||
|
$this->actingAs($reader)->get($this->url)->assertForbidden();
|
||||||
|
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertForbidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other tenant conflict and audit rollback cannot introduce recommendation decisions', function () {
|
||||||
|
$attempt = recommendationAttempt($this);
|
||||||
|
$tenant = Tenant::query()->create(['name' => 'Empresa fictícia distinta']);
|
||||||
|
$other = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||||
|
$this->actingAs($other)->get($this->url)->assertNotFound();
|
||||||
|
$this->get('/recomendacoes/'.$attempt->id.'/imprimir')->assertNotFound();
|
||||||
|
$this->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência de empresa distinta'])->assertNotFound();
|
||||||
|
$this->actingAs($this->f['buyer']);
|
||||||
|
DB::statement("CREATE TRIGGER fail_recommendation_audit BEFORE INSERT ON audit_logs WHEN NEW.action = 'recommendation.proposed' BEGIN SELECT RAISE(ABORT, 'test audit failure'); END");
|
||||||
|
try {
|
||||||
|
app(FormalRecommendation::class)->propose($this->f['buyer'], $this->f['r'], [...$this->f['proposal'], 'previous_id' => $attempt->id]);
|
||||||
|
$this->fail('Missing audit rollback');
|
||||||
|
} catch (QueryException) {
|
||||||
|
expect(RecommendationAttempt::query()->count())->toBe(1);
|
||||||
|
} finally {
|
||||||
|
DB::statement('DROP TRIGGER fail_recommendation_audit');
|
||||||
|
}
|
||||||
|
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->f['reviewer']->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício documentado']);
|
||||||
|
$this->actingAs($this->f['reviewer'])->post('/recomendacoes/'.$attempt->id.'/conferir', ['verdict' => 'approved', 'reason' => 'Conferência bloqueada por conflito'])->assertSessionHasErrors('conflict');
|
||||||
|
expect(RecommendationReview::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ test('process overview links records without declaring partial execution complet
|
|||||||
->where('stages.7.state', 'recorded')
|
->where('stages.7.state', 'recorded')
|
||||||
->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
|
->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
|
||||||
->where('stages.8.state', 'not_started')
|
->where('stages.8.state', 'not_started')
|
||||||
->has('future', 1)
|
->has('future', 0)
|
||||||
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
|
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PurchasingRole;
|
||||||
|
use App\Models\ProposalEvaluation;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\DocumentaryQualificationWorkflow;
|
||||||
|
use App\Services\FormalAdmission;
|
||||||
|
use App\Services\FormalRecommendation;
|
||||||
|
use App\Services\ProposalOpeningWorkflow;
|
||||||
|
|
||||||
|
require_once __DIR__.'/QualificationFixture.php';
|
||||||
|
|
||||||
|
/** Genuine three-quotation basis; underlying fixture refuses non-isolated databases. */
|
||||||
|
function recommendationFixture(int $quorum = 2): array
|
||||||
|
{
|
||||||
|
$f = qualificationFixture();
|
||||||
|
$tenantId = $f['tenant']->id;
|
||||||
|
$departmentId = $f['r']->department_id;
|
||||||
|
$admin = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Admin]);
|
||||||
|
$buyer = User::factory()->create(['tenant_id' => $tenantId, 'department_id' => $departmentId, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||||
|
$head = User::factory()->create(['tenant_id' => $tenantId, 'department_id' => $departmentId, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||||
|
$reviewer = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Finance]);
|
||||||
|
$otherReviewer = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Finance]);
|
||||||
|
auth()->guard()->setUser($buyer);
|
||||||
|
$f['r']->update(['approval_snapshot' => ['first_limit_minor' => 2500000, 'second_limit_minor' => 15000000, 'approval_includes_tax' => true, 'rules_provisional' => true]]);
|
||||||
|
$f['r']->steps()->create(['tenant_id' => $tenantId, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $head->id, 'decided_at' => now()]);
|
||||||
|
$supplier = Supplier::query()->findOrFail($f['quote']->supplier_id);
|
||||||
|
$quotes = [$f['quote']];
|
||||||
|
for ($i = 1; $i <= 2; $i++) {
|
||||||
|
$otherSupplier = $supplier->replicate();
|
||||||
|
$otherSupplier->fill(['nuit' => '12345678'.$i, 'name' => 'Fornecedor fictício '.$i])->save();
|
||||||
|
$quote = $f['quote']->replicate();
|
||||||
|
$quote->fill(['supplier_id' => $otherSupplier->id, 'reference' => 'PROP-FICTICIA-'.$i])->save();
|
||||||
|
$quotes[] = $quote;
|
||||||
|
}
|
||||||
|
$item = $f['r']->items()->firstOrFail();
|
||||||
|
foreach ($quotes as $index => $quote) {
|
||||||
|
$amount = 10000 * ($index + 1);
|
||||||
|
$quote->update(['created_by' => $buyer->id, 'subtotal_minor' => $amount, 'total_minor' => $amount]);
|
||||||
|
$quote->items()->create(['tenant_id' => $tenantId, 'requisition_item_id' => $item->id, 'description' => $item->description, 'quantity' => '1.000', 'unit_price_minor' => $amount, 'tax_rate' => '0', 'subtotal_minor' => $amount, 'tax_minor' => 0, 'total_minor' => $amount]);
|
||||||
|
foreach ([$buyer, $f['analyst']] as $evaluator) {
|
||||||
|
ProposalEvaluation::query()->create(['tenant_id' => $tenantId, 'quotation_id' => $quote->id, 'evaluator_id' => $evaluator->id, 'quality' => '8', 'history' => '5', 'payment' => '8', 'no_conflict' => true, 'comment' => 'Avaliação exclusivamente fictícia']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$opening = app(ProposalOpeningWorkflow::class);
|
||||||
|
$documentary = app(DocumentaryQualificationWorkflow::class);
|
||||||
|
$set = $documentary->define($buyer, $f['r'], [...$f['definition'], 'basis_hash' => $opening->basis($opening->proposals($f['r']))]);
|
||||||
|
$answers = $f['assessment']['answers'];
|
||||||
|
$answers[1] = ['criterion_id' => 2, 'result' => 'met', 'evidence' => 'Documento fictício', 'reason' => 'Conformidade exclusivamente fictícia'];
|
||||||
|
foreach ($quotes as $quote) {
|
||||||
|
auth()->guard()->setUser($f['analyst']);
|
||||||
|
$documentary->assess($f['analyst'], $set, [...$f['assessment'], 'quotation_id' => $quote->id, 'answers' => $answers]);
|
||||||
|
}
|
||||||
|
$admission = app(FormalAdmission::class);
|
||||||
|
auth()->guard()->setUser($admin);
|
||||||
|
$admissionPolicy = $admission->policy($admin, ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance'], 'required_count' => 1, 'reason' => 'Política de admissão exclusivamente fictícia']);
|
||||||
|
auth()->guard()->setUser($buyer);
|
||||||
|
$admissionAdoption = $admission->adopt($buyer, $f['r'], $admissionPolicy->id, 0, 'Adopção de admissão exclusivamente fictícia');
|
||||||
|
foreach ($quotes as $quote) {
|
||||||
|
auth()->guard()->setUser($buyer);
|
||||||
|
$decision = $admission->propose($buyer, $f['r'], ['quotation_id' => $quote->id, 'previous_id' => 0, 'basis_hash' => $admission->basis($f['r'], $quote->id)['hash'], 'outcome' => 'admit', 'reason' => 'Proposta de admissão exclusivamente fictícia']);
|
||||||
|
auth()->guard()->setUser($f['analyst']);
|
||||||
|
$admission->review($f['analyst'], $decision, 'approved', 'Conferência de admissão exclusivamente fictícia');
|
||||||
|
}
|
||||||
|
$workflow = app(FormalRecommendation::class);
|
||||||
|
$policyData = ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance', 'department_head'], 'required_count' => $quorum, 'reason' => 'Política de recomendação exclusivamente fictícia'];
|
||||||
|
auth()->guard()->setUser($admin);
|
||||||
|
$policy = $workflow->policy($admin, $policyData);
|
||||||
|
auth()->guard()->setUser($buyer);
|
||||||
|
$adoption = $workflow->adopt($buyer, $f['r'], $policy->id, 0, 'Adopção de recomendação exclusivamente fictícia');
|
||||||
|
$proposal = ['previous_id' => 0, 'quotation_id' => $quotes[0]->id, 'basis_hash' => $workflow->basis($f['r'])['hash'], 'reason' => 'Recomendação exclusivamente fictícia'];
|
||||||
|
$selection = ['quotation_id' => $quotes[0]->id, 'justification' => 'Proposta de maior pontuação com recomendação.', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Armazém fictício'];
|
||||||
|
|
||||||
|
return [...$f, 'quotes' => $quotes, 'set' => $set, 'admin' => $admin, 'buyer' => $buyer, 'head' => $head, 'reviewer' => $reviewer, 'other_reviewer' => $otherReviewer, 'admission_policy' => $admissionPolicy, 'admission_adoption' => $admissionAdoption, 'policy' => $policy, 'policy_data' => $policyData, 'adoption' => $adoption, 'proposal' => $proposal, 'selection' => $selection];
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Explicit operational entry point: backup, restore verification, then only migration 000020.
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
require __DIR__.'/../../vendor/autoload.php';
|
||||||
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
$app->make(Kernel::class)->bootstrap();
|
||||||
|
$connection = DB::connection();
|
||||||
|
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
|
||||||
|
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
|
||||||
|
}
|
||||||
|
$pdo = $connection->getPdo();
|
||||||
|
$migration = '2026_10_28_000020_create_formal_recommendations';
|
||||||
|
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
|
||||||
|
throw new LogicException('Migração já aplicada; não repetir.');
|
||||||
|
}
|
||||||
|
$config = $connection->getConfig();
|
||||||
|
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
||||||
|
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
|
||||||
|
if (! is_file($bin.$tool)) {
|
||||||
|
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$envHash = hash_file('sha256', base_path('.env'));
|
||||||
|
$directory = storage_path('app/private/backups/formal-recommendation-000020-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
|
||||||
|
if (! mkdir($directory, 0700, true)) {
|
||||||
|
throw new RuntimeException('Não foi possível criar backup privado.');
|
||||||
|
}
|
||||||
|
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
|
||||||
|
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
|
||||||
|
$columns = [];
|
||||||
|
$fingerprints = function (PDO $db, array $tables) use ($quote, &$columns): array {
|
||||||
|
$result = [];
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM (SELECT '.implode(', ', array_map($quote, $columns[$table])).' FROM public.'.$quote($table).') t')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
sort($rows, SORT_STRING);
|
||||||
|
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
};
|
||||||
|
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$statement = $pdo->prepare("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position");
|
||||||
|
$statement->execute([$table]);
|
||||||
|
$columns[$table] = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
|
||||||
|
$restoreCreated = false;
|
||||||
|
try {
|
||||||
|
// pg_dump and the manifest read exactly the same committed snapshot.
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||||
|
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
|
||||||
|
$backupState = $fingerprints($pdo, $tables);
|
||||||
|
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
|
||||||
|
$dump->setTimeout(120);
|
||||||
|
$dump->run();
|
||||||
|
if (! $dump->isSuccessful()) {
|
||||||
|
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
|
||||||
|
$restoreCreated = true;
|
||||||
|
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
|
||||||
|
$restore->setTimeout(120);
|
||||||
|
$restore->run();
|
||||||
|
if (! $restore->isSuccessful()) {
|
||||||
|
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
|
||||||
|
}
|
||||||
|
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
|
||||||
|
$restored = DB::connection('budget_restore')->getPdo();
|
||||||
|
if ($fingerprints($restored, $tables) !== $backupState) {
|
||||||
|
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
|
||||||
|
}
|
||||||
|
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'columns' => $columns, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||||
|
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
|
||||||
|
DB::disconnect('budget_restore');
|
||||||
|
unset($restored);
|
||||||
|
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
|
||||||
|
$restoreCreated = false;
|
||||||
|
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
|
||||||
|
$before = $fingerprints($pdo, $businessTables);
|
||||||
|
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
|
||||||
|
if ($exit !== 0) {
|
||||||
|
throw new RuntimeException('Migração falhou; consultar backup privado.');
|
||||||
|
}
|
||||||
|
$after = $fingerprints($pdo, $businessTables);
|
||||||
|
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
|
||||||
|
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
|
||||||
|
}
|
||||||
|
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'recommendation_policies_count' => (int) $pdo->query('SELECT count(*) FROM recommendation_policies')->fetchColumn(), 'recommendation_adoptions_count' => (int) $pdo->query('SELECT count(*) FROM recommendation_adoptions')->fetchColumn(), 'operational_validation' => false];
|
||||||
|
file_put_contents(base_path('docs/recomendacao-migracao-000020.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||||
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||||
|
} finally {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
DB::disconnect('budget_restore');
|
||||||
|
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
|
||||||
|
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user