Files
compras/app/Services/SupplySuggestions.php
T
2026-11-02 08:08:05 +02:00

255 lines
20 KiB
PHP

<?php
namespace App\Services;
use App\Http\Requests\StoreRequisitionRequest;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Warehouse;
use Brick\Math\BigDecimal;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplySuggestions
{
/** @param array<string,mixed> $input */
public function save(User $actor, array $input): SupplyRecord
{
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.');
return DB::transaction(function () use ($actor, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0',
'action' => 'required|in:save,approve,return,cancel,convert,close', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
$permission = match ($v['action']) {
'save', 'cancel' => 'supply_suggest_prepare',
'approve', 'return', 'close' => 'supply_suggest_review',
'convert' => 'supply_suggest_convert',
default => throw new \InvalidArgumentException('Acção de sugestão desconhecida.'),
};
abort_unless(app(SupplyPlanning::class)->allows($actor, $permission), 403);
if ($v['action'] === 'convert') {
abort_unless(app(OrganizationAccess::class)->canRequest($actor), 403);
}
$hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
$existing = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first();
if ($existing !== null) {
$this->require($existing->kind === 'suggestion' && $existing->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.');
return $existing;
}
$old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest('suggestion')->where('record_key', $v['record_key'])->firstOrFail() : null;
$this->require((int) $v['version'] === ($old->version ?? 0), 'version', 'A sugestão mudou. Actualize antes de continuar.');
if ($v['action'] === 'save') {
$this->require($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Uma sugestão aprovada ou convertida mantém a versão decidida.');
$data = Validator::make($v['data'], ['policy_key' => 'required|uuid', 'quantity_milli' => 'nullable|integer|min:1|max:1000000000', 'delivery_on' => 'nullable|date_format:Y-m-d|after_or_equal:today',
'unit_price' => ['nullable', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'], 'tax_rate' => ['nullable', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/']])->validate();
$this->require($old === null || $old->data['policy_key'] === $data['policy_key'], 'data.policy_key', 'A revisão conserva a política de origem.');
$basis = $this->basis($data['policy_key'], today()->toDateString());
$active = app(SupplyPlanning::class)->latest('suggestion')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])
->where('id', '!=', $old->id ?? 0)->whereIn('state', ['draft', 'returned', 'approved', 'converted'])->exists();
$this->require(! $active, 'data.policy_key', 'Já existe sugestão ou requisição acompanhada para este artigo/local. Consulte e reveja a existente.');
$projection = $basis['projection'];
$quantity = isset($data['quantity_milli']) ? (int) $data['quantity_milli'] : ($projection['quantity_milli'] ?? 0);
$delivery = $data['delivery_on'] ?? $projection['risk_date'] ?? null;
$this->require($quantity > 0 && is_string($delivery), 'data.quantity_milli', 'Sem necessidade calculada: indique quantidade e data manuais fundamentadas.');
$this->require($delivery >= today()->toDateString(), 'data.delivery_on', 'A data pretendida deve ser hoje ou uma data futura.');
$this->require($delivery <= CarbonImmutable::today()->addDays($basis['policy']['horizon_days'] - 1)->toDateString(), 'data.delivery_on', 'Escolha uma data dentro do horizonte da política para conferir o impacto.');
$this->require($quantity >= $basis['policy']['minimum_milli'] && $quantity % $basis['policy']['pack_milli'] === 0, 'data.quantity_milli', 'Respeite a quantidade mínima e o múltiplo de embalagem da política.');
$price = $data['unit_price'] ?? $basis['unit_price'];
$tax = $data['tax_rate'] ?? $basis['tax_rate'];
$this->require($price !== null, 'data.unit_price', 'Indique estimativa em MZN com fonte no fundamento; não existe preço local reutilizável.');
$this->require($tax !== null, 'data.tax_rate', 'Confirme a taxa aplicável; não existe valor configurado reutilizável.');
$draftForm = new StoreRequisitionRequest;
$draftForm->setUserResolver(fn () => $actor);
$draftRules = $draftForm->rules();
Validator::make(['items' => [['quantity' => BigDecimal::of($quantity)->dividedBy(1000, 3)->__toString(), 'unit_price' => $price, 'tax_rate' => $tax]]],
array_intersect_key($draftRules, array_flip(['items.*.quantity', 'items.*.unit_price', 'items.*.tax_rate'])), $draftForm->messages())->validate();
$snapshot = $basis;
$snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]));
$snapshot['prepared_on'] = today()->toDateString();
$data = ['policy_key' => $data['policy_key'], 'policy_version' => $basis['policy_version'], 'quantity_milli' => $quantity, 'delivery_on' => $delivery,
'unit_price' => BigDecimal::of($price)->toScale(2)->__toString(), 'tax_rate' => BigDecimal::of($tax)->toScale(2)->__toString(),
'price_source' => $basis['unit_price'] !== null && BigDecimal::of($price)->isEqualTo($basis['unit_price']) ? 'agreement' : 'manual_estimate',
'tax_source' => $basis['tax_rate'] !== null && BigDecimal::of($tax)->isEqualTo($basis['tax_rate']) ? 'company_default' : 'manual_rate',
'manual_plan' => $quantity !== ($projection['quantity_milli'] ?? 0) || $delivery !== ($projection['risk_date'] ?? null) || ! ($projection['demand_complete'] ?? false)];
$state = 'draft';
} else {
$this->require($old !== null, 'action', 'Prepare primeiro a sugestão.');
$data = $old->data;
$snapshot = $old->snapshot;
$state = $old->state;
if (in_array($v['action'], ['approve', 'return', 'close'], true)) {
$this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A conferência exige independência de todos os preparadores.');
}
if ($v['action'] === 'approve') {
$this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão não está disponível para decisão.');
$this->current($old);
$this->require(! $this->hasExcess($old), 'action', 'A reposição excede o máximo no horizonte. Reveja quantidade, calendário ou política antes da aprovação.');
if ($data['manual_plan'] || $snapshot['events']['warnings'] !== []) {
$ack = Validator::make($v['data'], ['confirm_exception' => 'required|accepted'])->validate();
$data['exception_confirmed'] = (bool) $ack['confirm_exception'];
}
$state = 'approved';
} elseif ($v['action'] === 'return') {
$this->require(in_array($state, ['draft', 'approved'], true), 'action', 'A sugestão já não pode ser devolvida nesta etapa.');
$state = 'returned';
} elseif ($v['action'] === 'cancel') {
$this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão aprovada deve ser devolvida; a convertida conserva a requisição.');
$state = 'cancelled';
} elseif ($v['action'] === 'convert') {
$this->require($state === 'approved', 'action', 'Só uma sugestão aprovada e ainda não convertida pode criar requisição.');
$this->require(($snapshot['reviewed_by'] ?? null) !== $actor->id, 'action', 'A pessoa que conferiu não pode tornar-se autora da requisição.');
$this->current($old);
$conversion = Validator::make($v['data'], ['cost_center_id' => 'required|integer|min:1'])->validate();
$catalogue = $snapshot['catalogue'];
$payload = ['cost_center_id' => $conversion['cost_center_id'], 'priority' => $data['delivery_on'] <= today()->toDateString() ? 'urgent' : 'normal',
'needed_by' => $data['delivery_on'], 'justification' => $v['reason'], 'items' => [['catalogue_entry_id' => $catalogue['id'], 'catalogue_version' => $catalogue['version'],
'quantity' => BigDecimal::of($data['quantity_milli'])->dividedBy(1000, 3)->__toString(), 'unit_price' => $data['unit_price'], 'tax_rate' => $data['tax_rate']]]];
$form = new StoreRequisitionRequest;
$form->setUserResolver(fn () => $actor);
$payload = Validator::make($payload, $form->rules(), $form->messages())->validate();
$requisition = app(RequisitionWorkflow::class)->save($actor, $payload);
$data['requisition_id'] = $requisition->id;
$data['requisition_number'] = $requisition->number;
$snapshot['conversion'] = ['actor_id' => $actor->id, 'approval_version' => $old->version, 'cost_center_id' => $requisition->cost_center_id,
'requisition_created' => [...$requisition->only(['id', 'number', 'cost_center_id', 'subtotal_minor', 'tax_minor', 'total_minor']),
'items' => $requisition->items()->get()->map(fn ($line) => $line->only(['id', 'description', 'unit', 'quantity', 'unit_price_minor', 'tax_rate', 'catalogue_entry_id', 'catalogue_snapshot', 'total_minor']))->all()]];
$state = 'converted';
} elseif ($v['action'] === 'close') {
$this->require($state === 'converted', 'action', 'Apenas uma sugestão convertida pode encerrar acompanhamento.');
$request = Requisition::query()->whereKey($data['requisition_id'])->firstOrFail();
$orders = PurchaseOrder::query()->where('requisition_id', $request->id)->get();
$complete = $orders->isNotEmpty() && $orders->every(fn ($order) => app(ReceiptWorkflow::class)->progress($order)['state'] === 'complete');
$this->require($request->status === 'rejected' || $complete, 'action', 'A necessidade continua em acompanhamento. Aguarde rejeição da requisição ou recepção completa da ordem.');
$state = 'closed';
}
if ($v['action'] === 'approve') {
$snapshot['reviewed_by'] = $actor->id;
}
}
$snapshot['actor'] = $actor->only(['id', 'name']);
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'suggestion', 'record_key' => $old->record_key ?? (string) Str::uuid(),
'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $snapshot['stock_item_id'], 'warehouse_id' => $snapshot['warehouse_id'],
'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'supply.suggestion.'.$v['action'], null, $old?->toArray(), $record->toArray());
return $record;
});
}
/** A repeatable source snapshot; never writes business events.
* @return array<string,mixed>
*/
public function basis(string $policyKey, string $date): array
{
$planning = app(SupplyPlanning::class);
$policy = $planning->latest('policy')->where('record_key', $policyKey)->firstOrFail();
$this->require($policy->state === 'approved', 'data.policy_key', 'Seleccione uma política aprovada e vigente.');
$stock = StockItem::query()->whereKey($policy->stock_item_id)->firstOrFail();
$warehouse = Warehouse::query()->whereKey($policy->warehouse_id)->firstOrFail();
$this->require($stock->only(['id', 'code', 'name', 'unit']) === $policy->snapshot['item'], 'data.policy_key', 'O artigo mudou; reveja a política antes de preparar uma sugestão.');
$this->require(Schema::hasTable('requisition_catalogue'), 'data.policy_key', 'Instale e publique o catálogo antes de preparar sugestões convertíveis.');
$catalogue = DB::table('requisition_catalogue')->where('tenant_id', $policy->tenant_id)->where('stock_item_id', $stock->id)->first();
$this->require($catalogue !== null && $catalogue->published && json_decode($catalogue->source_snapshot, true, 16, JSON_THROW_ON_ERROR) === app(RequisitionCatalogue::class)->source($stock), 'data.policy_key', 'Publique a versão actual do artigo no catálogo para reutilizar os seus dados na requisição.');
$agreement = null;
$price = null;
$supplierBasis = null;
if (! empty($policy->data['agreement_key'])) {
$agreement = $planning->latest('agreement')->where('record_key', $policy->data['agreement_key'])->firstOrFail();
$supplier = Supplier::query()->whereKey($agreement->supplier_id)->firstOrFail();
$this->require($agreement->state === 'approved' && $agreement->version === $policy->data['agreement_version'] && $agreement->data['valid_from'] <= today()->toDateString() && $agreement->data['valid_until'] >= today()->toDateString() && $supplier->eligible(), 'data.policy_key', 'O acordo de origem ou a validação do fornecedor deixou de estar vigente. Reveja a política.');
$this->require($agreement->snapshot['supplier'] === $supplier->only(['id', 'name', 'version']), 'data.policy_key', 'A versão do fornecedor mudou desde o acordo. Reveja o acordo e a política.');
if ($agreement->data['currency'] === 'MZN' && $agreement->data['price_minor'] !== null && $agreement->data['price_minor'] > 0) {
$price = BigDecimal::of($agreement->data['price_minor'])->dividedBy(100, 2)->__toString();
}
$supplierBasis = $supplier->only(['id', 'version', 'status', 'blocked', 'next_review']);
}
$balance = app(StockWorkflow::class)->balance($stock->id, $warehouse->id);
$events = app(SupplyCoordination::class)->events($stock->id, $warehouse->id, $date);
$forecast = app(SupplyHistory::class)->resolve($policy);
$projection = app(InventoryLots::class)->project($policy, $forecast['data'], $balance, $events, $date);
$sources = [];
foreach (['reservation', 'delivery', 'import'] as $kind) {
$rows = $planning->latest($kind);
if ($kind !== 'import') {
$rows->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id);
} else {
$orderIds = $planning->latest('delivery')->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id)->pluck('purchase_order_id');
$rows->whereIn('purchase_order_id', $orderIds);
}
$sources[$kind] = $rows->orderBy('id')->get(['id', 'record_key', 'version', 'state'])->toArray();
}
$tax = Tenant::query()->findOrFail($policy->tenant_id)->default_tax_rate;
$basis = ['stock_item_id' => $stock->id, 'warehouse_id' => $warehouse->id, 'item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']),
'policy_key' => $policy->record_key, 'policy_version' => $policy->version, 'policy' => $policy->data,
'catalogue' => ['id' => $catalogue->id, 'version' => $catalogue->version, 'category' => $catalogue->category, 'purchase_type' => $catalogue->purchase_type],
'agreement' => $agreement?->only(['id', 'record_key', 'version', 'data', 'snapshot']), 'supplier' => $supplierBasis,
'balance' => $balance, 'events' => $events, 'current_event_status' => app(SupplyCoordination::class)->events($stock->id, $warehouse->id, today()->toDateString()),
'projection' => $projection, 'sources' => $sources, 'unit_price' => $price, 'tax_rate' => $tax];
if ($forecast['history'] !== null) {
$basis['forecast_history'] = $forecast['history'];
}
$basis['hash'] = hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR));
return $basis;
}
public function hasExcess(SupplyRecord $record): bool
{
$max = $record->snapshot['policy']['maximum_milli'];
if (! ($record->snapshot['projection']['available'] ?? false)) {
return $record->snapshot['balance']['quantity'] + $record->data['quantity_milli'] > $max;
}
foreach ($record->snapshot['projection']['series'] ?? [] as $point) {
if ($point['date'] >= $record->data['delivery_on'] && $max < $point['balance_milli'] + $record->data['quantity_milli']) {
return true;
}
}
return false;
}
/** Minimal provenance for a requisition already authorised by its controller.
* @return array<string,mixed>|null
*/
public function origin(Requisition $requisition): ?array
{
if (! Schema::hasTable('supply_records')) {
return null;
}
$record = app(SupplyPlanning::class)->latest('suggestion')->whereIn('state', ['converted', 'closed'])->whereJsonContains('data->requisition_id', $requisition->id)->first();
if ($record === null) {
return null;
}
return ['approval_version' => $record->snapshot['conversion']['approval_version'], 'prepared_on' => $record->snapshot['prepared_on'],
'quantity_milli' => $record->data['quantity_milli'], 'unit' => $record->snapshot['item']['unit'], 'delivery_on' => $record->data['delivery_on'],
'purpose' => $record->snapshot['policy']['purpose'], 'origin' => $record->snapshot['policy']['origin']];
}
private function current(SupplyRecord $record): void
{
$this->require($record->data['delivery_on'] >= today()->toDateString(), 'action', 'A data pretendida passou. Devolva e actualize a sugestão.');
$basis = $this->basis($record->data['policy_key'], $record->snapshot['prepared_on']);
$this->require($basis['hash'] === $record->snapshot['hash'], 'action', 'O stock, as fontes ou a configuração mudaram. Devolva e actualize a sugestão antes de decidir ou converter.');
}
private function require(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}