242 lines
18 KiB
PHP
242 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\CostCenter;
|
|
use App\Models\Requisition;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\User;
|
|
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
|
|
{
|
|
public function ready(): bool
|
|
{
|
|
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
|
|
}
|
|
|
|
public function requester(User $executor, SupplyRecord $rule): User
|
|
{
|
|
$executor = app(CurrentActor::class)->lock($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(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(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;
|
|
}
|
|
|
|
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',
|
|
'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'] ?? (string) $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($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.');
|
|
$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']];
|
|
$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' || $snapshot['prepared_by'] !== $actor->id, '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;
|
|
});
|
|
}
|
|
|
|
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.');
|
|
|
|
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;
|
|
}
|
|
DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse));
|
|
}
|
|
|
|
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 = app(CurrentActor::class)->lock($actor);
|
|
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
|
$planning = app(SupplyPlanning::class);
|
|
$policy = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
|
|
$rule = $planning->latest('auto_rule')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
|
|
$old = $planning->latest('auto_need')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
|
|
$origins = [];
|
|
$unreserved = 0;
|
|
foreach (WarehouseRequest::query()->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 && $unreserved === 0 && $old === null) {
|
|
return;
|
|
}
|
|
$purchases = $old?->data['purchases'] ?? [];
|
|
$snapshot = ['item' => ['id' => $item], 'warehouse' => ['id' => $warehouse], 'origins' => $origins, 'rule_id' => $rule?->id];
|
|
$state = 'pending';
|
|
$message = 'Parametrize requisitante, centro de custo e reposição por artigo/armazém.';
|
|
$values = ['purchases' => $purchases, '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);
|
|
$coverage = 0;
|
|
$blocked = false;
|
|
foreach ($purchases as $purchase) {
|
|
$r = Requisition::query()->find($purchase['id']);
|
|
$this->check($r !== null, 'A requisição de reposição anterior requer reconciliação.');
|
|
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');
|
|
$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']['id'] ?? null) === $receipt->id);
|
|
if (! $voided) {
|
|
$received += (int) $receipt->accepted_milli;
|
|
}
|
|
}
|
|
$coverage += max(0, ReceiptWorkflow::milli($line->quantity) - $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($purchases, '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 = (int) (ceil(max($demand, $basis['policy']['minimum_milli']) / $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);
|
|
$r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, ['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'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]]);
|
|
$values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->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, 'Avaliação automática após alteração da disponibilidade ou parametrização.');
|
|
});
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|
|
}
|