This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\WorkContext;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class SgcDeliveryCheck extends Command
{
protected $signature = 'sgc:delivery-check {tenant : ID da empresa}';
protected $description = 'Conferir configuração e ausência de dados de desenvolvimento antes da entrega; não altera dados';
public function handle(WorkContext $contexts): int
{
$tenant = Tenant::query()->find($this->argument('tenant'));
if ($tenant === null || ! Schema::hasColumn('tenants', 'work_context_policy')) {
$this->error('Empresa inexistente ou instalação do contexto pendente.');
return self::FAILURE;
}
$users = User::query()->where('tenant_id', $tenant->id)->get();
$actor = $users->first();
if ($actor === null || $contexts->policy($actor) === null || $contexts->purchased($actor) === [] || ! app(OrganizationAccess::class)->active($tenant->id)) {
$this->error('Defina produto contratado, opções e administração da empresa antes da entrega.');
return self::FAILURE;
}
$administrators = 0;
foreach ($users as $user) {
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
->where('m.tenant_id', $tenant->id)->where('p.tenant_id', $tenant->id)->where('m.user_id', $user->id)
->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
$administrators++;
}
}
$clean = true;
foreach (Schema::getTables() as $table) {
$name = $table['name'];
if (in_array($name, ['users', 'departments', 'cost_centers', 'organization_profiles', 'organization_memberships', 'organization_access', 'audit_logs'], true)) {
continue;
}
if (Schema::hasColumn($name, 'tenant_id') && DB::table($name)->where('tenant_id', $tenant->id)->exists()) {
$this->error('Existem registos a reconciliar antes da entrega: '.$name);
$clean = false;
}
}
$enabled = $contexts->policy($actor)['enabled'] ?? [];
if ($administrators < 2 || $enabled === [] || array_diff($enabled, $contexts->purchased($actor)) !== []) {
$this->error('Confira dois administradores permanentes e opções activadas dentro do produto contratado.');
$clean = false;
}
foreach ($enabled as $key) {
if (! (WorkContext::OPTIONS[$key]['operational'] ?? false)) {
$this->error('Percurso operacional ainda pendente: '.$key);
$clean = false;
}
if (! $users->contains(fn ($user) => in_array($key, $contexts->available($user), true))) {
$this->error('Opção sem utilizador com perfil/contexto autorizado: '.$key);
$clean = false;
}
}
if ($clean) {
$this->info('Conferência técnica aprovada. Não substitui aceitação funcional, revisão dos cadastros de configuração ou instalação de RH.');
}
return $clean ? self::SUCCESS : self::FAILURE;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\AutomaticPurchases;
use App\Services\OrganizationAccess;
use App\Services\SupplyPlanning;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
class AutomaticPurchaseController extends Controller
{
public function index(Request $request, AutomaticPurchases $service)
{
$actor=$request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin','supply_read','supply_prepare','supply_review']),403);
$prepare=app(SupplyPlanning::class)->allows($actor,'supply_prepare');
$ready=$service->ready();
$planning=app(SupplyPlanning::class);
Inertia::encryptHistory();
return Inertia::render('stock/replenishment',[
'ready'=>$ready,'canPrepare'=>$prepare,'canReview'=>$planning->allows($actor,'supply_review'),
'rules'=>$ready?$planning->latest('auto_rule')->latest('id')->limit(100)->get():[],
'needs'=>$ready?$planning->latest('auto_need')->latest('id')->limit(100)->get():[],
'history'=>$ready?SupplyRecord::query()->whereIn('kind',['auto_rule','auto_need'])->latest('id')->limit(100)->get():[],
'policies'=>Schema::hasTable('supply_records') && $prepare?$planning->latest('policy')->where('state','approved')->where('data->purpose','internal')->get():[],
'requesters'=>$prepare?User::query()->where('tenant_id',$actor->tenant_id)->get(['id','name','department_id','purchasing_role','tenant_id'])->filter(fn($u)=>in_array('requester',app(OrganizationAccess::class)->permissions($u,'procurement.internal'),true))->map(fn($u)=>$u->only(['id','name','department_id']))->values():[],
'centers'=>$prepare?CostCenter::query()->orderBy('code')->get(['id','department_id','code','name']):[],
])->toResponse($request)->withHeaders(['Cache-Control'=>'private, no-store, max-age=0']);
}
public function save(Request $request, AutomaticPurchases $service)
{
$service->save($request->user('web'),$request->all());
return redirect('/stock/reposicao');
}
public function evaluate(Request $request, int $id, AutomaticPurchases $service)
{
$actor=$request->user('web');
abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare','supply_review'])) && !app(OrganizationAccess::class)->maintenanceOnly($actor),403);
$need=SupplyRecord::query()->where('kind','auto_need')->findOrFail($id);
$service->evaluate($actor,$need->stock_item_id,$need->warehouse_id);
return redirect('/stock/reposicao');
}
}
@@ -9,12 +9,15 @@ use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\ReceiptWorkflow;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
@@ -55,6 +58,8 @@ class GoodsReceiptController extends Controller
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']),
'stockItems' => StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'canConfirm' => $request->user()->can('create', [GoodsReceipt::class, $order]),
'lotScopes' => Schema::hasTable('inventory_lot_allocations')
? app(InventoryLots::class)->activeSetups()->get()->map(fn ($r) => ['stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'quarantine_on_receipt' => $r->data['quarantine_on_receipt'] ?? true])->values()->all() : [],
]);
}
@@ -73,6 +78,7 @@ class GoodsReceiptController extends Controller
Gate::authorize('view', $receipt);
return Inertia::render('receipts/show', [
'correctedReceiptItems' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all())->map(fn ($r) => $r->data['reference']),
'receipt' => $receipt->load('items'),
'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(),
]);
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryDispositions;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
class InventoryDispositionController extends Controller
{
public function index(Request $request, InventoryDispositions $workflow): Response|RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$capabilities = [];
foreach (['read', 'prepare', 'review', 'execute'] as $capability) {
$capabilities[$capability] = $workflow->allows($actor, $capability);
}
$canViewReversals = collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => app(InventoryReversals::class)->allows($actor, $a));
if (! collect($capabilities)->contains(fn (bool $allow) => $allow) && $canViewReversals) {
return redirect('/stock/estornos');
}
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? app(SupplyPlanning::class)->latest('stock_event')->latest('id')->limit(100)->get() : collect();
$receipts = [];
$lots = [];
if ($ready && $capabilities['prepare']) {
foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) {
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$receipts[] = ['id' => $line->id, 'stock_item_id' => $line->stock_item_id, 'warehouse_id' => $receipt->warehouse_id, 'accepted_milli' => $line->accepted_milli,
'returned_milli' => $workflow->returnedForReceipt($line->id), 'label' => $receipt->number.' · '.$receipt->snapshot['supplier']['name'].' · '.$line->snapshot['stock_item']['name']];
}
foreach (app(InventoryLots::class)->activeSetups()->get() as $setup) {
$context = app(InventoryLots::class)->context($setup->stock_item_id, $setup->warehouse_id);
foreach ($context['lots'] ?? [] as $lot) {
if ($lot['quantity_milli'] > 0) {
$lots[] = ['id' => $lot['id'], 'stock_item_id' => $setup->stock_item_id, 'warehouse_id' => $setup->warehouse_id, 'code' => $lot['data']['code'], 'quantity_milli' => $lot['quantity_milli'],
'receipt_item_id' => $lot['origin']['receipt_item_id'] ?? null, 'reconciled' => $context['reconciled']];
}
}
}
}
return Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, 'records' => $records,
'canViewOperations' => $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'inventory_adjust_review']),
'canViewReversals' => $canViewReversals,
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->only($records->pluck('data.stock_operation_id')->filter()->all())->map(fn ($r) => $r->data['reference']),
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'receipts' => $receipts, 'lots' => $lots,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]);
}
public function save(Request $request, InventoryDispositions $workflow): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário de stock pendente.');
$workflow->save($actor, $request->all());
return back()->with('success', 'Processo de stock registado.');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
class InventoryLotController extends Controller
{
public function index(Request $request, InventoryLots $lots): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$capabilities = [];
foreach (['read', 'prepare', 'review', 'hold', 'release'] as $capability) {
$capabilities[$capability] = $lots->allows($actor, 'inventory_lot_'.$capability);
}
abort_unless(collect($capabilities)->contains(fn (bool $allowed) => $allowed), 403);
$ready = Schema::hasTable('inventory_lot_allocations');
$records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->limit(100)->get() : collect();
if ($ready) {
$records = $records->concat($lots->activeSetups()->latest('id')->limit(100)->get())->unique('id')->sortByDesc('id')->values();
}
$contexts = [];
foreach ($records->where('state', 'active') as $record) {
$contexts[$record->record_key] = $lots->context($record->stock_item_id, $record->warehouse_id);
}
return Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities, 'records' => $records, 'contexts' => $contexts,
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->limit(200)->get() : []]);
}
public function save(Request $request, InventoryLots $lots): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(Schema::hasTable('inventory_lot_allocations'), 503, 'Instalação do controlo por lotes pendente.');
$lots->save($actor, $request->all());
return back()->with('success', 'Conferência de lotes registada.');
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
class InventoryReversalController extends Controller
{
public function index(Request $request, InventoryReversals $workflow): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$capabilities = [];
foreach (['read', 'prepare', 'review', 'execute'] as $capability) {
$capabilities[$capability] = $workflow->allows($actor, $capability);
}
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? app(SupplyPlanning::class)->latest('stock_reversal')->latest('id')->limit(100)->get() : collect();
$operations = [];
$receipts = [];
if ($ready && $capabilities['prepare']) {
$corrected = $workflow->corrections('operation');
$operations = StockOperation::query()->whereIn('kind', ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'])->latest('id')->limit(200)->get()
->reject(fn ($op) => isset($corrected[$op->id]))->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all();
$correctedReceipts = $workflow->corrections('receipt');
foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) {
if (isset($correctedReceipts[$line->id])) {
continue;
}
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$receipts[] = ['id' => $line->id, 'label' => $receipt->number.' · '.$line->snapshot['stock_item']['name'].' · '.$line->snapshot['warehouse']['name']];
}
}
$other = app(InventoryDispositions::class);
return Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
'canViewDispositions' => collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => $other->allows($actor, $a)),
'records' => $records, 'operations' => $operations, 'receipts' => $receipts,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]);
}
public function save(Request $request, InventoryReversals $workflow): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário pendente.');
$workflow->save($actor, $request->all());
return back()->with('success', 'Estorno registado com a origem preservada.');
}
}
+3 -1
View File
@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\InvoiceWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Http\RedirectResponse;
@@ -18,7 +20,7 @@ class InvoiceController extends Controller
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
return Inertia::render('invoices/index', ['stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(), 'invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
@@ -8,6 +8,8 @@ use App\Services\ComparisonPolicy;
use App\Services\IntelligencePolicy;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use App\Services\WorkContext;
use App\Services\WorkflowTasks;
use Brick\Math\BigDecimal;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -25,6 +27,7 @@ class OrganizationConfigurationController extends Controller
$state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null;
Inertia::encryptHistory();
$response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1,
'workflowTasks' => app(WorkflowTasks::class)->catalogue(),
'companyRules' => $this->companyRules((int) $actor->tenant_id),
'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
@@ -32,7 +35,9 @@ class OrganizationConfigurationController extends Controller
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
'permissions' => OrganizationAccess::PERMISSIONS,
'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR)]) : [],
'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null,
'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [],
'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR), 'work_contexts' => json_decode($p->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR)]) : [],
'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [],
'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']),
'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
@@ -49,8 +54,11 @@ class OrganizationConfigurationController extends Controller
abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.');
$rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited'];
if ($command === 'profile') {
$rules += ['work_contexts' => 'sometimes|array', 'work_contexts.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))]];
$rules += ['id' => 'nullable|integer|min:1', 'name' => ['required', 'string', 'max:120', Rule::unique('organization_profiles')->where('tenant_id', $actor->tenant_id)->ignore($request->integer('id'))],
'permissions' => 'present|array', 'permissions.*' => ['string', 'distinct', Rule::in(array_keys(OrganizationAccess::PERMISSIONS))]];
} elseif ($command === 'work_contexts') {
$rules += ['enabled' => 'present|array', 'enabled.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))], 'reason' => 'required|string|min:10|max:4000', 'confirm' => 'accepted'];
} elseif ($command === 'user') {
$rules += ['name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|string|min:12|confirmed',
'department_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'reason' => 'required|string|min:10|max:4000'];
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Models\PeopleProcess;
use App\Models\User;
use App\Services\PeopleAccess;
use App\Services\PeopleApplicationReport;
use App\Services\PeopleDeadlineReport;
use App\Services\PeopleHiring;
use App\Services\PeopleSelectionReport;
use App\Services\PeopleWorkdesk;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class PeopleReportController extends Controller
{
public function index(Request $request): Response
{
return DB::transaction(function () use ($request) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$kinds = app(PeopleHiring::class)->kinds($actor);
abort_unless($kinds !== [], 403);
$filters = $request->validate([
'view' => 'nullable|in:processes,applications,selections,deadlines',
'deadline_window' => 'nullable|in:all,overdue,today,future,undated',
'deadline_page' => 'nullable|integer|min:1|max:100000',
'kind' => ['nullable', Rule::in($kinds)], 'status' => ['nullable', Rule::in(PeopleWorkdesk::STATES)],
'search' => 'nullable|string|max:100', 'from' => 'nullable|date_format:Y-m-d',
'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')],
]);
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
foreach (['kind', 'status'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->whereLike('title', '%'.$search.'%');
if (preg_match('/^(?:PES-)?([1-9][0-9]*)$/D', $search, $matches)) {
$id = filter_var($matches[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id !== false) {
$q->orWhere('id', $id);
}
}
});
}
if (! empty($filters['from'])) {
$query->whereDate('created_at', '>=', $filters['from']);
}
if (! empty($filters['to'])) {
$query->whereDate('created_at', '<=', $filters['to']);
}
$columns = ['id', 'title', 'kind', 'status', 'closes_on', 'created_at'];
$page = $request->routeIs('people.reports.csv') ? null : (clone $query)->latest('id')->paginate(25, $columns)->withQueryString();
$applications = ($filters['view'] ?? 'processes') === 'applications';
$selections = ($filters['view'] ?? 'processes') === 'selections';
$deadlines = ($filters['view'] ?? 'processes') === 'deadlines';
$deadlineReport = $deadlines ? app(PeopleDeadlineReport::class)->build(clone $query, $actor, $filters['deadline_window'] ?? 'all') : null;
if ($deadlineReport !== null && ! $request->routeIs('people.reports.csv')) {
$number = (int) ($filters['deadline_page'] ?? 1);
$pager = (new LengthAwarePaginator(array_slice($deadlineReport['items'], ($number - 1) * 25, 25), $deadlineReport['total_items'], 25, $number,
['path' => $request->url(), 'pageName' => 'deadline_page']))->appends($request->except('deadline_page'));
$deadlineReport['items'] = $pager->items();
$deadlineReport['pagination'] = ['current_page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'prev_page_url' => $pager->previousPageUrl(), 'next_page_url' => $pager->nextPageUrl()];
}
$metrics = [];
if ($applications || $selections) {
$metricQuery = clone $query;
if ($page !== null) {
$metricQuery->whereIn('id', $page->getCollection()->modelKeys());
}
foreach ($metricQuery->orderBy('id')->lazyById(100) as $process) {
$metrics[$process->id] = $applications ? app(PeopleApplicationReport::class)->counts($process, $actor) : app(PeopleSelectionReport::class)->facts($process, $actor);
}
}
if ($request->routeIs('people.reports.csv') && $deadlineReport !== null) {
$response = $this->deadlineCsv($deadlineReport);
} elseif ($request->routeIs('people.reports.csv')) {
// Materialise within the read-only snapshot; streaming runs after the transaction.
$rows = (clone $query)->orderBy('id')->get($columns);
$response = response()->streamDownload(function () use ($rows, $applications, $selections, $metrics) {
$file = fopen('php://output', 'w');
if ($file === false) {
throw new \RuntimeException('Não foi possível abrir a exportação.');
}
fwrite($file, "\xEF\xBB\xBF");
$extra = ['Registadas para avaliação', 'Avaliação completa', 'Por concluir avaliação', 'Sem avaliação', 'Avaliação em curso', 'Base da avaliação', 'Recebidas no portal', 'Admitidas para análise no portal', 'Não admitidas no portal', 'Triagem pendente'];
$selectionColumns = ['Situação da selecção', 'Proposta em', 'Decisão em', 'Pendência', 'Acesso à minuta', 'Referência da minuta', 'Minuta preparada em', 'Início previsto na minuta'];
fputcsv($file, ['Referência', 'Título', 'Percurso', 'Estado', 'Fecho de candidaturas', 'Criado em', ...($applications ? $extra : ($selections ? $selectionColumns : []))], ';', '"', '');
foreach ($rows as $row) {
$title = preg_match('/^[\s]*[=+@-]/u', $row->title) ? "'".$row->title : $row->title;
$counts = $metrics[$row->id] ?? [];
$values = $applications ? array_values(array_map(fn ($value) => $value === null ? 'Sem acesso' : ($value === 'selection_snapshot' ? 'Selecção preservada' : ($value === 'current_eligibility' ? 'Elegibilidade actual' : $value)), $counts)) : [];
if ($selections) {
$values = array_values(array_map(fn ($value) => $value === null ? 'Não registado' : (is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value), $counts));
}
fputcsv($file, ['PES-'.$row->id, $title, $row->kind === 'service' ? 'Serviços individuais' : 'Recrutamento e selecção', self::states()[$row->status] ?? $row->status, $row->closes_on, $row->created_at?->toDateString(), ...$values], ';', '"', '');
}
fclose($file);
}, 'relatorio-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
} else {
$summary = (clone $query)->selectRaw('kind, status, count(*) as total')->groupBy('kind', 'status')->get();
Inertia::encryptHistory();
$response = Inertia::render('people/reports', [
'filters' => $filters, 'kinds' => $kinds, 'summary' => $summary,
'applicationMetrics' => $applications ? $metrics : [],
'selectionMetrics' => $selections ? $metrics : [],
'deadlineReport' => $deadlineReport,
'generatedAt' => now()->toIso8601String(),
'processes' => $page,
])->toResponse($request);
}
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
/** @param array<string,mixed> $report */
private function deadlineCsv(array $report): Response
{
return response()->streamDownload(function () use ($report) {
$file = fopen('php://output', 'w');
if ($file === false) {
throw new \RuntimeException('Não foi possível abrir a exportação.');
}
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['Referência', 'Processo', 'Categoria', 'Intervenção', 'Responsabilidade', 'Prazo', 'Dias de atraso', 'Consulta em', 'Filtro de prazo'], ';', '"', '');
$windows = ['all' => 'Todos', 'overdue' => 'Vencidos', 'today' => 'Hoje', 'future' => 'Futuros', 'undated' => 'Sem prazo definido'];
foreach ($report['items'] as $item) {
$values = [$item['reference'], $item['title'], $item['category'], $item['label'], $item['responsibility'], $item['due_on'] ?? 'Sem prazo definido', $item['days_overdue'] ?? 'Não determinável', $report['as_of'], $windows[$report['window']]];
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value, $values);
fputcsv($file, $values, ';', '"', '');
}
fclose($file);
}, 'relatorio-prazos-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/** @return array<string,string> */
private static function states(): array
{
return ['draft' => 'Abertura por conferir', 'open' => 'Recepção de candidaturas', 'assessment' => 'Avaliação',
'selection_review' => 'Selecção por conferir', 'selected' => 'Selecção aprovada', 'contract_draft' => 'Minuta contratual preparada'];
}
}
+18 -2
View File
@@ -12,14 +12,19 @@ use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\TechnicalReview;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ApprovalRouting;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\OrganizationAccess;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
use App\Services\SupplySuggestions;
use App\Services\WorkContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -79,6 +84,9 @@ class RequisitionController extends Controller
return Inertia::render('requisitions/show', [
'requisition' => $requisition,
'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $request->user()),
'canRefreshRouting' => $requisition->status === 'pending' && $request->user()->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($request->user()) && Schema::hasTable('workflow_notices'),
'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition),
'comparisonPolicy' => app(ComparisonPolicy::class)->forRequest($requisition),
'canEdit' => app(OrganizationAccess::class)->canRequest($request->user()) && $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
'canDecide' => $requisition->canDecide($request->user()),
@@ -140,7 +148,8 @@ class RequisitionController extends Controller
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$workflow->submit($request->user(), $requisition);
$command = $request->validate(['operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1']);
$workflow->submit($request->user(), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
return back();
}
@@ -150,14 +159,21 @@ class RequisitionController extends Controller
$data = $request->validate([
'decision' => ['required', Rule::in(['approved', 'rejected'])],
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1',
]);
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null);
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
return back();
}
public function dashboard(Request $request): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$contexts = app(WorkContext::class);
if ($contexts->enforced($actor)) {
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
}
$visible = Requisition::query()->visibleTo($request->user());
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
+2
View File
@@ -6,6 +6,7 @@ use App\Enums\PurchasingRole;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
@@ -44,6 +45,7 @@ class StockController extends Controller
$balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [
'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id,
'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(),
'lot_control' => app(InventoryLots::class)->summary($balance->stock_item_id, $balance->warehouse_id),
]);
return Inertia::render('stock/index', [
@@ -8,6 +8,9 @@ use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryCounts;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\StockWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\JsonResponse;
@@ -15,6 +18,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -22,31 +26,47 @@ class StockOperationController extends Controller
{
public function index(Request $request, StockWorkflow $workflow): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, 'inventory_adjust_review']), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = [];
$lotCounts = [];
foreach ($items as $item) {
foreach ($warehouses as $warehouse) {
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)];
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id),
'lot_control' => app(InventoryLots::class)->summary($item->id, $warehouse->id)];
if ($actor->hasFunctions([PurchasingRole::Warehouse]) && app(InventoryLots::class)->setup($item->id, $warehouse->id) !== null) {
try {
$source = app(InventoryCounts::class)->source($item->id, $warehouse->id);
$lotCounts[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$source];
} catch (ValidationException) {
// The summary still reports the divergence; no count can use an invented baseline.
}
}
}
}
return Inertia::render('stock/operations', [
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->map(fn ($r) => $r->data['reference']),
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]),
'canOperate' => $actor->hasFunctions([PurchasingRole::Warehouse]),
'canAdjust' => app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 'lotCounts' => $lotCounts,
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]);
}
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions($request->input('kind') === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id;
$tenant = $actor->tenant_id;
$data = $request->validate([
'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])],
'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000',
@@ -59,10 +79,31 @@ class StockOperationController extends Controller
'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'],
'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0',
'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')],
'lot_version' => 'exclude_unless:kind,count|nullable|string|size:64',
'lot_counts' => 'exclude_unless:kind,count|sometimes|array|max:500',
'lot_counts.*.lot_id' => 'nullable|integer|min:1', 'lot_counts.*.new_key' => 'nullable|uuid',
'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean',
]);
if (empty($data['equipment_id'])) {
unset($data['equipment_id']);
}
if (empty($data['lot_version'])) {
unset($data['lot_version']);
}
if (empty($data['lot_counts']) && empty($data['lot_version'])) {
unset($data['lot_counts']);
}
if (isset($data['lot_counts'])) {
$data['lot_counts'] = array_map(function (array $row) {
if (! empty($row['lot_id'])) {
$row['lot_id'] = (int) $row['lot_id'];
}
$row['quantity'] = (string) BigDecimal::of($row['quantity'])->toScale(3);
return $row;
}, $data['lot_counts']);
}
foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) {
if (isset($data[$key])) {
$data[$key] = (int) $data[$key];
@@ -70,7 +111,7 @@ class StockOperationController extends Controller
}
$data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000';
ksort($data);
$operation = $workflow->record($request->user(), $data);
$operation = $workflow->record($actor, $data);
return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back();
}
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\PurchaseOrder;
use App\Models\PurchaseOrderItem;
use App\Models\Requisition;
use App\Models\RequisitionItem;
use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryReversals;
use App\Services\OrganizationAccess;
use App\Services\ReceiptWorkflow;
use App\Services\SupplyCoordination;
use App\Services\SupplyPlanning;
use App\Services\SupplySuggestions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class SupplyPlanningController extends Controller
{
public function index(Request $request, SupplyPlanning $planning): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements';
if ($view === 'history') {
return $this->history($actor, $planning);
}
if ($view === 'suggestions') {
return $this->suggestions($actor, $planning);
}
if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) {
return $this->coordination($actor, $view, $planning);
}
$import = $view === 'imports';
$capabilities = $import ? ['import_read', 'import_prepare', 'import_customs'] : ['supply_read', 'supply_prepare', 'supply_review'];
abort_unless(collect($capabilities)->contains(fn ($p) => $planning->allows($actor, $p)), 403);
$ready = Schema::hasTable('supply_records');
$kind = $import ? 'import' : ($view === 'calendar' ? 'policy' : 'agreement');
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
$projections = [];
if ($view === 'calendar' && $ready) {
foreach ($records->where('state', 'approved') as $record) {
$projections[$record->record_key] = $planning->calendar($record, today()->toDateString());
}
}
$prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare');
return Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, 'records' => $records, 'projections' => $projections,
'asOf' => today()->toDateString(),
'histories' => $ready && $prepare && $kind === 'policy' ? $planning->latest('history')->where('state', 'approved')->latest('id')->limit(100)->get()->map(fn ($r) => [
'key' => $r->record_key, 'version' => $r->version, 'stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'purpose' => $r->data['purpose'],
'period_from' => $r->data['period_from'], 'period_until' => $r->data['period_until'], 'quality' => $r->data['quality'], 'pattern' => $r->data['pattern'], 'days' => $r->snapshot['analysis']['days'], 'daily_mean_milli' => $r->snapshot['analysis']['daily_mean_milli'],
]) : [],
'agreements' => $ready && $prepare && $kind === 'policy' ? $planning->latest('agreement')->where('state', 'approved')->get(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'), 'canCustoms' => $planning->allows($actor, 'import_customs'),
'items' => $prepare && ! $import ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $prepare && ! $import ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'suppliers' => $prepare && $kind === 'agreement' ? Supplier::query()->where('status', 'active')->orderBy('name')->get(['id', 'name']) : [],
'orders' => $prepare && $import ? PurchaseOrder::query()->latest('id')->limit(100)->get(['id', 'number']) : [],
]);
}
private function history(User $actor, SupplyPlanning $planning): Response
{
$prepare = $planning->allows($actor, 'supply_history_prepare');
$review = $planning->allows($actor, 'supply_history_review');
abort_unless($prepare || $review || $planning->allows($actor, 'supply_read'), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest('history')->latest('id')->limit(100)->get() : collect();
return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'canPrepare' => $prepare, 'canReview' => $review,
'defaultPeriod' => ['from' => today()->subDays(30)->toDateString(), 'until' => today()->subDay()->toDateString()],
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', 'history')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
private function suggestions(User $actor, SupplyPlanning $planning): Response
{
$prepare = $planning->allows($actor, 'supply_suggest_prepare');
$review = $planning->allows($actor, 'supply_suggest_review');
$convertPermission = $planning->allows($actor, 'supply_suggest_convert');
$convert = $convertPermission && app(OrganizationAccess::class)->canRequest($actor);
abort_unless($planning->allows($actor, 'supply_read') || $prepare || $review || $convertPermission, 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest('suggestion')->latest('id')->limit(100)->get() : collect();
$policies = [];
if ($ready && $prepare) {
foreach ($planning->latest('policy')->where('state', 'approved')->latest('id')->limit(100)->get() as $policy) {
$option = ['key' => $policy->record_key, 'version' => $policy->version, 'label' => $policy->snapshot['item']['code'].' · '.$policy->snapshot['item']['name'].' · '.$policy->snapshot['warehouse']['name'], 'issue' => null];
try {
$basis = app(SupplySuggestions::class)->basis($policy->record_key, today()->toDateString());
$option += ['unit_price' => $basis['unit_price'], 'tax_rate' => $basis['tax_rate'], 'quantity_milli' => $basis['projection']['quantity_milli'] ?? 0,
'delivery_on' => $basis['projection']['risk_date'] ?? null, 'unit' => $basis['item']['unit']];
} catch (ValidationException $e) {
$option['issue'] = $e->getMessage();
}
$policies[] = $option;
}
}
$requests = Requisition::query()->visibleTo($actor)->whereIn('id', $records->pluck('data.requisition_id')->filter())->get(['id', 'number'])->keyBy('id');
return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert,
'costCenters' => $convert ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
'requestLinks' => $requests,
'excess' => $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplySuggestions::class)->hasExcess($r)]),
'history' => $ready ? SupplyRecord::query()->where('kind', 'suggestion')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
private function coordination(User $actor, string $view, SupplyPlanning $planning): Response
{
[$kind, $prepare, $review] = match ($view) {
'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'],
'reservations' => ['reservation', 'supply_reserve', 'supply_reserve'],
'deliveries' => ['delivery', 'supply_delivery', 'supply_delivery'],
default => abort(404),
};
abort_unless($planning->allows($actor, 'supply_read') || $planning->allows($actor, $prepare) || $planning->allows($actor, $review), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
$canPrepare = $planning->allows($actor, $prepare);
$reversedIds = $ready && $kind === 'reservation' ? array_keys(app(InventoryReversals::class)->corrections('operation')) : [];
$orders = $canPrepare && $kind === 'delivery' ? PurchaseOrder::query()->latest('id')->limit(100)->get()->keyBy('id') : collect();
$orderLines = PurchaseOrderItem::query()->whereIn('purchase_order_id', $orders->keys())->get()->map(fn ($line) => [
'id' => $line->id, 'label' => $orders[$line->purchase_order_id]->number.' · '.$line->description.' · '.$line->quantity.' '.$line->unit,
'stock_item_id' => $orders[$line->purchase_order_id]->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null,
'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
]);
$requests = $canPrepare && $kind === 'reservation' ? Requisition::query()->where('status', 'approved')->latest('id')->limit(100)->get()->keyBy('id') : collect();
$requestLines = RequisitionItem::query()->whereIn('requisition_id', $requests->keys())->get()->filter(fn ($line) => isset($line->catalogue_snapshot['stock_item_id']))->map(fn ($line) => [
'id' => $line->id, 'label' => $requests[$line->requisition_id]->number.' · '.$line->description,
'stock_item_id' => $line->catalogue_snapshot['stock_item_id'], 'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
'reference' => $requests[$line->requisition_id]->number.' / '.$line->id, 'needed_on' => $requests[$line->requisition_id]->needed_by->toDateString(),
])->values();
return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready, 'records' => $records,
'eligible' => $kind === 'alternative' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->eligibleAlternative($r)]) : [],
'pending' => $kind === 'delivery' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingDelivery($r)]) : ($kind === 'reservation' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingReservation($r)]) : []),
'reversedOperations' => $reversedIds,
'canPrepare' => $canPrepare, 'canReview' => $planning->allows($actor, $review),
'items' => $canPrepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $canPrepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'orderLines' => $orderLines, 'requestLines' => $requestLines,
'operations' => $canPrepare && $kind === 'reservation' ? StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds)->latest('id')->limit(100)->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
public function save(Request $request, string $kind, SupplyPlanning $planning): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$planning->save($actor, $kind, $request->all());
return back();
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\WarehouseAvailability;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WarehouseAvailabilityController extends Controller
{
public function index(Request $request, WarehouseAvailability $availability): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'warehouse_availability_read']), 403);
return DB::transaction(function () use ($request, $actor, $availability) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$filters = $request->validate(['warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:100000']);
$warehouse = empty($filters['warehouse_id']) ? null : Warehouse::query()->whereKey($filters['warehouse_id'])->firstOrFail();
$planning = $actor->hasFunctions(['admin', 'supply_read', 'supply_reserve', 'warehouse_availability_read']);
$query = StockItem::query();
if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->whereLike('code', '%'.$filters['search'].'%')->orWhereLike('name', '%'.$filters['search'].'%'));
}
$page = $warehouse === null ? null : $query->orderBy('code')->paginate(25)->withQueryString()->through(fn ($item) => $availability->row($item, $warehouse, $planning));
$response = Inertia::render('stock/availability', ['filters' => $filters, 'planningVisible' => $planning, 'rows' => $page,
'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'checkedAt' => now()->toIso8601String(),
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name'])])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use App\Services\OrganizationAccess;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WarehouseRequestController extends Controller
{
public function index(Request $request): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
$prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
$ready = Schema::hasTable('warehouse_requests');
$query = WarehouseRequest::query();
if (! $actor->hasFunctions(['admin', 'warehouse_request_read']) && ! ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))) {
$query->where(function ($q) use ($actor) {
$q->where('created_by', $actor->id);
if ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null) {
$q->orWhere('snapshot->requester->department_id', $actor->department_id);
}
});
}
Inertia::encryptHistory();
$response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
'records' => $ready ? $query->latest('id')->paginate(15, ['id', 'status', 'snapshot', 'created_at'])->withQueryString()->through(fn ($r) => ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => app(WarehouseFulfilment::class)->progress($r)]) : null,
'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, WarehouseRequests $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$service->draft($actor, $request->all());
return redirect('/stock/requisicoes');
}
public function show(Request $request, int $id, WarehouseFulfilment $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$r = WarehouseRequest::query()->findOrFail($id);
$owner = $r->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']);
$review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id'];
$deliver = $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']);
abort_unless($owner || $review || $deliver || $actor->hasFunctions(['admin', 'warehouse_request_read']), 403);
$p = $service->progress($r);
$maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor);
Inertia::encryptHistory();
$response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
'canSubmit' => ! $maintenance && $owner && $p['state'] === 'draft', 'canReview' => ! $maintenance && $review && ! $owner && $r->created_by !== $actor->id && $p['state'] === 'submitted',
'canDeliver' => ! $maintenance && $deliver && $r->created_by !== $actor->id && $p['reviewer_id'] !== $actor->id && in_array($p['state'], ['authorized', 'partial'], true),
'canManageReservation' => ! $maintenance && $review && ! $owner && in_array($p['state'], ['authorized', 'partial'], true),
'canReceive' => ! $maintenance && $owner])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function command(Request $request, int $id, WarehouseFulfilment $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$service->command($actor, $id, $request->all());
return redirect('/stock/requisicoes/'.$id);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\RequisitionWorkflow;
use App\Services\WorkContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class WorkContextController extends Controller
{
public function select(Request $request, WorkContext $contexts): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$data = $request->validate(['area' => ['required', Rule::in(['procurement', 'people'])], 'option' => ['nullable', Rule::in(array_keys(WorkContext::OPTIONS))]]);
$available = array_values(array_filter($contexts->available($actor), fn ($key) => WorkContext::OPTIONS[$key]['area'] === $data['area']));
$key = $data['option'] ?? (count($available) === 1 ? $available[0] : null);
abort_unless($key !== null && in_array($key, $available, true), 403, 'Escolha uma opção configurada, contratada e autorizada.');
$state = ['user_id' => $actor->id, 'tenant_id' => $actor->tenant_id, 'key' => $key];
app(RequisitionWorkflow::class)->audit($actor, 'work_context.selected', null, $request->session()->get('work_context'), $state);
$request->session()->put('work_context', $state);
return redirect('/dashboard');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\WorkflowNotices;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WorkflowNoticeController extends Controller
{
public function index(Request $request, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$filter = $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000']);
$visible = collect($notices->visible($actor));
$unread = $visible->whereNull('read_at')->count();
$items = ($filter['view'] ?? 'all') === 'unread' ? $visible->whereNull('read_at')->values() : $visible;
if (in_array($filter['view'] ?? '', ['tasks', 'outcomes'], true)) {
$items = $visible->where('kind', $filter['view'] === 'tasks' ? 'task' : 'outcome')->values();
}
$page = (int) ($filter['page'] ?? 1);
$response = Inertia::render('workflow/notices', ['ready' => Schema::hasTable('workflow_notices'), 'unread' => $unread, 'view' => $filter['view'] ?? 'all',
'notices' => (new LengthAwarePaginator($items->slice(($page - 1) * 25, 25)->values(), $items->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function read(Request $request, int $notice, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$notices->read($actor, $notice);
return redirect('/requisicoes/avisos');
}
public function refresh(Request $request, int $requisition, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$notices->refresh($actor, $requisition, $request->all());
return redirect('/requisicoes/'.$requisition);
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Closure;
@@ -12,14 +13,15 @@ class EnsureOrganizationAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && $user->tenant_id !== null) {
$user = $request->user('web');
if ($user instanceof User && $user->tenant_id !== null) {
abort_unless(app(OrganizationAccess::class)->permissions($user) !== [], 403, 'Não existe uma atribuição de acesso válida. Contacte os administradores da empresa.');
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe(), 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.');
$contextSelection = $request->isMethod('POST') && $request->routeIs('work-context.select');
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe() && ! $contextSelection, 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.');
}
$response = $next($request);
if ($user !== null && app(OrganizationAccess::class)->maintenanceOnly($user)) {
if ($user instanceof User && app(OrganizationAccess::class)->maintenanceOnly($user)) {
app(RequisitionWorkflow::class)->audit($user, 'maintenance.consulted', null, null, ['path' => $request->path(), 'status' => $response->getStatusCode()]);
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\WorkContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkContext
{
public function handle(Request $request, Closure $next): Response
{
$contexts = app(WorkContext::class);
$user = $request->user('web');
if ($user instanceof User && $contexts->enforced($user) && ($area = WorkContext::area($request->path())) !== null) {
$key = $contexts->active($request);
abort_unless($key !== null && WorkContext::OPTIONS[$key]['area'] === $area, 403, 'Seleccione uma área e opção autorizadas no Painel Inicial.');
if (! WorkContext::OPTIONS[$key]['operational'] && $request->isMethod('GET')) {
return redirect('/dashboard');
}
abort_unless(WorkContext::OPTIONS[$key]['operational'], 409, 'O percurso operacional desta opção ainda está em implementação.');
}
return $next($request);
}
}
@@ -5,7 +5,9 @@ namespace App\Http\Middleware;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\SupplierPayables;
use App\Services\WorkContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@@ -49,6 +51,8 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'internalRequisitionReady' => $actor !== null && Schema::hasTable('requisition_commands'),
'workContext' => $actor === null ? null : app(WorkContext::class)->shared($request),
'canRequest' => $actor !== null && app(OrganizationAccess::class)->canRequest($actor),
'auth' => [
'user' => $actor,
@@ -28,6 +28,11 @@ class ConfirmReceiptRequest extends FormRequest
'items.*.accepted_quantity' => ['required', 'string', 'numeric', 'min:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
'items.*.quality_status' => ['required', Rule::in(['compliant', 'reservations', 'nonconforming'])],
'items.*.notes' => 'nullable|string|max:3000',
'items.*.lots' => 'sometimes|array|max:100',
'items.*.lots.*.code' => 'nullable|string|max:100',
'items.*.lots.*.expires_on' => 'nullable|date_format:Y-m-d',
'items.*.lots.*.no_expiry' => 'sometimes|boolean',
'items.*.lots.*.quantity' => ['nullable', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
'items.*.stock_item_id' => ['nullable', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $this->user()?->tenant_id)],
'tenant_id' => 'prohibited', 'received_by' => 'prohibited', 'status' => 'prohibited', 'snapshot' => 'prohibited',
];
@@ -18,6 +18,8 @@ class StoreRequisitionRequest extends FormRequest
public function rules(): array
{
return [
'operation_key' => 'nullable|uuid',
'process_version' => 'nullable|integer|min:1',
'cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)],
'priority' => ['required', Rule::in(['normal', 'high', 'urgent'])],
'needed_by' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'],
+4
View File
@@ -6,6 +6,10 @@ use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use LogicException;
/**
* @property array<string,mixed>|null $before
* @property array<string,mixed> $after
*/
class AuditLog extends Model
{
use BelongsToTenant;
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models\Concerns;
use App\Models\User;
use App\Services\WorkContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
trait HasWorkContext
{
protected static function bootHasWorkContext(): void
{
static::addGlobalScope('work_context', function (Builder $query) {
if (! app()->bound('request') || ! request()->hasSession() || request()->is('candidaturas/*')) {
return;
}
$actor = request()->user('web');
if ($actor instanceof User && app(WorkContext::class)->enforced($actor)) {
$key = app(WorkContext::class)->active(request());
$query->where($query->getModel()->qualifyColumn('work_context_key'), $key ?? '__no_context__');
}
});
static::creating(function (Model $model) {
if (! app()->bound('request') || ! request()->hasSession()) {
return;
}
$actor = request()->user('web');
if ($actor instanceof User && app(WorkContext::class)->enforced($actor)) {
$key = app(WorkContext::class)->active(request());
$area = $model->getTable() === 'people_processes' ? 'people' : 'procurement';
abort_unless($key !== null && WorkContext::OPTIONS[$key]['operational'] && WorkContext::OPTIONS[$key]['area'] === $area, 403);
$model->setAttribute('work_context_key', $key);
}
});
static::updating(function (Model $model) {
if ($model->isDirty('work_context_key')) {
throw ValidationException::withMessages(['context' => 'O contexto de origem do processo não pode ser alterado.']);
}
});
}
}
+1
View File
@@ -6,6 +6,7 @@ use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array<string,mixed> $snapshot */
class GoodsReceiptItem extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $lot_id */
class InventoryLotAllocation extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected static function booted(): void
{
static::updating(fn () => throw new \LogicException('Preservar repartições de lotes.'));
static::deleting(fn () => throw new \LogicException('Preservar repartições de lotes.'));
}
}
+2
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use App\Models\Concerns\HasWorkContext;
use Illuminate\Database\Eloquent\Model;
/**
@@ -22,6 +23,7 @@ use Illuminate\Database\Eloquent\Model;
class PeopleProcess extends Model
{
use BelongsToTenant;
use HasWorkContext;
protected $guarded = ['id'];
+7 -8
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\PurchasingRole;
use App\Models\Concerns\BelongsToTenant;
use App\Models\Concerns\HasWorkContext;
use App\Services\ConflictGuard;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Tempo\Purchasing\Internal\InternalRequisitions;
/**
* @property int $id
@@ -31,6 +33,7 @@ use Illuminate\Support\Carbon;
class Requisition extends Model
{
use BelongsToTenant;
use HasWorkContext;
protected $guarded = ['id'];
@@ -81,18 +84,14 @@ class Requisition extends Model
public function canDecide(User $user): bool
{
if ($this->tenant_id !== $user->tenant_id || $this->status !== 'pending' || $this->requester_id === $user->id) {
if ($this->tenant_id !== $user->tenant_id) {
return false;
}
if ($this->steps->contains('approver_id', $user->id)) {
return false;
}
$next = InternalRequisitions::nextStep(['status' => $this->status, 'requester_id' => $this->requester_id, 'steps' => $this->steps->toArray()], $user->id);
$next = $this->steps->firstWhere('decision', 'pending');
return $next !== null && $user->hasFunctions([$next->role])
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
return $next !== null && $user->hasFunctions([$next['role']])
&& ($next['role'] !== 'department_head' || $this->department_id === $user->department_id)
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
}
}
+7
View File
@@ -4,8 +4,15 @@ namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
/**
* @property CarbonInterface $moved_at
* @property int|null $equipment_id
* @property int|null $spare_part_id
* @property array<string,mixed> $snapshot
*/
class StockMovement extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
+2
View File
@@ -4,12 +4,14 @@ namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
/**
* @property array<string, mixed> $snapshot
* @property int|null $equipment_id
* @property int|null $spare_part_id
* @property CarbonInterface $recorded_at
*/
class StockOperation extends Model
{
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property array<string,mixed> $data
* @property array<string,mixed> $snapshot
* @property int $version
* @property string $record_key
* @property \Illuminate\Support\Carbon $created_at
*/
class SupplyRecord extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
/** @return array<string,string> */
protected function casts(): array
{
return ['data' => 'array', 'snapshot' => 'array', 'created_at' => 'datetime'];
}
protected static function booted(): void
{
static::updating(fn () => throw new \LogicException('Criar uma nova versão.'));
static::deleting(fn () => throw new \LogicException('Preservar histórico.'));
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ use Illuminate\Database\Eloquent\Model;
* @property int $second_limit_minor
* @property bool $approval_includes_tax
* @property bool $rules_provisional
* @property string|null $default_tax_rate
* @property array<string,mixed>|null $comparison_policy
* @property array{revision:int,enabled:list<string>}|null $work_context_policy
* @property array{revision:int,configured:bool,procurement_brief:bool,people_brief:bool,ai_plan?:array<string,mixed>}|null $intelligence_policy
*/
class Tenant extends Model
@@ -20,6 +22,6 @@ class Tenant extends Model
protected function casts(): array
{
return ['approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean', 'default_tax_rate' => 'decimal:2', 'comparison_policy' => 'array', 'intelligence_policy' => 'array'];
return ['work_context_policy' => 'array', 'approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean', 'default_tax_rate' => 'decimal:2', 'comparison_policy' => 'array', 'intelligence_policy' => 'array'];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use App\Models\Concerns\HasWorkContext;
use Illuminate\Database\Eloquent\Model;
/** @property array<string,mixed> $snapshot */
class WarehouseRequest extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant, HasWorkContext;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array'];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property array<string,mixed> $data */
class WarehouseRequestEvent extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['data' => 'array'];
}
}
+1
View File
@@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
require_once base_path('packages/purchasing/bootstrap.php');
$package = config('common-budget.path');
require_once $package.'/bootstrap.php';
$payables = config('common-payables.path');
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Services;
use App\Models\Requisition;
use App\Models\User;
/** Read the existing approval queue; never assign authority or change the workflow. */
class ApprovalRouting
{
/** @return array<string,mixed> */
public function current(Requisition $requisition, User $viewer): array
{
abort_unless($viewer->tenant_id === $requisition->tenant_id, 404);
Requisition::query()->visibleTo($viewer)->findOrFail($requisition->id);
$step = $requisition->status === 'pending' ? $requisition->steps->firstWhere('decision', 'pending') : null;
if ($step === null) {
return ['status' => 'inactive', 'step_id' => null, 'role' => null, 'people' => [], 'checked_at' => now()->toIso8601String()];
}
$people = [];
foreach (User::query()->where('tenant_id', $requisition->tenant_id)->orderBy('name')->orderBy('id')->get() as $person) {
if (! app(OrganizationAccess::class)->maintenanceOnly($person) && $requisition->canDecide($person)) {
$people[] = $person->only(['id', 'name']);
}
}
return ['status' => $people === [] ? 'unavailable' : 'available', 'step_id' => $step->id, 'role' => $step->role,
'task_key' => 'requisition.approve.'.$step->role,
'people' => $people, 'checked_at' => now()->toIso8601String()];
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
namespace App\Services;
use App\Models\CostCenter;
use App\Models\Requisition;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\WarehouseRequest;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
class AutomaticPurchases
{
public function ready(): bool
{
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
}
public function requester(User $executor, SupplyRecord $rule): User
{
$executor = app(CurrentActor::class)->lock($executor);
$current = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
$this->check($current !== null && $current->id === $rule->id && $current->tenant_id === $executor->tenant_id && $current->state === 'approved', 'A parametrização automática deixou de estar activa.');
$this->check(! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
$user = User::query()->where('tenant_id', $executor->tenant_id)->lockForUpdate()->find($rule->data['requester_id']);
$this->check($user !== null && in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Requisitante sem competência vigente no contexto de compras internas.');
$this->check(CostCenter::query()->whereKey($rule->data['cost_center_id'])->where('department_id', $user->department_id)->exists(), 'Centro de custo incompatível com o departamento actual do requisitante.');
$this->check($user->only(['id', 'name', 'department_id']) === $rule->snapshot['requester'], 'O requisitante mudou; reveja a parametrização.');
$center = CostCenter::query()->findOrFail($rule->data['cost_center_id']);
$this->check($center->only(['id', 'code', 'name', 'department_id']) === $rule->snapshot['cost_center'], 'O centro de custo mudou; reveja a parametrização.');
return $user;
}
public function save(User $actor, array $input): SupplyRecord
{
abort_unless($this->ready(), 503, 'Instalação da reserva e reposição pendente.');
return DB::transaction(function () use ($actor, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:save,approve,suspend', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
abort_unless(app(SupplyPlanning::class)->allows($actor, $data['action'] === 'save' ? 'supply_prepare' : 'supply_review'), 403);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $data['operation_key'])->first();
if ($retry !== null) {
$this->check($retry->kind === 'auto_rule' && $retry->created_by === $actor->id && $retry->input_hash === $hash, 'Chave já utilizada com outro conteúdo.');
return $retry;
}
$old = empty($data['record_key']) ? null : app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $data['record_key'])->firstOrFail();
$this->check($data['version'] === ($old?->version ?? 0), 'A parametrização mudou. Actualize a página.');
$snapshot = $old?->snapshot ?? [];
$values = $old?->data ?? [];
if ($data['action'] === 'save') {
$values = Validator::make($data['data'], ['policy_key' => 'required|uuid', 'requester_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1',
'unit_price' => 'nullable|string|regex:/^[0-9]{1,8}(\.[0-9]{1,2})?$/D|numeric|min:0.01', 'tax_rate' => 'nullable|string|numeric|min:0|max:100|regex:/^[0-9]{1,3}(\.[0-9]{1,2})?$/D',
'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate();
$basis = app(SupplySuggestions::class)->basis($values['policy_key'], today()->toDateString());
$values['unit_price'] = $values['unit_price'] ?? $basis['unit_price'];
$values['tax_rate'] = $values['tax_rate'] ?? (string) $basis['tax_rate'];
$this->check($values['unit_price'] !== null, 'O acordo não fornece um preço em MZN. Registe uma estimativa válida.');
$this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.');
$this->check($basis['catalogue']['purchase_type'] === 'consumable', 'Seleccione um consumível publicado; equipamento/peças exigem o seu destino técnico.');
$user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($values['requester_id']);
$this->check(in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Seleccione um requisitante com competência vigente em compras internas.');
$center = CostCenter::query()->where('department_id', $user->department_id)->findOrFail($values['cost_center_id']);
$snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id,
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
$duplicates = app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])->whereIn('state', ['draft', 'approved']);
if ($old !== null) {
$duplicates->where('record_key', '!=', $old->record_key);
}
$this->check(! $duplicates->exists(), 'Já existe uma parametrização neste artigo/armazém. Reveja a existente.');
} else {
$this->check($old !== null && $old->state === ($data['action'] === 'approve' ? 'draft' : 'approved'), 'Estado incompatível com a intervenção.');
$this->check($data['action'] !== 'approve' || $snapshot['prepared_by'] !== $actor->id, 'Quem preparou não pode activar a própria parametrização.');
}
$row = $this->append($actor, 'auto_rule', $old, $data['action'] === 'save' ? 'draft' : ($data['action'] === 'approve' ? 'approved' : 'suspended'), $values, $snapshot, $data['reason'], $data['operation_key'], $hash);
if ($row->state === 'approved') {
$this->requester($actor, $row);
$this->basis($row);
$this->evaluate($actor, $row->stock_item_id, $row->warehouse_id);
}
return $row;
});
}
private function basis(SupplyRecord $rule): array
{
$this->check($rule->data['valid_until'] >= today()->toDateString(), 'A estimativa/parametrização automática expirou.');
$basis = app(SupplySuggestions::class)->basis($rule->data['policy_key'], today()->toDateString());
$this->check($basis['policy_version'] === $rule->snapshot['policy_version'] && $basis['item'] === $rule->snapshot['item'] && $basis['warehouse'] === $rule->snapshot['warehouse'] && $basis['catalogue'] === $rule->snapshot['catalogue'], 'A política, o catálogo ou o armazém mudou. Reveja a parametrização automática.');
return $basis;
}
/** Called only by successful stock/request mutations; reads never prepare documents. */
public function signal(User $actor, int $item, int $warehouse): void
{
if (! $this->ready()) {
return;
}
DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse));
}
public function evaluate(User $actor, int $item, int $warehouse): void
{
if (! $this->ready()) {
return;
}
DB::transaction(function () use ($actor, $item, $warehouse) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$planning = app(SupplyPlanning::class);
$policy = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
$rule = $planning->latest('auto_rule')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
$old = $planning->latest('auto_need')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
$origins = [];
$unreserved = 0;
foreach (WarehouseRequest::query()->where('warehouse_id', $warehouse)->get() as $request) {
$p = app(WarehouseFulfilment::class)->progress($request);
if (! in_array($p['state'], ['submitted', 'authorized', 'partial'], true)) {
continue;
}
foreach ($request->snapshot['lines'] as $index => $line) {
if ($line['item']['id'] === $item && $p['remaining'][$index] > 0) {
$unreserved += max(0, $p['remaining'][$index] - $p['reserved'][$index]);
$origins[] = ['warehouse_request_id' => $request->id, 'version' => $p['version'], 'line_index' => $index, 'destination' => $line['destination'], 'equipment' => $line['equipment'], 'remaining_milli' => $p['remaining'][$index], 'reserved_milli' => $p['reserved'][$index]];
}
}
}
if ($policy === null && $unreserved === 0 && $old === null) {
return;
}
$purchases = $old?->data['purchases'] ?? [];
$snapshot = ['item' => ['id' => $item], 'warehouse' => ['id' => $warehouse], 'origins' => $origins, 'rule_id' => $rule?->id];
$state = 'pending';
$message = 'Parametrize requisitante, centro de custo e reposição por artigo/armazém.';
$values = ['purchases' => $purchases, 'message' => $message];
try {
$free = app(WarehouseReservations::class)->free($item, $warehouse);
$minimum = $policy?->data['minimum_stock_milli'] ?? 0;
$required = $unreserved > max(0, $free) || $free <= $minimum;
$snapshot += ['free_milli' => $free, 'unreserved_milli' => $unreserved, 'minimum_milli' => $minimum];
if (! $required) {
$state = 'satisfied';
$values['message'] = 'Disponibilidade acima do mínimo e sem falta no atendimento.';
} else {
$this->check($rule !== null, $message);
$requester = $this->requester($actor, $rule);
$basis = $this->basis($rule);
$coverage = 0;
$blocked = false;
foreach ($purchases as $purchase) {
$r = Requisition::query()->find($purchase['id']);
$this->check($r !== null, 'A requisição de reposição anterior requer reconciliação.');
if ($r->status === 'rejected') {
if ($purchase['rule_id'] === $rule->id) {
$blocked = true;
}
continue;
}
$this->check(in_array($r->status, ['draft', 'pending', 'approved'], true), 'Rever o estado da reposição anterior antes de gerar nova compra.');
$r->load('items');
$line = $r->items->sole();
$this->check($line->catalogue_entry_id === $basis['catalogue']['id'] && ($line->catalogue_snapshot['version'] ?? null) === $basis['catalogue']['version'], 'O rascunho automático anterior mudou de artigo; reconcilie a necessidade.');
$received = 0;
foreach (DB::table('goods_receipt_items as gi')->join('goods_receipts as g', 'g.id', '=', 'gi.goods_receipt_id')->join('purchase_order_items as oi', 'oi.id', '=', 'gi.purchase_order_item_id')->where('gi.tenant_id', $actor->tenant_id)->where('oi.requisition_item_id', $line->id)->where('g.warehouse_id', $warehouse)->where('gi.stock_item_id', $item)->get(['gi.id', 'gi.accepted_milli']) as $receipt) {
$voided = collect(app(InventoryReversals::class)->corrections('receipt'))->contains(fn ($correction) => ($correction->snapshot['source']['receipt']['id'] ?? null) === $receipt->id);
if (! $voided) {
$received += (int) $receipt->accepted_milli;
}
}
$coverage += max(0, ReceiptWorkflow::milli($line->quantity) - $received);
}
$this->check(! $blocked, 'A reposição foi rejeitada. Rever e activar nova versão da parametrização antes de tentar novamente.');
// A confirmed outside delivery also covers the need; never double-count an automatic order.
$automaticIds = array_column($purchases, 'id');
foreach ($planning->latest('delivery')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'active')->get() as $delivery) {
$order = DB::table('purchase_orders')->where('id', $delivery->purchase_order_id)->first();
if ($order !== null && ! in_array($order->requisition_id, $automaticIds, true)) {
$coverage += app(SupplyCoordination::class)->remainingDelivery($delivery);
}
}
$demand = max(0, $basis['policy']['target_milli'] - $free + $unreserved - $coverage);
$snapshot += ['coverage_milli' => $coverage, 'policy' => $basis['policy'], 'catalogue' => $basis['catalogue'], 'requester' => $requester->only(['id', 'name', 'department_id']), 'cost_center' => $rule->snapshot['cost_center']];
if ($demand === 0) {
$state = 'covered';
$values['message'] = 'Reposição efectiva já em curso; não foi duplicada.';
} else {
$pack = $basis['policy']['pack_milli'];
$quantity = (int) (ceil(max($demand, $basis['policy']['minimum_milli']) / $pack) * $pack);
$this->check($quantity <= 1000000000 && $quantity + $free - $unreserved + $coverage <= $basis['policy']['maximum_milli'], 'A embalagem ou quantidade mínima excede o máximo aprovado. Rever a política.');
$decimal = intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT);
$r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(),
'justification' => 'Reposição automática do armazém '.$basis['warehouse']['code'].', artigo '.$basis['item']['code'].'. Origem e utilização preservadas na necessidade de reposição.',
'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]]);
$values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->id];
$state = 'created';
$values['message'] = 'Rascunho criado; o requisitante deve conferir e submeter às aprovações habituais.';
}
}
} catch (ValidationException $error) {
$values['message'] = implode(' ', array_merge(...array_values($error->errors())));
} catch (ModelNotFoundException $error) {
$values['message'] = 'Uma origem configurada deixou de estar disponível. Reveja a parametrização.';
}
if ($old !== null && $old->state === $state && $old->data === $values && $old->snapshot === $snapshot) {
return;
}
$this->append($actor, 'auto_need', $old, $state, $values, $snapshot, 'Avaliação automática após alteração da disponibilidade ou parametrização.');
});
}
private function append(User $actor, string $kind, ?SupplyRecord $old, string $state, array $data, array $snapshot, string $reason, ?string $key = null, ?string $hash = null): SupplyRecord
{
$row = 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' => $snapshot['item']['id'], 'warehouse_id' => $snapshot['warehouse']['id'], 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason,
'operation_key' => $key ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode([$state, $data, $snapshot], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor,$kind.'.'.$state,null,null,['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]);
return $row;
}
private function check(bool $valid,string $message): void
{
if (! $valid) {
throw ValidationException::withMessages(['replenishment' => $message]);
}
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Services;
use App\Models\Requisition;
use App\Models\SupplyRecord;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Tempo\Purchasing\Internal\RequisitionPort;
final class InternalRequisitionPort implements RequisitionPort
{
/** @param \Closure(array<string,mixed>,?Requisition):Requisition $save */
public function __construct(private User $actor, private \Closure $save, private ?SupplyRecord $automaticRule = null) {}
private function model(int $id): Requisition
{
return Requisition::query()->whereKey($id)->firstOrFail();
}
public function actorId(): int
{
return $this->actor->id;
}
public function atomic(callable $work): mixed
{
return DB::transaction(function () use ($work) {
app(ConflictGuard::class)->lockCompany($this->actor);
$this->actor = app(CurrentActor::class)->lock($this->actor);
abort_unless(app(OrganizationAccess::class)->permissions($this->actor) !== [], 403);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($this->actor), 403);
if (request()->hasSession() && app(WorkContext::class)->enforced($this->actor)) {
abort_unless(app(WorkContext::class)->active(request()) === 'procurement.internal', 403);
}
return $work();
});
}
public function authorize(string $action, ?array $record): void
{
if ($this->automaticRule !== null) {
abort_unless($action === 'save' && $record === null, 403);
app(AutomaticPurchases::class)->requester($this->actor, $this->automaticRule);
return;
}
if ($record !== null) {
$r = $this->model($record['id']);
if ($action === 'view') {
Requisition::query()->visibleTo($this->actor)->findOrFail($record['id']);
}
abort_unless(($r->work_context_key ?? 'procurement.internal') === 'procurement.internal', 403);
if ($action === 'decide' || str_starts_with($action, 'step:')) {
abort_if(app(ConflictGuard::class)->participationBlocked($this->actor, $r), 403);
} elseif ($action !== 'view') {
app(ConflictGuard::class)->assertParticipation($this->actor, $r);
}
}
if (in_array($action, ['save', 'submit'], true)) {
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor), 403);
if ($record !== null) {
abort_unless($record['requester_id'] === $this->actor->id, 403);
}
} elseif ($action === 'decide') {
abort_unless($this->actor->hasFunctions(['department_head', 'procurement', 'finance']), 403);
} elseif (str_starts_with($action, 'step:')) {
$role = substr($action, 5);
abort_unless($this->actor->hasFunctions([$role]) && ($role !== 'department_head' || $this->actor->department_id === $record['department_id']), 403);
}
}
public function load(int $id): array
{
$r = Requisition::query()->lockForUpdate()->findOrFail($id);
return $this->snapshot($r);
}
/** @return array<string,mixed> */
private function snapshot(Requisition $r): array
{
$r->load(['steps' => fn ($q) => $q->orderBy('position'), 'items']);
return $r->toArray() + ['version' => (int) ($r->getAttribute('process_version') ?? 1), 'has_equipment' => $r->items->contains('purchase_type', 'equipment')];
}
public function draft(array $data, ?int $id): array
{
return $this->snapshot(($this->save)($data, $id === null ? null : $this->model($id)));
}
public function policy(array $record): array
{
$r = $this->model($record['id']);
if ($this->actor->department_id !== $r->department_id) {
throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']);
}
foreach ($r->items()->get() as $item) {
app(EquipmentWorkflow::class)->line($item->toArray(), $r->department_id, $r->priority);
}
app(EquipmentWorkflow::class)->assertTechnical($r);
if ($r->needed_by < today()) {
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
}
return Tenant::query()->findOrFail($this->actor->tenant_id)->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
}
public function reserve(array $record): ?array
{
return app(BudgetWorkflow::class)->record($this->actor, $this->model($record['id']), 'reserve', $record['total_minor'], $record['subtotal_minor']);
}
public function submitted(array $record, array $policy, array $roles): array
{
$r = $this->model($record['id']);
foreach ($roles as $position => $role) {
$r->steps()->create(['tenant_id' => $this->actor->tenant_id, 'position' => $position + 1, 'role' => $role]);
}
$values = ['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $policy];
if (Schema::hasColumn('requisitions', 'process_version')) {
$values['process_version'] = $record['version'] + 1;
}
$r->update($values);
app(RequisitionWorkflow::class)->audit($this->actor, 'requisition.submitted', $r, ['status' => 'draft'], ['status' => 'pending', 'rules' => $policy, 'roles' => $roles]);
app(WorkflowNotices::class)->capture($r, $this->actor);
return $this->snapshot($r);
}
public function decided(array $record, array $step, string $decision, ?string $comment, string $status): array
{
$r = $this->model($record['id']);
if ($decision === 'approved') {
app(EquipmentWorkflow::class)->assertTechnical($r);
}
$r->steps()->whereKey($step['id'])->firstOrFail()->update(['decision' => $decision, 'approver_id' => $this->actor->id, 'comment' => $comment, 'decided_at' => now()]);
$values = ['status' => $status];
if (Schema::hasColumn('requisitions', 'process_version')) {
$values['process_version'] = $record['version'] + 1;
}
$r->update($values);
app(RequisitionWorkflow::class)->audit($this->actor, 'approval.'.$decision, $r, ['status' => 'pending'], ['status' => $status, 'step_id' => $step['id'], 'role' => $step['role'], 'comment' => $comment]);
app(WorkflowNotices::class)->capture($r, $this->actor);
app(WorkflowNotices::class)->outcome($r, $step['id']);
return $this->snapshot($r);
}
public function release(array $record): void
{
app(BudgetWorkflow::class)->record($this->actor, $this->model($record['id']), 'release');
}
public function replay(string $key, string $hash): ?array
{
abort_unless(Schema::hasTable('requisition_commands'), 503, 'Instale a migração de recibos antes de usar chaves de operação.');
$old = DB::table('requisition_commands')->where(['tenant_id' => $this->actor->tenant_id, 'actor_id' => $this->actor->id, 'operation_key' => $key])->first();
if ($old === null) {
return null;
}
abort_unless(hash_equals($old->request_hash, $hash), 409, 'Chave usada com outro conteúdo.');
return json_decode($old->result, true, 64, JSON_THROW_ON_ERROR);
}
public function remember(string $key, string $hash, array $result): void
{
DB::table('requisition_commands')->insert(['tenant_id' => $this->actor->tenant_id, 'actor_id' => $this->actor->id, 'operation_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\StockOperation;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
/** Physical observations reuse lot identities and never themselves alter stock. */
class InventoryCounts
{
/** @return array{hash:string,lots:list<array<string,mixed>>,setup_id:int}|null */
public function source(int $item, int $warehouse): ?array
{
$context = app(InventoryLots::class)->context($item, $warehouse);
if ($context === null) {
return null;
}
$this->check($context['reconciled'], 'lot_counts', 'Confira as origens: a repartição não corresponde ao saldo físico.');
$lots = array_map(fn ($lot) => ['lot_id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'quantity_milli' => $lot['quantity_milli'],
'code' => $lot['data']['code'], 'expires_on' => $lot['data']['expires_on'], 'no_expiry' => $lot['data']['no_expiry']], $context['lots']);
$basis = ['setup_id' => $context['setup_id'], 'lots' => $lots];
return [...$basis, 'hash' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))];
}
/** @param array<string,mixed> $data
* @return array<string,mixed>|null
*/
public function prepare(int $item, int $warehouse, int $total, array $data): ?array
{
$source = $this->source($item, $warehouse);
if ($source === null) {
$this->check(empty($data['lot_counts']), 'lot_counts', 'Confira e active o controlo antes de contar por lote.');
return null;
}
$this->check(($data['lot_version'] ?? '') === $source['hash'], 'lot_version', 'Os lotes ou a identificação mudaram. Actualize e volte a conferir a contagem.');
$input = Validator::make($data, ['lot_counts' => 'present|array|max:500', 'lot_counts.*.lot_id' => 'nullable|integer|min:1',
'lot_counts.*.new_key' => 'nullable|uuid', 'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean'])->validate();
$rows = [];
$seen = [];
$known = collect($source['lots'])->keyBy('lot_id');
foreach ($input['lot_counts'] as $entry) {
$quantity = ReceiptWorkflow::milli($entry['quantity']);
if (! empty($entry['lot_id'])) {
$id = (int) $entry['lot_id'];
$this->check($known->has($id) && ! isset($seen['lot-'.$id]) && empty($entry['new_key']), 'lot_counts', 'Seleccione uma única vez cada lote deste artigo/local.');
$seen['lot-'.$id] = true;
$old = $known->get($id);
$rows[] = ['lot_id' => $id, 'metadata_id' => $old['metadata_id'], 'code' => $old['code'], 'expires_on' => $old['expires_on'], 'no_expiry' => $old['no_expiry'],
'previous_milli' => $old['quantity_milli'], 'counted_milli' => $quantity, 'delta_milli' => $quantity - $old['quantity_milli']];
} else {
$key = $entry['new_key'] ?? '';
$this->check($key !== '' && ! isset($seen['new-'.$key]) && $quantity > 0, 'lot_counts', 'Identifique cada novo lote encontrado com quantidade positiva, sem duplicar a linha.');
$this->check(empty($entry['expires_on']) || ! ($entry['no_expiry'] ?? false), 'lot_counts', 'Escolha prazo de validade ou confirmação de artigo sem prazo.');
$seen['new-'.$key] = true;
$rows[] = ['lot_id' => null, 'new_key' => $key, 'metadata_id' => null, 'code' => empty(trim($entry['code'] ?? '')) ? null : trim($entry['code']),
'expires_on' => $entry['expires_on'] ?? null, 'no_expiry' => (bool) ($entry['no_expiry'] ?? false), 'previous_milli' => 0, 'counted_milli' => $quantity, 'delta_milli' => $quantity];
}
}
foreach ($source['lots'] as $lot) {
$this->check($lot['quantity_milli'] === 0 || isset($seen['lot-'.$lot['lot_id']]), 'lot_counts', 'Indique a contagem de todos os lotes com saldo, incluindo zero quando não encontrou quantidade.');
}
$this->check(array_sum(array_column($rows, 'counted_milli')) === $total, 'quantity', 'O total deve corresponder exactamente às quantidades contadas por lote.');
return ['source_hash' => $source['hash'], 'setup_id' => $source['setup_id'], 'rows' => $rows,
'increase_milli' => array_sum(array_map(fn ($r) => max(0, $r['delta_milli']), $rows)), 'decrease_milli' => array_sum(array_map(fn ($r) => max(0, -$r['delta_milli']), $rows))];
}
/** @return array<string,mixed> */
public function adjustment(User $actor, StockOperation $count): array
{
abort_unless(app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 403);
$this->check(! in_array($actor->id, [$count->created_by, $count->responsible_id], true), 'count_id', 'A conferência do ajuste exige pessoa diferente de quem registou e assumiu a contagem.');
$plan = $count->snapshot['lot_count'] ?? null;
$this->check($plan !== null, 'count_id', 'Esta contagem não preservou repartição por lote. Faça nova contagem.');
$source = $this->source($count->stock_item_id, $count->warehouse_id);
$this->check($source !== null && $plan['source_hash'] === $source['hash'], 'count_id', 'A repartição ou identificação mudou desde a contagem. Faça nova conferência física.');
$this->check($plan['increase_milli'] + $plan['decrease_milli'] > 0, 'count_id', 'Contagem sem divergências de quantidade ou repartição.');
foreach ($plan['rows'] as &$row) {
if ($row['lot_id'] === null) {
$lot = app(InventoryLots::class)->found($actor, $count, $row);
$row['lot_id'] = $lot->id;
$row['metadata_id'] = $lot->id;
}
}
unset($row);
return $plan;
}
private function check(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}
+204
View File
@@ -0,0 +1,204 @@
<?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 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' && $actor->id !== $snapshot['reviewed_by'], '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(! in_array($actor->id, $snapshot['contributors'], true), '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]);
}
}
}
+453
View File
@@ -0,0 +1,453 @@
<?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 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(! in_array($actor->id, $old->snapshot['contributors'], true), '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.');
$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.');
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]);
}
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Equipment;
use App\Models\GoodsReceipt;
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 Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Full corrective reversals; original documents and financial facts remain immutable. */
class InventoryReversals
{
public function allows(User $actor, string $action): bool
{
return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($action) {
'read' => ['inventory_reversal_read', 'admin', PurchasingRole::Warehouse], 'execute' => [PurchasingRole::Warehouse], default => ['inventory_reversal_'.$action],
});
}
/** @return array<int,SupplyRecord> */
public function corrections(string $sourceKind): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
$key = $sourceKind === 'operation' ? 'source_operation_id' : 'receipt_item_id';
$result = [];
foreach (app(SupplyPlanning::class)->latest('stock_reversal')->where('state', 'executed')->whereJsonContains('data->source_kind', $sourceKind)->get() as $record) {
$result[$record->data[$key]] = $record;
}
return $result;
}
public function operationReversed(int $id): bool
{
return isset($this->corrections('operation')[$id]);
}
public function receiptReversed(int $id): bool
{
return isset($this->corrections('receipt')[$id]);
}
/** @return list<array<string,mixed>> */
public function financialNotices(): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
return array_values(app(SupplyPlanning::class)->latest('stock_reversal')->where('state', 'executed')->latest('id')->limit(100)->get()
->filter(fn ($r) => $r->data['source_kind'] === 'receipt' || in_array($r->snapshot['source']['document']['operation_kind'] ?? '', ['supplier_return', 'write_off', 'adjustment'], true))
->map(fn ($r) => ['reference' => $r->data['reference'], 'source_reference' => $r->snapshot['source']['document']['reference'], 'item_name' => $r->snapshot['source']['item']['name'], 'event_on' => $r->data['event']['event_on']])->all());
}
public function voidedReceiptQuantity(int $line, string $column): int
{
if (! in_array($column, ['received_milli', 'accepted_milli'], true)) {
throw new \InvalidArgumentException('Quantidade de recepção desconhecida.');
}
$sum = 0;
foreach ($this->corrections('receipt') as $record) {
$source = $record->snapshot['source']['receipt'];
if ($source['purchase_order_item_id'] === $line) {
$sum += $source[$column];
}
}
return $sum;
}
/** @param array<string,mixed> $data
* @return array{source:array<string,mixed>,observed:list<array<string,mixed>>}
*/
public function basis(array $data): array
{
$receiptSource = null;
if ($data['source_kind'] === 'operation') {
$operation = StockOperation::query()->whereKey($data['source_operation_id'])->firstOrFail();
$this->check(in_array($operation->kind, ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'], true), 'data.source_operation_id', 'Seleccione uma operação física; contagens e outros estornos não são estornados por este percurso.');
$this->check(! $this->operationReversed($operation->id), 'action', 'Esta operação já tem estorno integral.');
$movements = StockMovement::query()->where('stock_operation_id', $operation->id)->orderBy('id')->get();
$itemId = $operation->stock_item_id;
$sourceDocument = ['kind' => 'operation', 'id' => $operation->id, 'reference' => $operation->reference, 'operation_kind' => $operation->kind];
} else {
$line = GoodsReceiptItem::query()->whereKey($data['receipt_item_id'])->firstOrFail();
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$this->check($line->accepted_milli > 0 && $line->stock_item_id !== null, 'data.receipt_item_id', 'Este percurso estorna uma linha física aceite com movimento de stock.');
$this->check(! $this->receiptReversed($line->id), 'action', 'Esta linha de recepção já foi integralmente estornada.');
$this->check(app(InventoryDispositions::class)->returnedForReceipt($line->id) === 0, 'action', 'Existem devoluções efectivas desta origem. Reconcilie-as antes de estornar a recepção inteira.');
$movements = StockMovement::query()->where('goods_receipt_item_id', $line->id)->orderBy('id')->get();
$itemId = $line->stock_item_id;
$sourceDocument = ['kind' => 'receipt', 'id' => $line->id, 'reference' => $receipt->number];
$receiptSource = ['item_id' => $line->id, 'receipt_id' => $receipt->id, 'purchase_order_item_id' => $line->purchase_order_item_id,
'purchase_order_id' => $receipt->purchase_order_id, 'received_milli' => $line->received_milli, 'accepted_milli' => $line->accepted_milli];
}
$this->check($movements->isNotEmpty(), 'action', 'A origem não tem movimentos físicos comprovados.');
$item = StockItem::query()->whereKey($itemId)->firstOrFail();
$rows = [];
$observed = [];
foreach ($movements as $movement) {
$originalUnit = $movement->snapshot['stock_item']['unit'] ?? null;
$this->check($movement->stock_item_id === $item->id && is_string($originalUnit) && Str::lower(trim($originalUnit)) === Str::lower(trim($item->unit)), 'action', 'A unidade ou artigo mudou ou falta na origem. Reconcilie sem conversões presumidas.');
$inverse = BigDecimal::of($movement->quantity_milli)->negated()->toInt();
$this->check($inverse !== 0, 'action', 'A origem contém um movimento físico nulo; confira a integridade do registo.');
$warehouse = Warehouse::query()->whereKey($movement->warehouse_id)->firstOrFail();
$balance = app(StockWorkflow::class)->balance($item->id, $warehouse->id);
$this->check($inverse > 0 || $balance['quantity'] >= -$inverse, 'action', 'A quantidade a retirar no estorno já não existe neste local. Não há saldo negativo nem compensação com outro local.');
$setup = app(InventoryLots::class)->setup($item->id, $warehouse->id);
$parts = [];
$allocations = Schema::hasTable('inventory_lot_allocations') ? InventoryLotAllocation::query()->where('stock_movement_id', $movement->id)->orderBy('lot_id')->get() : collect();
$this->check($setup !== null || $allocations->isEmpty(), 'action', 'Preserve o controlo activo dos lotes da origem.');
if ($setup !== null) {
$context = app(InventoryLots::class)->context($item->id, $warehouse->id);
$this->check($context !== null && $context['reconciled'], 'action', 'Confira a repartição por lotes antes do estorno.');
if ($allocations->isEmpty()) {
$this->check($inverse > 0, 'action', 'A entrada original não tinha repartição por lote. Não presuma que o saldo inicial corresponde a essa recepção; reconcilie a origem.');
$parts[] = ['lot_id' => null, 'metadata_id' => null, 'quantity' => $inverse, 'code' => null];
} else {
$total = 0;
foreach ($allocations as $allocation) {
$quantity = BigDecimal::of($allocation->quantity_milli)->negated()->toInt();
$lot = collect($context['lots'])->firstWhere('id', $allocation->lot_id);
$this->check($lot !== null && ($quantity > 0 || $lot['quantity_milli'] >= -$quantity), 'action', 'A quantidade do lote original já não existe neste local. Não substitua por outro lote.');
$parts[] = ['lot_id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'quantity' => $quantity, 'code' => $lot['data']['code']];
$total += $quantity;
}
$this->check($total === $inverse, 'action', 'A repartição original não corresponde ao movimento integral.');
}
}
$rows[] = ['source_movement_id' => $movement->id, 'warehouse_id' => $warehouse->id, 'warehouse_name' => $warehouse->name,
'equipment_id' => $movement->equipment_id, 'spare_part_id' => $movement->spare_part_id,
'equipment' => $movement->snapshot['equipment'] ?? ($movement->equipment_id === null ? null : Equipment::query()->whereKey($movement->equipment_id)->firstOrFail()->only(['id', 'code', 'description'])),
'original_quantity_milli' => -$inverse, 'quantity_milli' => $inverse, 'original_type' => $movement->movement_type, 'setup_id' => $setup?->id, 'lots' => $parts];
$observed[] = ['warehouse_id' => $warehouse->id, 'balance' => $balance];
}
usort($rows, fn ($a, $b) => [$a['quantity_milli'] > 0, $a['source_movement_id']] <=> [$b['quantity_milli'] > 0, $b['source_movement_id']]);
return ['source' => ['document' => $sourceDocument, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'rows' => $rows, 'receipt' => $receiptSource], '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();
abort_unless($this->allows($actor, match ($v['action']) {
'save' => 'prepare', 'execute' => 'execute', default => 'review'
}), 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á usada com outros dados.');
return $retry;
}
$old = empty($v['record_key']) ? null : app(SupplyPlanning::class)->latest('stock_reversal')->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', 'Devolva para actualização antes de alterar a decisão.');
$data = Validator::make($v['data'], ['source_kind' => 'required|in:operation,receipt', 'source_operation_id' => 'required_if:source_kind,operation|nullable|integer|min:1',
'receipt_item_id' => 'required_if:source_kind,receipt|nullable|integer|min:1', 'reference' => 'required|string|max:100'])->validate();
$data['source_operation_id'] = $data['source_kind'] === 'operation' ? (int) $data['source_operation_id'] : null;
$data['receipt_item_id'] = $data['source_kind'] === 'receipt' ? (int) $data['receipt_item_id'] : null;
$data['reference'] = Str::upper(Str::squish($data['reference']));
$this->check($data['reference'] !== '' && ! StockOperation::query()->where('reference', $data['reference'])->exists(), 'data.reference', 'Indique referência única ainda não usada no stock.');
foreach (['stock_event', 'stock_reversal'] as $kind) {
$clash = app(SupplyPlanning::class)->latest($kind)->whereJsonContains('data->reference', $data['reference']);
if ($old !== null) {
$clash->where('record_key', '!=', $old->record_key);
}
$this->check(! $clash->exists(), 'data.reference', 'Referência já usada noutro processo de stock.');
}
$snapshot = $this->basis($data);
$snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]));
$state = 'draft';
} else {
$this->check($old !== null, 'action', 'Prepare primeiro o estorno.');
$data = $old->data;
$snapshot = $old->snapshot;
if ($v['action'] === 'execute') {
$this->check($old->state === 'approved' && $actor->id !== $snapshot['reviewed_by'], 'action', 'Exige aprovação actual e execução por pessoa 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', 'O estorno não pode anteceder a aprovação.');
$basis = $this->basis($data);
$this->check($basis['source'] === $snapshot['source'], 'action', 'A origem, identificação ou configuração mudou. Devolva para nova preparação.');
$result = app(StockWorkflow::class)->reverse($actor, $old);
$data['stock_operation_id'] = $result['operation']->id;
$data['event'] = $event;
$snapshot['execution_lots'] = $result['lots'];
$snapshot['execution_observed'] = $basis['observed'];
$state = 'executed';
} else {
$this->check(! in_array($actor->id, $snapshot['contributors'], true), '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.');
$state = $v['action'] === 'approve' ? 'approved' : 'returned';
if ($state === 'approved') {
$basis = $this->basis($data);
$this->check($basis['source'] === $snapshot['source'], 'action', 'A origem mudou; actualize a preparação.');
$snapshot['reviewed_by'] = $actor->id;
$snapshot['review_observed'] = $basis['observed'];
}
}
}
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'stock_reversal', 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1,
'state' => $state, 'stock_item_id' => $snapshot['source']['item']['id'], 'warehouse_id' => $snapshot['source']['rows'][0]['warehouse_id'], 'created_by' => $actor->id,
'data' => $data, '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.reversal.'.$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]);
}
}
}
+2
View File
@@ -44,6 +44,8 @@ class InvoiceWorkflow
$quantity = ReceiptWorkflow::milli((string) $input['quantity']);
$previous = InvoiceItem::query()->where('purchase_order_item_id', $source->id)->whereIn('invoice_id', Invoice::query()->where('status', 'validated')->select('id'))->sum('quantity_milli');
$accepted = GoodsReceiptItem::query()->where('purchase_order_item_id', $source->id)->sum('accepted_milli');
$accepted = (int) $accepted - app(InventoryDispositions::class)->returnedForOrderLine($source->id);
$accepted -= app(InventoryReversals::class)->voidedReceiptQuantity($source->id, 'accepted_milli');
if ($quantity <= 0 || (int) $previous + $quantity > (int) $accepted || (int) $previous + $quantity > ReceiptWorkflow::milli($source->quantity)) {
$this->invalid('Quantidade superior à recepção aceite ainda não facturada. Serviços também exigem aceitação.');
}
+48 -2
View File
@@ -10,6 +10,38 @@ use Illuminate\Support\Facades\Schema;
class OrganizationAccess
{
public const PERMISSIONS = [
'warehouse_request_review' => 'Autorizar ou rejeitar requisições internas ao armazém do departamento, de forma independente',
'warehouse_request_deliver' => 'Entregar material de requisições internas autorizadas (exige função de armazém)',
'warehouse_request_prepare' => 'Preparar requisições próprias ao armazém com destino de utilização',
'warehouse_request_read' => 'Consultar requisições ao armazém da empresa',
'warehouse_availability_read' => 'Consultar disponibilidade e necessidades planeadas para requisições ao armazém',
'inventory_lot_read' => 'Consultar lotes, validade e quarentena',
'inventory_lot_prepare' => 'Preparar controlo por lotes e correcções de identificação',
'inventory_lot_review' => 'Conferir activação e parâmetros do controlo por lotes',
'inventory_lot_hold' => 'Colocar lotes em quarentena',
'inventory_lot_release' => 'Conferir identificação e libertar lotes de forma independente',
'inventory_adjust_review' => 'Conferir e aplicar ajustes físicos por lote de forma independente',
'inventory_disposition_read' => 'Consultar devoluções a fornecedores e abates físicos',
'inventory_disposition_prepare' => 'Preparar devoluções a fornecedores e abates físicos',
'inventory_disposition_review' => 'Conferir devoluções e abates de forma independente',
'inventory_reversal_read' => 'Consultar estornos integrais de stock e respectivas origens',
'inventory_reversal_prepare' => 'Preparar estornos de stock ligados ao original',
'inventory_reversal_review' => 'Conferir estornos de stock de forma independente',
'supply_read' => 'Consultar fornecimentos e calendário de reposição',
'supply_prepare' => 'Preparar acordos habituais e políticas de reposição',
'supply_review' => 'Conferir e aprovar acordos e políticas de reposição',
'supply_suggest_prepare' => 'Preparar e actualizar sugestões de reposição',
'supply_suggest_review' => 'Conferir e decidir sugestões de reposição',
'supply_suggest_convert' => 'Converter sugestões aprovadas em requisições próprias',
'supply_history_prepare' => 'Preparar análise e cobertura do histórico de consumo',
'supply_history_review' => 'Conferir o histórico de consumo de forma independente',
'supply_reserve' => 'Reservar necessidades e conferir satisfação por saídas',
'supply_delivery' => 'Registar confirmações e replaneamento de entregas',
'supply_technical_prepare' => 'Preparar equivalências técnicas de artigos',
'supply_technical_review' => 'Validar equivalências técnicas de forma independente',
'import_read' => 'Consultar processos de importação',
'import_prepare' => 'Preparar e acompanhar logística de importações',
'import_customs' => 'Conferir documentação e libertação aduaneira',
'requester' => 'Solicitar e acompanhar compras próprias',
'department_head' => 'Supervisão e aprovação do departamento',
'procurement' => 'Preparação e gestão operacional de compras',
@@ -30,7 +62,7 @@ class OrganizationAccess
}
/** @return list<string> */
public function permissions(User $user): array
public function permissions(User $user, ?string $context = null): array
{
if ($user->tenant_id === null) {
return [];
@@ -38,11 +70,25 @@ class OrganizationAccess
if (! $this->active($user->tenant_id)) {
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
}
$contextKey = $context;
if ($contextKey === null && app()->bound('request') && request()->hasSession() && WorkContext::area(request()->path()) !== null && app(WorkContext::class)->enforced($user)) {
$contextKey = app(WorkContext::class)->active(request());
if ($contextKey === null) {
return [];
}
}
$columns = ['p.permissions'];
if (Schema::hasColumn('organization_profiles', 'work_contexts')) {
$columns[] = 'p.work_contexts';
}
$rows = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.permissions']);
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get($columns);
$permissions = [];
foreach ($rows as $row) {
if ($contextKey !== null && ! in_array($contextKey, json_decode($row->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR), true)) {
continue;
}
$permissions = [...$permissions, ...json_decode($row->permissions, true, 32, JSON_THROW_ON_ERROR)];
}
+16 -1
View File
@@ -56,13 +56,27 @@ class OrganizationConfiguration
}
$before = $profile ? (array) $profile : null;
$values = ['name' => $data['name'], 'permissions' => json_encode(array_values(array_unique($data['permissions'])), JSON_THROW_ON_ERROR), 'updated_at' => now()];
if (array_key_exists('work_contexts', $data)) {
abort_unless(Schema::hasColumn('organization_profiles', 'work_contexts'), 503, 'Instale a migração de contexto antes de associar perfis.');
$values['work_contexts'] = json_encode($data['work_contexts'], JSON_THROW_ON_ERROR);
}
if ($profile) {
DB::table('organization_profiles')->where('id', $profile->id)->update([...$values, 'version' => $profile->version + 1]);
$id = $profile->id;
} else {
$id = DB::table('organization_profiles')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'created_at' => now()]);
}
$after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions']];
$after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions'], 'work_contexts' => $data['work_contexts'] ?? null];
} elseif ($command === 'work_contexts') {
abort_unless(Schema::hasColumn('tenants', 'work_context_policy'), 503, 'Instalação do contexto de trabalho pendente.');
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['work_context_policy']);
if (array_diff($data['enabled'], app(WorkContext::class)->purchased($actor)) !== []) {
throw ValidationException::withMessages(['enabled' => 'Escolha apenas opções contratadas para esta empresa.']);
}
$policy = ['revision' => ($company->work_context_policy['revision'] ?? 0) + 1, 'enabled' => $data['enabled']];
$company->update(['work_context_policy' => $policy]);
$after = ['work_context_policy' => $policy, 'reason' => $data['reason'], 'effect' => 'access_immediate_existing_processes_unchanged'];
} elseif ($command === 'user') {
$department = DB::table('departments')->where('tenant_id', $actor->tenant_id)->where('id', $data['department_id'])->exists();
abort_unless($department, 422, 'Departamento não autorizado.');
@@ -161,6 +175,7 @@ class OrganizationConfiguration
public function initialise(User $first, User $second): void
{
Tenant::query()->whereKey($first->tenant_id)->update(['work_context_policy' => json_encode(['revision' => 1, 'enabled' => []], JSON_THROW_ON_ERROR)]);
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $first->tenant_id, 'name' => 'Administração da empresa', 'permissions' => json_encode(['admin'], JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
foreach ([$first, $second] as $user) {
$this->assign($first, $user->id, [$profile], false, null, 'Configuração inicial e entrega do produto.');
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Services;
use App\Models\PeopleProcess;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class PeopleApplicationReport
{
/** @return array<string,int|string|null> */
public function counts(PeopleProcess $process, User $actor): array
{
$frozen = in_array($process->status, ['selected', 'contract_draft'], true);
$rows = $frozen ? ($process->selection['rows'] ?? []) : app(PeopleHiring::class)->matrix($process);
$complete = count(array_filter($rows, fn ($row) => $row['score_minor'] !== null));
$started = count(array_filter($rows, fn ($row) => $row['evaluation_count'] > 0));
$triage = app(CandidateTriage::class);
$portal = $triage->canRead($actor, $process) && Schema::hasTable('candidate_triage');
$received = $portal ? DB::table('candidate_submissions')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->count() : null;
$decisions = $portal ? DB::table('candidate_triage')->where('tenant_id', $process->tenant_id)->where('people_process_id', $process->id)->get(['outcome']) : collect();
return ['registered' => count($rows), 'evaluation_complete' => $complete, 'evaluation_pending' => count($rows) - $complete,
'evaluation_unstarted' => count($rows) - $started, 'evaluation_in_progress' => $started - $complete,
'basis' => $frozen ? 'selection_snapshot' : 'current_eligibility',
'portal_received' => $received, 'portal_accepted' => $portal ? $decisions->where('outcome', 'accepted_for_analysis')->count() : null,
'portal_rejected' => $portal ? $decisions->where('outcome', 'not_accepted')->count() : null,
'portal_pending' => $portal ? $triage->pending($process) : null];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services;
use App\Models\PeopleProcess;
/** Future RH boundary: no transport, dispatch, employee creation or legal validation. */
class PeopleContractHandoff
{
/** @return array<string,mixed> */
public function envelope(PeopleProcess $process): array
{
abort_if($process->isDirty(), 409, 'A ligação a RH exige uma versão persistida do contrato formalizado.');
// Selection approval and today's contract draft must never cross this boundary.
abort_unless($process->exists && $process->getAttribute('work_context_key') === 'people.internal'
&& $process->status === 'contract_formalized'
&& ($process->contract['status'] ?? null) === 'formalized'
&& ! empty($process->contract['formalized_at'])
&& ! empty($process->contract['reference'])
&& $process->selected_application_id !== null, 409, 'A ligação a RH exige contrato interno formalizado.');
return ['schema' => 'sgc.people.contract_formalized.v1',
'idempotency_key' => 'sgc:'.$process->tenant_id.':people:'.$process->id.':contract:'.$process->version,
'tenant_id' => $process->tenant_id, 'process_id' => $process->id, 'process_version' => $process->version,
'application_id' => $process->selected_application_id, 'kind' => $process->kind,
'work_context_key' => $process->getAttribute('work_context_key'),
'contract' => $process->contract];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Models\PeopleProcess;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
class PeopleDeadlineReport
{
/** @param Builder<PeopleProcess> $query
* @return array<string,mixed>
*/
public function build(Builder $query, User $actor, string $window = 'all'): array
{
$desk = app(PeopleWorkdesk::class)->build($query, $actor);
$today = CarbonImmutable::parse($desk['updated_at'])->setTimezone(config('app.timezone'))->startOfDay();
$items = [];
foreach (['tasks' => 'Tarefa', 'approvals' => 'Conferência/decisão', 'alerts' => 'Alerta do processo'] as $group => $label) {
foreach ($desk[$group] as $item) {
$due = $item['due_on'];
$days = $due === null ? null : max(0, (int) CarbonImmutable::parse($due, config('app.timezone'))->startOfDay()->diffInDays($today));
$items[] = ['id' => $group.':'.$item['id'], 'process_id' => $item['process_id'], 'reference' => $item['reference'], 'title' => $item['title'],
'category' => $label, 'label' => $item['label'], 'responsibility' => $group === 'alerts' ? 'Alerta do processo; sem atribuição pessoal' : 'Minha intervenção autorizada',
'due_on' => $due, 'days_overdue' => $days, 'href' => $item['href']];
}
}
$items = array_values(array_filter($items, fn ($item) => match ($window) {
'overdue' => ($item['days_overdue'] ?? 0) > 0,
'today' => $item['due_on'] === $today->toDateString(),
'future' => $item['due_on'] !== null && $item['due_on'] > $today->toDateString(),
'undated' => $item['due_on'] === null,
default => true,
}));
usort($items, fn ($a, $b) => (($b['days_overdue'] ?? -1) <=> ($a['days_overdue'] ?? -1)) ?: strcmp($a['due_on'] ?? '9999', $b['due_on'] ?? '9999') ?: strcmp($a['id'], $b['id']));
return ['as_of' => $today->toDateString(), 'window' => $window, 'processes_considered' => $desk['counts']['processes'],
'matching_processes' => count(array_unique(array_column($items, 'process_id'))), 'total_items' => count($items),
'tasks' => count(array_filter($items, fn ($item) => $item['category'] === 'Tarefa')),
'approvals' => count(array_filter($items, fn ($item) => $item['category'] === 'Conferência/decisão')),
'alerts' => count(array_filter($items, fn ($item) => $item['category'] === 'Alerta do processo')), 'items' => $items,
'without_deadline' => count(array_filter($items, fn ($item) => $item['due_on'] === null)),
'overdue_items' => count(array_filter($items, fn ($item) => ($item['days_overdue'] ?? 0) > 0))];
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use App\Models\PeopleProcess;
use App\Models\User;
class PeopleSelectionReport
{
/** @return array<string,string|null> */
public function facts(PeopleProcess $process, User $actor): array
{
$access = app(PeopleAccess::class);
$contract = $access->can($actor, $process, 'contract') || $access->can($actor, $process, 'contract_view');
$selection = $process->selection ?? [];
$state = match ($process->status) {
'selection_review' => 'Proposta por conferir',
'selected', 'contract_draft' => 'Selecção aprovada',
default => ($selection['outcome'] ?? null) === 'returned' ? 'Proposta devolvida' : 'Sem proposta de selecção',
};
$pending = match ($process->status) {
'draft' => 'Conferir abertura', 'open' => 'Concluir recepção e triagem de candidaturas',
'assessment' => ($selection['outcome'] ?? null) === 'returned' ? 'Rever avaliação e proposta devolvida' : 'Concluir avaliação e preparar proposta',
'selection_review' => 'Conferir proposta por pessoa independente',
'selected' => 'Preparar minuta contratual',
'contract_draft' => 'Minuta preparada; formalização não registada neste percurso',
default => 'Consultar processo',
};
return ['selection_state' => $state, 'proposed_at' => $selection['created_at'] ?? null,
'decided_at' => $selection['decision_at'] ?? null, 'pending' => $pending,
'contract_visibility' => $contract ? 'Consulta autorizada' : 'Sem acesso aos dados da minuta',
'contract_reference' => $contract ? ($process->contract['reference'] ?? null) : 'Sem acesso',
'contract_prepared_at' => $contract ? ($process->contract['prepared_at'] ?? null) : 'Sem acesso',
'contract_starts_on' => $contract ? ($process->contract['starts_on'] ?? null) : 'Sem acesso'];
}
}
+5 -27
View File
@@ -8,10 +8,10 @@ use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\BigRational;
use Brick\Math\Exception\MathException;
use Brick\Math\RoundingMode;
use Illuminate\Validation\ValidationException;
use Tempo\Purchasing\Economic\ProposalScoring;
class ProposalComparison
{
@@ -54,35 +54,13 @@ class ProposalComparison
];
}
$eligible = array_values(array_filter($rows, fn ($row) => $row['eligible']));
$minimum = null;
$days = null;
foreach ($eligible as $row) {
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
}
$best = null;
$topIds = [];
$ranking = (new ProposalScoring)->rank($rows, $policy['minimum_evaluators']);
$topIds = $ranking['top_ids'];
foreach ($rows as &$row) {
if ($row['eligible'] && $row['evaluation_count'] >= $policy['minimum_evaluators']) {
$weights = $row['weights'];
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
foreach (['quality', 'history', 'payment'] as $criterion) {
$sum = BigRational::zero();
foreach ($row['qualified_evaluations'] as $evaluation) {
$sum = $sum->plus($evaluation[$criterion]);
}
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
}
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
if (isset($ranking['scores'][$row['id']])) {
$minor = $ranking['scores'][$row['id']];
$row['score_minor'] = $minor;
$row['score'] = $minor / 100;
if ($best === null || $minor > $best) {
$best = $minor;
$topIds = [$row['id']];
} elseif ($minor === $best) {
$topIds[] = $row['id'];
}
}
}
unset($row);
+5 -18
View File
@@ -2,9 +2,8 @@
namespace App\Services;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class QuotationPricing
{
@@ -13,22 +12,10 @@ class QuotationPricing
*/
public function unit(string $price, string $type = 'none', string $value = '0'): array
{
$gross = BigDecimal::of($price)->multipliedBy(100)->toInt();
$discount = BigDecimal::of($value);
if ($gross < 1 || $discount->isNegative() || ! in_array($type, ['none', 'percent', 'amount'], true) ||
($type === 'none' && ! $discount->isZero()) || ($type === 'percent' && $discount->isGreaterThan(100))) {
throw ValidationException::withMessages(['items' => 'Indique um desconto unitário válido: percentagem ou valor em MZN, antes do IVA.']);
try {
return (new \Tempo\Purchasing\Economic\QuotationPricing)->unit($price, $type, $value);
} catch (InvalidArgumentException $error) {
throw ValidationException::withMessages(['items' => $error->getMessage()]);
}
$reduction = match ($type) {
'percent' => BigDecimal::of($gross)->multipliedBy($discount)->dividedBy(100, 4),
'amount' => $discount->multipliedBy(100),
default => BigDecimal::zero(),
};
$net = BigDecimal::of($gross)->minus($reduction)->toScale(0, RoundingMode::HalfUp)->toInt();
if ($net < 1) {
throw ValidationException::withMessages(['items' => 'O preço unitário após desconto deve ser pelo menos 0,01 MZN.']);
}
return ['gross_unit_minor' => $gross, 'discount_type' => $type, 'discount_value' => (string) $discount->toScale(2), 'net_unit_minor' => $net];
}
}
+32 -6
View File
@@ -14,6 +14,7 @@ use App\Models\Warehouse;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -26,14 +27,17 @@ class ReceiptWorkflow
return BigDecimal::of($quantity)->multipliedBy(1000)->toScale(0)->toInt();
}
private function recordedQuantity(int $lineId, string $column): int
public function recordedQuantity(int $lineId, string $column): int
{
if (! in_array($column, ['received_milli', 'accepted_milli'], true)) {
throw new \InvalidArgumentException('Quantidade de recepção desconhecida.');
}
$sum = GoodsReceiptItem::query()->where('purchase_order_item_id', $lineId)->sum($column);
if (! is_int($sum) && ! is_string($sum)) {
throw new \LogicException('A soma das quantidades deve ser um inteiro exacto.');
}
return BigDecimal::of($sum)->toInt();
return BigDecimal::of($sum)->minus(app(InventoryReversals::class)->voidedReceiptQuantity($lineId, $column))->toInt();
}
/** @return array{lines: list<array<string, mixed>>, state: string, has_divergences: bool} */
@@ -48,13 +52,16 @@ class ReceiptWorkflow
$ordered = self::milli($item->quantity);
$received = $this->recordedQuantity($item->id, 'received_milli');
$accepted = $this->recordedQuantity($item->id, 'accepted_milli');
$returned = app(InventoryDispositions::class)->returnedForOrderLine($item->id);
$effective = $accepted - $returned;
$lines[] = [
...$item->only(['id', 'description', 'unit', 'purchase_type']),
...(isset($order->snapshot['catalogue_sources'][$item->requisition_item_id]['stock_item_id']) ? ['stock_item_id' => $order->snapshot['catalogue_sources'][$item->requisition_item_id]['stock_item_id']] : []),
'ordered_milli' => $ordered, 'received_milli' => $received, 'accepted_milli' => $accepted,
'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $accepted,
'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $effective,
...($returned > 0 ? ['supplier_returned_milli' => $returned, 'effective_accepted_milli' => $effective] : []),
];
$complete = $complete && $accepted === $ordered;
$complete = $complete && $effective === $ordered;
$delivered = $delivered && $received === $ordered;
$started = $started || $received > 0;
$divergences = $divergences || GoodsReceiptItem::query()->where('purchase_order_item_id', $item->id)->where('quality_status', '!=', 'compliant')->exists();
@@ -76,9 +83,18 @@ class ReceiptWorkflow
throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']);
}
$inputs = [];
/** @var list<array{purchase_order_item_id: int, received_quantity: string, accepted_quantity: string, quality_status: string, stock_item_id?: int|null, notes?: string|null}> $requested */
/** @var list<array{purchase_order_item_id: int, received_quantity: string, accepted_quantity: string, quality_status: string, stock_item_id?: int|null, notes?: string|null, lots?:list<array<string,mixed>>}> $requested */
$requested = $data['items'];
$supplyMappings = Schema::hasTable('supply_records')
? app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id', $order->id)->get() : collect();
foreach ($requested as $line) {
$mapping = $supplyMappings->first(fn ($record) => $record->data['purchase_order_item_id'] == $line['purchase_order_item_id']);
if ($mapping !== null) {
if ((! empty($line['stock_item_id']) && (int) $line['stock_item_id'] !== $mapping->stock_item_id) || (int) ($data['warehouse_id'] ?? 0) !== $mapping->warehouse_id) {
throw ValidationException::withMessages(['items' => 'Reutilize o artigo e armazém já associados à confirmação desta linha.']);
}
$line['stock_item_id'] = $mapping->stock_item_id;
}
if (! empty($order->snapshot['catalogue_sources']) && empty($line['stock_item_id'])) {
$orderLine = $order->items()->whereKey($line['purchase_order_item_id'])->first();
if ($orderLine !== null && isset($order->snapshot['catalogue_sources'][$orderLine->requisition_item_id]['stock_item_id'])) {
@@ -90,6 +106,7 @@ class ReceiptWorkflow
}
}
$inputs[] = [
...(! empty($line['lots']) ? ['lots' => $line['lots']] : []),
'purchase_order_item_id' => $line['purchase_order_item_id'],
'received_milli' => self::milli($line['received_quantity']), 'accepted_milli' => self::milli($line['accepted_quantity']),
'quality_status' => $line['quality_status'], 'stock_item_id' => $line['stock_item_id'] ?? null,
@@ -117,6 +134,12 @@ class ReceiptWorkflow
if (GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('delivery_note', $deliveryNote)->exists()) {
throw ValidationException::withMessages(['delivery_note' => 'Esta guia já foi confirmada nesta ordem. Consulte o histórico em vez de repetir a recepção.']);
}
if (Schema::hasTable('supply_records')) {
$import = app(SupplyPlanning::class)->latest('import')->where('purchase_order_id', $order->id)->first();
if ($import !== null && $import->state !== 'released') {
throw ValidationException::withMessages(['items' => 'A importação ainda não tem libertação aduaneira comprovada. A chegada ou o pagamento não autorizam entrada utilizável.']);
}
}
if ($order->status !== 'issued' || $data['received_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) {
throw ValidationException::withMessages(['received_on' => 'A recepção deve referir uma ordem emitida e não pode anteceder a sua emissão.']);
}
@@ -193,12 +216,15 @@ class ReceiptWorkflow
foreach ($lines as $line) {
$item = $receipt->items()->create($line);
if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) {
StockMovement::query()->create([
$movement = StockMovement::query()->create([
'tenant_id' => $user->tenant_id, 'goods_receipt_item_id' => $item->id, 'stock_item_id' => $line['stock_item_id'],
'warehouse_id' => $warehouse?->id, 'moved_by' => $user->id, 'quantity_milli' => $line['accepted_milli'], 'moved_at' => now(),
'equipment_id' => $line['snapshot']['equipment_id'], 'spare_part_id' => $line['snapshot']['spare_part_id'],
'snapshot' => [...$line['snapshot'], 'receipt_id' => $receipt->id, 'order_number' => $order->number, 'delivery_note' => $deliveryNote],
]);
$sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']);
app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']);
app(AutomaticPurchases::class)->signal($user,$line['stock_item_id'],$warehouse->id);
}
}
$progress = $this->progress($order);
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Services;
/** Compatibility facade; the canonical implementation ships to both compositions. */
final class ReplenishmentProjection
{
/** @param array<string,mixed> $policy
* @param list<array{date:string,quantity:int,additional?:bool}> $reservations
* @param list<array{date:string,quantity:int,confirmed:bool}> $receipts
* @param list<array{date:string,quantity:int}> $fulfilledNeeds
* @param list<array{quantity:int,last_usable_on:?string}>|null $lotBuckets
* @return array<string,mixed>
*/
public function calculate(array $policy, int $physical, string $start, array $reservations = [], array $receipts = [], int $consumedToday = 0, array $fulfilledNeeds = [], ?array $lotBuckets = null): array
{
return (new \Tempo\Purchasing\Supply\ReplenishmentProjection)->calculate($policy, $physical, $start, $reservations, $receipts, $consumedToday, $fulfilledNeeds, $lotBuckets);
}
}
+62 -64
View File
@@ -5,22 +5,60 @@ namespace App\Services;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Requisition;
use App\Models\SupplyRecord;
use App\Models\Tenant;
use App\Models\User;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Tempo\Purchasing\Internal\InternalRequisitions;
use Tempo\Purchasing\Internal\RequisitionFailure;
class RequisitionWorkflow
{
/** @param array<string, mixed> $data */
private function process(User $user): InternalRequisitions
{
return new InternalRequisitions(new InternalRequisitionPort($user, fn (array $data, ?Requisition $existing) => $this->persistDraft($user, $data, $existing)));
}
/** @param array<string,mixed> $data */
public function save(User $user, array $data, ?Requisition $existing = null): Requisition
{
return DB::transaction(function () use ($user, $data, $existing) {
$key = $data['operation_key'] ?? null;
$version = isset($data['process_version']) ? (int) $data['process_version'] : null;
unset($data['operation_key'], $data['process_version']);
$result = $this->execute($user, 'save', $existing?->id, $version, $key, $data);
return Requisition::query()->whereKey($result['id'])->firstOrFail();
}
/**
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function execute(User $user, string $action, ?int $id, ?int $version, ?string $key, array $input = []): array
{
try {
return $this->process($user)->command($action, $id, $version, $key, $input);
} catch (RequisitionFailure $error) {
abort_if($error->status !== 422, $error->status, $error->getMessage());
throw ValidationException::withMessages(['requisition' => $error->getMessage()]);
}
}
/** @param array<string, mixed> $data */
private function persistDraft(User $user, array $data, ?Requisition $existing = null, ?SupplyRecord $automaticRule = null): Requisition
{
return DB::transaction(function () use ($user, $data, $existing, $automaticRule) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
$executor = $user;
if ($automaticRule !== null) {
abort_unless($existing === null, 403);
$user = app(AutomaticPurchases::class)->requester($executor, $automaticRule);
} else {
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
}
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
}
@@ -39,9 +77,10 @@ class RequisitionWorkflow
foreach ($lines as $line) {
$line = app(RequisitionCatalogue::class)->line($user, $line, $oldLines);
app(EquipmentWorkflow::class)->line($line, (int) $user->department_id, $data['priority']);
$price = BigDecimal::of($line['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
$lineSubtotal = BigDecimal::of($line['quantity'])->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
$lineTax = BigDecimal::of($lineSubtotal)->multipliedBy($line['tax_rate'])->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
$calculation = InternalRequisitions::line($line['quantity'], $line['unit_price'], $line['tax_rate']);
$price = $calculation['unit_price_minor'];
$lineSubtotal = $calculation['subtotal_minor'];
$lineTax = $calculation['tax_minor'];
$items[] = [
'tenant_id' => $user->tenant_id,
...(! empty($line['catalogue_entry_id']) ? ['catalogue_entry_id' => $line['catalogue_entry_id'], 'catalogue_snapshot' => $line['catalogue_snapshot']] : []),
@@ -63,6 +102,7 @@ class RequisitionWorkflow
'needed_by' => $data['needed_by'], 'justification' => $data['justification'],
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax,
...($existing ? [] : ['comparison_policy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($user->tenant_id))]),
...(Schema::hasColumn('requisitions', 'process_version') ? ['process_version' => $existing ? (int) $requisition->getAttribute('process_version') + 1 : 1] : []),
])->save();
if (! $existing) {
$requisition->update(['number' => sprintf('REQ-%s-%06d', now()->format('Y'), $requisition->id)]);
@@ -70,71 +110,29 @@ class RequisitionWorkflow
$requisition->items()->delete();
$requisition->items()->createMany($items);
$requisition->load('items');
$this->audit($user, $existing ? 'requisition.updated' : 'requisition.created', $requisition, $before, $requisition->toArray());
$this->audit($executor, $automaticRule !== null ? 'requisition.automatically_created' : ($existing ? 'requisition.updated' : 'requisition.created'), $requisition, $before, $requisition->toArray() + ($automaticRule === null ? [] : ['automatic_rule_id' => $automaticRule->id, 'designated_requester_id' => $user->id]));
return $requisition;
});
}
public function submit(User $user, Requisition $requisition): void
/** Only an independently activated rule may create a draft on behalf of its designated requester. */
public function automaticDraft(User $executor, SupplyRecord $rule, array $data): Requisition
{
DB::transaction(function () use ($user, $requisition) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
if ($user->department_id !== $requisition->department_id) {
throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']);
}
app(ConflictGuard::class)->assertParticipation($user, $requisition);
foreach ($requisition->items()->get() as $item) {
app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority);
}
app(EquipmentWorkflow::class)->assertTechnical($requisition);
if ($requisition->needed_by < today()) {
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
}
$tenant = Tenant::query()->findOrFail($user->tenant_id);
app(BudgetWorkflow::class)->record($user, $requisition, 'reserve', $requisition->total_minor, $requisition->subtotal_minor);
$amount = $tenant->approval_includes_tax ? $requisition->total_minor : $requisition->subtotal_minor;
$roles = ['department_head'];
if ($amount > $tenant->first_limit_minor) {
$roles[] = 'procurement';
}
if ($amount > $tenant->second_limit_minor || $requisition->items()->where('purchase_type', 'equipment')->exists()) {
$roles[] = 'finance';
}
foreach ($roles as $position => $role) {
$requisition->steps()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
}
$snapshot = $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
$requisition->update(['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $snapshot]);
$this->audit($user, 'requisition.submitted', $requisition, ['status' => 'draft'], ['status' => 'pending', 'rules' => $snapshot, 'roles' => $roles]);
});
$process = new InternalRequisitions(new InternalRequisitionPort($executor, fn (array $input, ?Requisition $existing) => $this->persistDraft($executor, $input, $existing, $rule), $rule));
$result = $process->command('save', null, null, null, $data);
return Requisition::query()->findOrFail($result['id']);
}
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment): void
public function submit(User $user, Requisition $requisition, ?string $operationKey = null, ?int $version = null): void
{
DB::transaction(function () use ($user, $requisition, $decision, $comment) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
$requisition->load('steps');
abort_unless($requisition->canDecide($user), 403);
if ($decision === 'approved') {
app(EquipmentWorkflow::class)->assertTechnical($requisition);
}
$step = $requisition->steps->firstWhere('decision', 'pending');
abort_if($step === null, 409);
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
$status = $decision === 'rejected' ? 'rejected' : ($requisition->steps()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
$requisition->update(['status' => $status]);
if ($decision === 'rejected') {
app(BudgetWorkflow::class)->record($user, $requisition, 'release');
}
$this->audit($user, 'approval.'.$decision, $requisition, ['status' => 'pending'], ['status' => $status, 'step_id' => $step->id, 'role' => $step->role, 'comment' => $comment]);
});
$this->execute($user, 'submit', $requisition->id, $version, $operationKey);
}
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment, ?string $operationKey = null, ?int $version = null): void
{
$this->execute($user, 'decide', $requisition->id, $version, $operationKey, ['decision' => $decision, 'comment' => $comment]);
}
/** @param array<string, mixed>|null $before
+182 -10
View File
@@ -8,10 +8,12 @@ use App\Models\SparePart;
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 Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class StockWorkflow
@@ -24,15 +26,20 @@ class StockWorkflow
return ['quantity' => BigDecimal::of((string) (clone $query)->sum('quantity_milli'))->toInt(), 'version' => (int) $query->max('id')];
}
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int,lot_counts?:list<array<string,mixed>>,lot_version?:string} $data */
public function record(User $actor, array $data): StockOperation
{
abort_unless($actor->tenant_id !== null && $actor->hasFunctions([PurchasingRole::Warehouse]) && auth()->id() === $actor->id, 403);
if (! in_array($data['kind'], ['issue', 'transfer', 'count', 'adjustment'], true)) {
throw ValidationException::withMessages(['kind' => 'Esta operação exige o seu próprio percurso de preparação e aprovação.']);
}
$roles = $data['kind'] === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse];
abort_unless($actor->tenant_id !== null && $actor->hasFunctions($roles) && auth()->id() === $actor->id, 403);
return DB::transaction(function () use ($actor, $data) {
// Same first lock as receipts: includes empty balances and concurrent receipts.
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
$actor = app(CurrentActor::class)->lock($actor);
abort_unless($actor->hasFunctions($data['kind'] === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first();
if ($existing) {
@@ -61,7 +68,12 @@ class StockWorkflow
throw ValidationException::withMessages(['destination_warehouse_id' => 'Escolha outro armazém da mesma empresa.']);
}
$balance = $this->balance($item->id, $warehouse->id);
$reservations = app(WarehouseReservations::class);
$reservationBefore = $reservations->committed($item->id, $warehouse->id) > 0 ? $reservations->free($item->id, $warehouse->id) : null;
$quantity = ReceiptWorkflow::milli($data['quantity']);
if (($data['kind'] === 'count' && $quantity < 0) || (in_array($data['kind'], ['issue', 'transfer'], true) && $quantity <= 0)) {
throw ValidationException::withMessages(['quantity' => 'Use uma quantidade física válida; correcções exigem contagem ou estorno ligado à origem.']);
}
$delta = -$quantity;
$count = null;
if ($data['kind'] === 'count') {
@@ -76,13 +88,36 @@ class StockWorkflow
}
$quantity = $count->quantity_milli;
$delta = $quantity - $balance['quantity'];
if ($delta === 0) {
if ($delta === 0 && ! isset($count->snapshot['lot_count'])) {
throw ValidationException::withMessages(['count_id' => 'Contagem sem divergência; não há ajuste a efectuar.']);
}
}
if ($balance['quantity'] + $delta < 0) {
throw ValidationException::withMessages(['quantity' => 'Stock insuficiente. A operação não pode produzir saldo negativo.']);
}
$lotService = app(InventoryLots::class);
$tracked = $lotService->setup($item->id, $warehouse->id) !== null;
$lotCount = $data['kind'] === 'count' ? app(InventoryCounts::class)->prepare($item->id, $warehouse->id, $quantity, $data) : null;
$adjustment = null;
if ($data['kind'] === 'adjustment') {
if ($tracked) {
$adjustment = app(InventoryCounts::class)->adjustment($actor, $count);
} else {
abort_unless($actor->hasFunctions([PurchasingRole::Warehouse]), 403);
}
}
$lotPlan = $data['kind'] === 'issue' ? $lotService->issuePlan($item->id, $warehouse->id, $quantity) : [];
if ($destination !== null) {
$lotPlan = $lotService->transferPlan($item->id, $warehouse->id, $destination->id, $quantity);
}
$lotSnapshot = $lotPlan === [] ? [] : ['lot_partition' => ['plan' => $lotService->partitionSnapshot($lotPlan, $warehouse->id),
'source_setup_id' => $lotService->setup($item->id, $warehouse->id)?->id, 'destination_setup_id' => $destination === null ? null : $lotService->setup($item->id, $destination->id)?->id]];
if ($lotCount !== null) {
$lotSnapshot['lot_count'] = $lotCount;
}
if ($adjustment !== null) {
$lotSnapshot['lot_adjustment'] = $adjustment;
}
$operation = StockOperation::query()->create([
'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $responsible->id,
'warehouse_id' => $warehouse->id, 'destination_warehouse_id' => $destination?->id, 'stock_item_id' => $item->id,
@@ -90,22 +125,159 @@ class StockWorkflow
'equipment_id' => $equipment?->id, 'spare_part_id' => $part?->id,
'payload_hash' => $hash, 'destination' => $data['destination'] ?? null, 'reason' => $data['reason'],
'quantity_milli' => $quantity, 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(),
'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $responsible->only(['id', 'name']), 'stock_item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'destination_warehouse' => $destination?->only(['id', 'code', 'name']), 'equipment' => $equipment?->toArray(), 'spare_part' => $part?->toArray(), 'difference_milli' => $quantity - $balance['quantity'], 'budget_checked' => false, 'development_only' => true],
'snapshot' => [...$lotSnapshot, 'actor' => $actor->only(['id', 'name']), 'responsible' => $responsible->only(['id', 'name']), 'stock_item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'destination_warehouse' => $destination?->only(['id', 'code', 'name']), 'equipment' => $equipment?->toArray(), 'spare_part' => $part?->toArray(), 'difference_milli' => $quantity - $balance['quantity'], 'budget_checked' => false, 'development_only' => true],
]);
if ($delta !== 0) {
$this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']);
if ($adjustment !== null) {
foreach ([['decrease_milli', -1, 'adjustment_out'], ['increase_milli', 1, 'adjustment_in']] as [$key, $sign, $type]) {
if ($adjustment[$key] > 0) {
$movement = $this->movement($actor, $operation, $warehouse->id, $sign * $adjustment[$key], $type);
$lotService->adjusted($actor, $count, $movement, $adjustment['rows'], $responsible->id);
}
}
if (! $lotService->context($item->id, $warehouse->id)['reconciled']) {
throw ValidationException::withMessages(['count_id' => 'O ajuste não reconciliou a repartição por lotes.']);
}
} elseif ($delta !== 0) {
$movement = $this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']);
foreach ($lotPlan as $allocation) {
$lotService->allocate($allocation['lot_id'], $warehouse->id, -$allocation['quantity'], $movement);
}
if ($destination) {
$this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in');
$arrival = $this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in');
if ($lotPlan !== []) {
$lotService->transferred($actor, $arrival, $lotPlan, $responsible->id);
}
}
}
app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray());
if ($reservationBefore !== null) {
$reservations->protect($item->id, $warehouse->id, $reservationBefore);
}
app(AutomaticPurchases::class)->signal($actor, $item->id, $warehouse->id);
if ($destination !== null) {
app(AutomaticPurchases::class)->signal($actor, $item->id, $destination->id);
}
return $operation;
});
}
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): void
/** Executes an approved physical disposition through the same signed ledger. */
public function disposition(User $actor, SupplyRecord $approved): StockOperation
{
StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]);
return DB::transaction(function () use ($actor, $approved) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
$current = app(SupplyPlanning::class)->latest('stock_event')->where('record_key', $approved->record_key)->firstOrFail();
if ($current->id !== $approved->id || $current->state !== 'approved' || $actor->id === $current->snapshot['reviewed_by']) {
throw ValidationException::withMessages(['action' => 'A saída exige a aprovação actual e operador independente da decisão.']);
}
$basis = app(InventoryDispositions::class)->basis($current->data);
if ($basis['source'] !== $current->snapshot['source']) {
throw ValidationException::withMessages(['action' => 'A origem mudou. Actualize a preparação.']);
}
$data = $current->data;
$quantity = $data['quantity_milli'];
if (! is_int($quantity) || $quantity < 1) {
throw new \LogicException('A saída física exige quantidade positiva em milésimos exactos.');
}
if (StockOperation::query()->where('reference', $data['reference'])->exists()) {
throw ValidationException::withMessages(['data.reference' => 'Já existe uma operação de stock com esta referência.']);
}
$balance = $this->balance($data['stock_item_id'], $data['warehouse_id']);
$reservations = app(WarehouseReservations::class);
$reservationBefore = $reservations->committed($data['stock_item_id'], $data['warehouse_id']) > 0 ? $reservations->free($data['stock_item_id'], $data['warehouse_id']) : null;
$snapshot = ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']),
'stock_item' => $basis['source']['item'], 'warehouse' => $basis['source']['warehouse'], 'destination_warehouse' => null,
'disposition' => ['approved_id' => $current->id, 'record_key' => $current->record_key, 'source' => $basis['source'], 'financial_correction_created' => false],
'budget_checked' => false];
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id,
'warehouse_id' => $data['warehouse_id'], 'stock_item_id' => $data['stock_item_id'], 'kind' => $data['kind'], 'reference' => $data['reference'],
'idempotency_key' => $current->record_key, 'payload_hash' => hash('sha256', json_encode([$current->id, $data, $basis['source']], JSON_THROW_ON_ERROR)),
'quantity_milli' => $data['quantity_milli'], 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(),
'destination' => Str::limit($basis['source']['receipt']['supplier_name'] ?? 'Abate físico autorizado', 200, ''), 'reason' => $current->reason, 'snapshot' => $snapshot]);
$movement = $this->movement($actor, $operation, $data['warehouse_id'], -$quantity, $data['kind']);
if ($basis['source']['lot'] !== null) {
app(InventoryLots::class)->allocate($data['lot_id'], $data['warehouse_id'], -$quantity, $movement);
if (! app(InventoryLots::class)->context($data['stock_item_id'], $data['warehouse_id'])['reconciled']) {
throw ValidationException::withMessages(['action' => 'A saída não reconciliou o lote.']);
}
}
app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray());
if ($reservationBefore !== null) {
$reservations->protect($data['stock_item_id'], $data['warehouse_id'], $reservationBefore);
}
app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']);
return $operation;
});
}
/** @return array{operation:StockOperation,lots:list<array{movement_id:int,lot_id:int,quantity:int}>} */
public function reverse(User $actor, SupplyRecord $approved): array
{
return DB::transaction(function () use ($actor, $approved) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
$current = app(SupplyPlanning::class)->latest('stock_reversal')->where('record_key', $approved->record_key)->firstOrFail();
if ($current->id !== $approved->id || $current->state !== 'approved' || $actor->id === $current->snapshot['reviewed_by']) {
throw ValidationException::withMessages(['action' => 'O estorno exige a aprovação actual e execução independente da decisão.']);
}
$basis = app(InventoryReversals::class)->basis($current->data);
if ($basis['source'] !== $current->snapshot['source'] || StockOperation::query()->where('reference', $current->data['reference'])->exists()) {
throw ValidationException::withMessages(['action' => 'A origem mudou ou a referência já foi utilizada. Actualize a preparação.']);
}
$source = $basis['source'];
$reservations = app(WarehouseReservations::class);
$reservationBefore = [];
foreach ($source['rows'] as $row) {
if ($reservations->committed($source['item']['id'], $row['warehouse_id']) > 0) {
$reservationBefore[$row['warehouse_id']] = $reservations->free($source['item']['id'], $row['warehouse_id']);
}
}
$warehouseId = $source['rows'][0]['warehouse_id'];
$balance = $this->balance($source['item']['id'], $warehouseId);
$gross = 0;
foreach ($source['rows'] as $row) {
$gross = BigDecimal::of($gross)->plus(BigDecimal::of($row['quantity_milli'])->abs())->toInt();
}
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'stock_item_id' => $source['item']['id'],
'equipment_id' => $source['rows'][0]['equipment_id'], 'spare_part_id' => $source['rows'][0]['spare_part_id'],
'warehouse_id' => $warehouseId, 'kind' => 'reversal', 'reference' => $current->data['reference'], 'idempotency_key' => $current->record_key,
'payload_hash' => hash('sha256', json_encode([$current->id, $source], JSON_THROW_ON_ERROR)), 'quantity_milli' => $gross,
'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'reason' => $current->reason, 'recorded_at' => now(),
'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'],
'warehouse' => Warehouse::query()->whereKey($warehouseId)->firstOrFail()->only(['id', 'code', 'name']), 'destination_warehouse' => null, 'equipment' => $source['rows'][0]['equipment'],
'reversal' => ['record_key' => $current->record_key, 'approved_id' => $current->id, 'source' => $source, 'quantity_basis' => 'gross_absolute_movement_quantities', 'financial_correction_created' => false]]]);
$links = [];
foreach ($source['rows'] as $row) {
$quantity = $row['quantity_milli'];
if (! is_int($quantity) || $quantity === 0) {
throw new \LogicException('O estorno exige quantidades inteiras exactas em milésimos.');
}
$movement = $this->movement($actor, $operation, $row['warehouse_id'], $quantity, $quantity > 0 ? 'reversal_in' : 'reversal_out');
$links = [...$links, ...app(InventoryLots::class)->reverseMovement($actor, $current, $movement, $row['lots'], $row['source_movement_id'])];
}
foreach (array_unique(array_column($source['rows'], 'warehouse_id')) as $warehouse) {
$context = app(InventoryLots::class)->context($source['item']['id'], $warehouse);
if ($context !== null && ! $context['reconciled']) {
throw ValidationException::withMessages(['action' => 'O estorno não reconciliou a repartição por lotes.']);
}
}
app(RequisitionWorkflow::class)->audit($actor, 'stock.reversal', null, null, $operation->toArray());
foreach ($reservationBefore as $location => $before) {
$reservations->protect($source['item']['id'], $location, $before);
}
foreach ($source['rows'] as $row) {
app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $row['warehouse_id']);
}
return ['operation' => $operation, 'lots' => $links];
});
}
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): StockMovement
{
return StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]);
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
namespace App\Services;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\PurchaseOrder;
use App\Models\PurchaseOrderItem;
use App\Models\Requisition;
use App\Models\RequisitionItem;
use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplyCoordination
{
/** @param array<string,mixed> $input */
public function save(User $actor, string $kind, array $input): SupplyRecord
{
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.');
return DB::transaction(function () use ($actor, $kind, $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,fulfil,cancel', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
$permission = match ($kind) {
'alternative' => $v['action'] === 'save' ? 'supply_technical_prepare' : 'supply_technical_review',
'reservation' => 'supply_reserve', 'delivery' => 'supply_delivery',
default => abort(404),
};
abort_unless(app(SupplyPlanning::class)->allows($actor, $permission), 403);
$hash = hash('sha256', json_encode([$kind, $v], JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first();
if ($retry) {
$this->require($retry->input_hash === $hash, 'operation_key', 'Chave já usada com outro conteúdo.');
return $retry;
}
$old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest($kind)->where('record_key', $v['record_key'])->firstOrFail() : null;
$this->require((int) $v['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.');
$data = $old->data ?? [];
$snapshot = $old->snapshot ?? [];
$state = $old->state ?? 'draft';
if ($v['action'] === 'save') {
$openCorrected = $kind === 'reservation' && $old !== null && $state === 'fulfilled' && $this->remainingReservation($old) > 0;
$this->require($kind === 'alternative' ? $old === null || in_array($state, ['draft', 'returned'], true) : $old === null || $state === 'active' || $openCorrected, 'action', 'O registo já está encerrado ou aprovado.');
$data = $this->prepare($actor, $kind, $v['data'], $old);
$stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail();
$warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail();
$snapshot = ['item' => $stock->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']),
'contributors' => array_values(array_unique([...($snapshot['contributors'] ?? []), $actor->id]))];
if ($kind === 'alternative') {
$substitute = StockItem::query()->whereKey($data['substitute_id'])->firstOrFail();
$this->require($substitute->id !== $stock->id, 'data.substitute_id', 'O artigo não pode substituir-se a si próprio.');
$snapshot['substitute'] = $substitute->only(['id', 'code', 'name', 'unit']);
}
if ($kind === 'delivery') {
$line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail();
$order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail();
$snapshot['order'] = $order->only(['id', 'number']);
$snapshot['order_line'] = $line->only(['id', 'quantity', 'unit', 'description']);
}
$state = $kind === 'alternative' ? 'draft' : 'active';
} elseif ($kind === 'alternative') {
$this->require($old !== null && in_array($v['action'], ['approve', 'return', 'suspend'], true), 'action', 'Acção técnica inválida.');
$this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A validação técnica exige independência dos preparadores.');
$this->require($v['action'] === 'suspend' ? $state === 'approved' : in_array($state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
if ($v['action'] === 'approve') {
$this->require($data['compatibility'] !== 'pending', 'action', 'Conclua a análise técnica antes de aprovar.');
$this->require($data['valid_until'] >= today()->toDateString(), 'action', 'A aprovação não pode nascer expirada.');
foreach (['item' => $data['stock_item_id'], 'substitute' => $data['substitute_id']] as $key => $id) {
$this->require(StockItem::query()->whereKey($id)->firstOrFail()->only(['id', 'code', 'name', 'unit']) === $snapshot[$key], 'action', 'As especificações de cadastro mudaram. Devolva e reveja a preparação.');
}
$duplicate = app(SupplyPlanning::class)->latest('alternative')->where('state', 'approved')->where('stock_item_id', $old->stock_item_id)->where('warehouse_id', $old->warehouse_id)
->where('record_key', '!=', $old->record_key)->get()->contains(fn ($r) => $r->data['substitute_id'] === $data['substitute_id'] && $r->data['application'] === $data['application'] && $r->data['valid_until'] >= today()->toDateString());
$this->require(! $duplicate, 'action', 'Já existe relação técnica aprovada neste sentido, aplicação e local.');
}
$state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$v['action']];
} elseif ($v['action'] === 'fulfil' && $kind === 'reservation') {
$this->require($old !== null && in_array($state, ['active', 'fulfilled'], true) && $this->remainingReservation($old) > 0, 'action', 'A reserva não tem quantidade pendente.');
$f = Validator::make($v['data'], ['stock_operation_id' => 'required|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000'])->validate();
$f['quantity_milli'] = (int) $f['quantity_milli'];
$operation = StockOperation::query()->whereKey($f['stock_operation_id'])->firstOrFail();
$this->require($operation->kind === 'issue' && $operation->stock_item_id === $old->stock_item_id && $operation->warehouse_id === $old->warehouse_id, 'data.stock_operation_id', 'Seleccione uma saída efectiva deste artigo/local.');
$this->require(! app(InventoryReversals::class)->operationReversed($operation->id), 'data.stock_operation_id', 'A saída foi estornada e não pode satisfazer uma reserva.');
$used = app(SupplyPlanning::class)->latest('reservation')->get()->sum(fn ($r) => array_sum(array_column(array_filter($r->data['applications'] ?? [], fn ($a) => $a['operation_id'] === $operation->id), 'quantity_milli')));
$remaining = $this->remainingReservation($old);
$this->require($f['quantity_milli'] <= $remaining && $used + $f['quantity_milli'] <= $operation->quantity_milli, 'data.quantity_milli', 'A afectação excede a reserva pendente ou a saída ainda disponível.');
$data['applications'][] = ['operation_id' => $operation->id, 'quantity_milli' => (int) $f['quantity_milli'], 'reference' => $operation->reference];
$state = $f['quantity_milli'] === $remaining ? 'fulfilled' : 'active';
} elseif ($v['action'] === 'cancel') {
$this->require($old !== null && ($state === 'active' || ($kind === 'reservation' && $state === 'fulfilled' && $this->remainingReservation($old) > 0)), 'action', 'O registo não está activo.');
$state = 'cancelled';
} else {
throw ValidationException::withMessages(['action' => 'Acção incompatível com a responsabilidade ou registo.']);
}
if ($kind === 'reservation') {
$corrections = app(InventoryReversals::class)->corrections('operation');
$correctedApplications = array_values(array_filter(array_column($data['applications'], 'operation_id'), fn ($id) => isset($corrections[$id])));
if ($correctedApplications !== []) {
$snapshot['reversed_operations'] = array_values(array_unique($correctedApplications));
$snapshot['effective_allocated_milli'] = array_sum(array_column($this->effectiveApplications($data['applications']), 'quantity_milli'));
}
}
$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'],
'purchase_order_id' => $snapshot['order']['id'] ?? null, 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot,
'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'reason' => $v['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'supply.'.$kind.'.'.$v['action'], null, $old?->toArray(), $record->toArray());
return $record;
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function prepare(User $actor, string $kind, array $input, ?SupplyRecord $old): array
{
$rules = ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1'];
$rules += match ($kind) {
'alternative' => ['substitute_id' => 'required|integer|min:1', 'application' => 'required|string|max:200', 'compatibility' => 'required|in:equivalent,conditional,incompatible,pending',
'conditions' => 'required_if:compatibility,conditional|nullable|string|max:4000', 'evidence' => 'required|string|min:10|max:4000', 'factor_numerator' => 'required|integer|min:1|max:1000000', 'factor_denominator' => 'required|integer|min:1|max:1000000', 'valid_until' => 'required|date_format:Y-m-d'],
'reservation' => ['requisition_item_id' => 'nullable|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000', 'needed_on' => 'required|date_format:Y-m-d', 'additional' => 'required|boolean', 'reference' => 'required|string|max:200'],
'delivery' => ['purchase_order_item_id' => 'required|integer|min:1', 'quantity_milli' => 'required|integer|min:1|max:1000000000', 'expected_on' => 'required|date_format:Y-m-d', 'confirmed_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:5|max:2000'],
default => abort(404),
};
$data = Validator::make($input, $rules)->validate();
foreach (['stock_item_id', 'warehouse_id', 'substitute_id', 'factor_numerator', 'factor_denominator', 'quantity_milli', 'requisition_item_id', 'purchase_order_item_id'] as $field) {
if (isset($data[$field]) && $data[$field] !== '') {
$data[$field] = (int) $data[$field];
}
}
if (isset($data['additional'])) {
$data['additional'] = (bool) $data['additional'];
}
if ($old !== null) {
$this->require((int) $data['stock_item_id'] === $old->stock_item_id && (int) $data['warehouse_id'] === $old->warehouse_id, 'data.stock_item_id', 'A revisão mantém o artigo e local originais.');
}
if ($kind === 'reservation') {
$this->require($old === null || ($old->data['requisition_item_id'] ?? null) == ($data['requisition_item_id'] ?? null), 'data.requisition_item_id', 'A revisão conserva a necessidade de origem.');
$data['applications'] = $old?->data['applications'] ?? [];
$allocated = array_sum(array_column($this->effectiveApplications($data['applications']), 'quantity_milli'));
$this->require($data['quantity_milli'] >= $allocated, 'data.quantity_milli', 'A reserva não pode ficar abaixo das saídas já afectadas.');
if (! empty($data['requisition_item_id'])) {
$line = RequisitionItem::query()->whereKey($data['requisition_item_id'])->firstOrFail();
$request = Requisition::query()->whereKey($line->requisition_id)->firstOrFail();
$this->require($request->status === 'approved' && ($line->catalogue_snapshot['stock_item_id'] ?? null) === (int) $data['stock_item_id'], 'data.requisition_item_id', 'Seleccione uma linha aprovada ligada a este artigo do catálogo.');
$data['reference'] = $request->number.' / '.$line->id;
$reserved = app(SupplyPlanning::class)->latest('reservation')->where('id', '!=', $old->id ?? 0)->get()->filter(fn ($r) => ($r->data['requisition_item_id'] ?? null) == $line->id)
->sum(fn ($r) => $r->state === 'cancelled' ? array_sum(array_column($this->effectiveApplications($r->data['applications']), 'quantity_milli')) : $r->data['quantity_milli']);
$this->require($reserved + $data['quantity_milli'] <= ReceiptWorkflow::milli($line->quantity), 'data.quantity_milli', 'As reservas desta linha excedem a necessidade aprovada.');
}
$duplicate = app(SupplyPlanning::class)->latest('reservation')->whereIn('state', ['active', 'fulfilled'])->where('id', '!=', $old->id ?? 0)->where('stock_item_id', $data['stock_item_id'])->where('warehouse_id', $data['warehouse_id'])->get()->contains(fn ($r) => $r->data['reference'] === $data['reference'] && ($r->state === 'active' || $this->remainingReservation($r) > 0));
$this->require(! $duplicate, 'data.reference', 'Esta necessidade já tem reserva activa. Consulte e reveja a reserva existente.');
}
if ($kind === 'delivery') {
$line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail();
$order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail();
$stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail();
$linked = $order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null;
$this->require($order->status === 'issued' && $line->unit === $stock->unit && ($linked === null || $linked === $stock->id), 'data.stock_item_id', 'A confirmação deve respeitar o artigo e unidade da ordem emitida.');
$this->require($old === null || $old->data['purchase_order_item_id'] === (int) $data['purchase_order_item_id'], 'data.purchase_order_item_id', 'A revisão mantém a linha da ordem original.');
$others = app(SupplyPlanning::class)->latest('delivery')->where('state', 'active')->where('id', '!=', $old->id ?? 0)->get()->filter(fn ($r) => $r->data['purchase_order_item_id'] == $line->id)->sum(fn ($r) => $r->data['quantity_milli']);
$this->require($others === 0, 'data.purchase_order_item_id', 'Esta linha já tem confirmação activa. Reveja-a para actualizar quantidade ou prazo.');
$this->require($others + $data['quantity_milli'] <= ReceiptWorkflow::milli($line->quantity), 'data.quantity_milli', 'As confirmações excedem a quantidade da ordem. Reveja a confirmação existente.');
$this->require($data['quantity_milli'] >= app(ReceiptWorkflow::class)->recordedQuantity($line->id, 'received_milli'), 'data.quantity_milli', 'A quantidade acumulada confirmada não pode ficar abaixo da já recebida.');
// A line has one canonical destination/article mapping, even across revisions.
$mapping = app(SupplyPlanning::class)->latest('delivery')->get()->first(fn ($r) => $r->data['purchase_order_item_id'] == $line->id);
$this->require($mapping === null || ($mapping->stock_item_id === $stock->id && $mapping->warehouse_id === (int) $data['warehouse_id']), 'data.warehouse_id', 'Esta linha já tem artigo/local associados. Reutilize a associação existente.');
$receivedStocks = GoodsReceiptItem::query()->where('purchase_order_item_id', $line->id)->whereNotNull('stock_item_id')->pluck('stock_item_id')->unique();
$this->require($receivedStocks->isEmpty() || ($receivedStocks->count() === 1 && (int) $receivedStocks->first() === $stock->id), 'data.stock_item_id', 'A confirmação deve reutilizar o artigo já recebido nesta linha.');
$differentWarehouse = GoodsReceipt::query()->whereIn('id', GoodsReceiptItem::query()->where('purchase_order_item_id', $line->id)->select('goods_receipt_id'))->where('warehouse_id', '!=', $data['warehouse_id'])->exists();
$this->require(! $differentWarehouse, 'data.warehouse_id', 'Reutilize o armazém em que a linha já foi recebida.');
}
return $data;
}
public function eligibleAlternative(SupplyRecord $record): bool
{
if ($record->kind !== 'alternative' || $record->state !== 'approved' || $record->data['compatibility'] !== 'equivalent' || $record->data['valid_until'] < today()->toDateString()) {
return false;
}
foreach (['item' => $record->stock_item_id, 'substitute' => $record->data['substitute_id']] as $key => $id) {
if (StockItem::query()->whereKey($id)->first()?->only(['id', 'code', 'name', 'unit']) !== $record->snapshot[$key]) {
return false;
}
}
return true;
}
public function remainingDelivery(SupplyRecord $record): int
{
$received = app(ReceiptWorkflow::class)->recordedQuantity($record->data['purchase_order_item_id'], 'received_milli');
return max(0, BigDecimal::of($record->data['quantity_milli'])->minus($received)->toInt());
}
/** @param list<array<string,mixed>> $applications
* @return list<array<string,mixed>>
*/
public function effectiveApplications(array $applications): array
{
$reversed = app(InventoryReversals::class)->corrections('operation');
return array_values(array_filter($applications, fn ($a) => ! isset($reversed[$a['operation_id']])));
}
public function remainingReservation(SupplyRecord $record): int
{
return BigDecimal::of($record->data['quantity_milli'])->minus(array_sum(array_column($this->effectiveApplications($record->data['applications']), 'quantity_milli')))->toInt();
}
/** @return array{reservations:list<array{date:string,quantity:int,additional:bool,record_key:string,version:int}>,receipts:list<array{date:string,quantity:int,confirmed:bool,record_key:string,version:int}>,warnings:list<string>,consumed_today:int,fulfilled_needs:list<array{date:string,quantity:int}>} */
public function events(int $stock, int $warehouse, string $today): array
{
$planning = app(SupplyPlanning::class);
$reservations = $planning->latest('reservation')->whereIn('state', ['active', 'fulfilled'])->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get()->filter(fn ($r) => $r->state === 'active' || $this->remainingReservation($r) > 0)->map(fn ($r) => [
'date' => max($today, $this->dateField($r, 'needed_on')), 'quantity' => $this->remainingReservation($r), 'additional' => $r->data['additional'] === true || $this->dateField($r, 'needed_on') < $today, 'record_key' => $r->record_key, 'version' => $r->version])->values()->all();
$receipts = [];
$warnings = [];
$deliveries = $planning->latest('delivery')->where('state', 'active')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get();
foreach ($deliveries as $r) {
$remaining = $this->remainingDelivery($r);
if ($remaining === 0) {
continue;
}
$import = $planning->latest('import')->where('purchase_order_id', $r->purchase_order_id)->first();
if ($r->data['expected_on'] < $today || ($import && $import->state !== 'released')) {
$warnings[] = 'Entrega '.$r->snapshot['order']['number'].' sem disponibilidade utilizável confirmada; rever atraso ou libertação.';
continue;
}
$receipts[] = ['date' => $this->dateField($r, 'expected_on'), 'quantity' => $remaining, 'confirmed' => true, 'record_key' => $r->record_key, 'version' => $r->version];
}
$corrections = app(InventoryReversals::class)->corrections('operation');
$issues = StockOperation::query()->where('kind', 'issue')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->whereDate('recorded_at', $today)->get()->reject(fn ($op) => isset($corrections[$op->id]));
$allReservations = $planning->latest('reservation')->where('stock_item_id', $stock)->where('warehouse_id', $warehouse)->get();
$operationIds = [];
foreach ($allReservations as $reservation) {
foreach ($this->effectiveApplications($reservation->data['applications']) as $application) {
$operationIds[] = $application['operation_id'];
}
}
$sources = StockOperation::query()->whereIn('id', $operationIds)->get()->keyBy('id');
$allocatedToday = 0;
$fulfilledNeeds = [];
foreach ($allReservations as $reservation) {
foreach ($this->effectiveApplications($reservation->data['applications']) as $application) {
$source = $sources->get($application['operation_id']);
if ($source === null) {
throw new \LogicException('Saída de origem da reserva não encontrada.');
}
$date = $source->recorded_at->toDateString();
$applied = BigDecimal::of($application['quantity_milli'])->toInt();
if ($date === $today) {
$allocatedToday += $applied;
}
$needed = max($date, $this->dateField($reservation, 'needed_on'));
if ($reservation->data['additional'] === false && $needed >= $today) {
$fulfilledNeeds[] = ['date' => $needed, 'quantity' => $applied];
}
}
}
return ['reservations' => array_values($reservations), 'receipts' => $receipts, 'warnings' => $warnings, 'consumed_today' => max(0, BigDecimal::of($issues->sum('quantity_milli'))->minus($allocatedToday)->toInt()), 'fulfilled_needs' => $fulfilledNeeds];
}
private function dateField(SupplyRecord $record, string $field): string
{
$value = $record->data[$field] ?? null;
if (! is_string($value)) {
throw new \LogicException('Data inválida no histórico de fornecimento.');
}
return $value;
}
private function require(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}
+208
View File
@@ -0,0 +1,208 @@
<?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]);
}
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
namespace App\Services;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
use App\Models\StockItem;
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
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;
class SupplyPlanning
{
public function allows(User $actor, string $permission): bool
{
$functions = in_array($permission, ['supply_read', 'import_read'], true) ? [$permission, 'admin'] : [$permission];
return $actor->hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
/** @return Builder<SupplyRecord> */
public function latest(string $kind)
{
return SupplyRecord::query()->where('kind', $kind)->whereIn('id', SupplyRecord::query()->selectRaw('MAX(id)')->where('kind', $kind)->groupBy('record_key'));
}
/** Live calendar: physical risk can remain known when a contractual lead time needs reconfirmation.
* @return array<string,mixed>
*/
public function calendar(SupplyRecord $policy, string $date): array
{
$stock = StockItem::query()->whereKey($policy->stock_item_id)->first();
if ($stock === null || $stock->only(['id', 'code', 'name', 'unit']) !== ($policy->snapshot['item'] ?? null)) {
return ['available' => false, 'reason' => 'O cadastro do artigo mudou ou está incompleto. Reveja a política por referência à versão actual antes de interpretar quantidades.'];
}
$balance = app(StockWorkflow::class)->balance($policy->stock_item_id, $policy->warehouse_id);
$events = app(SupplyCoordination::class)->events($policy->stock_item_id, $policy->warehouse_id, $date);
$forecast = app(SupplyHistory::class)->resolve($policy);
$projection = [...app(InventoryLots::class)->project($policy, $forecast['data'], $balance, $events, $date), 'events' => $events, 'forecast_history' => $forecast['history']];
if (! empty($policy->data['agreement_key'])) {
$agreement = $this->latest('agreement')->where('record_key', $policy->data['agreement_key'])->first();
$supplier = $agreement === null ? null : Supplier::query()->whereKey($agreement->supplier_id)->first();
$valid = $agreement !== null && $agreement->state === 'approved' && $agreement->version === ($policy->data['agreement_version'] ?? null)
&& $agreement->stock_item_id === $policy->stock_item_id && $agreement->warehouse_id === $policy->warehouse_id
&& $agreement->data['valid_from'] <= $date && $agreement->data['valid_until'] >= $date && $supplier !== null && $supplier->eligible()
&& $supplier->only(['id', 'name', 'version']) === $agreement->snapshot['supplier'];
if (! $valid) {
$projection = [...$projection, 'lead_time_available' => false, 'lead_time_issue' => 'O acordo, fornecedor ou validação documental deixou de sustentar o prazo aprovado. Reconfirme o fornecimento; as datas de pedir/encomendar ficam por determinar.',
'request_date' => null, 'order_date' => null, 'reorder_point_milli' => null];
}
}
return $projection;
}
/** @param array<string,mixed> $input */
public function save(User $actor, string $kind, array $input): SupplyRecord
{
if ($kind === 'history') {
return app(SupplyHistory::class)->save($actor, $input);
}
if ($kind === 'suggestion') {
return app(SupplySuggestions::class)->save($actor, $input);
}
if (in_array($kind, ['reservation', 'delivery', 'alternative'], true)) {
return app(SupplyCoordination::class)->save($actor, $kind, $input);
}
abort_unless(in_array($kind, ['policy', 'agreement', 'import'], true), 404);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.');
return DB::transaction(function () use ($actor, $kind, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$envelope = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0',
'action' => 'required|in:save,approve,return,suspend,transit,arrive,hold,release', 'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
$action = $envelope['action'];
$permission = $kind === 'import' ? (in_array($action, ['hold', 'release'], true) ? 'import_customs' : 'import_prepare')
: (in_array($action, ['approve', 'return', 'suspend'], true) ? 'supply_review' : 'supply_prepare');
abort_unless($this->allows($actor, $permission), 403);
$hash = hash('sha256', json_encode([$kind, $envelope], JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $envelope['operation_key'])->first();
if ($retry !== null) {
$this->check($retry->input_hash === $hash, 'operation_key', 'Chave já utilizada com outro conteúdo.');
return $retry;
}
$old = isset($envelope['record_key']) ? SupplyRecord::query()->where('kind', $kind)->where('record_key', $envelope['record_key'])->latest('version')->firstOrFail() : null;
$this->check((int) $envelope['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.');
$state = 'draft';
if ($action === 'save') {
$this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Crie um novo acordo ou política; preserve a versão já aprovada.');
$data = $this->validateData($kind, $envelope['data']);
if ($kind === 'policy' && ($data['forecast_method'] ?? 'manual') === 'historical') {
$history = app(SupplyHistory::class)->reference($data);
$this->check($history['available'], 'data.history_key', $history['reason'] ?? 'Histórico não disponível.');
$data['history_version'] = $history['version'];
}
if ($kind === 'policy' && ! empty($data['agreement_key'])) {
$agreement = $this->latest('agreement')->where('record_key', $data['agreement_key'])->firstOrFail();
$this->check($agreement->state === 'approved' && $agreement->stock_item_id === (int) $data['stock_item_id'] && $agreement->warehouse_id === (int) $data['warehouse_id'], 'data.agreement_key', 'Seleccione um acordo aprovado deste artigo/local.');
$this->check($agreement->data['lead_days'] !== null && $agreement->data['valid_from'] <= today()->toDateString() && $agreement->data['valid_until'] >= today()->toDateString(), 'data.agreement_key', 'O acordo deve estar vigente e ter prazo conhecido.');
$data['lead_days'] = $agreement->data['lead_days'];
$data['origin'] = $agreement->data['origin'];
$data['agreement_version'] = $agreement->version;
}
} else {
$this->check($old !== null, 'action', 'Guarde primeiro a preparação.');
$data = $old->data;
if ($kind !== 'import') {
$this->check(in_array($action, ['approve', 'return', 'suspend'], true), 'action', 'Acção incompatível com este registo.');
$this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.');
$this->check($action === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
$state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$action];
} else {
$this->check(in_array($action, ['transit', 'arrive', 'hold', 'release'], true), 'action', 'Acção incompatível com a importação.');
$allowed = ['transit' => ['draft'], 'arrive' => ['transit'], 'hold' => ['draft', 'transit', 'arrived', 'released'], 'release' => ['arrived', 'held']];
$this->check(in_array($old->state, $allowed[$action], true), 'action', 'O marco anterior ainda não foi registado.');
$event = Validator::make($envelope['data'], ['evidence' => 'required|string|min:5|max:2000', 'event_date' => 'required|date_format:Y-m-d|before_or_equal:today'])->validate();
$this->check($event['event_date'] >= ($old->data['event']['event_date'] ?? substr($old->created_at->toDateString(), 0, 10)), 'data.event_date', 'O marco não pode anteceder o registo anterior.');
if ($action === 'release') {
$this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A libertação exige conferência independente da preparação.');
}
$data['event'] = $event;
$state = ['transit' => 'transit', 'arrive' => 'arrived', 'hold' => 'held', 'release' => 'released'][$action];
}
}
$item = isset($data['stock_item_id']) ? StockItem::query()->whereKey($data['stock_item_id'])->lockForUpdate()->firstOrFail() : null;
$warehouse = isset($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail() : null;
$supplier = isset($data['supplier_id']) ? Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail() : null;
$order = isset($data['purchase_order_id']) ? PurchaseOrder::query()->whereKey($data['purchase_order_id'])->firstOrFail() : null;
if ($kind === 'agreement' && $state === 'approved') {
$this->check($supplier !== null && $supplier->eligible(), 'supplier_id', 'O fornecedor deve estar activo e com revisão documental vigente.');
$this->check(! app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, null), 'supplier_id', 'Existe conflito declarado com este fornecedor.');
$this->check(($old->snapshot['supplier'] ?? null) === $supplier->only(['id', 'name', 'version']), 'supplier_id', 'O fornecedor mudou desde a preparação. Devolva e actualize a versão.');
}
if ($kind === 'policy' && $state === 'approved') {
if (($data['forecast_method'] ?? 'manual') === 'historical') {
$history = app(SupplyHistory::class)->reference($data);
$this->check($history['available'], 'action', $history['reason'] ?? 'Histórico não disponível.');
}
if (! empty($data['agreement_key'])) {
$agreement = $this->latest('agreement')->where('record_key', $data['agreement_key'])->firstOrFail();
$this->check($agreement->state === 'approved' && $agreement->version === $data['agreement_version'], 'action', 'O acordo mudou desde a preparação. Devolva e seleccione a versão actual.');
}
$clash = $this->latest('policy')->where('state', 'approved')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->where('record_key', '!=', $old->record_key)->exists();
$this->check(! $clash, 'action', 'Suspenda a política anterior deste artigo/local antes de aprovar a substituição.');
}
if ($kind === 'agreement' && $state === 'approved' && $data['habitual']) {
$clash = $this->latest('agreement')->where('state', 'approved')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->get()
->contains(fn ($r) => $r->record_key !== $old->record_key && $r->data['habitual'] && $r->data['valid_from'] <= $data['valid_until'] && $r->data['valid_until'] >= $data['valid_from']);
$this->check(! $clash, 'action', 'Já existe fornecedor habitual com vigência sobreposta neste artigo/local.');
}
if ($kind === 'import' && $old === null) {
$this->check(! $this->latest('import')->where('purchase_order_id', $order->id)->exists(), 'purchase_order_id', 'A ordem já tem processo de importação. Consulte-o sem duplicar a compra.');
$this->check(! GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists(), 'purchase_order_id', 'Registe a importação antes da primeira recepção da ordem.');
}
$snapshot = $action === 'save' ? ['prepared_by' => $actor->id, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])), 'item' => $item?->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse?->only(['id', 'code', 'name']),
'supplier' => $supplier?->only(['id', 'name', 'version']), 'order' => $order?->only(['id', 'number', 'currency', 'supplier_id', 'total_minor'])] : $old->snapshot;
if ($state === 'approved') {
$this->check($snapshot['item'] === $item?->only(['id', 'code', 'name', 'unit']), 'action', 'O cadastro do artigo mudou. Devolva e actualize a preparação.');
}
$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' => $item?->id, 'warehouse_id' => $warehouse?->id,
'supplier_id' => $supplier?->id, 'purchase_order_id' => $order?->id, 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot,
'operation_key' => $envelope['operation_key'], 'input_hash' => $hash, 'reason' => $envelope['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'supply.'.$kind.'.'.$action, null, $old?->toArray(), $record->toArray());
return $record;
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
private function validateData(string $kind, array $input): array
{
$predecessor = null;
if ($kind === 'policy' && ! empty($input['predecessor_key'])) {
$source = Validator::make($input, ['predecessor_key' => 'required|uuid', 'predecessor_version' => 'required|integer|min:1'])->validate();
$predecessor = SupplyRecord::query()->where('kind', 'policy')->where('record_key', $source['predecessor_key'])->where('version', $source['predecessor_version'])->firstOrFail();
$this->check(in_array($predecessor->state, ['approved', 'suspended'], true), 'data.predecessor_key', 'Reutilize uma versão de política já aprovada.');
$input = [...$predecessor->data, ...$input];
}
$rules = match ($kind) {
'policy' => ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1',
'predecessor_key' => 'nullable|required_with:predecessor_version|uuid', 'predecessor_version' => 'nullable|required_with:predecessor_key|integer|min:1',
'forecast_method' => 'nullable|in:manual,historical',
'history_key' => 'exclude_unless:forecast_method,historical|required|uuid',
'history_min_days' => 'exclude_unless:forecast_method,historical|required|integer|min:1|max:3660',
'history_max_age_days' => 'exclude_unless:forecast_method,historical|required|integer|min:1|max:3660',
'agreement_key' => 'nullable|uuid',
'purpose' => 'required|in:resale,internal', 'origin' => 'required|in:local,import',
'safety_milli' => 'required|integer|min:0|max:1000000000', 'minimum_stock_milli' => 'required|integer|min:0|max:1000000000',
'target_milli' => 'required|integer|min:0|max:1000000000', 'maximum_milli' => 'required|integer|min:0|max:1000000000',
'daily_milli' => 'nullable|integer|min:0|max:1000000000', 'lead_days' => 'required|integer|min:0|max:3650', 'approval_days' => 'required|integer|min:0|max:365',
'horizon_days' => 'required|integer|min:1|max:366', 'minimum_milli' => 'required|integer|min:0|max:1000000000', 'pack_milli' => 'required|integer|min:1|max:1000000000',
'basis' => 'required|string|min:5|max:2000'],
'agreement' => ['stock_item_id' => 'required|integer|min:1', 'warehouse_id' => 'required|integer|min:1', 'supplier_id' => 'required|integer|min:1',
'habitual' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from',
'origin' => 'required|in:local,import', 'currency' => 'required|regex:/^[A-Z]{3}$/', 'price_minor' => 'nullable|integer|min:0|max:1000000000000',
'lead_days' => 'nullable|integer|min:0|max:3650', 'reference' => 'required|string|max:200'],
'import' => ['purchase_order_id' => 'required|integer|min:1', 'purpose' => 'required|in:resale,internal', 'jurisdiction' => 'required|string|max:100',
'origin_country' => 'required|string|max:100', 'dispatch_country' => 'required|string|max:100',
'incoterm' => 'nullable|string|max:100', 'regime' => 'required|string|max:200', 'expected_usable_on' => 'nullable|date_format:Y-m-d'],
default => throw new \InvalidArgumentException('Tipo de fornecimento desconhecido.'),
};
$data = Validator::make($input, $rules)->validate();
if ($predecessor !== null) {
$this->check((int) $data['stock_item_id'] === $predecessor->stock_item_id && (int) $data['warehouse_id'] === $predecessor->warehouse_id, 'data.stock_item_id', 'A política reutilizada mantém o artigo e local de origem.');
$data['predecessor_version'] = (int) $data['predecessor_version'];
}
if ($kind === 'policy' && ($data['forecast_method'] ?? 'manual') === 'historical') {
$data['daily_milli'] = null;
$data['history_min_days'] = (int) $data['history_min_days'];
$data['history_max_age_days'] = (int) $data['history_max_age_days'];
}
if ($kind === 'policy') {
$this->check($data['safety_milli'] <= $data['minimum_stock_milli'] && $data['minimum_stock_milli'] <= $data['target_milli'] && $data['target_milli'] <= $data['maximum_milli'], 'data.target_milli', 'Respeite segurança ≤ mínimo ≤ alvo ≤ máximo.');
}
return $data;
}
private function check(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}
+254
View File
@@ -0,0 +1,254 @@
<?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]);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Models\StockItem;
use App\Models\Warehouse;
use Illuminate\Support\Facades\Schema;
class WarehouseAvailability
{
/** @return array<string,mixed> */
public function row(StockItem $item, Warehouse $warehouse, bool $planning): array
{
abort_unless($item->tenant_id === $warehouse->tenant_id && auth()->user()?->tenant_id === $item->tenant_id, 404);
$physical = app(StockWorkflow::class)->balance($item->id, $warehouse->id);
$lots = app(InventoryLots::class)->context($item->id, $warehouse->id);
$usable = $lots === null ? $physical['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null);
$needs = null;
$minimum = null;
if ($planning && Schema::hasTable('supply_records')) {
$needs = 0;
foreach (app(SupplyPlanning::class)->latest('reservation')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->whereIn('state', ['active', 'fulfilled'])->get() as $need) {
if (($need->snapshot['item']['unit'] ?? null) !== $item->unit) {
$needs = null;
break;
}
$needs += app(SupplyCoordination::class)->remainingReservation($need);
}
$policy = app(SupplyPlanning::class)->latest('policy')->where('stock_item_id', $item->id)->where('warehouse_id', $warehouse->id)->where('state', 'approved')->first();
if ($policy !== null && $policy->snapshot['item'] === $item->only(['id', 'code', 'name', 'unit'])) {
$minimum = $policy->data['minimum_stock_milli'] ?? null;
}
}
return ['id' => $item->id, 'code' => $item->code, 'name' => $item->name, 'unit' => $item->unit,
'physical_milli' => $physical['quantity'], 'usable_milli' => $usable, 'blocked_milli' => $lots === null || ! $lots['reconciled'] ? null : $lots['blocked_milli'],
'lot_control' => $lots !== null, 'reconciled' => $lots === null || $lots['reconciled'], 'planned_milli' => $needs,
'minimum_milli' => $minimum, 'at_minimum' => $usable !== null && $minimum !== null && $usable - app(WarehouseReservations::class)->committed($item->id, $warehouse->id) <= $minimum,
'physical_commitment_milli' => app(WarehouseReservations::class)->ready() ? app(WarehouseReservations::class)->committed($item->id, $warehouse->id) : null,
'free_milli' => $usable === null ? null : $usable - app(WarehouseReservations::class)->committed($item->id, $warehouse->id), 'ledger_version' => $physical['version']];
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace App\Services;
use App\Models\Equipment;
use App\Models\StockItem;
use App\Models\User;
use App\Models\WarehouseRequest;
use App\Models\WarehouseRequestEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class WarehouseFulfilment
{
/** @return array<string,mixed> */
public function progress(WarehouseRequest $request): array
{
$events = Schema::hasTable('warehouse_request_events') ? WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->orderBy('version')->get() : collect();
$state = 'draft';
$reviewer = null;
$partial = false;
$partialRequested = false;
$delivered = array_fill(0, count($request->snapshot['lines']), 0);
$receipts = [];
foreach ($events as $event) {
if ($event->action === 'submit') {
$state = 'submitted';
$partialRequested = $event->data['partial_allowed'] ?? false;
}
if ($event->action === 'reject') {
$state = 'rejected';
}
if ($event->action === 'cancel') {
$state = 'cancelled';
}
if ($event->action === 'authorize') {
$state = 'authorized';
$reviewer = $event->actor_id;
$partial = $event->data['partial_allowed'];
}
if ($event->action === 'deliver') {
foreach ($event->data['lines'] as $line) {
$delivered[$line['index']] += $line['quantity_milli'];
} $state = 'partial';
}
if ($event->action === 'receive') {
$receipts[] = $event->data['delivery_event_id'];
}
}
$remaining = [];
foreach ($request->snapshot['lines'] as $index => $line) {
$remaining[$index] = $line['quantity_milli'] - $delivered[$index];
}
$cancelled = $state === 'cancelled' ? $remaining : array_fill(0, count($remaining), 0);
if ($state === 'cancelled') {
$remaining = array_fill(0, count($remaining), 0);
}
if ($state === 'partial' && array_sum($remaining) === 0) {
$state = 'delivered';
}
if ($state === 'delivered' && count($receipts) === $events->where('action', 'deliver')->count()) {
$state = 'closed';
}
return ['state' => $state, 'version' => $events->max('version') ?? 0, 'reviewer_id' => $reviewer, 'partial_allowed' => $partial, 'partial_requested' => $partialRequested,
'delivered' => $delivered, 'remaining' => $remaining, 'cancelled' => $cancelled, 'reserved' => app(WarehouseReservations::class)->lines($request), 'reservation_ready' => app(WarehouseReservations::class)->ready(), 'received_deliveries' => $receipts, 'events' => $events->map(fn ($event) => $event->only(['id', 'version', 'action', 'actor_id', 'data', 'reason', 'created_at']))->all()];
}
/** @param array<string,mixed> $input */
public function command(User $actor, int $id, array $input): WarehouseRequestEvent
{
return DB::transaction(function () use ($actor, $id, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Instalação da autorização/entrega pendente.');
$r = WarehouseRequest::query()->lockForUpdate()->findOrFail($id);
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:submit,authorize,reject,deliver,receive,reserve,cancel', 'reason' => 'required|string|min:10|max:4000',
'partial_allowed' => 'sometimes|boolean', 'lines' => 'sometimes|array|min:1|max:50', 'lines.*.index' => 'required|integer|distinct|min:0', 'lines.*.quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'delivery_event_id' => 'sometimes|integer|min:1'])->validate();
$action = $data['action'];
$owner = $r->created_by === $actor->id;
if (in_array($action, ['submit', 'receive'], true)) {
abort_unless($owner && $actor->hasFunctions(['warehouse_request_prepare']), 403);
}
if (in_array($action, ['authorize', 'reject', 'reserve', 'cancel'], true)) {
abort_unless(! $owner && $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id === $r->snapshot['requester']['department_id'], 403);
}
if ($action === 'deliver') {
abort_unless(! $owner && $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']), 403);
}
$hash = hash('sha256', json_encode([$id, $data], JSON_THROW_ON_ERROR));
$old = WarehouseRequestEvent::query()->where('actor_id', $actor->id)->where('operation_key', $data['operation_key'])->first();
if ($old !== null) {
$this->check($old->input_hash === $hash, 'operation_key', 'Chave utilizada com outro conteúdo.');
return $old;
}
$p = $this->progress($r);
$this->check((int) $data['version'] === $p['version'], 'version', 'O pedido mudou. Actualize antes de continuar.');
$eventData = [];
if (in_array($action, ['reserve', 'cancel'], true)) {
$this->check(in_array($p['state'], ['authorized', 'partial'], true), 'action', 'A reserva/libertação exige um pedido autorizado com saldo pendente.');
}
if ($action === 'submit') {
$this->check($p['state'] === 'draft', 'action', 'Só pode submeter um rascunho.');
$eventData = ['partial_allowed' => $data['partial_allowed'] ?? false];
}
if (in_array($action, ['authorize', 'reject'], true)) {
$this->check($p['state'] === 'submitted', 'action', 'O pedido não aguarda decisão.');
}
if ($action === 'authorize') {
$partial = $data['partial_allowed'] ?? false;
$this->check(! $partial || $p['partial_requested'], 'partial_allowed', 'O requisitante não aceitou entregas parciais na submissão.');
$eventData = ['partial_allowed' => $partial, 'availability' => []];
$grouped = [];
foreach ($r->snapshot['lines'] as $line) {
$grouped[$line['item']['id']] = ($grouped[$line['item']['id']] ?? 0) + $line['quantity_milli'];
}
foreach ($grouped as $itemId => $quantity) {
$balance = app(StockWorkflow::class)->balance($itemId, $r->warehouse_id);
$lots = app(InventoryLots::class)->context($itemId, $r->warehouse_id);
$usable = $lots === null ? $balance['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null);
$this->check($usable !== null && ($partial || $usable >= $quantity), 'availability', 'Stock utilizável insuficiente para autorização de entrega integral.');
$eventData['availability'][] = ['stock_item_id' => $itemId, 'usable_milli' => $usable, 'requested_milli' => $quantity, 'ledger_version' => $balance['version']];
}
}
if (in_array($action, ['submit', 'authorize', 'deliver', 'reserve'], true)) {
foreach ($r->snapshot['lines'] as $line) {
$item = StockItem::query()->whereKey($line['item']['id'])->firstOrFail();
$this->check($item->only(['id', 'code', 'name', 'unit']) === $line['item'], 'source', 'O cadastro do artigo mudou; é necessário um novo rascunho conferido.');
if ($line['equipment'] !== null) {
$equipment = Equipment::query()->whereKey($line['equipment']['id'])->firstOrFail();
$this->check($equipment->only(['id', 'code', 'description']) === $line['equipment'] && $equipment->department_id === $r->snapshot['requester']['department_id'], 'source', 'O equipamento de destino mudou. Reveja a origem.');
}
}
}
if ($action === 'deliver') {
$this->check(in_array($p['state'], ['authorized', 'partial'], true), 'action', 'A entrega exige uma requisição autorizada com saldo pendente.');
abort_if($actor->id === $p['reviewer_id'], 403, 'Quem autorizou não pode entregar.');
$this->check(! empty($data['lines']), 'lines', 'Indique as quantidades efectivamente entregues.');
$quantities = array_fill(0, count($p['remaining']), 0);
foreach ($data['lines'] as $line) {
$index = (int) $line['index'];
$quantity = ReceiptWorkflow::milli($line['quantity']);
$this->check(isset($p['remaining'][$index]) && $quantity > 0 && $quantity <= $p['remaining'][$index], 'lines', 'Quantidade fora do saldo autorizado.');
$quantities[$index] = $quantity;
}
if (! $p['partial_allowed']) {
$this->check($quantities === $p['remaining'], 'lines', 'A decisão exige entrega integral.');
}
$eventData['lines'] = [];
foreach ($quantities as $index => $quantity) {
if ($quantity === 0) {
continue;
}
$line = $r->snapshot['lines'][$index];
app(WarehouseReservations::class)->consume($actor, $r, $index, $quantity, $data['operation_key']);
$operation = app(StockWorkflow::class)->record($actor, ['kind' => 'issue', 'reference' => 'RA-'.$r->id.'-V'.($p['version'] + 1).'-L'.$index,
'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $r->created_by, 'warehouse_id' => $r->warehouse_id, 'stock_item_id' => $line['item']['id'],
'quantity' => intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT), 'destination' => $r->snapshot['destination'], 'reason' => 'Saída pela requisição RA-'.$r->id.': '.$data['reason'],
...($line['equipment'] === null ? [] : ['equipment_id' => $line['equipment']['id']])]);
$eventData['lines'][] = ['index' => $index, 'quantity_milli' => $quantity, 'stock_operation_id' => $operation->id];
}
$eventData['recipient'] = $r->snapshot['requester'];
}
if ($action === 'receive') {
$delivery = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'deliver')->whereKey($data['delivery_event_id'] ?? 0)->firstOrFail();
$this->check(! in_array($delivery->id, $p['received_deliveries'], true), 'delivery_event_id', 'Recepção já confirmada.');
$eventData = ['delivery_event_id' => $delivery->id];
}
$eventData['actor'] = $actor->only(['id', 'name']);
$event = WarehouseRequestEvent::query()->create(['tenant_id' => $actor->tenant_id, 'warehouse_request_id' => $id, 'actor_id' => $actor->id, 'version' => $p['version'] + 1,
'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'action' => $action, 'reason' => $data['reason'], 'data' => $eventData, 'created_at' => now()]);
if ($action === 'authorize') {
app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $eventData['partial_allowed'], $data['operation_key']);
}
if ($action === 'reserve') {
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'authorize')->firstOrFail();
app(WarehouseReservations::class)->reserve($actor, $r, $authorization, $p['remaining'], $p['partial_allowed'], $data['operation_key']);
}
if ($action === 'cancel') {
app(WarehouseReservations::class)->release($actor, $r, $data['operation_key']);
}
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_request.'.$action, null, null, ['warehouse_request_id' => $id, 'event_id' => $event->id, 'data' => $eventData, 'reason' => $data['reason']]);
foreach ($r->snapshot['lines'] as $line) {
app(AutomaticPurchases::class)->signal($actor, $line['item']['id'], $r->warehouse_id);
}
return $event;
});
}
private function check(bool $valid, string $field, string $message): void
{
if (! $valid) {
throw ValidationException::withMessages([$field => $message]);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class WarehouseRequests
{
/** @param array<string,mixed> $input */
public function draft(User $actor, array $input): WarehouseRequest
{
return DB::transaction(function () use ($actor, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
abort_unless($actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
abort_unless(Schema::hasTable('warehouse_requests'), 503, 'Instalação de requisições ao armazém pendente.');
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'warehouse_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1',
'destination' => 'required|string|min:3|max:300', 'reason' => 'required|string|min:10|max:4000', 'priority' => 'required|in:normal,urgent',
'lines' => 'required|array|min:1|max:50', 'lines.*.stock_item_id' => 'required|integer|min:1', 'lines.*.quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D',
'lines.*.use' => 'required|in:internal,equipment', 'lines.*.equipment_id' => 'nullable|integer|min:1'])->validate();
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = WarehouseRequest::query()->where('created_by', $actor->id)->where('operation_key', $data['operation_key'])->first();
if ($old !== null) {
if ($old->input_hash !== $hash) {
throw ValidationException::withMessages(['operation_key' => 'Chave utilizada com outro conteúdo.']);
}
return $old;
}
$warehouse = Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail();
$center = CostCenter::query()->where('department_id', $actor->department_id)->whereKey($data['cost_center_id'])->firstOrFail();
$lines = [];
foreach ($data['lines'] as $index => $line) {
$item = StockItem::query()->whereKey($line['stock_item_id'])->firstOrFail();
$quantity = ReceiptWorkflow::milli($line['quantity']);
if ($quantity <= 0) {
throw ValidationException::withMessages(['lines.'.$index.'.quantity' => 'A quantidade deve ser positiva.']);
}
$equipment = null;
if ($line['use'] === 'equipment') {
if (empty($line['equipment_id'])) {
throw ValidationException::withMessages(['lines.'.$index.'.equipment_id' => 'Seleccione o equipamento de destino.']);
}
$equipment = Equipment::query()->where('department_id', $actor->department_id)->whereKey($line['equipment_id'])->firstOrFail();
$part = SparePart::query()->where('stock_item_id', $item->id)->first();
if ($part !== null) {
app(EquipmentWorkflow::class)->linkedPart($part->id, $equipment->id);
}
} elseif (! empty($line['equipment_id'])) {
throw ValidationException::withMessages(['lines.'.$index.'.equipment_id' => 'Escolha utilização em equipamento para associar este destino.']);
}
$lines[] = ['item' => $item->only(['id', 'code', 'name', 'unit']), 'quantity_milli' => $quantity, 'use' => $line['use'],
'destination' => $data['destination'], 'equipment' => $equipment?->only(['id', 'code', 'description'])];
}
$record = WarehouseRequest::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'warehouse_id' => $warehouse->id,
'cost_center_id' => $center->id, 'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'status' => 'draft', 'created_at' => now(),
'snapshot' => ['requester' => $actor->only(['id', 'name', 'department_id']), 'warehouse' => $warehouse->only(['id', 'code', 'name']),
'cost_center' => $center->only(['id', 'code', 'name']), 'destination' => $data['destination'], 'reason' => $data['reason'], 'priority' => $data['priority'], 'lines' => $lines]]);
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_request.drafted', null, null, ['warehouse_request_id' => $record->id, 'snapshot' => $record->snapshot]);
return $record;
});
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\WarehouseRequest;
use App\Models\WarehouseRequestEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
class WarehouseReservations
{
public function ready(): bool
{
return Schema::hasTable('warehouse_reservations');
}
public function committed(int $item, int $warehouse): int
{
return $this->ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0;
}
/** @return array<int,int> */
public function lines(WarehouseRequest $request): array
{
$values = array_fill(0, count($request->snapshot['lines']), 0);
if ($this->ready()) {
foreach (DB::table('warehouse_reservations')->where('tenant_id', $request->tenant_id)->where('warehouse_request_id', $request->id)->selectRaw('line_index,SUM(quantity_milli) as quantity')->groupBy('line_index')->get() as $row) {
$values[$row->line_index] = (int) $row->quantity;
}
}
return $values;
}
public function usable(int $item, int $warehouse): int
{
$lots = app(InventoryLots::class)->context($item, $warehouse);
if ($lots !== null && ! $lots['reconciled']) {
throw ValidationException::withMessages(['availability' => 'Reconcilie os lotes antes de comprometer ou retirar stock.']);
}
return $lots === null ? app(StockWorkflow::class)->balance($item, $warehouse)['quantity'] : $lots['usable_milli'];
}
public function free(int $item, int $warehouse): int
{
return $this->usable($item, $warehouse) - $this->committed($item, $warehouse);
}
/** @param array<int,int> $remaining */
public function reserve(User $actor, WarehouseRequest $request, WarehouseRequestEvent $authorization, array $remaining, bool $partial, string $key): void
{
if (! $this->ready()) {
return;
}
$own = $this->lines($request);
foreach ($request->snapshot['lines'] as $index => $line) {
$need = $remaining[$index] - $own[$index];
if ($need <= 0) {
continue;
}
$free = max(0, $this->free($line['item']['id'], $request->warehouse_id));
if (! $partial && $free < $need) {
throw ValidationException::withMessages(['availability' => 'Stock livre insuficiente: parte do material está reservada para outros pedidos.']);
}
$quantity = min($need, $free);
if ($quantity > 0) {
$this->entry($actor, $request, $index, $authorization->id, $key, 'reserve', $quantity);
}
}
}
public function consume(User $actor, WarehouseRequest $request, int $index, int $quantity, string $key): void
{
if (! $this->ready()) {
return;
}
$own = $this->lines($request);
if ($quantity > $own[$index]) {
throw ValidationException::withMessages(['lines' => 'A entrega excede a reserva desta linha. Actualize a reserva do pedido.']);
}
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'authorize')->firstOrFail();
$this->entry($actor, $request, $index, $authorization->id, $key, 'consume', -$quantity);
}
public function release(User $actor, WarehouseRequest $request, string $key): void
{
if (! $this->ready()) {
return;
}
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'authorize')->first();
if ($authorization === null) {
return;
}
foreach ($this->lines($request) as $index => $quantity) {
if ($quantity > 0) {
$this->entry($actor, $request, $index, $authorization->id, $key, 'release', -$quantity);
}
}
}
private function entry(User $actor, WarehouseRequest $request, int $index, int $authorization, string $key, string $kind, int $quantity): void
{
if (DB::transactionLevel() === 0) {
throw new \LogicException('Reserva exige a transacção do processo.');
}
DB::table('warehouse_reservations')->insert(['tenant_id' => $request->tenant_id, 'warehouse_request_id' => $request->id, 'line_index' => $index,
'stock_item_id' => $request->snapshot['lines'][$index]['item']['id'], 'warehouse_id' => $request->warehouse_id, 'authorization_event_id' => $authorization,
'actor_id' => $actor->id, 'command_key' => $key, 'kind' => $kind, 'quantity_milli' => $quantity, 'created_at' => now()]);
}
/** Refuse worsening a shortage of usable reserved material; quality holds remain independent. */
public function protect(int $item, int $warehouse, int $before): void
{
if (! $this->ready() || $this->committed($item,$warehouse) === 0) {
return;
}
if ($this->free($item,$warehouse) < min(0,$before)) {
throw ValidationException::withMessages(['quantity' => 'Esta operação consumiria material reservado. Liberte ou regularize a reserva autorizadamente.']);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
class WorkContext
{
public const OPTIONS = [
'procurement.internal' => ['area' => 'procurement', 'areaLabel' => 'Compras', 'label' => 'Compras internas', 'operational' => true],
'procurement.sales' => ['area' => 'procurement', 'areaLabel' => 'Compras', 'label' => 'Venda para terceiros', 'operational' => false],
'people.internal' => ['area' => 'people', 'areaLabel' => 'Pessoas e contratações', 'label' => 'Contratações internas', 'operational' => true],
'people.third_party' => ['area' => 'people', 'areaLabel' => 'Pessoas e contratações', 'label' => 'Contratações para terceiros', 'operational' => false],
];
/** @return list<string> */
public function purchased(User $user): array
{
if (! in_array(config('work_context.composition'), ['sgc', 'sgf'], true)) {
return [];
}
$keys = config('work_context.entitlements.'.$user->tenant_id, $this->evaluation($user) ? array_keys(self::OPTIONS) : []);
if (config('work_context.composition') === 'sgf' && is_array($keys)) {
$keys = array_diff($keys, ['people.third_party']);
}
return is_array($keys) ? array_values(array_intersect(array_keys(self::OPTIONS), $keys)) : [];
}
/** @return array<string,mixed>|null */
public function policy(User $user): ?array
{
if ($user->tenant_id === null || ! Schema::hasColumn('tenants', 'work_context_policy')) {
return null;
}
$value = DB::table('tenants')->where('id', $user->tenant_id)->value('work_context_policy');
return $value === null ? ($this->evaluation($user) ? ['revision' => 0, 'enabled' => array_keys(self::OPTIONS), 'evaluation_only' => true] : null) : json_decode($value, true, 32, JSON_THROW_ON_ERROR);
}
private function evaluation(User $user): bool
{
if (! app()->bound('request') || ! EvaluationNavigation::local(request()) || request()->getPort() !== 8005
|| DB::connection()->getDriverName() !== 'pgsql') {
return false;
}
return app(EvaluationAccess::class)->source(request())->tenant_id === $user->tenant_id;
}
public function enforced(User $user): bool
{
// Existing installations require explicit, audited activation.
// New company delivery initialises an empty policy, never implicit grants.
return $this->policy($user) !== null;
}
/** @return list<string> */
public function available(User $user): array
{
$enabled = $this->policy($user)['enabled'] ?? [];
$granted = [];
if (app(OrganizationAccess::class)->active($user->tenant_id)) {
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.work_contexts', 'p.permissions']);
foreach ($profiles as $profile) {
$permissions = json_decode($profile->permissions, true, 32, JSON_THROW_ON_ERROR);
foreach (json_decode($profile->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR) as $key) {
if ($this->functional($permissions, $key)) {
$granted[] = $key;
}
}
}
} elseif (app()->environment(['testing', 'payable-testing'])) {
// Development roles remain usable locally; never become customer grants.
foreach (array_keys(self::OPTIONS) as $key) {
if ($this->functional(app(OrganizationAccess::class)->permissions($user), $key)) {
$granted[] = $key;
}
}
}
return array_values(array_intersect($this->purchased($user), $enabled, array_unique($granted)));
}
/** @param list<string> $permissions */
private function functional(array $permissions, string $key): bool
{
if (! isset(self::OPTIONS[$key])) {
return false;
}
$people = array_values(array_filter(array_keys(OrganizationAccess::PERMISSIONS), fn ($p) => str_starts_with($p, 'people_')));
$wanted = self::OPTIONS[$key]['area'] === 'people' ? ['admin', ...$people] : array_diff(array_keys(OrganizationAccess::PERMISSIONS), $people);
return array_intersect($permissions, $wanted) !== [];
}
public function active(Request $request): ?string
{
$user = $request->user();
$state = $request->session()->get('work_context');
if (! $user instanceof User || ! is_array($state) || ($state['user_id'] ?? null) !== $user->id || ($state['tenant_id'] ?? null) !== $user->tenant_id) {
return null;
}
$key = $state['key'] ?? null;
return is_string($key) && in_array($key, $this->available($user), true) ? $key : null;
}
/** @return array<string,mixed> */
public function shared(Request $request): array
{
$user = $request->user();
$enabled = $user instanceof User && $this->enforced($user);
$active = $enabled ? $this->active($request) : null;
$permissions = $active !== null ? app(OrganizationAccess::class)->permissions($user, $active) : [];
if ($active === 'procurement.internal' && ! app(OrganizationAccess::class)->active($user->tenant_id) && app(OrganizationAccess::class)->canRequest($user)) {
$permissions[] = 'requester';
}
$paths = [];
$sharedPaths = [];
foreach (Route::getRoutes()->getRoutes() as $route) {
$path = '/'.$route->uri();
if (! str_contains($path, '{') && in_array('GET', $route->methods(), true)) {
$area = self::area($path);
if ($area === null) {
$sharedPaths[] = $path;
}
if ($area === null || ($active !== null && self::OPTIONS[$active]['operational'] && self::OPTIONS[$active]['area'] === $area)) {
$paths[] = $path;
}
}
}
return ['enforced' => $enabled, 'active' => $active, 'paths' => $paths, 'sharedPaths' => $sharedPaths, 'permissions' => $permissions,
'options' => self::OPTIONS, 'available' => $enabled ? $this->available($user) : [],
'purchased' => $user instanceof User ? $this->purchased($user) : []];
}
public static function area(string $path): ?string
{
$path = '/'.ltrim(explode('?', $path)[0], '/');
if ($path === '/dashboard' || str_starts_with($path, '/administracao/') || str_starts_with($path, '/empresa') || str_starts_with($path, '/settings') || str_starts_with($path, '/contexto')) {
return null;
}
return $path === '/pessoas' || str_starts_with($path, '/pessoas/') ? 'people' : 'procurement';
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class WorkflowNotices
{
/** @param array<string,mixed> $input */
public function refresh(User $actor, int $id, array $input): void
{
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'process_version' => 'required|integer|min:1',
'step_id' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:4000'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
abort_unless($actor->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
abort_unless(Schema::hasTable('workflow_notices'), 503, 'Instalação dos avisos pendente.');
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = AuditLog::query()->where('user_id', $actor->id)->where('requisition_id', $id)->where('action', 'workflow.routing_refreshed')
->where('after->operation_key', $data['operation_key'])->first();
if ($old !== null) {
if (($old->after['input_hash'] ?? null) !== $hash) {
throw ValidationException::withMessages(['operation_key' => 'Chave utilizada com outro conteúdo.']);
}
return;
}
$step = $request->steps->firstWhere('decision', 'pending');
if ($request->status !== 'pending' || $step === null || $step->id !== (int) $data['step_id']
|| (int) $request->getAttribute('process_version') !== (int) $data['process_version']) {
throw ValidationException::withMessages(['routing' => 'A etapa ou o pedido mudou. Actualize antes de encaminhar.']);
}
$before = DB::table('workflow_notices')->where('approval_step_id', $step->id)->pluck('recipient_id')->all();
$this->capture($request, $actor);
$after = DB::table('workflow_notices')->where('approval_step_id', $step->id)->pluck('recipient_id')->all();
app(RequisitionWorkflow::class)->audit($actor, 'workflow.routing_refreshed', $request, ['recipients' => $before],
['recipients' => $after, 'step_id' => $step->id, 'reason' => $data['reason'], 'operation_key' => $data['operation_key'], 'input_hash' => $hash]);
});
}
public function outcome(Requisition $requisition, int $stepId): void
{
if (! Schema::hasTable('workflow_notices') || ! in_array($requisition->status, ['approved', 'rejected'], true)) {
return;
}
if (DB::connection()->transactionLevel() === 0) {
throw new \LogicException('Aviso de resultado exige a transacção da decisão.');
}
$step = $requisition->steps()->whereKey($stepId)->firstOrFail();
if ($step->decision !== $requisition->status) {
throw new \LogicException('O aviso deve referenciar a decisão final efectivamente registada.');
}
$recipient = User::query()->where('tenant_id', $requisition->tenant_id)->findOrFail($requisition->requester_id);
DB::table('workflow_notices')->insertOrIgnore(['tenant_id' => $requisition->tenant_id, 'requisition_id' => $requisition->id,
'approval_step_id' => $step->id, 'recipient_id' => $recipient->id, 'reference' => $requisition->number, 'role' => $step->role, 'created_at' => now()]);
}
public function capture(Requisition $requisition, User $actor): void
{
if (! Schema::hasTable('workflow_notices')) {
return; // Existing installations keep the approved workflow until the additive migration.
}
if (DB::connection()->transactionLevel() === 0) {
throw new \LogicException('Aviso exige a transacção do processo.');
}
$requisition->unsetRelation('steps');
$routing = app(ApprovalRouting::class)->current($requisition, $actor);
foreach ($routing['people'] as $person) {
DB::table('workflow_notices')->insertOrIgnore(['tenant_id' => $requisition->tenant_id, 'requisition_id' => $requisition->id,
'approval_step_id' => $routing['step_id'], 'recipient_id' => $person['id'], 'reference' => $requisition->number,
'role' => $routing['role'], 'created_at' => now()]);
}
}
/** @return list<array<string,mixed>> */
public function visible(User $actor): array
{
if (! Schema::hasTable('workflow_notices') || app(OrganizationAccess::class)->maintenanceOnly($actor) || app(OrganizationAccess::class)->permissions($actor) === []) {
return [];
}
$items = [];
foreach (DB::table('workflow_notices')->where('tenant_id', $actor->tenant_id)->where('recipient_id', $actor->id)->orderByDesc('id')->get() as $notice) {
$request = Requisition::query()->visibleTo($actor)->whereKey($notice->requisition_id)->first();
if ($request === null) {
continue;
}
$step = $request->steps->firstWhere('id', $notice->approval_step_id);
$outcome = $request->requester_id === $actor->id && $step !== null && in_array($step->decision, ['approved', 'rejected'], true);
if (! $outcome && (! $request->canDecide($actor) || $request->steps->firstWhere('decision', 'pending')?->id !== $notice->approval_step_id)) {
continue;
}
$items[] = ['id' => $notice->id, 'reference' => $notice->reference, 'role' => $notice->role,
'kind' => $outcome ? 'outcome' : 'task', 'outcome' => $outcome ? $step->decision : null, 'current_status' => $request->status,
'created_at' => $notice->created_at, 'read_at' => $notice->read_at, 'href' => '/requisicoes/'.$request->id];
}
return $items;
}
public function read(User $actor, int $id): void
{
DB::transaction(function () use ($actor, $id) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$notice = collect($this->visible($actor))->firstWhere('id', $id);
abort_unless($notice !== null, 404);
if ($notice['read_at'] === null) {
DB::table('workflow_notices')->where('tenant_id', $actor->tenant_id)->where('recipient_id', $actor->id)->where('id', $id)->update(['read_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'workflow.notice_read', null, null, ['notice_id' => $id]);
}
});
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Services;
/** Task descriptions share the existing permission keys; they are not extra grants. */
class WorkflowTasks
{
/** @return array<string,list<array{key:string,label:string}>> */
public function catalogue(): array
{
$specific = [
'requester' => ['requisition.prepare' => 'Preparar e submeter requisições próprias; acompanhar decisões'],
'department_head' => ['requisition.approve.department_head' => 'Decidir a etapa de aprovação do próprio departamento'],
'procurement' => ['requisition.approve.procurement' => 'Decidir a etapa de Compras quando exigida pelo percurso', 'procurement.prepare' => 'Preparar e acompanhar consulta ao mercado e aquisição'],
'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Exercer as conferências e operações financeiras permitidas pelo perfil'],
'people_service_prepare' => ['people.service.prepare' => 'Preparar processo, candidaturas, proposta de selecção e minuta de prestador individual'],
'people_staff_prepare' => ['people.staff.prepare' => 'Preparar processo, candidaturas, proposta de selecção e minuta de recrutamento'],
'people_service_review' => ['people.service.review' => 'Conferir abertura, avaliar e decidir selecção de prestador individual conforme a equipa atribuída'],
'people_staff_review' => ['people.staff.review' => 'Conferir abertura, avaliar e decidir selecção de recrutamento conforme a equipa atribuída'],
];
$catalogue = [];
foreach (OrganizationAccess::PERMISSIONS as $permission => $label) {
$catalogue[$permission] = [];
foreach ($specific[$permission] ?? ['function.'.$permission => $label] as $key => $task) {
$catalogue[$permission][] = ['key' => $key, 'label' => $task];
}
}
return $catalogue;
}
}