Implementa planeamento de compras com marcos e responsabilidades por empresa
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\PurchasePlanItem;
|
||||
use App\Models\PurchasePlanMilestone;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\PurchasePlanningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PurchasePlanningController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
$canManage = in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
$filters = $request->validate(['item' => 'nullable|integer|min:1']);
|
||||
$items = PurchasePlanItem::query()->visibleTo($actor)->when($filters['item'] ?? null, fn ($query, $id) => $query->whereKey($id))->latest('id')->paginate(15)->withQueryString();
|
||||
$milestones = PurchasePlanMilestone::query()->whereIn('purchase_plan_item_id', $items->pluck('id'))->orderBy('planned_on')->orderBy('id')->get();
|
||||
$responsibles = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $items->pluck('responsible_id'))->pluck('name', 'id');
|
||||
$departments = Department::query()->whereIn('id', $items->pluck('department_id'))->pluck('name', 'id');
|
||||
$requisitions = Requisition::query()->visibleTo($actor)->whereIn('id', $items->pluck('requisition_id'))->get(['id', 'number'])->keyBy('id');
|
||||
$rows = $items->through(fn (PurchasePlanItem $item) => [
|
||||
...$item->only(['id', 'title', 'nature', 'responsible_id', 'version', 'department_id']),
|
||||
'canLink' => $canManage && $item->requisition_id === null,
|
||||
'responsible' => $responsibles[$item->responsible_id] ?? 'Responsável indisponível',
|
||||
'department' => $departments[$item->department_id] ?? '',
|
||||
'requisition' => isset($requisitions[$item->requisition_id]) ? $requisitions[$item->requisition_id]->only(['id', 'number']) : null,
|
||||
'canRecord' => $canManage || $item->responsible_id === $actor->id,
|
||||
]);
|
||||
$history = [];
|
||||
if ($canManage) {
|
||||
$history = AuditLog::query()->whereIn('action', ['planning.created', 'planning.linked', 'planning.assigned', 'planning.milestone_created', 'planning.milestone_updated'])->latest('id')->limit(50)->get(['id', 'action', 'user_id', 'before', 'after', 'created_at']);
|
||||
$authors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $history->pluck('user_id'))->pluck('name', 'id');
|
||||
foreach ($history as $event) {
|
||||
$event->setAttribute('actor', $authors[$event->user_id] ?? 'Utilizador indisponível');
|
||||
}
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('planning/index', [
|
||||
'items' => $rows, 'milestones' => $milestones, 'history' => $history, 'canManage' => $canManage,
|
||||
'today' => today()->format('Y-m-d'),
|
||||
'departments' => $canManage ? Department::query()->orderBy('name')->get(['id', 'name']) : [],
|
||||
'users' => $canManage ? User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']) : [],
|
||||
'requisitions' => $canManage ? Requisition::query()->visibleTo($actor)->whereNotIn('id', PurchasePlanItem::query()->whereNotNull('requisition_id')->select('requisition_id'))->latest('id')->get(['id', 'number', 'department_id']) : [],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchasePlanningWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['title' => 'required|string|min:3|max:200', 'department_id' => 'required|integer', 'responsible_id' => 'required|integer', 'requisition_id' => 'nullable|integer', 'nature' => 'required|in:goods,works,services,consulting']);
|
||||
$data['department_id'] = (int) $data['department_id'];
|
||||
$data['responsible_id'] = (int) $data['responsible_id'];
|
||||
$workflow->create($request->user(), $data);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function assign(Request $request, PurchasePlanItem $item, PurchasePlanningWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['responsible_id' => 'required|integer', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:2000']);
|
||||
$workflow->assign($request->user(), $item, (int) $data['responsible_id'], (int) $data['version'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function milestone(Request $request, PurchasePlanItem $item, PurchasePlanningWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['label' => 'required|string|min:3|max:120', 'planned_on' => 'required|date_format:Y-m-d']);
|
||||
$workflow->milestone($request->user(), $item, $data['label'], $data['planned_on']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function link(Request $request, PurchasePlanItem $item, PurchasePlanningWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['requisition_id' => 'required|integer', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:2000']);
|
||||
$workflow->link($request->user(), $item, (int) $data['requisition_id'], (int) $data['version'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchasePlanMilestone $milestone, PurchasePlanningWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'planned_on' => 'sometimes|required|date_format:Y-m-d', 'actual_on' => 'sometimes|nullable|date_format:Y-m-d|before_or_equal:today', 'reason' => 'required|string|min:10|max:2000']);
|
||||
if (! array_key_exists('planned_on', $data) && ! array_key_exists('actual_on', $data)) {
|
||||
throw ValidationException::withMessages(['actual_on' => 'Indique a data prevista ou a data realizada a actualizar.']);
|
||||
}
|
||||
$changes = ['version' => (int) $data['version'], 'reason' => $data['reason']];
|
||||
if (array_key_exists('planned_on', $data)) {
|
||||
$changes['planned_on'] = $data['planned_on'];
|
||||
}
|
||||
if (array_key_exists('actual_on', $data)) {
|
||||
$changes['actual_on'] = $data['actual_on'];
|
||||
}
|
||||
$workflow->update($request->user(), $milestone, $changes);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Models\GoodsReceipt;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchasePlanItem;
|
||||
use App\Models\PurchaseRevision;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
@@ -34,6 +35,10 @@ class PurchaseProcessController extends Controller
|
||||
$base = '/requisicoes/'.$requisition->id;
|
||||
$stages = [];
|
||||
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
|
||||
$planning = PurchasePlanItem::query()->visibleTo($actor)->where('requisition_id', $requisition->id)->first(['id', 'title']);
|
||||
if ($planning) {
|
||||
$documents[] = ['label' => $planning->title, 'kind' => 'Planeamento', 'href' => '/planeamento?item='.$planning->id];
|
||||
}
|
||||
$decisions = [];
|
||||
$stages[] = ['label' => 'Requisição e aprovação da necessidade', 'state' => $requisition->status, 'owner' => 'Solicitante e aprovadores', 'detail' => 'Estado do pedido; as decisões individuais constam abaixo.', 'href' => $base];
|
||||
foreach ($requisition->steps as $step) {
|
||||
@@ -108,7 +113,7 @@ class PurchaseProcessController extends Controller
|
||||
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
|
||||
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
|
||||
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
|
||||
'future' => ['Plano de compras e marcos previstos/realizados', 'Sessão de abertura e acta', 'Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
|
||||
'future' => ['Sessão de abertura e acta', 'Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchasePlanItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['version' => 1];
|
||||
|
||||
/** @param Builder<PurchasePlanItem> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
match ($user->purchasing_role) {
|
||||
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
|
||||
PurchasingRole::DepartmentHead => $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id)),
|
||||
default => $query->where('responsible_id', $user->id),
|
||||
};
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(fn () => throw new \LogicException('Preservar o planeamento.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchasePlanMilestone extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['version' => 1, 'actual_on' => null];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(fn () => throw new \LogicException('Preservar marcos e datas anteriores na auditoria.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Department;
|
||||
use App\Models\PurchasePlanItem;
|
||||
use App\Models\PurchasePlanMilestone;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchasePlanningWorkflow
|
||||
{
|
||||
private function manager(User $actor): bool
|
||||
{
|
||||
return in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
}
|
||||
|
||||
private function responsible(User $actor, int $id): void
|
||||
{
|
||||
if (! User::query()->where('tenant_id', $actor->tenant_id)->whereKey($id)->exists()) {
|
||||
throw ValidationException::withMessages(['responsible_id' => 'O responsável deve pertencer à empresa.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function version(int $expected, int $current): void
|
||||
{
|
||||
if ($expected !== $current) {
|
||||
throw ValidationException::withMessages(['version' => 'O registo mudou. Actualize a página antes de voltar a guardar.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function create(User $actor, array $data): PurchasePlanItem
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
Department::query()->findOrFail($data['department_id']);
|
||||
$this->responsible($actor, $data['responsible_id']);
|
||||
$requisition = null;
|
||||
if ($data['requisition_id'] ?? null) {
|
||||
$requisition = Requisition::query()->visibleTo($actor)->whereKey($data['requisition_id'])->firstOrFail();
|
||||
if ($requisition->department_id !== $data['department_id'] || PurchasePlanItem::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['requisition_id' => 'A requisição deve pertencer ao departamento e ainda não ter planeamento associado.']);
|
||||
}
|
||||
}
|
||||
$item = PurchasePlanItem::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'department_id' => $data['department_id'], 'requisition_id' => $requisition?->id, 'responsible_id' => $data['responsible_id'], 'title' => $data['title'], 'nature' => $data['nature']]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'planning.created', $requisition, null, $item->toArray());
|
||||
|
||||
return $item;
|
||||
});
|
||||
}
|
||||
|
||||
public function assign(User $actor, PurchasePlanItem $item, int $responsibleId, int $version, string $reason): void
|
||||
{
|
||||
DB::transaction(function () use ($actor, $item, $responsibleId, $version, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
$item = PurchasePlanItem::query()->lockForUpdate()->findOrFail($item->id);
|
||||
$this->version($version, $item->version);
|
||||
$this->responsible($actor, $responsibleId);
|
||||
$before = $item->toArray();
|
||||
$item->update(['responsible_id' => $responsibleId, 'version' => $item->version + 1]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'planning.assigned', null, $before, ['item' => $item->toArray(), 'reason' => $reason]);
|
||||
});
|
||||
}
|
||||
|
||||
public function link(User $actor, PurchasePlanItem $item, int $requisitionId, int $version, string $reason): void
|
||||
{
|
||||
DB::transaction(function () use ($actor, $item, $requisitionId, $version, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
$item = PurchasePlanItem::query()->lockForUpdate()->findOrFail($item->id);
|
||||
$this->version($version, $item->version);
|
||||
$requisition = Requisition::query()->visibleTo($actor)->findOrFail($requisitionId);
|
||||
if ($item->requisition_id !== null || $requisition->department_id !== $item->department_id || PurchasePlanItem::query()->where('requisition_id', $requisitionId)->exists()) {
|
||||
throw ValidationException::withMessages(['requisition_id' => 'A ligação deve ser única, do mesmo departamento, e não pode substituir uma ligação anterior.']);
|
||||
}
|
||||
$before = $item->toArray();
|
||||
$item->update(['requisition_id' => $requisitionId, 'version' => $item->version + 1]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'planning.linked', $requisition, $before, ['item' => $item->toArray(), 'reason' => $reason]);
|
||||
});
|
||||
}
|
||||
|
||||
public function milestone(User $actor, PurchasePlanItem $item, string $label, string $plannedOn): void
|
||||
{
|
||||
DB::transaction(function () use ($actor, $item, $label, $plannedOn) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Admin, PurchasingRole::Procurement]);
|
||||
$item = PurchasePlanItem::query()->lockForUpdate()->findOrFail($item->id);
|
||||
if (PurchasePlanMilestone::query()->where('purchase_plan_item_id', $item->id)->where('label', trim($label))->exists()) {
|
||||
throw ValidationException::withMessages(['label' => 'Já existe um marco com este nome neste item.']);
|
||||
}
|
||||
$milestone = PurchasePlanMilestone::query()->create(['tenant_id' => $actor->tenant_id, 'purchase_plan_item_id' => $item->id, 'created_by' => $actor->id, 'label' => trim($label), 'planned_on' => $plannedOn]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'planning.milestone_created', null, null, $milestone->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{version:int,planned_on?:string,actual_on?:string|null,reason:string} $data */
|
||||
public function update(User $actor, PurchasePlanMilestone $milestone, array $data): void
|
||||
{
|
||||
DB::transaction(function () use ($actor, $milestone, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
$item = PurchasePlanItem::query()->visibleTo($actor)->lockForUpdate()->findOrFail($milestone->purchase_plan_item_id);
|
||||
$milestone = PurchasePlanMilestone::query()->lockForUpdate()->findOrFail($milestone->id);
|
||||
abort_unless($this->manager($actor) || $item->responsible_id === $actor->id, 403);
|
||||
abort_if(! $this->manager($actor) && array_key_exists('planned_on', $data), 403);
|
||||
$this->version($data['version'], $milestone->version);
|
||||
$before = $milestone->toArray();
|
||||
$changes = array_intersect_key($data, array_flip(['planned_on', 'actual_on']));
|
||||
$milestone->update([...$changes, 'version' => $milestone->version + 1]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'planning.milestone_updated', null, $before, ['milestone' => $milestone->toArray(), 'reason' => $data['reason']]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user