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

336 lines
25 KiB
PHP

<?php
namespace App\Services;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\PurchaseOrder;
use App\Models\PurchaseOrderItem;
use App\Models\Requisition;
use App\Models\RequisitionItem;
use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplyCoordination
{
public function canManage(User $actor, SupplyRecord $record, string $action): bool
{
if ($actor->tenant_id !== $record->tenant_id || ! in_array($record->kind, ['reservation', 'delivery'], true)
|| ! app(SupplyPlanning::class)->allows($actor, $record->kind === 'reservation' ? 'supply_reserve' : 'supply_delivery')) {
return false;
}
$latest = app(SupplyPlanning::class)->latest($record->kind)->where('record_key', $record->record_key)->first();
if ($latest?->id !== $record->id) {
return false;
}
$open = $record->state === 'active' || ($record->kind === 'reservation' && $record->state === 'fulfilled' && $this->remainingReservation($record) > 0);
return match ($action) {
'edit', 'cancel' => $open,
'fulfil' => $record->kind === 'reservation' && in_array($record->state, ['active', 'fulfilled'], true) && $this->remainingReservation($record) > 0,
default => false,
};
}
private function independentTechnicalReviewer(User $actor, SupplyRecord $record): bool
{
return $actor->tenant_id === $record->tenant_id && app(SupplyPlanning::class)->allows($actor, 'supply_technical_review')
&& ! in_array($actor->id, $record->snapshot['contributors'], true);
}
public function canReviewAlternative(User $actor, SupplyRecord $record, string $action = 'approve'): bool
{
if ($record->kind !== 'alternative' || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentTechnicalReviewer($actor, $record)) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('alternative')->where('record_key', $record->record_key)->first();
return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
}
/** @param array<string,mixed> $input */
public function save(User $actor, string $kind, array $input): SupplyRecord
{
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);
$v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0',
'action' => 'required|in:save,approve,return,suspend,fulfil,cancel', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
$permission = match ($kind) {
'alternative' => $v['action'] === 'save' ? 'supply_technical_prepare' : 'supply_technical_review',
'reservation' => 'supply_reserve', 'delivery' => 'supply_delivery',
default => abort(404),
};
abort_unless(app(SupplyPlanning::class)->allows($actor, $permission), 403);
$hash = hash('sha256', json_encode([$kind, $v], JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first();
if ($retry) {
$this->require($retry->input_hash === $hash, 'operation_key', 'Chave já usada com outro conteúdo.');
return $retry;
}
$old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest($kind)->where('record_key', $v['record_key'])->firstOrFail() : null;
$this->require((int) $v['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.');
if ($old !== null && in_array($kind, ['reservation', 'delivery'], true)) {
$this->require($this->canManage($actor, $old, $v['action'] === 'save' ? 'edit' : $v['action']), 'action', 'O registo não admite esta intervenção no estado actual.');
}
$data = $old->data ?? [];
$snapshot = $old->snapshot ?? [];
$state = $old->state ?? 'draft';
if ($v['action'] === 'save') {
$openCorrected = $kind === 'reservation' && $old !== null && $state === 'fulfilled' && $this->remainingReservation($old) > 0;
$this->require($kind === 'alternative' ? $old === null || in_array($state, ['draft', 'returned'], true) : $old === null || $state === 'active' || $openCorrected, 'action', 'O registo já está encerrado ou aprovado.');
$data = $this->prepare($actor, $kind, $v['data'], $old);
$stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail();
$warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail();
$snapshot = ['item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']),
'contributors' => array_values(array_unique([...($snapshot['contributors'] ?? []), $actor->id]))];
if ($kind === 'alternative') {
$substitute = StockItem::query()->whereKey($data['substitute_id'])->firstOrFail();
$this->require($substitute->id !== $stock->id, 'data.substitute_id', 'O artigo não pode substituir-se a si próprio.');
$snapshot['substitute'] = $substitute->only(['id', 'code', 'name', 'unit']);
}
if ($kind === 'delivery') {
$line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail();
$order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail();
$snapshot['order'] = $order->only(['id', 'number']);
$snapshot['order_line'] = $line->only(['id', 'quantity', 'unit', 'description']);
}
$state = $kind === 'alternative' ? 'draft' : 'active';
} elseif ($kind === 'alternative') {
$this->require($old !== null && in_array($v['action'], ['approve', 'return', 'suspend'], true), 'action', 'Acção técnica inválida.');
$this->require($this->independentTechnicalReviewer($actor, $old), 'action', 'A validação técnica exige independência dos preparadores.');
$this->require($v['action'] === 'suspend' ? $state === 'approved' : in_array($state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
if ($v['action'] === 'approve') {
$this->require($data['compatibility'] !== 'pending', 'action', 'Conclua a análise técnica antes de aprovar.');
$this->require($data['valid_until'] >= today()->toDateString(), 'action', 'A aprovação não pode nascer expirada.');
foreach (['item' => $data['stock_item_id'], 'substitute' => $data['substitute_id']] as $key => $id) {
$this->require(StockItem::query()->whereKey($id)->firstOrFail()->only(['id', 'code', 'name', 'unit']) === $snapshot[$key], 'action', 'As especificações de cadastro mudaram. Devolva e reveja a preparação.');
}
$duplicate = app(SupplyPlanning::class)->latest('alternative')->where('state', 'approved')->where('stock_item_id', $old->stock_item_id)->where('warehouse_id', $old->warehouse_id)
->where('record_key', '!=', $old->record_key)->get()->contains(fn ($r) => $r->data['substitute_id'] === $data['substitute_id'] && $r->data['application'] === $data['application'] && $r->data['valid_until'] >= today()->toDateString());
$this->require(! $duplicate, 'action', 'Já existe relação técnica aprovada neste sentido, aplicação e local.');
}
$state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$v['action']];
} elseif ($v['action'] === 'fulfil' && $kind === 'reservation') {
$this->require($old !== null && in_array($state, ['active', 'fulfilled'], true) && $this->remainingReservation($old) > 0, 'action', 'A reserva não tem quantidade pendente.');
$f = Validator::make($v['data'], ['stock_operation_id' => 'required|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000'])->validate();
$f['quantity_milli'] = (int) $f['quantity_milli'];
$operation = StockOperation::query()->whereKey($f['stock_operation_id'])->firstOrFail();
$this->require($operation->kind === 'issue' && $operation->stock_item_id === $old->stock_item_id && $operation->warehouse_id === $old->warehouse_id, 'data.stock_operation_id', 'Seleccione uma saída efectiva deste artigo/local.');
$this->require(! app(InventoryReversals::class)->operationReversed($operation->id), 'data.stock_operation_id', 'A saída foi estornada e não pode satisfazer uma reserva.');
$used = app(SupplyPlanning::class)->latest('reservation')->get()->sum(fn ($r) => array_sum(array_column(array_filter($r->data['applications'] ?? [], fn ($a) => $a['operation_id'] === $operation->id), 'quantity_milli')));
$remaining = $this->remainingReservation($old);
$this->require($f['quantity_milli'] <= $remaining && $used + $f['quantity_milli'] <= $operation->quantity_milli, 'data.quantity_milli', 'A afectação excede a reserva pendente ou a saída ainda disponível.');
$data['applications'][] = ['operation_id' => $operation->id, 'quantity_milli' => (int) $f['quantity_milli'], 'reference' => $operation->reference];
$state = $f['quantity_milli'] === $remaining ? 'fulfilled' : 'active';
} elseif ($v['action'] === 'cancel') {
$this->require($old !== null && ($state === 'active' || ($kind === 'reservation' && $state === 'fulfilled' && $this->remainingReservation($old) > 0)), 'action', 'O registo não está activo.');
$state = 'cancelled';
} else {
throw ValidationException::withMessages(['action' => 'Acção incompatível com a responsabilidade ou registo.']);
}
if ($kind === 'reservation') {
$corrections = app(InventoryReversals::class)->corrections('operation');
$correctedApplications = array_values(array_filter(array_column($data['applications'], 'operation_id'), fn ($id) => isset($corrections[$id])));
if ($correctedApplications !== []) {
$snapshot['reversed_operations'] = array_values(array_unique($correctedApplications));
$snapshot['effective_allocated_milli'] = array_sum(array_column($this->effectiveApplications($data['applications']), 'quantity_milli'));
}
}
$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' => $data['stock_item_id'], 'warehouse_id' => $data['warehouse_id'],
'purchase_order_id' => $snapshot['order']['id'] ?? null, 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot,
'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'reason' => $v['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'supply.'.$kind.'.'.$v['action'], null, $old?->toArray(), $record->toArray());
return $record;
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function prepare(User $actor, string $kind, array $input, ?SupplyRecord $old): array
{
$rules = ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1'];
$rules += match ($kind) {
'alternative' => ['substitute_id' => 'required|integer|min:1', 'application' => 'required|string|max:200', 'compatibility' => 'required|in:equivalent,conditional,incompatible,pending',
'conditions' => 'required_if:compatibility,conditional|nullable|string|max:4000', 'evidence' => 'required|string|min:10|max:4000', 'factor_numerator' => 'required|integer|min:1|max:1000000', 'factor_denominator' => 'required|integer|min:1|max:1000000', 'valid_until' => 'required|date_format:Y-m-d'],
'reservation' => ['requisition_item_id' => 'nullable|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000', 'needed_on' => 'required|date_format:Y-m-d', 'additional' => 'required|boolean', 'reference' => 'required|string|max:200'],
'delivery' => ['purchase_order_item_id' => 'required|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000', 'expected_on' => 'required|date_format:Y-m-d', 'confirmed_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:5|max:2000'],
default => abort(404),
};
$data = Validator::make($input, $rules)->validate();
foreach (['stock_item_id', 'warehouse_id', 'substitute_id', 'factor_numerator', 'factor_denominator', 'quantity_milli', 'requisition_item_id', 'purchase_order_item_id'] as $field) {
if (isset($data[$field]) && $data[$field] !== '') {
$data[$field] = (int) $data[$field];
}
}
if (isset($data['additional'])) {
$data['additional'] = (bool) $data['additional'];
}
if ($old !== null) {
$this->require((int) $data['stock_item_id'] === $old->stock_item_id && (int) $data['warehouse_id'] === $old->warehouse_id, 'data.stock_item_id', 'A revisão mantém o artigo e local originais.');
}
if ($kind === 'reservation') {
$this->require($old === null || ($old->data['requisition_item_id'] ?? null) == ($data['requisition_item_id'] ?? null), 'data.requisition_item_id', 'A revisão conserva a necessidade de origem.');
$data['applications'] = $old?->data['applications'] ?? [];
$allocated = array_sum(array_column($this->effectiveApplications($data['applications']), 'quantity_milli'));
$this->require($data['quantity_milli'] >= $allocated, 'data.quantity_milli', 'A reserva não pode ficar abaixo das saídas já afectadas.');
if (! empty($data['requisition_item_id'])) {
$line = RequisitionItem::query()->whereKey($data['requisition_item_id'])->firstOrFail();
$request = Requisition::query()->whereKey($line->requisition_id)->firstOrFail();
$this->require($request->status === 'approved' && ($line->catalogue_snapshot['stock_item_id'] ?? null) === (int) $data['stock_item_id'], 'data.requisition_item_id', 'Seleccione uma linha aprovada ligada a este artigo do catálogo.');
$data['reference'] = $request->number.' / '.$line->id;
$reserved = app(SupplyPlanning::class)->latest('reservation')->where('id', '!=', $old->id ?? 0)->get()->filter(fn ($r) => ($r->data['requisition_item_id'] ?? null) == $line->id)
->sum(fn ($r) => $r->state === 'cancelled' ? array_sum(array_column($this->effectiveApplications($r->data['applications']), 'quantity_milli')) : $r->data['quantity_milli']);
$this->require($reserved + $data['quantity_milli'] <= ReceiptWorkflow::milli($line->quantity), 'data.quantity_milli', 'As reservas desta linha excedem a necessidade aprovada.');
}
$duplicate = app(SupplyPlanning::class)->latest('reservation')->whereIn('state', ['active', 'fulfilled'])->where('id', '!=', $old->id ?? 0)->where('stock_item_id', $data['stock_item_id'])->where('warehouse_id', $data['warehouse_id'])->get()->contains(fn ($r) => $r->data['reference'] === $data['reference'] && ($r->state === 'active' || $this->remainingReservation($r) > 0));
$this->require(! $duplicate, 'data.reference', 'Esta necessidade já tem reserva activa. Consulte e reveja a reserva existente.');
}
if ($kind === 'delivery') {
$line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail();
$order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail();
$stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail();
$linked = $order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null;
$this->require($order->status === 'issued' && $line->unit === $stock->unit && ($linked === null || $linked === $stock->id), 'data.stock_item_id', 'A confirmação deve respeitar o artigo e unidade da ordem emitida.');
$this->require($old === null || $old->data['purchase_order_item_id'] === (int) $data['purchase_order_item_id'], 'data.purchase_order_item_id', 'A revisão mantém a linha da ordem original.');
$others = app(SupplyPlanning::class)->latest('delivery')->where('state', 'active')->where('id', '!=', $old->id ?? 0)->get()->filter(fn ($r) => $r->data['purchase_order_item_id'] == $line->id)->sum(fn ($r) => $r->data['quantity_milli']);
$this->require($others === 0, 'data.purchase_order_item_id', 'Esta linha já tem confirmação activa. Reveja-a para actualizar quantidade ou prazo.');
$this->require($others + $data['quantity_milli'] <= ReceiptWorkflow::milli($line->quantity), 'data.quantity_milli', 'As confirmações excedem a quantidade da ordem. Reveja a confirmação existente.');
$this->require($data['quantity_milli'] >= app(ReceiptWorkflow::class)->recordedQuantity($line->id, 'received_milli'), 'data.quantity_milli', 'A quantidade acumulada confirmada não pode ficar abaixo da já recebida.');
// A line has one canonical destination/article mapping, even across revisions.
$mapping = app(SupplyPlanning::class)->latest('delivery')->get()->first(fn ($r) => $r->data['purchase_order_item_id'] == $line->id);
$this->require($mapping === null || ($mapping->stock_item_id === $stock->id && $mapping->warehouse_id === (int) $data['warehouse_id']), 'data.warehouse_id', 'Esta linha já tem artigo/local associados. Reutilize a associação existente.');
$receivedStocks = GoodsReceiptItem::query()->where('purchase_order_item_id', $line->id)->whereNotNull('stock_item_id')->pluck('stock_item_id')->unique();
$this->require($receivedStocks->isEmpty() || ($receivedStocks->count() === 1 && (int) $receivedStocks->first() === $stock->id), 'data.stock_item_id', 'A confirmação deve reutilizar o artigo já recebido nesta linha.');
$differentWarehouse = GoodsReceipt::query()->whereIn('id', GoodsReceiptItem::query()->where('purchase_order_item_id', $line->id)->select('goods_receipt_id'))->where('warehouse_id', '!=', $data['warehouse_id'])->exists();
$this->require(! $differentWarehouse, 'data.warehouse_id', 'Reutilize o armazém em que a linha já foi recebida.');
}
return $data;
}
public function eligibleAlternative(SupplyRecord $record): bool
{
if ($record->kind !== 'alternative' || $record->state !== 'approved' || $record->data['compatibility'] !== 'equivalent' || $record->data['valid_until'] < today()->toDateString()) {
return false;
}
foreach (['item' => $record->stock_item_id, 'substitute' => $record->data['substitute_id']] as $key => $id) {
if (StockItem::query()->whereKey($id)->first()?->only(['id', 'code', 'name', 'unit']) !== $record->snapshot[$key]) {
return false;
}
}
return true;
}
public function remainingDelivery(SupplyRecord $record): int
{
$received = app(ReceiptWorkflow::class)->recordedQuantity($record->data['purchase_order_item_id'], 'received_milli');
return max(0, BigDecimal::of($record->data['quantity_milli'])->minus($received)->toInt());
}
/** @param list<array<string,mixed>> $applications
* @return list<array<string,mixed>>
*/
public function effectiveApplications(array $applications): array
{
$reversed = app(InventoryReversals::class)->corrections('operation');
return array_values(array_filter($applications, fn ($a) => ! isset($reversed[$a['operation_id']])));
}
public function remainingReservation(SupplyRecord $record): int
{
return BigDecimal::of($record->data['quantity_milli'])->minus(array_sum(array_column($this->effectiveApplications($record->data['applications']), 'quantity_milli')))->toInt();
}
/** @return array{reservations:list<array{date:string,quantity:int,additional:bool,record_key:string,version:int}>,receipts:list<array{date:string,quantity:int,confirmed:bool,record_key:string,version:int}>,warnings:list<string>,consumed_today:int,fulfilled_needs:list<array{date:string,quantity:int}>} */
public function events(int $stock, int $warehouse, string $today): array
{
$planning = app(SupplyPlanning::class);
$reservations = $planning->latest('reservation')->whereIn('state', ['active', 'fulfilled'])->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get()->filter(fn ($r) => $r->state === 'active' || $this->remainingReservation($r) > 0)->map(fn ($r) => [
'date' => max($today, $this->dateField($r, 'needed_on')), 'quantity' => $this->remainingReservation($r), 'additional' => $r->data['additional'] === true || $this->dateField($r, 'needed_on') < $today, 'record_key' => $r->record_key, 'version' => $r->version])->values()->all();
$receipts = [];
$warnings = [];
$deliveries = $planning->latest('delivery')->where('state', 'active')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get();
foreach ($deliveries as $r) {
$remaining = $this->remainingDelivery($r);
if ($remaining === 0) {
continue;
}
$import = $planning->latest('import')->where('purchase_order_id', $r->purchase_order_id)->first();
if ($r->data['expected_on'] < $today || ($import && $import->state !== 'released')) {
$warnings[] = 'Entrega '.$r->snapshot['order']['number'].' sem disponibilidade utilizável confirmada; rever atraso ou libertação.';
continue;
}
$receipts[] = ['date' => $this->dateField($r, 'expected_on'), 'quantity' => $remaining, 'confirmed' => true, 'record_key' => $r->record_key, 'version' => $r->version];
}
$corrections = app(InventoryReversals::class)->corrections('operation');
$issues = StockOperation::query()->where('kind', 'issue')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->whereDate('recorded_at', $today)->get()->reject(fn ($op) => isset($corrections[$op->id]));
$allReservations = $planning->latest('reservation')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get();
$operationIds = [];
foreach ($allReservations as $reservation) {
foreach ($this->effectiveApplications($reservation->data['applications']) as $application) {
$operationIds[] = $application['operation_id'];
}
}
$sources = StockOperation::query()->whereIn('id', $operationIds)->get()->keyBy('id');
$allocatedToday = 0;
$fulfilledNeeds = [];
foreach ($allReservations as $reservation) {
foreach ($this->effectiveApplications($reservation->data['applications']) as $application) {
$source = $sources->get($application['operation_id']);
if ($source === null) {
throw new \LogicException('Saída de origem da reserva não encontrada.');
}
$date = $source->recorded_at->toDateString();
$applied = BigDecimal::of($application['quantity_milli'])->toInt();
if ($date === $today) {
$allocatedToday += $applied;
}
$needed = max($date, $this->dateField($reservation, 'needed_on'));
if ($reservation->data['additional'] === false && $needed >= $today) {
$fulfilledNeeds[] = ['date' => $needed, 'quantity' => $applied];
}
}
}
return ['reservations' => array_values($reservations), 'receipts' => $receipts, 'warnings' => $warnings, 'consumed_today' => max(0, BigDecimal::of($issues->sum('quantity_milli'))->minus($allocatedToday)->toInt()), 'fulfilled_needs' => $fulfilledNeeds];
}
private function dateField(SupplyRecord $record, string $field): string
{
$value = $record->data[$field] ?? null;
if (! is_string($value)) {
throw new \LogicException('Data inválida no histórico de fornecimento.');
}
return $value;
}
private function require(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}