259 lines
20 KiB
PHP
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]);
|
|
}
|
|
}
|
|
}
|