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

496 lines
34 KiB
PHP

<?php
namespace App\Services;
use App\Models\GoodsReceiptItem;
use App\Models\InventoryLotAllocation;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Lot quantities partition the physical ledger; they never create physical stock. */
class InventoryLots
{
public function canIdentify(User $actor, ?SupplyRecord $metadata): bool
{
return $metadata !== null && $metadata->kind === 'lot' && $metadata->tenant_id === $actor->tenant_id
&& $this->allows($actor, 'inventory_lot_prepare')
&& app(SupplyPlanning::class)->latest('lot')->where('record_key', $metadata->record_key)->value('id') === $metadata->id;
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $entry
*/
public function canDecideLot(User $actor, ?SupplyRecord $metadata, array $context, array $entry, string $action): bool
{
if ($metadata === null || $metadata->tenant_id !== $actor->tenant_id || $metadata->kind !== 'lot'
|| ($entry['metadata_id'] ?? null) !== $metadata->id || ! ($context['reconciled'] ?? false)
|| ($entry['quantity_milli'] ?? 0) <= 0 || ! in_array($action, ['hold', 'release'], true)
|| ! $this->allows($actor, 'inventory_lot_'.$action)
|| app(SupplyPlanning::class)->latest('lot')->where('record_key', $metadata->record_key)->value('id') !== $metadata->id) {
return false;
}
return $action === 'hold' || (($entry['eligible'] ?? false) && ! in_array($actor->id, $entry['contributors'], true));
}
private function independentSetupReviewer(User $actor, SupplyRecord $record): bool
{
return ! in_array($actor->id, $record->snapshot['contributors'], true);
}
/** Participation only: activation still checks balance, active setup and metadata stamps. */
public function canActivate(User $actor, SupplyRecord $record): bool
{
if ($record->kind !== 'lot_setup' || $record->state !== 'draft' || $record->tenant_id !== $actor->tenant_id
|| ! $this->allows($actor, 'inventory_lot_review') || ! $this->independentSetupReviewer($actor, $record)) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('lot_setup')->where('record_key', $record->record_key)->first();
return $latest?->id === $record->id && $this->setup($record->stock_item_id, $record->warehouse_id)?->id === ($record->snapshot['previous_setup_id'] ?? null);
}
public function allows(User $actor, string $permission): bool
{
return $actor->hasFunctions($permission === 'inventory_lot_read' ? [$permission, 'admin'] : [$permission])
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
public function setup(int $item, int $warehouse): ?SupplyRecord
{
if (! Schema::hasTable('inventory_lot_allocations')) {
return null;
}
return $this->activeSetups()->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
}
/** Active versions remain available while a proposed revision awaits review.
* @return Builder<SupplyRecord>
*/
public function activeSetups(): Builder
{
return SupplyRecord::query()->where('kind', 'lot_setup')->where('state', 'active')->whereIn('id', SupplyRecord::query()->selectRaw('MAX(id)')->where('kind', 'lot_setup')->where('state', 'active')->groupBy('stock_item_id', 'warehouse_id'));
}
/** @return array{setup_id:int,physical:array{quantity:int,version:int},reconciled:bool,usable_milli:int,blocked_milli:int,lots:list<array<string,mixed>>}|null */
public function context(int $item, int $warehouse): ?array
{
// Read book and partitions under the same lock used by every stock writer.
$reader = auth()->user();
if (DB::transactionLevel() === 0 && $reader instanceof User) {
return DB::transaction(function () use ($item, $warehouse, $reader) {
app(ConflictGuard::class)->lockCompany($reader);
return $this->context($item, $warehouse);
});
}
$setup = $this->setup($item, $warehouse);
if ($setup === null) {
return null;
}
$physical = app(StockWorkflow::class)->balance($item, $warehouse);
$lots = [];
foreach (InventoryLotAllocation::query()->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->selectRaw('lot_id, SUM(quantity_milli) AS quantity')->groupBy('lot_id')->orderBy('lot_id')->get() as $allocation) {
$identity = SupplyRecord::query()->whereKey($allocation->lot_id)->firstOrFail();
$lot = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail();
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $warehouse)->first();
$lastDay = empty($lot->data['expires_on']) ? null : CarbonImmutable::parse($lot->data['expires_on'])->subDays($setup->data['minimum_remaining_days'])->toDateString();
$identified = ! empty($lot->data['code']) && (! empty($lot->data['expires_on']) || (! $setup->data['requires_expiry'] && ($lot->data['no_expiry'] ?? false)));
$eligible = $identified && ($lastDay === null || $lastDay >= today()->toDateString());
$released = $state?->state === 'released' && ($state->data['metadata_id'] ?? null) === $lot->id;
$quantity = (int) $allocation->getAttribute('quantity');
$lots[] = ['id' => $identity->id, 'record_key' => $lot->record_key, 'version' => $lot->version, 'metadata_id' => $lot->id, 'data' => $lot->data,
'quantity_milli' => $quantity, 'usable_milli' => $eligible && $released ? $quantity : 0, 'eligible' => $eligible,
'state' => ! $identified ? 'unidentified' : (! $eligible ? 'expired' : ($released ? 'released' : 'held')), 'last_usable_on' => $lastDay,
'quality_released' => $released, 'release_mode' => $state?->snapshot['release_mode'] ?? 'independent', 'release_setup_id' => $state?->snapshot['release_setup_id'] ?? null,
'state_version' => $state->version ?? 0, 'contributors' => array_values(array_unique([...$lot->snapshot['contributors'], ...($state?->snapshot['pending_contributors'] ?? [])])), 'origin' => $identity->snapshot['origin'] ?? null];
}
$total = array_sum(array_column($lots, 'quantity_milli'));
$usable = array_sum(array_column($lots, 'usable_milli'));
return ['setup_id' => $setup->id, 'physical' => $physical, 'reconciled' => $total === $physical['quantity'], 'usable_milli' => $usable,
'blocked_milli' => $total - $usable, 'lots' => $lots];
}
/** @param array<string,mixed> $forecast
* @param array{quantity:int,version:int} $balance
* @param array<string,mixed> $events
* @return array<string,mixed>
*/
public function project(SupplyRecord $policy, array $forecast, array $balance, array $events, string $date): array
{
$context = $this->context($policy->stock_item_id, $policy->warehouse_id);
if ($context === null) {
return app(ReplenishmentProjection::class)->calculate($forecast, $balance['quantity'], $date, $events['reservations'], $events['receipts'], $events['consumed_today'], $events['fulfilled_needs']);
}
if (! $context['reconciled']) {
return ['available' => false, 'reason' => 'Confira a repartição por lotes: não corresponde ao saldo físico.', 'lot_context' => $context];
}
$buckets = array_map(fn ($l) => ['quantity' => $l['usable_milli'], 'last_usable_on' => $l['last_usable_on']], $context['lots']);
usort($buckets, fn ($a, $b) => ($a['last_usable_on'] ?? '9999-12-31') <=> ($b['last_usable_on'] ?? '9999-12-31'));
$projection = app(ReplenishmentProjection::class)->calculate($forecast, $context['usable_milli'], $date, $events['reservations'], [], $events['consumed_today'], $events['fulfilled_needs'], $buckets);
return [...$projection, 'physical_milli' => $context['physical']['quantity'], 'usable_milli' => $context['usable_milli'], 'lot_context' => $context,
'lot_warning' => 'Entradas futuras sem identificação e libertação de lote não são consideradas utilizáveis nesta previsão.'];
}
/** @return array{usable_milli:int,blocked_milli:int,reconciled:bool}|null */
public function summary(int $item, int $warehouse): ?array
{
$context = $this->context($item, $warehouse);
return $context === null ? null : ['usable_milli' => $context['usable_milli'], 'blocked_milli' => $context['blocked_milli'], 'reconciled' => $context['reconciled']];
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input): SupplyRecord
{
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:prepare,activate,identify,hold,release', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
$permission = match ($v['action']) {
'activate' => 'inventory_lot_review', 'hold' => 'inventory_lot_hold', 'release' => 'inventory_lot_release', default => 'inventory_lot_prepare',
};
abort_unless($this->allows($actor, $permission), 403);
$hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first();
if ($retry) {
$this->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outros dados.');
return $retry;
}
$old = empty($v['record_key']) ? null : SupplyRecord::query()->where('record_key', $v['record_key'])->latest('version')->firstOrFail();
$this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O registo mudou. Actualize a página.');
if ($v['action'] === 'prepare') {
$this->check($old === null || ($old->kind === 'lot_setup' && in_array($old->state, ['draft', 'active'], true)), 'action', 'Seleccione a configuração deste artigo/local.');
$d = Validator::make($v['data'], ['stock_item_id' => 'required|integer', 'warehouse_id' => 'required|integer', 'requires_expiry' => 'required|boolean',
'minimum_remaining_days' => 'required|integer|min:0|max:3650', 'quarantine_on_receipt' => 'sometimes|boolean', 'lots' => 'present|array|max:100'])->validate();
StockItem::query()->findOrFail($d['stock_item_id']);
Warehouse::query()->findOrFail($d['warehouse_id']);
$active = $this->setup((int) $d['stock_item_id'], (int) $d['warehouse_id']);
$this->check($active === null || ($old !== null && $old->record_key === $active->record_key), 'action', 'Actualize por referência à configuração activa; não crie um segundo controlo.');
$this->check($old?->state !== 'active' || ($old->stock_item_id === (int) $d['stock_item_id'] && $old->warehouse_id === (int) $d['warehouse_id']), 'data', 'Uma revisão conserva o artigo/local da origem.');
$this->check($old?->state !== 'draft' || empty($old->snapshot['previous_setup_id']) || ($active !== null && $active->id === $old->snapshot['previous_setup_id']), 'action', 'A configuração de origem mudou. Prepare nova revisão.');
$balance = app(StockWorkflow::class)->balance((int) $d['stock_item_id'], (int) $d['warehouse_id']);
$d['stock_item_id'] = (int) $d['stock_item_id'];
$d['warehouse_id'] = (int) $d['warehouse_id'];
$d['requires_expiry'] = (bool) $d['requires_expiry'];
$d['minimum_remaining_days'] = (int) $d['minimum_remaining_days'];
$d['quarantine_on_receipt'] = (bool) ($d['quarantine_on_receipt'] ?? true);
if ($active !== null) {
$this->check($d['lots'] === [], 'data.lots', 'Uma revisão altera parâmetros; não volta a repartir ou criar saldo inicial.');
} else {
$d['lots'] = $this->split($d['lots'], $balance['quantity']);
}
$context = $active === null ? null : $this->context($active->stock_item_id, $active->warehouse_id);
$this->check($context === null || $context['reconciled'], 'action', 'Reconcilie o saldo antes de alterar parâmetros.');
$snapshot = ['balance' => $balance, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])),
'previous_setup_id' => $active?->id, 'lot_stamp' => $context === null ? null : hash('sha256', json_encode($context, JSON_THROW_ON_ERROR)),
'impact' => $context === null ? null : $this->impact($context, $d),
'item' => StockItem::query()->findOrFail($d['stock_item_id'])->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->findOrFail($d['warehouse_id'])->only(['id', 'code', 'name'])];
return $this->append($actor, 'lot_setup', 'draft', $d, $snapshot, $v['reason'], $old, $v['operation_key'], $hash);
}
if ($v['action'] === 'activate') {
$this->check($old?->kind === 'lot_setup' && $old->state === 'draft', 'action', 'Prepare primeiro a conferência do saldo.');
$this->check($this->independentSetupReviewer($actor, $old), 'action', 'A activação exige conferência independente da preparação.');
$active = $this->setup($old->stock_item_id, $old->warehouse_id);
$this->check(($active?->id) === ($old->snapshot['previous_setup_id'] ?? null), 'action', 'A configuração activa mudou desde a preparação. Actualize a revisão.');
$this->check(app(StockWorkflow::class)->balance($old->stock_item_id, $old->warehouse_id) === $old->snapshot['balance'], 'action', 'O saldo mudou. Actualize a preparação e confira novamente.');
if ($active !== null) {
$context = $this->context($old->stock_item_id, $old->warehouse_id);
$this->check(hash('sha256', json_encode($context, JSON_THROW_ON_ERROR)) === $old->snapshot['lot_stamp'], 'action', 'A identificação, qualidade ou disponibilidade mudou. Actualize a previsão de impacto.');
}
$active = $this->append($actor, 'lot_setup', 'active', $old->data, $old->snapshot, $v['reason'], $old, $v['operation_key'], $hash);
foreach ($old->data['lots'] as $entry) {
$lot = $this->append($actor, 'lot', 'registered', [...$entry, 'stock_item_id' => $old->stock_item_id, 'warehouse_id' => $old->warehouse_id],
['contributors' => $old->snapshot['contributors'], 'origin' => ['setup_id' => $active->id]], 'Identificação inicial do saldo conferido.');
$this->allocate($lot->id, $old->warehouse_id, $entry['quantity_milli'], null, $active);
}
return $active;
}
if ($v['action'] === 'identify') {
$this->check($old?->kind === 'lot', 'action', 'Seleccione a identificação existente.');
$this->check($this->canIdentify($actor, $old), 'action', 'A identificação exige competência e versão actual.');
$metadata = $this->metadata($v['data']);
// A correction invalidates the previous quality release by metadata version.
return $this->append($actor, 'lot', 'registered', [...$old->data, ...$metadata],
[...$old->snapshot, 'contributors' => array_values(array_unique([...$old->snapshot['contributors'], $actor->id]))], $v['reason'], $old, $v['operation_key'], $hash);
}
$d = Validator::make($v['data'], ['lot_id' => 'required|integer', 'warehouse_id' => 'required|integer', 'state_version' => 'required|integer|min:0'])->validate();
$identity = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->whereKey($d['lot_id'])->firstOrFail();
$context = $this->context($identity->stock_item_id, (int) $d['warehouse_id']);
$entry = collect($context['lots'] ?? [])->firstWhere('id', $identity->id);
$this->check($entry !== null && $context['reconciled'] && $entry['quantity_milli'] > 0, 'action', 'Lote sem saldo conferido neste armazém.');
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $d['warehouse_id'])->first();
$this->check((int) $d['state_version'] === ($state->version ?? 0) && $old?->id === $entry['metadata_id'], 'version', 'A identificação ou a decisão mudou. Actualize a página.');
$this->check($this->canDecideLot($actor, $old, $context, $entry, $v['action']), 'action', 'A decisão exige saldo conferido, competência, identificação válida e independência na libertação.');
if ($v['action'] === 'release') {
$this->check(! in_array($actor->id, $entry['contributors'], true), 'action', 'A libertação exige pessoa independente da identificação e recepção.');
$this->check($entry['eligible'], 'action', 'Confirme identificação e validade suficiente antes de libertar.');
}
return $this->append($actor, 'lot_state', $v['action'] === 'release' ? 'released' : 'held', ['lot_id' => $identity->id, 'metadata_id' => $old->id,
'stock_item_id' => $identity->stock_item_id, 'warehouse_id' => (int) $d['warehouse_id']],
['pending_contributors' => $v['action'] === 'release' ? [] : ($state?->snapshot['pending_contributors'] ?? [])], $v['reason'], $state, $v['operation_key'], $hash);
});
}
/** @param array<string,mixed> $input
* @return array{code:?string,expires_on:?string,no_expiry:bool}
*/
private function metadata(array $input): array
{
$d = Validator::make($input, ['code' => 'nullable|string|max:100', 'expires_on' => 'nullable|date_format:Y-m-d', 'no_expiry' => 'sometimes|boolean'])->validate();
$this->check(empty($d['expires_on']) || ! ($d['no_expiry'] ?? false), 'data', 'Escolha validade ou confirmação de artigo sem prazo.');
return ['code' => empty(trim($d['code'] ?? '')) ? null : trim($d['code']), 'expires_on' => $d['expires_on'] ?? null, 'no_expiry' => (bool) ($d['no_expiry'] ?? false)];
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $data
* @return array{before_milli:int,after_milli:int}
*/
private function impact(array $context, array $data): array
{
$after = 0;
foreach ($context['lots'] as $lot) {
$identified = ! empty($lot['data']['code']) && (! empty($lot['data']['expires_on']) || (! $data['requires_expiry'] && $lot['data']['no_expiry']));
$last = empty($lot['data']['expires_on']) ? null : CarbonImmutable::parse($lot['data']['expires_on'])->subDays($data['minimum_remaining_days'])->toDateString();
if ($lot['quality_released'] && $identified && ($last === null || $last >= today()->toDateString())) {
$after += $lot['quantity_milli'];
}
}
return ['before_milli' => $context['usable_milli'], 'after_milli' => $after];
}
/** @param list<array<string,mixed>> $entries
* @return list<array<string,mixed>>
*/
public function split(array $entries, int $quantity): array
{
if ($quantity === 0) {
$this->check($entries === [], 'data.lots', 'Saldo nulo não recebe lotes iniciais.');
return [];
}
if ($entries === []) {
$entries = [[]]; // Unknown identity is quarantined, never treated as unlimited validity.
}
$result = [];
foreach ($entries as $entry) {
$v = Validator::make($entry, ['quantity' => ['nullable', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/']])->validate();
$q = count($entries) === 1 && empty($v['quantity']) ? $quantity : ReceiptWorkflow::milli($v['quantity'] ?? '0');
$this->check($q > 0, 'data.lots', 'Indique a quantidade positiva de cada lote quando existe repartição.');
$result[] = [...$this->metadata($entry), 'quantity_milli' => $q];
}
$this->check(array_sum(array_column($result, 'quantity_milli')) === $quantity, 'data.lots', 'A soma dos lotes deve corresponder exactamente à quantidade física.');
return $result;
}
/** @param list<array<string,mixed>> $entries */
public function receive(User $actor, StockMovement $movement, array $entries, string $receivedOn): void
{
$setup = $this->setup($movement->stock_item_id, $movement->warehouse_id);
if ($setup === null) {
$this->check($entries === [], 'items', 'Active e confira o controlo por lotes antes de registar a repartição.');
return;
}
foreach ($this->split($entries, $movement->quantity_milli) as $entry) {
$this->check(empty($entry['expires_on']) || $entry['expires_on'] >= $receivedOn, 'items', 'Um lote já vencido na data da recepção não pode ser aceite.');
$lot = $this->append($actor, 'lot', 'registered', [...$entry, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id],
['contributors' => [$actor->id], 'origin' => ['movement_id' => $movement->id, 'receipt_item_id' => $movement->goods_receipt_item_id, 'receipt_setup_id' => $setup->id]], 'Identificação da recepção sob a configuração aprovada; disponibilidade determinada pela regra e qualidade.');
$this->allocate($lot->id, $movement->warehouse_id, $entry['quantity_milli'], $movement);
$quality = GoodsReceiptItem::query()->whereKey($movement->goods_receipt_item_id)->firstOrFail();
$identified = ! empty($entry['code']) && (! empty($entry['expires_on']) || (! $setup->data['requires_expiry'] && $entry['no_expiry']));
$valid = empty($entry['expires_on']) || CarbonImmutable::parse($entry['expires_on'])->subDays($setup->data['minimum_remaining_days'])->toDateString() >= today()->toDateString();
if (! ($setup->data['quarantine_on_receipt'] ?? true) && $quality->quality_status === 'compliant' && $identified && $valid) {
$this->append($actor, 'lot_state', 'released', ['lot_id' => $lot->id, 'metadata_id' => $lot->id, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id],
['release_mode' => 'receipt_policy', 'release_setup_id' => $setup->id, 'pending_contributors' => []], 'Dispensa de quarentena de recepção conforme, por regra previamente aprovada.');
}
}
$this->check($this->context($movement->stock_item_id, $movement->warehouse_id)['reconciled'], 'items', 'Existe divergência na repartição anterior do stock. Confira as origens antes de receber.');
}
/** @return list<array{lot_id:int,quantity:int}> */
public function issuePlan(int $item, int $warehouse, int $quantity): array
{
$context = $this->context($item, $warehouse);
if ($context === null) {
return [];
}
$this->check($context['reconciled'], 'quantity', 'Repartição por lotes divergente do saldo físico. Suspenda operações e confira as origens.');
$this->check($quantity <= $context['usable_milli'], 'quantity', 'Quantidade utilizável insuficiente: lotes em quarentena, sem identificação ou sem validade suficiente não podem sair.');
$lots = $context['lots'];
usort($lots, fn ($a, $b) => [$a['last_usable_on'] ?? '9999-12-31', $a['id']] <=> [$b['last_usable_on'] ?? '9999-12-31', $b['id']]);
$plan = [];
foreach ($lots as $lot) {
$take = min($quantity, $lot['usable_milli']);
if ($take > 0) {
$plan[] = ['lot_id' => $lot['id'], 'quantity' => $take];
$quantity -= $take;
}
}
return $plan;
}
public function allocate(int $lotId, int $warehouse, int $quantity, ?StockMovement $movement = null, ?SupplyRecord $setup = null): void
{
$lot = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->findOrFail($lotId);
InventoryLotAllocation::query()->create(['tenant_id' => $lot->tenant_id, 'stock_item_id' => $lot->stock_item_id, 'warehouse_id' => $warehouse,
'lot_id' => $lot->id, 'stock_movement_id' => $movement?->id, 'setup_record_id' => $setup?->id, 'quantity_milli' => $quantity, 'created_at' => now()]);
}
/** @return list<array{lot_id:int,quantity:int}> */
public function transferPlan(int $item, int $source, int $destination, int $quantity): array
{
$from = $this->setup($item, $source);
$to = $this->setup($item, $destination);
if ($from === null && $to === null) {
return [];
}
$this->check($quantity > 0, 'quantity', 'A transferência exige quantidade positiva.');
$this->check($from !== null && $to !== null, 'destination_warehouse_id', 'Confira e active o controlo por lotes deste artigo nos dois armazéns antes de transferir.');
$context = $this->context($item, $destination);
$this->check($context !== null && $context['reconciled'], 'destination_warehouse_id', 'A repartição dos lotes no destino deve corresponder ao saldo físico.');
return $this->issuePlan($item, $source, $quantity);
}
/** Reuses identities; destination quality is independent of custody.
* @param list<array{lot_id:int,quantity:int}> $plan
*/
public function transferred(User $actor, StockMovement $arrival, array $plan, int $responsible): void
{
foreach ($plan as $allocation) {
$identity = SupplyRecord::query()->whereKey($allocation['lot_id'])->firstOrFail();
$metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail();
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $arrival->warehouse_id)->first();
$this->allocate($identity->id, $arrival->warehouse_id, $allocation['quantity'], $arrival);
$this->append($actor, 'lot_state', 'held', ['lot_id' => $identity->id, 'metadata_id' => $metadata->id, 'stock_item_id' => $arrival->stock_item_id, 'warehouse_id' => $arrival->warehouse_id],
['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), $actor->id, $responsible])),
'transfer' => ['movement_id' => $arrival->id, 'operation_id' => $arrival->stock_operation_id, 'quantity_milli' => $allocation['quantity'], 'previous_state_id' => $state?->id]],
'Entrada por transferência; o lote neste local aguarda nova conferência independente.', $state);
}
$context = $this->context($arrival->stock_item_id, $arrival->warehouse_id);
$this->check($context !== null && $context['reconciled'], 'destination_warehouse_id', 'A transferência não reconciliou os lotes no destino.');
}
/** Freeze only the selected lots, without exposing unrelated quality history.
* @param list<array{lot_id:int,quantity:int}> $plan
* @return list<array<string,mixed>>
*/
public function partitionSnapshot(array $plan, int $warehouse): array
{
$rows = [];
foreach ($plan as $entry) {
$identity = SupplyRecord::query()->whereKey($entry['lot_id'])->firstOrFail();
$metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail();
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $warehouse)->first();
$rows[] = [...$entry, 'metadata_id' => $metadata->id, 'source_quality_id' => $state?->id,
'code' => $metadata->data['code'], 'expires_on' => $metadata->data['expires_on'], 'no_expiry' => $metadata->data['no_expiry']];
}
return $rows;
}
/** @param array<string,mixed> $row */
public function found(User $actor, StockOperation $count, array $row): SupplyRecord
{
return $this->append($actor, 'lot', 'registered', ['stock_item_id' => $count->stock_item_id, 'warehouse_id' => $count->warehouse_id,
'code' => $row['code'], 'expires_on' => $row['expires_on'], 'no_expiry' => $row['no_expiry'], 'quantity_milli' => $row['counted_milli']],
['contributors' => array_values(array_unique([$count->created_by, $count->responsible_id])), 'origin' => ['count_id' => $count->id, 'found_key' => $row['new_key']]],
'Lote encontrado na contagem; origem física preservada, sem inventar recepção de fornecedor.');
}
/** @param list<array<string,mixed>> $rows */
public function adjusted(User $actor, StockOperation $count, StockMovement $movement, array $rows, int $responsible): void
{
foreach ($rows as $row) {
if ($row['delta_milli'] === 0 || ($row['delta_milli'] > 0) !== ($movement->quantity_milli > 0)) {
continue;
}
$this->allocate($row['lot_id'], $movement->warehouse_id, $row['delta_milli'], $movement);
if ($row['delta_milli'] > 0) {
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $row['lot_id'])->where('warehouse_id', $movement->warehouse_id)->first();
$this->append($actor, 'lot_state', 'held', ['lot_id' => $row['lot_id'], 'metadata_id' => $row['metadata_id'], 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id],
['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), $actor->id, $responsible, $count->created_by, $count->responsible_id])),
'adjustment' => ['movement_id' => $movement->id, 'count_id' => $count->id, 'quantity_milli' => $row['delta_milli']]],
'Quantidade encontrada em contagem; o lote neste local aguarda conferência independente de qualidade.', $state);
}
}
}
/** @param list<array<string,mixed>> $parts
* @return list<array{movement_id:int,lot_id:int,quantity:int}>
*/
public function reverseMovement(User $actor, SupplyRecord $reversal, StockMovement $movement, array $parts, int $sourceMovement): array
{
$links = [];
$contributors = array_values(array_unique([...$reversal->snapshot['contributors'], $reversal->snapshot['reviewed_by'], $actor->id]));
foreach ($parts as $part) {
if ($part['lot_id'] === null) {
$identity = $this->append($actor, 'lot', 'registered', ['stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id,
'code' => null, 'expires_on' => null, 'no_expiry' => false, 'quantity_milli' => $part['quantity']],
['contributors' => $contributors, 'origin' => ['reversal_key' => $reversal->record_key, 'source_movement_id' => $sourceMovement]],
'Reposição de uma saída anterior ao controlo por lotes; identidade desconhecida, sem recepção de fornecedor presumida.');
} else {
$identity = SupplyRecord::query()->where('kind', 'lot')->where('version', 1)->whereKey($part['lot_id'])->firstOrFail();
}
$this->allocate($identity->id, $movement->warehouse_id, $part['quantity'], $movement);
if ($part['quantity'] > 0) {
$metadata = app(SupplyPlanning::class)->latest('lot')->where('record_key', $identity->record_key)->firstOrFail();
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $movement->warehouse_id)->first();
$this->append($actor, 'lot_state', 'held', ['lot_id' => $identity->id, 'metadata_id' => $metadata->id, 'stock_item_id' => $movement->stock_item_id, 'warehouse_id' => $movement->warehouse_id],
['pending_contributors' => array_values(array_unique([...($state?->snapshot['pending_contributors'] ?? []), ...$contributors])), 'reversal' => ['record_key' => $reversal->record_key, 'movement_id' => $movement->id, 'source_movement_id' => $sourceMovement]],
'Quantidade reposta por estorno; o lote no local exige nova conferência independente de qualidade.', $state);
}
$links[] = ['movement_id' => $movement->id, 'lot_id' => $identity->id, 'quantity' => $part['quantity']];
}
return $links;
}
/** @param array<string,mixed> $data
* @param array<string,mixed> $snapshot
*/
private function append(User $actor, string $kind, string $state, array $data, array $snapshot, string $reason, ?SupplyRecord $old = null, ?string $operation = null, ?string $hash = null): SupplyRecord
{
$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'],
'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $reason, 'created_by' => $actor->id,
'operation_key' => $operation ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'inventory.'.$kind.'.'.$state, null, $old?->toArray(), $record->toArray());
app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']);
return $record;
}
private function check(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}