tenant_id === $record->tenant_id && app(SupplyPlanning::class)->allows($actor, 'supply_history_review') && ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true); } /** Eligibility to confer; approval still checks ledger, scope and source changes. */ public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool { if ($record->kind !== 'history' || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentReviewer($actor, $record)) { return false; } $latest = app(SupplyPlanning::class)->latest('history')->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 $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,suspend', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate(); abort_unless(app(SupplyPlanning::class)->allows($actor, $v['action'] === 'save' ? 'supply_history_prepare' : 'supply_history_review'), 403); $hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR)); $retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first(); if ($retry !== null) { $this->require($retry->kind === 'history' && $retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.'); return $retry; } $old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest('history')->where('record_key', $v['record_key'])->firstOrFail() : null; $this->require((int) $v['version'] === ($old->version ?? 0), 'version', 'A análise mudou. Actualize antes de continuar.'); if ($v['action'] === 'save') { $this->require($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Preserve a análise aprovada; prepare outra ou suspenda-a.'); $data = Validator::make($v['data'], ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', 'purpose' => 'required|in:resale,internal', 'period_from' => 'required|date_format:Y-m-d|before:today', 'period_until' => 'required|date_format:Y-m-d|after_or_equal:period_from|before:today', 'quality' => 'required|in:complete,incomplete,stockout,unknown', 'pattern' => 'required|in:regular,seasonal,intermittent,unknown'])->validate(); $data['stock_item_id'] = (int) $data['stock_item_id']; $data['warehouse_id'] = (int) $data['warehouse_id']; if ($old !== null) { $this->require($data['stock_item_id'] === $old->stock_item_id && $data['warehouse_id'] === $old->warehouse_id && $data['purpose'] === $old->data['purpose'], 'data.stock_item_id', 'A revisão conserva o artigo, local e finalidade da análise.'); } $stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail(); $warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail(); $analysis = $this->analyse($stock->id, $warehouse->id, $data['period_from'], $data['period_until']); $snapshot = ['item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'analysis' => $analysis, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]))]; $state = 'draft'; } else { $this->require($old !== null, 'action', 'Prepare primeiro a análise.'); $data = $old->data; $snapshot = $old->snapshot; $this->require($this->independentReviewer($actor, $old), 'action', 'A conferência exige independência de todos os preparadores.'); $this->require($v['action'] === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com esta decisão.'); if ($v['action'] === 'approve') { $confirm = Validator::make($v['data'], ['confirm_scope' => 'required|accepted'])->validate(); $data['scope_confirmed'] = (bool) $confirm['confirm_scope']; $current = $this->analyse($old->stock_item_id, $old->warehouse_id, $data['period_from'], $data['period_until']); $this->require($current['source_hash'] === $snapshot['analysis']['source_hash'], 'action', 'O razão histórico mudou. Devolva e actualize a análise.'); $stock = StockItem::query()->whereKey($old->stock_item_id)->firstOrFail(); $this->require($stock->only(['id', 'code', 'name', 'unit']) === $snapshot['item'], 'action', 'O cadastro do artigo mudou. Devolva e actualize a análise.'); $duplicate = app(SupplyPlanning::class)->latest('history')->where('state', 'approved')->where('stock_item_id', $old->stock_item_id)->where('warehouse_id', $old->warehouse_id)->where('id', '!=', $old->id) ->get()->contains(fn ($r) => $r->data['purpose'] === $data['purpose'] && $r->data['period_from'] === $data['period_from'] && $r->data['period_until'] === $data['period_until']); $this->require(! $duplicate, 'action', 'Já existe análise aprovada deste período e finalidade. Suspenda a anterior para a substituir.'); } $state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$v['action']]; } $snapshot['actor'] = $actor->only(['id', 'name']); $record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'history', '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'], '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.history.'.$v['action'], null, $old?->toArray(), $record->toArray()); return $record; }); } /** @return array */ public function analyse(int $stock, int $warehouse, string $from, string $until): array { StockItem::query()->whereKey($stock)->firstOrFail(); Warehouse::query()->whereKey($warehouse)->firstOrFail(); $start = CarbonImmutable::parse($from)->startOfDay(); $end = CarbonImmutable::parse($until)->startOfDay(); $days = (int) $start->diffInDays($end) + 1; $this->require($days >= 1 && $days <= 3660, 'data.period_until', 'O período deve ter entre 1 e 3660 dias corridos.'); $series = []; for ($day = $start; $day <= $end; $day = $day->addDay()) { $series[$day->toDateString()] = ['date' => $day->toDateString(), 'consumption_milli' => 0, 'delta_milli' => 0]; } $opening = BigDecimal::zero(); $hash = hash_init('sha256'); $counts = []; $invalid = false; $movementCount = 0; $operationCorrections = app(InventoryReversals::class)->corrections('operation'); $receiptCorrections = app(InventoryReversals::class)->corrections('receipt'); foreach (StockMovement::query()->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->whereDate('moved_at', '<=', $until)->orderBy('moved_at')->orderBy('id')->cursor() as $movement) { $date = $movement->moved_at->toDateString(); $quantity = BigDecimal::of($movement->quantity_milli); hash_update($hash, json_encode([$movement->id, $movement->movement_type, (string) $quantity, $movement->moved_at->format('Y-m-d H:i:s.uP'), $movement->stock_operation_id, $movement->goods_receipt_item_id], JSON_THROW_ON_ERROR)."\n"); $correction = $operationCorrections[$movement->stock_operation_id] ?? $receiptCorrections[$movement->goods_receipt_item_id] ?? null; if ($correction !== null) { hash_update($hash, json_encode(['stock_reversal', $correction->id, $correction->record_key, $correction->version], JSON_THROW_ON_ERROR)."\n"); } if ($date < $from) { $opening = $opening->plus($quantity); continue; } $movementCount++; $counts[$movement->movement_type] = ($counts[$movement->movement_type] ?? 0) + 1; if (! isset($series[$date])) { throw new \LogicException('A data do movimento está fora do período de análise.'); } $entry = $series[$date]; $consumption = $entry['consumption_milli']; if ($movement->movement_type === 'issue' && $correction === null) { if ($quantity->isGreaterThanOrEqualTo(0)) { $invalid = true; } else { $consumption = BigDecimal::of($consumption)->minus($quantity)->toInt(); } } $series[$date] = ['date' => $date, 'consumption_milli' => $consumption, 'delta_milli' => BigDecimal::of($entry['delta_milli'])->plus($quantity)->toInt()]; } $total = BigDecimal::zero(); $balance = $opening; $zeroDays = 0; $negativeDays = 0; $activeDays = 0; foreach ($series as &$day) { $balance = $balance->plus($day['delta_milli']); $day['balance_milli'] = $balance->toInt(); $total = $total->plus($day['consumption_milli']); $activeDays += $day['consumption_milli'] > 0 ? 1 : 0; $zeroDays += $balance->isZero() ? 1 : 0; $negativeDays += $balance->isLessThan(0) ? 1 : 0; } unset($day); return ['days' => $days, 'total_milli' => $total->toInt(), 'daily_mean_milli' => (string) $total->dividedBy($days, 6, RoundingMode::HalfUp), 'active_days' => $activeDays, 'zero_balance_days' => $zeroDays, 'negative_balance_days' => $negativeDays, 'invalid_issue' => $invalid, 'opening_milli' => $opening->toInt(), 'movement_count' => $movementCount, 'movement_types' => $counts, 'series' => array_values($series), 'source_hash' => hash_final($hash), 'convention' => 'Dias corridos completos; apenas saídas de consumo (issue) entram na média. Transferências, ajustes, contagens e recepções não são consumo.']; } /** @param array $data * @return array */ public function reference(array $data): array { $history = app(SupplyPlanning::class)->latest('history')->where('record_key', $data['history_key'])->firstOrFail(); $result = ['available' => false, 'key' => $history->record_key, 'version' => $history->version, 'unit' => $history->snapshot['item']['unit'], 'period_from' => $history->data['period_from'], 'period_until' => $history->data['period_until'], 'analysis' => $history->snapshot['analysis']]; $reason = null; if ($history->stock_item_id !== (int) $data['stock_item_id'] || $history->warehouse_id !== (int) $data['warehouse_id'] || $history->data['purpose'] !== $data['purpose']) { $reason = 'O histórico deve corresponder ao artigo, local e finalidade da política.'; } elseif ($history->state !== 'approved' || (isset($data['history_version']) && $history->version !== $data['history_version'])) { $reason = 'A análise mudou ou deixou de estar aprovada. Seleccione uma versão conferida.'; } elseif ($history->data['quality'] !== 'complete' || $history->data['pattern'] !== 'regular') { $reason = 'Cobertura incompleta, ruptura ou padrão sazonal/intermitente: use planeamento manual até existir método validado.'; } elseif ($history->snapshot['analysis']['days'] < $data['history_min_days']) { $reason = 'O período é inferior ao mínimo de dias configurado na política.'; } elseif ($history->data['period_until'] < CarbonImmutable::today()->subDays($data['history_max_age_days'])->toDateString()) { $reason = 'O período ultrapassa a idade máxima configurada na política.'; } elseif ($history->snapshot['analysis']['opening_milli'] < 0 || $history->snapshot['analysis']['negative_balance_days'] > 0 || $history->snapshot['analysis']['invalid_issue']) { $reason = 'Existem inconsistências no razão; reconcilie-as antes de utilizar a média.'; } else { $stock = StockItem::query()->whereKey($history->stock_item_id)->firstOrFail(); $current = $this->analyse($history->stock_item_id, $history->warehouse_id, $history->data['period_from'], $history->data['period_until']); if ($stock->only(['id', 'code', 'name', 'unit']) !== $history->snapshot['item'] || $current['source_hash'] !== $history->snapshot['analysis']['source_hash']) { $reason = 'O cadastro ou os movimentos históricos mudaram. Reavalie o período antes de reutilizá-lo.'; } } return [...$result, 'available' => $reason === null, 'reason' => $reason]; } /** @return array{data:array,history:array|null} */ public function resolve(SupplyRecord $policy): array { if (($policy->data['forecast_method'] ?? 'manual') !== 'historical') { return ['data' => $policy->data, 'history' => null]; } $history = $this->reference([...$policy->data, 'stock_item_id' => $policy->stock_item_id, 'warehouse_id' => $policy->warehouse_id]); $effective = [...$policy->data, 'daily_milli' => null]; if ($history['available']) { $effective['daily_rate'] = ['numerator_milli' => $history['analysis']['total_milli'], 'denominator_days' => $history['analysis']['days']]; } return ['data' => $effective, 'history' => $history]; } private function require(bool $condition, string $field, string $message): void { if (! $condition) { throw ValidationException::withMessages([$field => $message]); } } }