Files
compras/app/Services/AutomaticPurchases.php
T
2026-11-03 11:51:44 +02:00

378 lines
27 KiB
PHP

<?php
namespace App\Services;
use App\Http\Requests\StoreRequisitionRequest;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\Requisition;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
class AutomaticPurchases
{
private function independentReviewer(User $actor, SupplyRecord $rule): bool
{
return ! in_array($actor->id, $rule->snapshot['contributors'] ?? [$rule->created_by], true);
}
public function canReview(User $actor, SupplyRecord $rule, string $action = 'approve'): bool
{
if (! $this->ready() || $actor->tenant_id !== $rule->tenant_id || $rule->kind !== 'auto_rule' || ! in_array($action, ['approve', 'suspend'], true)
|| ! app(SupplyPlanning::class)->allows($actor, 'supply_review')) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
return $latest?->id === $rule->id && $rule->state === ($action === 'approve' ? 'draft' : 'approved')
&& ($action !== 'approve' || $this->independentReviewer($actor, $rule));
}
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();
}
/** @return array<string,mixed>|null */
public function origin(Requisition $requisition): ?array
{
if (! $this->ready()) {
return null;
}
$record = SupplyRecord::query()->where('kind', 'auto_need')->whereJsonContains('data->requisition_ids', $requisition->id)->oldest('id')->first();
return $record === null ? null : ['need_id' => $record->id, 'rule_id' => $record->snapshot['rule_id'], 'item' => $record->snapshot['item'], 'warehouse' => $record->snapshot['warehouse'], 'origins' => $record->snapshot['origins'], 'created_at' => $record->created_at->toDateTimeString()];
}
public function requisitionChanged(User $actor, Requisition $requisition): void
{
$origin = $this->origin($requisition);
if ($origin !== null) {
$this->signal($actor, $origin['item']['id'], $origin['warehouse']['id']);
}
}
public function requester(User $executor, SupplyRecord $rule): User
{
$executor = $this->lockExecutor($executor);
$current = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
$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(ReplenishmentRun::class)->tenantId() !== null || ! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
return $this->configuredRequester($rule, true);
}
private function configuredRequester(SupplyRecord $rule, bool $lock): User
{
$query = User::query()->where('tenant_id', $rule->tenant_id)->whereKey($rule->data['requester_id']);
if ($lock) {
$query->lockForUpdate();
}
$user = $query->firstOrFail();
$this->check($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']);
$this->check($center->only(['id', 'code', 'name', 'department_id']) === $rule->snapshot['cost_center'], 'O centro de custo mudou; reveja a parametrização.');
return $user;
}
/** Inspect the same sources used by generation, without actor substitution, locks or commands.
* @return array{valid:bool,issues:list<string>}
*/
public function inspectRule(User $viewer, SupplyRecord $rule): array
{
abort_unless(auth()->id() === $viewer->id && $viewer->tenant_id === $rule->tenant_id && $viewer->hasFunctions(['admin']), 403);
try {
$latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
$this->check($latest !== null && $latest->id === $rule->id && $latest->state === 'approved', 'A regra já não está activa.');
$requester = $this->configuredRequester($rule, false);
$form = new StoreRequisitionRequest;
$form->setUserResolver(fn () => $requester);
Validator::make(['items' => [['unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]], array_intersect_key($form->rules(), array_flip(['items.*.unit_price', 'items.*.tax_rate'])), $form->messages())->validate();
$this->basis($rule);
return ['valid' => true, 'issues' => []];
} catch (ValidationException $error) {
return ['valid' => false, 'issues' => array_values(array_merge(...array_values($error->errors())))];
} catch (ModelNotFoundException) {
return ['valid' => false, 'issues' => ['Uma origem deixou de estar disponível; reveja a parametrização.']];
}
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input): SupplyRecord
{
abort_unless($this->ready(), 503, 'Instalação da reserva e reposição pendente.');
return DB::transaction(function () use ($actor, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:save,approve,suspend', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
abort_unless(app(SupplyPlanning::class)->allows($actor, $data['action'] === 'save' ? 'supply_prepare' : 'supply_review'), 403);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $data['operation_key'])->first();
if ($retry !== null) {
$this->check($retry->kind === 'auto_rule' && $retry->created_by === $actor->id && $retry->input_hash === $hash, 'Chave já utilizada com outro conteúdo.');
return $retry;
}
$old = empty($data['record_key']) ? null : app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $data['record_key'])->firstOrFail();
$this->check($data['version'] === ($old->version ?? 0), 'A parametrização mudou. Actualize a página.');
$snapshot = $old->snapshot ?? [];
$values = $old->data ?? [];
if ($data['action'] === 'save') {
$values = Validator::make($data['data'], ['policy_key' => 'required|uuid', 'requester_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1',
'unit_price' => 'nullable|string|regex:/^[0-9]{1,8}(\.[0-9]{1,2})?$/D|numeric|min:0.01', 'tax_rate' => 'nullable|string|numeric|min:0|max:100|regex:/^[0-9]{1,3}(\.[0-9]{1,2})?$/D',
'equipment_id' => 'nullable|integer|min:1', 'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate();
$basis = app(SupplySuggestions::class)->basis($values['policy_key'], today()->toDateString());
$values['unit_price'] = $values['unit_price'] ?? $basis['unit_price'];
$values['tax_rate'] = $values['tax_rate'] ?? $basis['tax_rate'];
$this->check($values['unit_price'] !== null, 'O acordo não fornece um preço em MZN. Registe uma estimativa válida.');
$this->check($values['tax_rate'] !== null, 'A empresa não tem imposto configurado. Registe uma estimativa fundamentada.');
$values['tax_rate'] = (string) $values['tax_rate'];
$this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.');
$this->check(in_array($basis['catalogue']['purchase_type'], ['consumable', 'other', 'spare_part'], true), 'Seleccione um artigo de stock publicado. Equipamento novo e serviços seguem o seu processo próprio.');
$user = User::query()->where('tenant_id', $actor->tenant_id)->whereKey($values['requester_id'])->firstOrFail();
$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, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])),
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
$this->check(! empty($values['equipment_id']), 'Seleccione o equipamento de destino da peça.');
$part = SparePart::query()->where('stock_item_id', $basis['stock_item_id'])->firstOrFail();
app(EquipmentWorkflow::class)->linkedPart($part->id, (int) $values['equipment_id'], $user->department_id);
$equipment = Equipment::query()->whereKey($values['equipment_id'])->firstOrFail();
$values['spare_part_id'] = $part->id;
$snapshot['equipment'] = $equipment->only(['id', 'code', 'description', 'department_id']);
$snapshot['spare_part'] = $part->toArray();
} else {
$this->check(empty($values['equipment_id']), 'O equipamento na parametrização aplica-se apenas a peças. Os destinos dos pedidos são preservados na origem.');
}
$duplicates = app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])->whereIn('state', ['draft', 'approved']);
if ($old !== null) {
$duplicates->where('record_key', '!=', $old->record_key);
}
$this->check(! $duplicates->exists(), 'Já existe uma parametrização neste artigo/armazém. Reveja a existente.');
} else {
$this->check($old !== null && $old->state === ($data['action'] === 'approve' ? 'draft' : 'approved'), 'Estado incompatível com a intervenção.');
$this->check($data['action'] !== 'approve' || $this->independentReviewer($actor, $old), 'Quem preparou não pode activar a própria parametrização.');
}
$row = $this->append($actor, 'auto_rule', $old, $data['action'] === 'save' ? 'draft' : ($data['action'] === 'approve' ? 'approved' : 'suspended'), $values, $snapshot, $data['reason'], $data['operation_key'], $hash);
if ($row->state === 'approved') {
$this->requester($actor, $row);
$this->basis($row);
$this->evaluate($actor, $row->stock_item_id, $row->warehouse_id);
}
return $row;
});
}
/** @return array<string,mixed> */
private function basis(SupplyRecord $rule): array
{
$this->check($rule->data['valid_until'] >= today()->toDateString(), 'A estimativa/parametrização automática expirou.');
$basis = app(SupplySuggestions::class)->basis($rule->data['policy_key'], today()->toDateString());
$this->check($basis['policy_version'] === $rule->snapshot['policy_version'] && $basis['item'] === $rule->snapshot['item'] && $basis['warehouse'] === $rule->snapshot['warehouse'] && $basis['catalogue'] === $rule->snapshot['catalogue'], 'A política, o catálogo ou o armazém mudou. Reveja a parametrização automática.');
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
$part = app(EquipmentWorkflow::class)->linkedPart($rule->data['spare_part_id'], (int) $rule->data['equipment_id'], $rule->snapshot['requester']['department_id']);
$equipment = Equipment::query()->whereKey($rule->data['equipment_id'])->firstOrFail();
$this->check($part->toArray() === $rule->snapshot['spare_part'] && $equipment->only(['id', 'code', 'description', 'department_id']) === $rule->snapshot['equipment'], 'O destino técnico mudou. Reveja a parametrização da peça.');
}
return $basis;
}
/** Called only by successful stock/request mutations; reads never prepare documents. */
public function signal(User $actor, int $item, int $warehouse): void
{
if (! $this->ready()) {
return;
}
if (app(ReplenishmentQueue::class)->ready()) {
$id = app(ReplenishmentQueue::class)->enqueue($actor, $item, $warehouse);
DB::afterCommit(fn () => app(ReplenishmentQueue::class)->interactive($actor, $id));
return;
}
DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse));
}
public function lockExecutor(User $actor): User
{
return app(ReplenishmentRun::class)->tenantId() === null ? app(CurrentActor::class)->lock($actor) : app(ReplenishmentRun::class)->lockResponsible($actor);
}
public function evaluate(User $actor, int $item, int $warehouse): void
{
if (! $this->ready()) {
return;
}
DB::transaction(function () use ($actor, $item, $warehouse) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = $this->lockExecutor($actor);
abort_if(app(ReplenishmentRun::class)->tenantId() === null && app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$planning = app(SupplyPlanning::class);
$rule = $planning->latest('auto_rule')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
$policies = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->where('data->purpose', 'internal');
if ($rule !== null) {
$policies->where('record_key', $rule->data['policy_key']);
}
$policy = $policies->latest('id')->first();
$old = $planning->latest('auto_need')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
$origins = [];
$unreserved = 0;
foreach (WarehouseRequest::query()->where('work_context_key', 'procurement.internal')->where('warehouse_id', $warehouse)->get() as $request) {
$p = app(WarehouseFulfilment::class)->progress($request);
if (! in_array($p['state'], ['submitted', 'authorized', 'partial'], true)) {
continue;
}
foreach ($request->snapshot['lines'] as $index => $line) {
if ($line['item']['id'] === $item && $p['remaining'][$index] > 0) {
$unreserved += max(0, $p['remaining'][$index] - $p['reserved'][$index]);
$origins[] = ['warehouse_request_id' => $request->id, 'version' => $p['version'], 'line_index' => $index, 'destination' => $line['destination'], 'equipment' => $line['equipment'], 'remaining_milli' => $p['remaining'][$index], 'reserved_milli' => $p['reserved'][$index]];
}
}
}
if ($policy === null && $origins === [] && $old === null) {
return;
}
$purchases = $old?->data['purchases'] ?? [];
$snapshot = ['item' => StockItem::query()->whereKey($item)->firstOrFail()->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->whereKey($warehouse)->firstOrFail()->only(['id', 'code', 'name']), 'origins' => $origins, 'rule_id' => $rule?->id];
$state = 'pending';
$message = 'Parametrize requisitante, centro de custo e reposição por artigo/armazém.';
$values = ['purchases' => $purchases, 'requisition_ids' => array_column($purchases, 'id'), 'message' => $message];
try {
$free = app(WarehouseReservations::class)->free($item, $warehouse);
$minimum = $policy?->data['minimum_stock_milli'] ?? 0;
$required = $unreserved > max(0, $free) || $free <= $minimum;
$snapshot += ['free_milli' => $free, 'unreserved_milli' => $unreserved, 'minimum_milli' => $minimum];
if (! $required) {
$state = 'satisfied';
$values['message'] = 'Disponibilidade acima do mínimo e sem falta no atendimento.';
} else {
$this->check($rule !== null, $message);
$requester = $this->requester($actor, $rule);
$basis = $this->basis($rule);
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
foreach ($origins as $origin) {
$this->check(($origin['equipment']['id'] ?? null) === (int) $rule->data['equipment_id'], 'O equipamento do pedido não coincide com o destino técnico da parametrização. Reconciliar os destinos antes de gerar a compra.');
}
}
$coverage = 0;
$blocked = false;
$tracked = $purchases;
foreach ($planning->latest('suggestion')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->whereIn('state', ['converted', 'closed'])->get() as $suggestion) {
if (! in_array($suggestion->data['requisition_id'], array_column($tracked, 'id'), true)) {
$tracked[] = ['id' => $suggestion->data['requisition_id'], 'rule_id' => null];
}
}
foreach ($tracked as $purchase) {
$r = Requisition::query()->whereKey($purchase['id'])->firstOrFail();
if ($r->status === 'rejected') {
if ($purchase['rule_id'] === $rule->id) {
$blocked = true;
}
continue;
}
$this->check(in_array($r->status, ['draft', 'pending', 'approved'], true), 'Rever o estado da reposição anterior antes de gerar nova compra.');
$r->load('items');
$this->check($r->items->count() === 1, 'Uma reposição anterior foi repartida ou alterada; reconcilie as linhas antes de gerar nova compra.');
$line = $r->items->sole();
$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']['item_id'] ?? null) === $receipt->id);
if (! $voided) {
$received += (int) $receipt->accepted_milli;
}
}
$orderLines = DB::table('purchase_order_items')->where('tenant_id', $actor->tenant_id)->where('requisition_item_id', $line->id)->get(['quantity']);
$expected = $orderLines->isEmpty() ? ReceiptWorkflow::milli($line->quantity) : $orderLines->sum(fn ($ordered) => ReceiptWorkflow::milli($ordered->quantity));
$coverage += max(0, $expected - $received);
}
$this->check(! $blocked, 'A reposição foi rejeitada. Rever e activar nova versão da parametrização antes de tentar novamente.');
// A confirmed outside delivery also covers the need; never double-count an automatic order.
$automaticIds = array_column($tracked, 'id');
foreach ($planning->latest('delivery')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'active')->get() as $delivery) {
$order = DB::table('purchase_orders')->where('id', $delivery->purchase_order_id)->first();
if ($order !== null && ! in_array($order->requisition_id, $automaticIds, true)) {
$coverage += app(SupplyCoordination::class)->remainingDelivery($delivery);
}
}
$demand = max(0, $basis['policy']['target_milli'] - $free + $unreserved - $coverage);
$snapshot += ['coverage_milli' => $coverage, 'policy' => $basis['policy'], 'catalogue' => $basis['catalogue'], 'requester' => $requester->only(['id', 'name', 'department_id']), 'cost_center' => $rule->snapshot['cost_center']];
if ($demand === 0) {
$state = 'covered';
$values['message'] = 'Reposição efectiva já em curso; não foi duplicada.';
} else {
$pack = $basis['policy']['pack_milli'];
$quantity = intdiv(max($demand, $basis['policy']['minimum_milli']) + $pack - 1, $pack) * $pack;
$this->check($quantity <= 1000000000 && $quantity + $free - $unreserved + $coverage <= $basis['policy']['maximum_milli'], 'A embalagem ou quantidade mínima excede o máximo aprovado. Rever a política.');
$decimal = intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT);
$payload = ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(),
'justification' => 'Reposição automática do armazém '.$basis['warehouse']['code'].', artigo '.$basis['item']['code'].'. Origem e utilização preservadas na necessidade de reposição.',
'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'purchase_type' => $basis['catalogue']['purchase_type'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate'],
...($basis['catalogue']['purchase_type'] === 'spare_part' ? ['equipment_id' => $rule->data['equipment_id'], 'spare_part_id' => $rule->data['spare_part_id']] : [])]]];
$form = new StoreRequisitionRequest;
$form->setUserResolver(fn () => $requester);
$payload = Validator::make($payload, $form->rules(), $form->messages())->validate();
$r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, $payload);
$values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->id];
$values['requisition_ids'][] = $r->id;
$state = 'created';
$values['message'] = 'Rascunho criado; o requisitante deve conferir e submeter às aprovações habituais.';
}
}
} catch (ValidationException $error) {
$values['message'] = implode(' ', array_merge(...array_values($error->errors())));
} catch (ModelNotFoundException $error) {
$values['message'] = 'Uma origem configurada deixou de estar disponível. Reveja a parametrização.';
}
if ($old !== null && $old->state === $state && $old->data === $values && $old->snapshot === $snapshot) {
return;
}
$this->append($actor, 'auto_need', $old, $state, $values, $snapshot, app(ReplenishmentRun::class)->tenantId() === null ? 'Avaliação automática após alteração da disponibilidade ou parametrização.' : 'Reavaliação periódica ou recuperação da reposição, sem decisão automática de compra.');
});
}
/** @param array<string,mixed> $data
* @param array<string,mixed> $snapshot
*/
private function append(User $actor, string $kind, ?SupplyRecord $old, string $state, array $data, array $snapshot, string $reason, ?string $key = null, ?string $hash = null): SupplyRecord
{
$row = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1, 'state' => $state,
'stock_item_id' => $snapshot['item']['id'], 'warehouse_id' => $snapshot['warehouse']['id'], 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason,
'operation_key' => $key ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode([$state, $data, $snapshot], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, $kind.'.'.$state, null, null, ['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]);
return $row;
}
private function check(bool $valid, string $message): void
{
if (! $valid) {
throw ValidationException::withMessages(['replenishment' => $message]);
}
}
}