255 lines
20 KiB
PHP
255 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Requests\StoreRequisitionRequest;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\Requisition;
|
|
use App\Models\StockItem;
|
|
use App\Models\Supplier;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Warehouse;
|
|
use Brick\Math\BigDecimal;
|
|
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 SupplySuggestions
|
|
{
|
|
/** @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,cancel,convert,close', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
|
|
$permission = match ($v['action']) {
|
|
'save', 'cancel' => 'supply_suggest_prepare',
|
|
'approve', 'return', 'close' => 'supply_suggest_review',
|
|
'convert' => 'supply_suggest_convert',
|
|
default => throw new \InvalidArgumentException('Acção de sugestão desconhecida.'),
|
|
};
|
|
abort_unless(app(SupplyPlanning::class)->allows($actor, $permission), 403);
|
|
if ($v['action'] === 'convert') {
|
|
abort_unless(app(OrganizationAccess::class)->canRequest($actor), 403);
|
|
}
|
|
$hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
|
|
$existing = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first();
|
|
if ($existing !== null) {
|
|
$this->require($existing->kind === 'suggestion' && $existing->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.');
|
|
|
|
return $existing;
|
|
}
|
|
$old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest('suggestion')->where('record_key', $v['record_key'])->firstOrFail() : null;
|
|
$this->require((int) $v['version'] === ($old->version ?? 0), 'version', 'A sugestão mudou. Actualize antes de continuar.');
|
|
if ($v['action'] === 'save') {
|
|
$this->require($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Uma sugestão aprovada ou convertida mantém a versão decidida.');
|
|
$data = Validator::make($v['data'], ['policy_key' => 'required|uuid', 'quantity_milli' => 'nullable|integer|min:1|max:1000000000', 'delivery_on' => 'nullable|date_format:Y-m-d|after_or_equal:today',
|
|
'unit_price' => ['nullable', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'], 'tax_rate' => ['nullable', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/']])->validate();
|
|
$this->require($old === null || $old->data['policy_key'] === $data['policy_key'], 'data.policy_key', 'A revisão conserva a política de origem.');
|
|
$basis = $this->basis($data['policy_key'], today()->toDateString());
|
|
$active = app(SupplyPlanning::class)->latest('suggestion')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])
|
|
->where('id', '!=', $old->id ?? 0)->whereIn('state', ['draft', 'returned', 'approved', 'converted'])->exists();
|
|
$this->require(! $active, 'data.policy_key', 'Já existe sugestão ou requisição acompanhada para este artigo/local. Consulte e reveja a existente.');
|
|
$projection = $basis['projection'];
|
|
$quantity = isset($data['quantity_milli']) ? (int) $data['quantity_milli'] : ($projection['quantity_milli'] ?? 0);
|
|
$delivery = $data['delivery_on'] ?? $projection['risk_date'] ?? null;
|
|
$this->require($quantity > 0 && is_string($delivery), 'data.quantity_milli', 'Sem necessidade calculada: indique quantidade e data manuais fundamentadas.');
|
|
$this->require($delivery >= today()->toDateString(), 'data.delivery_on', 'A data pretendida deve ser hoje ou uma data futura.');
|
|
$this->require($delivery <= CarbonImmutable::today()->addDays($basis['policy']['horizon_days'] - 1)->toDateString(), 'data.delivery_on', 'Escolha uma data dentro do horizonte da política para conferir o impacto.');
|
|
$this->require($quantity >= $basis['policy']['minimum_milli'] && $quantity % $basis['policy']['pack_milli'] === 0, 'data.quantity_milli', 'Respeite a quantidade mínima e o múltiplo de embalagem da política.');
|
|
$price = $data['unit_price'] ?? $basis['unit_price'];
|
|
$tax = $data['tax_rate'] ?? $basis['tax_rate'];
|
|
$this->require($price !== null, 'data.unit_price', 'Indique estimativa em MZN com fonte no fundamento; não existe preço local reutilizável.');
|
|
$this->require($tax !== null, 'data.tax_rate', 'Confirme a taxa aplicável; não existe valor configurado reutilizável.');
|
|
$draftForm = new StoreRequisitionRequest;
|
|
$draftForm->setUserResolver(fn () => $actor);
|
|
$draftRules = $draftForm->rules();
|
|
Validator::make(['items' => [['quantity' => BigDecimal::of($quantity)->dividedBy(1000, 3)->__toString(), 'unit_price' => $price, 'tax_rate' => $tax]]],
|
|
array_intersect_key($draftRules, array_flip(['items.*.quantity', 'items.*.unit_price', 'items.*.tax_rate'])), $draftForm->messages())->validate();
|
|
$snapshot = $basis;
|
|
$snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]));
|
|
$snapshot['prepared_on'] = today()->toDateString();
|
|
$data = ['policy_key' => $data['policy_key'], 'policy_version' => $basis['policy_version'], 'quantity_milli' => $quantity, 'delivery_on' => $delivery,
|
|
'unit_price' => BigDecimal::of($price)->toScale(2)->__toString(), 'tax_rate' => BigDecimal::of($tax)->toScale(2)->__toString(),
|
|
'price_source' => $basis['unit_price'] !== null && BigDecimal::of($price)->isEqualTo($basis['unit_price']) ? 'agreement' : 'manual_estimate',
|
|
'tax_source' => $basis['tax_rate'] !== null && BigDecimal::of($tax)->isEqualTo($basis['tax_rate']) ? 'company_default' : 'manual_rate',
|
|
'manual_plan' => $quantity !== ($projection['quantity_milli'] ?? 0) || $delivery !== ($projection['risk_date'] ?? null) || ! ($projection['demand_complete'] ?? false)];
|
|
$state = 'draft';
|
|
} else {
|
|
$this->require($old !== null, 'action', 'Prepare primeiro a sugestão.');
|
|
$data = $old->data;
|
|
$snapshot = $old->snapshot;
|
|
$state = $old->state;
|
|
if (in_array($v['action'], ['approve', 'return', 'close'], true)) {
|
|
$this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A conferência exige independência de todos os preparadores.');
|
|
}
|
|
if ($v['action'] === 'approve') {
|
|
$this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão não está disponível para decisão.');
|
|
$this->current($old);
|
|
$this->require(! $this->hasExcess($old), 'action', 'A reposição excede o máximo no horizonte. Reveja quantidade, calendário ou política antes da aprovação.');
|
|
if ($data['manual_plan'] || $snapshot['events']['warnings'] !== []) {
|
|
$ack = Validator::make($v['data'], ['confirm_exception' => 'required|accepted'])->validate();
|
|
$data['exception_confirmed'] = (bool) $ack['confirm_exception'];
|
|
}
|
|
$state = 'approved';
|
|
} elseif ($v['action'] === 'return') {
|
|
$this->require(in_array($state, ['draft', 'approved'], true), 'action', 'A sugestão já não pode ser devolvida nesta etapa.');
|
|
$state = 'returned';
|
|
} elseif ($v['action'] === 'cancel') {
|
|
$this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão aprovada deve ser devolvida; a convertida conserva a requisição.');
|
|
$state = 'cancelled';
|
|
} elseif ($v['action'] === 'convert') {
|
|
$this->require($state === 'approved', 'action', 'Só uma sugestão aprovada e ainda não convertida pode criar requisição.');
|
|
$this->require(($snapshot['reviewed_by'] ?? null) !== $actor->id, 'action', 'A pessoa que conferiu não pode tornar-se autora da requisição.');
|
|
$this->current($old);
|
|
$conversion = Validator::make($v['data'], ['cost_center_id' => 'required|integer|min:1'])->validate();
|
|
$catalogue = $snapshot['catalogue'];
|
|
$payload = ['cost_center_id' => $conversion['cost_center_id'], 'priority' => $data['delivery_on'] <= today()->toDateString() ? 'urgent' : 'normal',
|
|
'needed_by' => $data['delivery_on'], 'justification' => $v['reason'], 'items' => [['catalogue_entry_id' => $catalogue['id'], 'catalogue_version' => $catalogue['version'],
|
|
'quantity' => BigDecimal::of($data['quantity_milli'])->dividedBy(1000, 3)->__toString(), 'unit_price' => $data['unit_price'], 'tax_rate' => $data['tax_rate']]]];
|
|
$form = new StoreRequisitionRequest;
|
|
$form->setUserResolver(fn () => $actor);
|
|
$payload = Validator::make($payload, $form->rules(), $form->messages())->validate();
|
|
$requisition = app(RequisitionWorkflow::class)->save($actor, $payload);
|
|
$data['requisition_id'] = $requisition->id;
|
|
$data['requisition_number'] = $requisition->number;
|
|
$snapshot['conversion'] = ['actor_id' => $actor->id, 'approval_version' => $old->version, 'cost_center_id' => $requisition->cost_center_id,
|
|
'requisition_created' => [...$requisition->only(['id', 'number', 'cost_center_id', 'subtotal_minor', 'tax_minor', 'total_minor']),
|
|
'items' => $requisition->items()->get()->map(fn ($line) => $line->only(['id', 'description', 'unit', 'quantity', 'unit_price_minor', 'tax_rate', 'catalogue_entry_id', 'catalogue_snapshot', 'total_minor']))->all()]];
|
|
$state = 'converted';
|
|
} elseif ($v['action'] === 'close') {
|
|
$this->require($state === 'converted', 'action', 'Apenas uma sugestão convertida pode encerrar acompanhamento.');
|
|
$request = Requisition::query()->whereKey($data['requisition_id'])->firstOrFail();
|
|
$orders = PurchaseOrder::query()->where('requisition_id', $request->id)->get();
|
|
$complete = $orders->isNotEmpty() && $orders->every(fn ($order) => app(ReceiptWorkflow::class)->progress($order)['state'] === 'complete');
|
|
$this->require($request->status === 'rejected' || $complete, 'action', 'A necessidade continua em acompanhamento. Aguarde rejeição da requisição ou recepção completa da ordem.');
|
|
$state = 'closed';
|
|
}
|
|
if ($v['action'] === 'approve') {
|
|
$snapshot['reviewed_by'] = $actor->id;
|
|
}
|
|
}
|
|
$snapshot['actor'] = $actor->only(['id', 'name']);
|
|
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'suggestion', 'record_key' => $old->record_key ?? (string) Str::uuid(),
|
|
'version' => ($old->version ?? 0) + 1, 'state' => $state, 'stock_item_id' => $snapshot['stock_item_id'], 'warehouse_id' => $snapshot['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.suggestion.'.$v['action'], null, $old?->toArray(), $record->toArray());
|
|
|
|
return $record;
|
|
});
|
|
}
|
|
|
|
/** A repeatable source snapshot; never writes business events.
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function basis(string $policyKey, string $date): array
|
|
{
|
|
$planning = app(SupplyPlanning::class);
|
|
$policy = $planning->latest('policy')->where('record_key', $policyKey)->firstOrFail();
|
|
$this->require($policy->state === 'approved', 'data.policy_key', 'Seleccione uma política aprovada e vigente.');
|
|
$stock = StockItem::query()->whereKey($policy->stock_item_id)->firstOrFail();
|
|
$warehouse = Warehouse::query()->whereKey($policy->warehouse_id)->firstOrFail();
|
|
$this->require($stock->only(['id', 'code', 'name', 'unit']) === $policy->snapshot['item'], 'data.policy_key', 'O artigo mudou; reveja a política antes de preparar uma sugestão.');
|
|
$this->require(Schema::hasTable('requisition_catalogue'), 'data.policy_key', 'Instale e publique o catálogo antes de preparar sugestões convertíveis.');
|
|
$catalogue = DB::table('requisition_catalogue')->where('tenant_id', $policy->tenant_id)->where('stock_item_id', $stock->id)->first();
|
|
$this->require($catalogue !== null && $catalogue->published && json_decode($catalogue->source_snapshot, true, 16, JSON_THROW_ON_ERROR) === app(RequisitionCatalogue::class)->source($stock), 'data.policy_key', 'Publique a versão actual do artigo no catálogo para reutilizar os seus dados na requisição.');
|
|
$agreement = null;
|
|
$price = null;
|
|
$supplierBasis = null;
|
|
if (! empty($policy->data['agreement_key'])) {
|
|
$agreement = $planning->latest('agreement')->where('record_key', $policy->data['agreement_key'])->firstOrFail();
|
|
$supplier = Supplier::query()->whereKey($agreement->supplier_id)->firstOrFail();
|
|
$this->require($agreement->state === 'approved' && $agreement->version === $policy->data['agreement_version'] && $agreement->data['valid_from'] <= today()->toDateString() && $agreement->data['valid_until'] >= today()->toDateString() && $supplier->eligible(), 'data.policy_key', 'O acordo de origem ou a validação do fornecedor deixou de estar vigente. Reveja a política.');
|
|
$this->require($agreement->snapshot['supplier'] === $supplier->only(['id', 'name', 'version']), 'data.policy_key', 'A versão do fornecedor mudou desde o acordo. Reveja o acordo e a política.');
|
|
if ($agreement->data['currency'] === 'MZN' && $agreement->data['price_minor'] !== null && $agreement->data['price_minor'] > 0) {
|
|
$price = BigDecimal::of($agreement->data['price_minor'])->dividedBy(100, 2)->__toString();
|
|
}
|
|
$supplierBasis = $supplier->only(['id', 'version', 'status', 'blocked', 'next_review']);
|
|
}
|
|
$balance = app(StockWorkflow::class)->balance($stock->id, $warehouse->id);
|
|
$events = app(SupplyCoordination::class)->events($stock->id, $warehouse->id, $date);
|
|
$forecast = app(SupplyHistory::class)->resolve($policy);
|
|
$projection = app(InventoryLots::class)->project($policy, $forecast['data'], $balance, $events, $date);
|
|
$sources = [];
|
|
foreach (['reservation', 'delivery', 'import'] as $kind) {
|
|
$rows = $planning->latest($kind);
|
|
if ($kind !== 'import') {
|
|
$rows->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id);
|
|
} else {
|
|
$orderIds = $planning->latest('delivery')->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id)->pluck('purchase_order_id');
|
|
$rows->whereIn('purchase_order_id', $orderIds);
|
|
}
|
|
$sources[$kind] = $rows->orderBy('id')->get(['id', 'record_key', 'version', 'state'])->toArray();
|
|
}
|
|
$tax = Tenant::query()->findOrFail($policy->tenant_id)->default_tax_rate;
|
|
$basis = ['stock_item_id' => $stock->id, 'warehouse_id' => $warehouse->id, 'item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']),
|
|
'policy_key' => $policy->record_key, 'policy_version' => $policy->version, 'policy' => $policy->data,
|
|
'catalogue' => ['id' => $catalogue->id, 'version' => $catalogue->version, 'category' => $catalogue->category, 'purchase_type' => $catalogue->purchase_type],
|
|
'agreement' => $agreement?->only(['id', 'record_key', 'version', 'data', 'snapshot']), 'supplier' => $supplierBasis,
|
|
'balance' => $balance, 'events' => $events, 'current_event_status' => app(SupplyCoordination::class)->events($stock->id, $warehouse->id, today()->toDateString()),
|
|
'projection' => $projection, 'sources' => $sources, 'unit_price' => $price, 'tax_rate' => $tax];
|
|
if ($forecast['history'] !== null) {
|
|
$basis['forecast_history'] = $forecast['history'];
|
|
}
|
|
$basis['hash'] = hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR));
|
|
|
|
return $basis;
|
|
}
|
|
|
|
public function hasExcess(SupplyRecord $record): bool
|
|
{
|
|
$max = $record->snapshot['policy']['maximum_milli'];
|
|
if (! ($record->snapshot['projection']['available'] ?? false)) {
|
|
return $record->snapshot['balance']['quantity'] + $record->data['quantity_milli'] > $max;
|
|
}
|
|
foreach ($record->snapshot['projection']['series'] ?? [] as $point) {
|
|
if ($point['date'] >= $record->data['delivery_on'] && $max < $point['balance_milli'] + $record->data['quantity_milli']) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** Minimal provenance for a requisition already authorised by its controller.
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public function origin(Requisition $requisition): ?array
|
|
{
|
|
if (! Schema::hasTable('supply_records')) {
|
|
return null;
|
|
}
|
|
$record = app(SupplyPlanning::class)->latest('suggestion')->whereIn('state', ['converted', 'closed'])->whereJsonContains('data->requisition_id', $requisition->id)->first();
|
|
if ($record === null) {
|
|
return null;
|
|
}
|
|
|
|
return ['approval_version' => $record->snapshot['conversion']['approval_version'], 'prepared_on' => $record->snapshot['prepared_on'],
|
|
'quantity_milli' => $record->data['quantity_milli'], 'unit' => $record->snapshot['item']['unit'], 'delivery_on' => $record->data['delivery_on'],
|
|
'purpose' => $record->snapshot['policy']['purpose'], 'origin' => $record->snapshot['policy']['origin']];
|
|
}
|
|
|
|
private function current(SupplyRecord $record): void
|
|
{
|
|
$this->require($record->data['delivery_on'] >= today()->toDateString(), 'action', 'A data pretendida passou. Devolva e actualize a sugestão.');
|
|
$basis = $this->basis($record->data['policy_key'], $record->snapshot['prepared_on']);
|
|
$this->require($basis['hash'] === $record->snapshot['hash'], 'action', 'O stock, as fontes ou a configuração mudaram. Devolva e actualize a sugestão antes de decidir ou converter.');
|
|
}
|
|
|
|
private function require(bool $condition, string $field, string $message): void
|
|
{
|
|
if (! $condition) {
|
|
throw ValidationException::withMessages([$field => $message]);
|
|
}
|
|
}
|
|
}
|