This commit is contained in:
2026-11-02 08:10:00 +02:00
parent 504b01b244
commit b44378b899
14 changed files with 655 additions and 121 deletions
@@ -16,35 +16,38 @@ class AutomaticPurchaseController extends Controller
{
public function index(Request $request, AutomaticPurchases $service)
{
$actor=$request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin','supply_read','supply_prepare','supply_review']),403);
$prepare=app(SupplyPlanning::class)->allows($actor,'supply_prepare');
$ready=$service->ready();
$planning=app(SupplyPlanning::class);
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'supply_read', 'supply_prepare', 'supply_review']), 403);
$prepare = app(SupplyPlanning::class)->allows($actor, 'supply_prepare');
$ready = $service->ready();
$planning = app(SupplyPlanning::class);
Inertia::encryptHistory();
return Inertia::render('stock/replenishment',[
'ready'=>$ready,'canPrepare'=>$prepare,'canReview'=>$planning->allows($actor,'supply_review'),
'rules'=>$ready?$planning->latest('auto_rule')->latest('id')->limit(100)->get():[],
'needs'=>$ready?$planning->latest('auto_need')->latest('id')->limit(100)->get():[],
'history'=>$ready?SupplyRecord::query()->whereIn('kind',['auto_rule','auto_need'])->latest('id')->limit(100)->get():[],
'policies'=>Schema::hasTable('supply_records') && $prepare?$planning->latest('policy')->where('state','approved')->where('data->purpose','internal')->get():[],
'requesters'=>$prepare?User::query()->where('tenant_id',$actor->tenant_id)->get(['id','name','department_id','purchasing_role','tenant_id'])->filter(fn($u)=>in_array('requester',app(OrganizationAccess::class)->permissions($u,'procurement.internal'),true))->map(fn($u)=>$u->only(['id','name','department_id']))->values():[],
'centers'=>$prepare?CostCenter::query()->orderBy('code')->get(['id','department_id','code','name']):[],
])->toResponse($request)->withHeaders(['Cache-Control'=>'private, no-store, max-age=0']);
return Inertia::render('stock/replenishment', [
'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'),
'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->limit(100)->get() : [],
'needs' => $ready ? $planning->latest('auto_need')->latest('id')->limit(100)->get() : [],
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->limit(100)->get() : [],
'policies' => Schema::hasTable('supply_records') && $prepare ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->get() : [],
'requesters' => $prepare ? User::query()->where('tenant_id', $actor->tenant_id)->get(['id', 'name', 'department_id', 'purchasing_role', 'tenant_id'])->filter(fn ($u) => $service->eligibleRequester($u))->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->values() : [],
'centers' => $prepare ? CostCenter::query()->orderBy('code')->get(['id', 'department_id', 'code', 'name']) : [],
])->toResponse($request)->withHeaders(['Cache-Control' => 'private, no-store, max-age=0']);
}
public function save(Request $request, AutomaticPurchases $service)
{
$service->save($request->user('web'),$request->all());
$service->save($request->user('web'), $request->all());
return redirect('/stock/reposicao');
}
public function evaluate(Request $request, int $id, AutomaticPurchases $service)
{
$actor=$request->user('web');
abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare','supply_review'])) && !app(OrganizationAccess::class)->maintenanceOnly($actor),403);
$need=SupplyRecord::query()->where('kind','auto_need')->findOrFail($id);
$service->evaluate($actor,$need->stock_item_id,$need->warehouse_id);
$actor = $request->user('web');
abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare', 'supply_review'])) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$need = SupplyRecord::query()->where('kind', 'auto_need')->findOrFail($id);
$service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
return redirect('/stock/reposicao');
}
}
+7 -3
View File
@@ -17,6 +17,10 @@ use Illuminate\Validation\ValidationException;
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
class AutomaticPurchases
{
public function eligibleRequester(User $user): bool
{
return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && !app(OrganizationAccess::class)->maintenanceOnly($user);
}
public function ready(): bool
{
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
@@ -29,7 +33,7 @@ class AutomaticPurchases
$this->check($current !== null && $current->id === $rule->id && $current->tenant_id === $executor->tenant_id && $current->state === 'approved', 'A parametrização automática deixou de estar activa.');
$this->check(! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
$user = User::query()->where('tenant_id', $executor->tenant_id)->lockForUpdate()->find($rule->data['requester_id']);
$this->check($user !== null && in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Requisitante sem competência vigente no contexto de compras internas.');
$this->check($user !== null && $this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
$this->check(CostCenter::query()->whereKey($rule->data['cost_center_id'])->where('department_id', $user->department_id)->exists(), 'Centro de custo incompatível com o departamento actual do requisitante.');
$this->check($user->only(['id', 'name', 'department_id']) === $rule->snapshot['requester'], 'O requisitante mudou; reveja a parametrização.');
$center = CostCenter::query()->findOrFail($rule->data['cost_center_id']);
@@ -69,7 +73,7 @@ class AutomaticPurchases
$this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.');
$this->check($basis['catalogue']['purchase_type'] === 'consumable', 'Seleccione um consumível publicado; equipamento/peças exigem o seu destino técnico.');
$user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($values['requester_id']);
$this->check(in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Seleccione um requisitante com competência vigente em compras internas.');
$this->check($this->eligibleRequester($user), 'Seleccione um requisitante com competência vigente em compras internas.');
$center = CostCenter::query()->where('department_id', $user->department_id)->findOrFail($values['cost_center_id']);
$snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id,
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
@@ -176,7 +180,7 @@ continue;
$this->check($line->catalogue_entry_id === $basis['catalogue']['id'] && ($line->catalogue_snapshot['version'] ?? null) === $basis['catalogue']['version'], 'O rascunho automático anterior mudou de artigo; reconcilie a necessidade.');
$received = 0;
foreach (DB::table('goods_receipt_items as gi')->join('goods_receipts as g', 'g.id', '=', 'gi.goods_receipt_id')->join('purchase_order_items as oi', 'oi.id', '=', 'gi.purchase_order_item_id')->where('gi.tenant_id', $actor->tenant_id)->where('oi.requisition_item_id', $line->id)->where('g.warehouse_id', $warehouse)->where('gi.stock_item_id', $item)->get(['gi.id', 'gi.accepted_milli']) as $receipt) {
$voided = collect(app(InventoryReversals::class)->corrections('receipt'))->contains(fn ($correction) => ($correction->snapshot['source']['receipt']['id'] ?? null) === $receipt->id);
$voided = collect(app(InventoryReversals::class)->corrections('receipt'))->contains(fn ($correction) => ($correction->snapshot['source']['receipt']['item_id'] ?? null) === $receipt->id);
if (! $voided) {
$received += (int) $receipt->accepted_milli;
}
+1 -1
View File
@@ -439,7 +439,7 @@ class InventoryLots
'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $reason, 'created_by' => $actor->id,
'operation_key' => $operation ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'inventory.'.$kind.'.'.$state, null, $old?->toArray(), $record->toArray());
app(AutomaticPurchases::class)->signal($actor,$data['stock_item_id'],$data['warehouse_id']);
app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']);
return $record;
}
+1 -1
View File
@@ -224,7 +224,7 @@ class ReceiptWorkflow
]);
$sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']);
app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']);
app(AutomaticPurchases::class)->signal($user,$line['stock_item_id'],$warehouse->id);
app(AutomaticPurchases::class)->signal($user, $line['stock_item_id'], $warehouse->id);
}
}
$progress = $this->progress($order);