338 lines
26 KiB
PHP
338 lines
26 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();
|
|
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
|
$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();
|
|
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
|
$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]);
|
|
}
|
|
}
|
|
}
|