216 lines
15 KiB
PHP
216 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\GoodsReceipt;
|
|
use App\Models\GoodsReceiptItem;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\StockItem;
|
|
use App\Models\StockOperation;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\User;
|
|
use App\Models\Warehouse;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** Physical supplier returns and write-offs; financial corrections remain distinct. */
|
|
class InventoryDispositions
|
|
{
|
|
public function executionSourceCurrent(SupplyRecord $record): bool
|
|
{
|
|
return $record->kind === 'stock_event' && app(InventoryReviewParticipation::class)->executionSourceCurrent($record, fn (array $data) => $this->basis($data));
|
|
}
|
|
|
|
/** Participation only: source, stock and physical evidence are revalidated by save. */
|
|
public function canAct(User $actor, SupplyRecord $record, string $action): bool
|
|
{
|
|
return app(InventoryReviewParticipation::class)->canAct($actor, $record, 'stock_event', $action, fn (string $capability) => $this->allows($actor, $capability));
|
|
}
|
|
|
|
public function allows(User $actor, string $capability): bool
|
|
{
|
|
return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($capability) {
|
|
'read' => ['inventory_disposition_read', 'admin', PurchasingRole::Warehouse], 'execute' => [PurchasingRole::Warehouse],
|
|
default => ['inventory_disposition_'.$capability],
|
|
});
|
|
}
|
|
|
|
public function returnedForReceipt(int $receiptItem): int
|
|
{
|
|
if (! Schema::hasTable('supply_records')) {
|
|
return 0;
|
|
}
|
|
|
|
$reversed = app(InventoryReversals::class)->corrections('operation');
|
|
|
|
return (int) app(SupplyPlanning::class)->latest('stock_event')->where('state', 'executed')->whereJsonContains('data->kind', 'supplier_return')
|
|
->whereJsonContains('data->receipt_item_id', $receiptItem)->get()->reject(fn ($r) => isset($reversed[$r->data['stock_operation_id']]))->sum(fn ($r) => $r->data['quantity_milli']);
|
|
}
|
|
|
|
public function returnedForOrderLine(int $line): int
|
|
{
|
|
if (! Schema::hasTable('supply_records')) {
|
|
return 0;
|
|
}
|
|
|
|
$reversed = app(InventoryReversals::class)->corrections('operation');
|
|
|
|
return (int) app(SupplyPlanning::class)->latest('stock_event')->where('state', 'executed')->whereJsonContains('data->kind', 'supplier_return')
|
|
->whereJsonContains('data->purchase_order_item_id', $line)->get()->reject(fn ($r) => isset($reversed[$r->data['stock_operation_id']]))->sum(fn ($r) => $r->data['quantity_milli']);
|
|
}
|
|
|
|
/** Minimal receipt facts for the existing Finance reconciliation screen.
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
public function financialNotices(): array
|
|
{
|
|
if (! Schema::hasTable('supply_records')) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(app(SupplyPlanning::class)->latest('stock_event')->where('state', 'executed')->whereJsonContains('data->kind', 'supplier_return')->latest('id')->limit(100)->get()
|
|
->map(fn ($record) => ['reference' => $record->data['reference'], 'quantity_milli' => $record->data['quantity_milli'], 'unit' => $record->snapshot['source']['item']['unit'],
|
|
'order_number' => $record->snapshot['source']['receipt']['order_number'], 'supplier_name' => $record->snapshot['source']['receipt']['supplier_name'], 'event_on' => $record->data['event']['event_on'], 'reversed' => app(InventoryReversals::class)->operationReversed($record->data['stock_operation_id'])])->all());
|
|
}
|
|
|
|
/** @param array<string,mixed> $data
|
|
* @return array{source:array<string,mixed>,observed:array<string,mixed>}
|
|
*/
|
|
public function basis(array $data): array
|
|
{
|
|
$item = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail();
|
|
$warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail();
|
|
$balance = app(StockWorkflow::class)->balance($item->id, $warehouse->id);
|
|
$this->check($data['quantity_milli'] > 0 && $data['quantity_milli'] <= $balance['quantity'], 'data.quantity', 'A quantidade deve ser positiva e existir fisicamente neste local.');
|
|
$source = ['item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'setup_id' => null, 'lot' => null, 'receipt' => null];
|
|
$observed = ['balance' => $balance];
|
|
$context = app(InventoryLots::class)->context($item->id, $warehouse->id);
|
|
if ($context !== null) {
|
|
$this->check($context['reconciled'], 'data.lot_id', 'Confira a repartição por lote antes de retirar material.');
|
|
$lot = collect($context['lots'])->firstWhere('id', $data['lot_id'] ?? 0);
|
|
$this->check($lot !== null && $lot['quantity_milli'] >= $data['quantity_milli'], 'data.lot_id', 'Seleccione o lote exacto deste local com quantidade física suficiente.');
|
|
$source['setup_id'] = $context['setup_id'];
|
|
$source['lot'] = ['id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'code' => $lot['data']['code'], 'expires_on' => $lot['data']['expires_on'],
|
|
'no_expiry' => $lot['data']['no_expiry'], 'origin' => $lot['origin']];
|
|
$observed['lot_quantity_milli'] = $lot['quantity_milli'];
|
|
} else {
|
|
$this->check(empty($data['lot_id']), 'data.lot_id', 'Não associe um lote sem controlo activo e conferido neste local.');
|
|
}
|
|
if ($data['kind'] === 'supplier_return') {
|
|
$line = GoodsReceiptItem::query()->whereKey($data['receipt_item_id'])->firstOrFail();
|
|
$this->check(! app(InventoryReversals::class)->receiptReversed($line->id), 'data.receipt_item_id', 'Esta linha foi estornada. Seleccione a recepção efectiva de origem.');
|
|
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
|
|
$order = PurchaseOrder::query()->whereKey($receipt->purchase_order_id)->firstOrFail();
|
|
$this->check($line->stock_item_id === $item->id && $line->accepted_milli > 0, 'data.receipt_item_id', 'Seleccione uma recepção física aceite deste artigo.');
|
|
$originalUnit = $line->snapshot['stock_item']['unit'] ?? null;
|
|
$this->check(is_string($originalUnit) && Str::lower(trim($originalUnit)) === Str::lower(trim($item->unit)), 'data.receipt_item_id', 'A unidade mudou ou falta na origem. Reconcilie antes de devolver; não há conversão automática.');
|
|
if ($context === null) {
|
|
$this->check($receipt->warehouse_id === $warehouse->id, 'data.warehouse_id', 'Sem lotes rastreados, a devolução usa o local da recepção original.');
|
|
} else {
|
|
$this->check(($source['lot']['origin']['receipt_item_id'] ?? null) === $line->id, 'data.receipt_item_id', 'O lote deve ter origem comprovada nesta linha de recepção; igualdade de código não prova a origem.');
|
|
}
|
|
$returned = $this->returnedForReceipt($line->id);
|
|
$this->check($returned + $data['quantity_milli'] <= $line->accepted_milli, 'data.quantity', 'A soma das devoluções não pode exceder a quantidade originalmente aceite.');
|
|
$source['receipt'] = ['id' => $receipt->id, 'number' => $receipt->number, 'item_id' => $line->id, 'purchase_order_item_id' => $line->purchase_order_item_id,
|
|
'purchase_order_id' => $order->id, 'order_number' => $order->number, 'supplier_id' => $order->supplier_id, 'supplier_name' => $receipt->snapshot['supplier']['name'], 'accepted_milli' => $line->accepted_milli];
|
|
$observed['previously_returned_milli'] = $returned;
|
|
}
|
|
|
|
return ['source' => $source, 'observed' => $observed];
|
|
}
|
|
|
|
/** @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:save,approve,return,execute', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
|
|
$capability = match ($v['action']) {
|
|
'save' => 'prepare', 'execute' => 'execute', default => 'review'
|
|
};
|
|
abort_unless($this->allows($actor, $capability), 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->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outros dados.');
|
|
|
|
return $retry;
|
|
}
|
|
$old = empty($v['record_key']) ? null : app(SupplyPlanning::class)->latest('stock_event')->where('record_key', $v['record_key'])->firstOrFail();
|
|
$this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O processo mudou. Actualize a página.');
|
|
if ($v['action'] === 'save') {
|
|
$this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Preserve a decisão: devolva para actualização antes de alterar.');
|
|
$data = Validator::make($v['data'], ['kind' => 'required|in:supplier_return,write_off', 'stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1',
|
|
'lot_id' => 'nullable|integer|min:1', 'receipt_item_id' => 'required_if:kind,supplier_return|nullable|integer|min:1', 'reference' => 'required|string|max:100',
|
|
'quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/']])->validate();
|
|
foreach (['stock_item_id', 'warehouse_id', 'lot_id', 'receipt_item_id'] as $id) {
|
|
$data[$id] = empty($data[$id]) ? null : (int) $data[$id];
|
|
}
|
|
if ($data['kind'] === 'write_off') {
|
|
$data['receipt_item_id'] = null;
|
|
}
|
|
$data['quantity_milli'] = ReceiptWorkflow::milli($data['quantity']);
|
|
unset($data['quantity']);
|
|
$data['reference'] = Str::upper(Str::squish($data['reference']));
|
|
$this->check($data['reference'] !== '', 'data.reference', 'Indique a referência do documento de saída.');
|
|
$clash = app(SupplyPlanning::class)->latest('stock_event')->whereJsonContains('data->reference', $data['reference']);
|
|
if ($old !== null) {
|
|
$clash->where('record_key', '!=', $old->record_key);
|
|
}
|
|
$this->check(! $clash->exists(), 'data.reference', 'Referência já utilizada noutro processo de stock.');
|
|
$this->check(! StockOperation::query()->where('reference', $data['reference'])->exists(), 'data.reference', 'Referência já utilizada numa operação de stock.');
|
|
$snapshot = $this->basis($data);
|
|
$snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]));
|
|
$data['purchase_order_item_id'] = $snapshot['source']['receipt']['purchase_order_item_id'] ?? null;
|
|
$state = 'draft';
|
|
} else {
|
|
$this->check($old !== null, 'action', 'Prepare primeiro o processo.');
|
|
$data = $old->data;
|
|
$snapshot = $old->snapshot;
|
|
if ($v['action'] === 'execute') {
|
|
$this->check($old->state === 'approved' && app(InventoryReviewParticipation::class)->independentExecutor($actor, $old), 'action', 'A saída exige aprovação prévia e operador diferente de quem aprovou.');
|
|
$event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate();
|
|
$this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'A saída não pode anteceder a aprovação.');
|
|
$current = $this->basis($data);
|
|
$this->check($current['source'] === $snapshot['source'], 'action', 'A origem, identificação ou configuração mudou. Devolva para nova preparação.');
|
|
$operation = app(StockWorkflow::class)->disposition($actor, $old);
|
|
$data['stock_operation_id'] = $operation->id;
|
|
$data['event'] = $event;
|
|
$snapshot['execution_observed'] = $current['observed'];
|
|
$state = 'executed';
|
|
} else {
|
|
$this->check(app(InventoryReviewParticipation::class)->independentReviewer($actor, $old), 'action', 'A decisão exige pessoa independente de todos os preparadores.');
|
|
$this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com a decisão.');
|
|
$state = $v['action'] === 'approve' ? 'approved' : 'returned';
|
|
if ($state === 'approved') {
|
|
$current = $this->basis($data);
|
|
$this->check($current['source'] === $snapshot['source'], 'action', 'A origem ou identificação mudou. Devolva para actualizar.');
|
|
$snapshot['reviewed_by'] = $actor->id;
|
|
$snapshot['review_observed'] = $current['observed'];
|
|
}
|
|
}
|
|
}
|
|
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'stock_event', '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,
|
|
'supplier_id' => $snapshot['source']['receipt']['supplier_id'] ?? null, 'purchase_order_id' => $snapshot['source']['receipt']['purchase_order_id'] ?? null,
|
|
'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
|
|
app(RequisitionWorkflow::class)->audit($actor, 'inventory.disposition.'.$v['action'], null, $old?->toArray(), $record->toArray());
|
|
|
|
return $record;
|
|
});
|
|
}
|
|
|
|
private function check(bool $condition, string $field, string $message): void
|
|
{
|
|
if (! $condition) {
|
|
throw ValidationException::withMessages([$field => $message]);
|
|
}
|
|
}
|
|
}
|