209 lines
14 KiB
PHP
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]);
|
|
}
|
|
}
|
|
}
|