Files
compras/app/Services/SupplyHistory.php
T
2026-11-02 08:08:05 +02:00

209 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplyHistory
{
/** @param array<string,mixed> $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(! in_array($actor->id, $snapshot['contributors'], true), '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<string,mixed> */
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<string,mixed> $data
* @return array<string,mixed>
*/
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<string,mixed>,history:array<string,mixed>|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]);
}
}
}