Files
compras/app/Services/SupplyPlanning.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

259 lines
20 KiB
PHP

<?php
namespace App\Services;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
use App\Models\StockItem;
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplyPlanning
{
public function allows(User $actor, string $permission): bool
{
$functions = in_array($permission, ['supply_read', 'import_read'], true) ? [$permission, 'admin'] : [$permission];
return $actor->hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
public function independentReviewer(User $actor, SupplyRecord $record): bool
{
return $actor->tenant_id === $record->tenant_id && $this->allows($actor, 'supply_review')
&& ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true);
}
/** Eligibility to confer, not a promise that changed sources permit approval. */
public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool
{
if (! in_array($record->kind, ['agreement', 'policy'], true) || ! in_array($action, ['approve', 'return', 'suspend'], true)
|| ! $this->independentReviewer($actor, $record)) {
return false;
}
$latest = $this->latest($record->kind)->where('record_key', $record->record_key)->first();
return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
}
/** @return Builder<SupplyRecord> */
public function latest(string $kind)
{
return SupplyRecord::query()->where('kind', $kind)->whereIn('id', SupplyRecord::query()->selectRaw('MAX(id)')->where('kind', $kind)->groupBy('record_key'));
}
/** Live calendar: physical risk can remain known when a contractual lead time needs reconfirmation.
* @return array<string,mixed>
*/
public function calendar(SupplyRecord $policy, string $date): array
{
$stock = StockItem::query()->whereKey($policy->stock_item_id)->first();
if ($stock === null || $stock->only(['id', 'code', 'name', 'unit']) !== ($policy->snapshot['item'] ?? null)) {
return ['available' => false, 'reason' => 'O cadastro do artigo mudou ou está incompleto. Reveja a política por referência à versão actual antes de interpretar quantidades.'];
}
$balance = app(StockWorkflow::class)->balance($policy->stock_item_id, $policy->warehouse_id);
$events = app(SupplyCoordination::class)->events($policy->stock_item_id, $policy->warehouse_id, $date);
$forecast = app(SupplyHistory::class)->resolve($policy);
$projection = [...app(InventoryLots::class)->project($policy, $forecast['data'], $balance, $events, $date), 'events' => $events, 'forecast_history' => $forecast['history']];
if (! empty($policy->data['agreement_key'])) {
$agreement = $this->latest('agreement')->where('record_key', $policy->data['agreement_key'])->first();
$supplier = $agreement === null ? null : Supplier::query()->whereKey($agreement->supplier_id)->first();
$valid = $agreement !== null && $agreement->state === 'approved' && $agreement->version === ($policy->data['agreement_version'] ?? null)
&& $agreement->stock_item_id === $policy->stock_item_id && $agreement->warehouse_id === $policy->warehouse_id
&& $agreement->data['valid_from'] <= $date && $agreement->data['valid_until'] >= $date && $supplier !== null && $supplier->eligible()
&& $supplier->only(['id', 'name', 'version']) === $agreement->snapshot['supplier'];
if (! $valid) {
$projection = [...$projection, 'lead_time_available' => false, 'lead_time_issue' => 'O acordo, fornecedor ou validação documental deixou de sustentar o prazo aprovado. Reconfirme o fornecimento; as datas de pedir/encomendar ficam por determinar.',
'request_date' => null, 'order_date' => null, 'reorder_point_milli' => null];
}
}
return $projection;
}
/** @param array<string,mixed> $input */
public function save(User $actor, string $kind, array $input): SupplyRecord
{
if ($kind === 'history') {
return app(SupplyHistory::class)->save($actor, $input);
}
if ($kind === 'suggestion') {
return app(SupplySuggestions::class)->save($actor, $input);
}
if (in_array($kind, ['reservation', 'delivery', 'alternative'], true)) {
return app(SupplyCoordination::class)->save($actor, $kind, $input);
}
abort_unless(in_array($kind, ['policy', 'agreement', 'import'], true), 404);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.');
return DB::transaction(function () use ($actor, $kind, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$envelope = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0',
'action' => 'required|in:save,approve,return,suspend,transit,arrive,hold,release', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
$action = $envelope['action'];
$permission = $kind === 'import' ? (in_array($action, ['hold', 'release'], true) ? 'import_customs' : 'import_prepare')
: (in_array($action, ['approve', 'return', 'suspend'], true) ? 'supply_review' : 'supply_prepare');
abort_unless($this->allows($actor, $permission), 403);
$hash = hash('sha256', json_encode([$kind, $envelope], JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $envelope['operation_key'])->first();
if ($retry !== null) {
$this->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.');
return $retry;
}
$old = isset($envelope['record_key']) ? SupplyRecord::query()->where('kind', $kind)->where('record_key', $envelope['record_key'])->latest('version')->firstOrFail() : null;
$this->check((int) $envelope['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.');
$state = 'draft';
if ($action === 'save') {
$this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Crie um novo acordo ou política; preserve a versão já aprovada.');
$data = $this->validateData($kind, $envelope['data']);
if ($kind === 'policy' && ($data['forecast_method'] ?? 'manual') === 'historical') {
$history = app(SupplyHistory::class)->reference($data);
$this->check($history['available'], 'data.history_key', $history['reason'] ?? 'Histórico não disponível.');
$data['history_version'] = $history['version'];
}
if ($kind === 'policy' && ! empty($data['agreement_key'])) {
$agreement = $this->latest('agreement')->where('record_key', $data['agreement_key'])->firstOrFail();
$this->check($agreement->state === 'approved' && $agreement->stock_item_id === (int) $data['stock_item_id'] && $agreement->warehouse_id === (int) $data['warehouse_id'], 'data.agreement_key', 'Seleccione um acordo aprovado deste artigo/local.');
$this->check($agreement->data['lead_days'] !== null && $agreement->data['valid_from'] <= today()->toDateString() && $agreement->data['valid_until'] >= today()->toDateString(), 'data.agreement_key', 'O acordo deve estar vigente e ter prazo conhecido.');
$data['lead_days'] = $agreement->data['lead_days'];
$data['origin'] = $agreement->data['origin'];
$data['agreement_version'] = $agreement->version;
}
} else {
$this->check($old !== null, 'action', 'Guarde primeiro a preparação.');
$data = $old->data;
if ($kind !== 'import') {
$this->check(in_array($action, ['approve', 'return', 'suspend'], true), 'action', 'Acção incompatível com este registo.');
$this->check($this->independentReviewer($actor, $old), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.');
$this->check($action === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
$state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$action];
} else {
$this->check(in_array($action, ['transit', 'arrive', 'hold', 'release'], true), 'action', 'Acção incompatível com a importação.');
$allowed = ['transit' => ['draft'], 'arrive' => ['transit'], 'hold' => ['draft', 'transit', 'arrived', 'released'], 'release' => ['arrived', 'held']];
$this->check(in_array($old->state, $allowed[$action], true), 'action', 'O marco anterior ainda não foi registado.');
$event = Validator::make($envelope['data'], ['evidence' => 'required|string|min:5|max:2000', 'event_date' => 'required|date_format:Y-m-d|before_or_equal:today'])->validate();
$this->check($event['event_date'] >= ($old->data['event']['event_date'] ?? substr($old->created_at->toDateString(), 0, 10)), 'data.event_date', 'O marco não pode anteceder o registo anterior.');
if ($action === 'release') {
$this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A libertação exige conferência independente da preparação.');
}
$data['event'] = $event;
$state = ['transit' => 'transit', 'arrive' => 'arrived', 'hold' => 'held', 'release' => 'released'][$action];
}
}
$item = isset($data['stock_item_id']) ? StockItem::query()->whereKey($data['stock_item_id'])->lockForUpdate()->firstOrFail() : null;
$warehouse = isset($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail() : null;
$supplier = isset($data['supplier_id']) ? Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail() : null;
$order = isset($data['purchase_order_id']) ? PurchaseOrder::query()->whereKey($data['purchase_order_id'])->firstOrFail() : null;
if ($order !== null) {
app(OrderCancellationWorkflow::class)->assertOperational($order);
}
if ($kind === 'agreement' && $state === 'approved') {
$this->check($supplier !== null && $supplier->eligible(), 'supplier_id', 'O fornecedor deve estar activo e com revisão documental vigente.');
$this->check(! app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, null), 'supplier_id', 'Existe conflito declarado com este fornecedor.');
$this->check(($old->snapshot['supplier'] ?? null) === $supplier->only(['id', 'name', 'version']), 'supplier_id', 'O fornecedor mudou desde a preparação. Devolva e actualize a versão.');
}
if ($kind === 'policy' && $state === 'approved') {
if (($data['forecast_method'] ?? 'manual') === 'historical') {
$history = app(SupplyHistory::class)->reference($data);
$this->check($history['available'], 'action', $history['reason'] ?? 'Histórico não disponível.');
}
if (! empty($data['agreement_key'])) {
$agreement = $this->latest('agreement')->where('record_key', $data['agreement_key'])->firstOrFail();
$this->check($agreement->state === 'approved' && $agreement->version === $data['agreement_version'], 'action', 'O acordo mudou desde a preparação. Devolva e seleccione a versão actual.');
}
$clash = $this->latest('policy')->where('state', 'approved')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->where('record_key', '!=', $old->record_key)->exists();
$this->check(! $clash, 'action', 'Suspenda a política anterior deste artigo/local antes de aprovar a substituição.');
}
if ($kind === 'agreement' && $state === 'approved' && $data['habitual']) {
$clash = $this->latest('agreement')->where('state', 'approved')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->get()
->contains(fn ($r) => $r->record_key !== $old->record_key && $r->data['habitual'] && $r->data['valid_from'] <= $data['valid_until'] && $r->data['valid_until'] >= $data['valid_from']);
$this->check(! $clash, 'action', 'Já existe fornecedor habitual com vigência sobreposta neste artigo/local.');
}
if ($kind === 'import' && $old === null) {
$this->check(! $this->latest('import')->where('purchase_order_id', $order->id)->exists(), 'purchase_order_id', 'A ordem já tem processo de importação. Consulte-o sem duplicar a compra.');
$this->check(! GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists(), 'purchase_order_id', 'Registe a importação antes da primeira recepção da ordem.');
}
$snapshot = $action === 'save' ? ['prepared_by' => $actor->id, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])), 'item' => $item?->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse?->only(['id', 'code', 'name']),
'supplier' => $supplier?->only(['id', 'name', 'version']), 'order' => $order?->only(['id', 'number', 'currency', 'supplier_id', 'total_minor'])] : $old->snapshot;
if ($state === 'approved') {
$this->check($snapshot['item'] === $item?->only(['id', 'code', 'name', 'unit']), 'action', 'O cadastro do artigo mudou. Devolva e actualize a preparação.');
}
$record = 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' => $item?->id, 'warehouse_id' => $warehouse?->id,
'supplier_id' => $supplier?->id, 'purchase_order_id' => $order?->id, 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot,
'operation_key' => $envelope['operation_key'], 'input_hash' => $hash, 'reason' => $envelope['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'supply.'.$kind.'.'.$action, null, $old?->toArray(), $record->toArray());
return $record;
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function validateData(string $kind, array $input): array
{
$predecessor = null;
if ($kind === 'policy' && ! empty($input['predecessor_key'])) {
$source = Validator::make($input, ['predecessor_key' => 'required|uuid', 'predecessor_version' => 'required|integer|min:1'])->validate();
$predecessor = SupplyRecord::query()->where('kind', 'policy')->where('record_key', $source['predecessor_key'])->where('version', $source['predecessor_version'])->firstOrFail();
$this->check(in_array($predecessor->state, ['approved', 'suspended'], true), 'data.predecessor_key', 'Reutilize uma versão de política já aprovada.');
$input = [...$predecessor->data, ...$input];
}
$rules = match ($kind) {
'policy' => ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1',
'predecessor_key' => 'nullable|required_with:predecessor_version|uuid', 'predecessor_version' => 'nullable|required_with:predecessor_key|integer|min:1',
'forecast_method' => 'nullable|in:manual,historical',
'history_key' => 'exclude_unless:forecast_method,historical|required|uuid',
'history_min_days' => 'exclude_unless:forecast_method,historical|required|integer|min:1|max:3660',
'history_max_age_days' => 'exclude_unless:forecast_method,historical|required|integer|min:1|max:3660',
'agreement_key' => 'nullable|uuid',
'purpose' => 'required|in:resale,internal', 'origin' => 'required|in:local,import',
'safety_milli' => 'required|integer|min:0|max:1000000000', 'minimum_stock_milli' => 'required|integer|min:0|max:1000000000',
'target_milli' => 'required|integer|min:0|max:1000000000', 'maximum_milli' => 'required|integer|min:0|max:1000000000',
'daily_milli' => 'nullable|integer|min:0|max:1000000000', 'lead_days' => 'required|integer|min:0|max:3650', 'approval_days' => 'required|integer|min:0|max:365',
'horizon_days' => 'required|integer|min:1|max:366', 'minimum_milli' => 'required|integer|min:0|max:1000000000', 'pack_milli' => 'required|integer|min:1|max:1000000000',
'basis' => 'required|string|min:5|max:2000'],
'agreement' => ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', 'supplier_id' => 'required|integer|min:1',
'habitual' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from',
'origin' => 'required|in:local,import', 'currency' => 'required|regex:/^[A-Z]{3}$/', 'price_minor' => 'nullable|integer|min:0|max:1000000000000',
'lead_days' => 'nullable|integer|min:0|max:3650', 'reference' => 'required|string|max:200'],
'import' => ['purchase_order_id' => 'required|integer|min:1', 'purpose' => 'required|in:resale,internal', 'jurisdiction' => 'required|string|max:100',
'origin_country' => 'required|string|max:100', 'dispatch_country' => 'required|string|max:100',
'incoterm' => 'nullable|string|max:100', 'regime' => 'required|string|max:200', 'expected_usable_on' => 'nullable|date_format:Y-m-d'],
default => throw new \InvalidArgumentException('Tipo de fornecimento desconhecido.'),
};
$data = Validator::make($input, $rules)->validate();
if ($predecessor !== null) {
$this->check((int) $data['stock_item_id'] === $predecessor->stock_item_id && (int) $data['warehouse_id'] === $predecessor->warehouse_id, 'data.stock_item_id', 'A política reutilizada mantém o artigo e local de origem.');
$data['predecessor_version'] = (int) $data['predecessor_version'];
}
if ($kind === 'policy' && ($data['forecast_method'] ?? 'manual') === 'historical') {
$data['daily_milli'] = null;
$data['history_min_days'] = (int) $data['history_min_days'];
$data['history_max_age_days'] = (int) $data['history_max_age_days'];
}
if ($kind === 'policy') {
$this->check($data['safety_milli'] <= $data['minimum_stock_milli'] && $data['minimum_stock_milli'] <= $data['target_milli'] && $data['target_milli'] <= $data['maximum_milli'], 'data.target_milli', 'Respeite segurança ≤ mínimo ≤ alvo ≤ máximo.');
}
return $data;
}
private function check(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}