commit13
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.']);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"purchasing:publish": "@php scripts/publish-purchasing.php",
|
||||
"purchasing:transfer": "@php scripts/transfer-purchasing.php",
|
||||
"runtime:check": "@php scripts/check-runtime-packages.php",
|
||||
"setup": [
|
||||
"composer install",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'composition' => env('SGC_PRODUCT_COMPOSITION', 'sgc'),
|
||||
// Supplied with the installation, independently of company permissions.
|
||||
// Keys are tenant IDs; values are the context keys purchased by that company.
|
||||
'entitlements' => json_decode(env('SGC_PRODUCT_ENTITLEMENTS', '{}'), true, 32, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::connection(DB::getDefaultConnection())->create('supply_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->restrictOnDelete();
|
||||
$table->string('kind', 20);
|
||||
$table->uuid('record_key');
|
||||
$table->unsignedInteger('version');
|
||||
$table->string('state', 30);
|
||||
$table->foreignId('stock_item_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('warehouse_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('supplier_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('purchase_order_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->json('data');
|
||||
$table->json('snapshot');
|
||||
$table->text('reason');
|
||||
$table->uuid('operation_key');
|
||||
$table->string('input_hash', 64);
|
||||
$table->timestamp('created_at');
|
||||
$table->unique(['tenant_id', 'kind', 'record_key', 'version'], 'supply_record_version');
|
||||
$table->unique(['tenant_id', 'operation_key']);
|
||||
$table->index(['tenant_id', 'kind', 'stock_item_id', 'warehouse_id']);
|
||||
$table->index(['tenant_id', 'purchase_order_id']);
|
||||
});
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::unprepared("CREATE TRIGGER supply_records_update BEFORE UPDATE ON supply_records BEGIN SELECT RAISE(ABORT, 'Preservar historico de fornecimento'); END;
|
||||
CREATE TRIGGER supply_records_delete BEFORE DELETE ON supply_records BEGIN SELECT RAISE(ABORT, 'Preservar historico de fornecimento'); END;");
|
||||
} elseif (DB::getDriverName() === 'pgsql') {
|
||||
DB::unprepared("CREATE FUNCTION preserve_supply_records() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Preservar historico de fornecimento'; END $$;
|
||||
CREATE TRIGGER supply_records_immutable BEFORE UPDATE OR DELETE ON supply_records FOR EACH ROW EXECUTE FUNCTION preserve_supply_records();");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar fornecimentos, politicas e processos de importacao.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection(DB::getDefaultConnection());
|
||||
$schema->table('supply_records', function (Blueprint $table) {
|
||||
$table->unique(['tenant_id', 'id', 'stock_item_id'], 'lot_record_item_scope');
|
||||
$table->unique(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'], 'lot_record_location_scope');
|
||||
});
|
||||
$schema->table('stock_movements', fn (Blueprint $table) => $table->unique(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'], 'lot_movement_location_scope'));
|
||||
$schema->create('inventory_lot_allocations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->unsignedBigInteger('stock_item_id');
|
||||
$table->unsignedBigInteger('warehouse_id');
|
||||
$table->unsignedBigInteger('lot_id');
|
||||
$table->unsignedBigInteger('stock_movement_id')->nullable();
|
||||
$table->unsignedBigInteger('setup_record_id')->nullable();
|
||||
$table->bigInteger('quantity_milli');
|
||||
$table->timestamp('created_at');
|
||||
$table->foreign(['tenant_id', 'lot_id', 'stock_item_id'], 'allocation_lot_scope')->references(['tenant_id', 'id', 'stock_item_id'])->on('supply_records')->restrictOnDelete();
|
||||
$table->foreign(['tenant_id', 'stock_movement_id', 'stock_item_id', 'warehouse_id'], 'allocation_movement_scope')->references(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'])->on('stock_movements')->restrictOnDelete();
|
||||
$table->foreign(['tenant_id', 'setup_record_id', 'stock_item_id', 'warehouse_id'], 'allocation_setup_scope')->references(['tenant_id', 'id', 'stock_item_id', 'warehouse_id'])->on('supply_records')->restrictOnDelete();
|
||||
$table->unique(['stock_movement_id', 'lot_id'], 'allocation_movement_once');
|
||||
$table->unique(['setup_record_id', 'lot_id'], 'allocation_baseline_once');
|
||||
$table->index(['tenant_id', 'stock_item_id', 'warehouse_id']);
|
||||
});
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::unprepared("CREATE TRIGGER allocation_insert BEFORE INSERT ON inventory_lot_allocations BEGIN
|
||||
SELECT CASE WHEN NEW.quantity_milli = 0 OR (NEW.stock_movement_id IS NULL) = (NEW.setup_record_id IS NULL) THEN RAISE(ABORT, 'Origem unica da reparticao obrigatoria') END;
|
||||
SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM supply_records WHERE id=NEW.lot_id AND kind='lot' AND version=1) THEN RAISE(ABORT, 'Identidade de lote invalida') END;
|
||||
SELECT CASE WHEN COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE lot_id=NEW.lot_id AND warehouse_id=NEW.warehouse_id),0)+NEW.quantity_milli < 0 THEN RAISE(ABORT, 'Saldo de lote negativo') END;
|
||||
SELECT CASE WHEN NEW.stock_movement_id IS NOT NULL AND ((SELECT quantity_milli FROM stock_movements WHERE id=NEW.stock_movement_id)*NEW.quantity_milli<=0 OR COALESCE((SELECT SUM(ABS(quantity_milli)) FROM inventory_lot_allocations WHERE stock_movement_id=NEW.stock_movement_id),0)+ABS(NEW.quantity_milli)>ABS((SELECT quantity_milli FROM stock_movements WHERE id=NEW.stock_movement_id))) THEN RAISE(ABORT, 'Reparticao excede movimento') END;
|
||||
SELECT CASE WHEN NEW.setup_record_id IS NOT NULL AND (NEW.quantity_milli<0 OR NOT EXISTS(SELECT 1 FROM supply_records WHERE id=NEW.setup_record_id AND kind='lot_setup' AND state='active') OR COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE setup_record_id=NEW.setup_record_id),0)+NEW.quantity_milli>(SELECT CAST(json_extract(snapshot,'$.balance.quantity') AS INTEGER) FROM supply_records WHERE id=NEW.setup_record_id)) THEN RAISE(ABORT, 'Reparticao excede saldo conferido') END;
|
||||
END;
|
||||
CREATE TRIGGER allocation_update BEFORE UPDATE ON inventory_lot_allocations BEGIN SELECT RAISE(ABORT, 'Preservar reparticoes de lotes'); END;
|
||||
CREATE TRIGGER allocation_delete BEFORE DELETE ON inventory_lot_allocations BEGIN SELECT RAISE(ABORT, 'Preservar reparticoes de lotes'); END;");
|
||||
} elseif (DB::getDriverName() === 'pgsql') {
|
||||
DB::unprepared("CREATE FUNCTION preserve_inventory_lot_allocations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE source_quantity numeric; baseline_quantity numeric;
|
||||
BEGIN
|
||||
IF TG_OP <> 'INSERT' THEN RAISE EXCEPTION 'Preservar reparticoes de lotes'; END IF;
|
||||
IF NEW.quantity_milli=0 OR (NEW.stock_movement_id IS NULL)=(NEW.setup_record_id IS NULL) THEN RAISE EXCEPTION 'Origem unica da reparticao obrigatoria'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM supply_records WHERE id=NEW.lot_id AND kind='lot' AND version=1) THEN RAISE EXCEPTION 'Identidade de lote invalida'; END IF;
|
||||
PERFORM id FROM supply_records WHERE id=NEW.lot_id FOR UPDATE;
|
||||
IF COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE lot_id=NEW.lot_id AND warehouse_id=NEW.warehouse_id),0)+NEW.quantity_milli<0 THEN RAISE EXCEPTION 'Saldo de lote negativo'; END IF;
|
||||
IF NEW.stock_movement_id IS NOT NULL THEN
|
||||
SELECT quantity_milli INTO source_quantity FROM stock_movements WHERE id=NEW.stock_movement_id FOR UPDATE;
|
||||
IF source_quantity*NEW.quantity_milli<=0 OR COALESCE((SELECT SUM(ABS(quantity_milli)) FROM inventory_lot_allocations WHERE stock_movement_id=NEW.stock_movement_id),0)+ABS(NEW.quantity_milli)>ABS(source_quantity) THEN RAISE EXCEPTION 'Reparticao excede movimento'; END IF;
|
||||
ELSE
|
||||
SELECT (snapshot->'balance'->>'quantity')::numeric INTO baseline_quantity FROM supply_records WHERE id=NEW.setup_record_id AND kind='lot_setup' AND state='active' FOR UPDATE;
|
||||
IF baseline_quantity IS NULL OR NEW.quantity_milli<0 OR COALESCE((SELECT SUM(quantity_milli) FROM inventory_lot_allocations WHERE setup_record_id=NEW.setup_record_id),0)+NEW.quantity_milli>baseline_quantity THEN RAISE EXCEPTION 'Reparticao excede saldo conferido'; END IF;
|
||||
END IF;
|
||||
RETURN NEW; END $$;
|
||||
CREATE TRIGGER preserve_lot_allocations BEFORE INSERT OR UPDATE OR DELETE ON inventory_lot_allocations FOR EACH ROW EXECUTE FUNCTION preserve_inventory_lot_allocations();");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar lotes, origens e reparticoes do razao.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::connection(DB::getDefaultConnection())->table('stock_movements', function (Blueprint $table) {
|
||||
$table->dropUnique(['stock_operation_id', 'warehouse_id']);
|
||||
$table->unique(['stock_operation_id', 'warehouse_id', 'movement_type'], 'stock_operation_location_type_once');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar os movimentos de ajuste e o histórico de lotes.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->json('work_context_policy')->nullable();
|
||||
});
|
||||
Schema::table('organization_profiles', function (Blueprint $table) {
|
||||
$table->json('work_contexts')->nullable();
|
||||
});
|
||||
foreach (['requisitions' => 'procurement.internal', 'people_processes' => 'people.internal'] as $name => $key) {
|
||||
Schema::table($name, function (Blueprint $table) use ($key) {
|
||||
$table->string('work_context_key', 40)->default($key)->index();
|
||||
});
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::unprepared("CREATE TRIGGER {$name}_context_immutable BEFORE UPDATE OF work_context_key ON {$name} WHEN NEW.work_context_key != OLD.work_context_key BEGIN SELECT RAISE(ABORT, 'Process context is immutable'); END");
|
||||
} elseif (DB::getDriverName() === 'pgsql') {
|
||||
DB::unprepared("CREATE FUNCTION {$name}_context_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.work_context_key IS DISTINCT FROM OLD.work_context_key THEN RAISE EXCEPTION 'Process context is immutable'; END IF; RETURN NEW; END; \$\$; CREATE TRIGGER {$name}_context_immutable BEFORE UPDATE ON {$name} FOR EACH ROW EXECUTE FUNCTION {$name}_context_immutable();");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (['requisitions', 'people_processes'] as $name) {
|
||||
DB::unprepared("DROP TRIGGER IF EXISTS {$name}_context_immutable".(DB::getDriverName() === 'pgsql' ? " ON {$name}" : ''));
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
DB::unprepared("DROP FUNCTION IF EXISTS {$name}_context_immutable()");
|
||||
}
|
||||
Schema::table($name, function (Blueprint $table) {
|
||||
$table->dropIndex(['work_context_key']);
|
||||
$table->dropColumn('work_context_key');
|
||||
});
|
||||
}
|
||||
Schema::table('organization_profiles', fn (Blueprint $table) => $table->dropColumn('work_contexts'));
|
||||
Schema::table('tenants', fn (Blueprint $table) => $table->dropColumn('work_context_policy'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('requisitions', fn (Blueprint $t) => $t->unsignedInteger('process_version')->default(1));
|
||||
Schema::create('requisition_commands', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->unsignedBigInteger('tenant_id');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->uuid('operation_key');
|
||||
$t->string('request_hash', 64);
|
||||
$t->json('result');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['tenant_id', 'actor_id', 'operation_key']);
|
||||
});
|
||||
foreach (['update', 'delete'] as $event) {
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
DB::unprepared("CREATE TRIGGER rq_command_{$event} BEFORE {$event} ON requisition_commands BEGIN SELECT RAISE(ABORT, 'Command receipts are immutable'); END");
|
||||
} elseif (DB::getDriverName() === 'pgsql') {
|
||||
DB::unprepared("CREATE FUNCTION rq_command_{$event}() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Command receipts are immutable'; END; \$\$; CREATE TRIGGER rq_command_{$event} BEFORE {$event} ON requisition_commands FOR EACH ROW EXECUTE FUNCTION rq_command_{$event}();");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('requisition_commands')->exists()) {
|
||||
throw new RuntimeException('Não apagar recibos de operações. Recuperar por migração compatível ou restauro ensaiado.');
|
||||
}
|
||||
Schema::drop('requisition_commands');
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
foreach (['update', 'delete'] as $event) {
|
||||
DB::unprepared("DROP FUNCTION IF EXISTS rq_command_{$event}()");
|
||||
}
|
||||
}
|
||||
Schema::table('requisitions', fn (Blueprint $t) => $t->dropColumn('process_version'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workflow_notices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('approval_step_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('recipient_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('reference');
|
||||
$table->string('role');
|
||||
$table->timestampTz('created_at');
|
||||
$table->timestampTz('read_at')->nullable();
|
||||
$table->unique(['approval_step_id', 'recipient_id']);
|
||||
$table->index(['tenant_id', 'recipient_id', 'read_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workflow_notices');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('warehouse_requests', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('warehouse_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('cost_center_id')->constrained()->restrictOnDelete();
|
||||
$table->string('work_context_key')->default('procurement.internal');
|
||||
$table->uuid('operation_key');
|
||||
$table->string('input_hash', 64);
|
||||
$table->string('status')->default('draft');
|
||||
$table->json('snapshot');
|
||||
$table->timestampTz('created_at');
|
||||
$table->unique(['tenant_id', 'created_by', 'operation_key']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('warehouse_requests');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('warehouse_request_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('warehouse_request_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version');
|
||||
$table->uuid('operation_key');
|
||||
$table->string('input_hash', 64);
|
||||
$table->string('action');
|
||||
$table->json('data');
|
||||
$table->text('reason');
|
||||
$table->timestampTz('created_at');
|
||||
$table->unique(['warehouse_request_id', 'version']);
|
||||
$table->unique(['tenant_id', 'actor_id', 'operation_key']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('warehouse_request_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('warehouse_reservations',function(Blueprint $table) {
|
||||
$table->id(); $table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('warehouse_request_id')->constrained()->restrictOnDelete();
|
||||
$table->unsignedInteger('line_index'); $table->foreignId('stock_item_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('warehouse_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('authorization_event_id')->constrained('warehouse_request_events')->restrictOnDelete();
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->uuid('command_key'); $table->string('kind'); $table->bigInteger('quantity_milli'); $table->timestampTz('created_at');
|
||||
$table->unique(['warehouse_request_id','line_index','command_key','kind'],'warehouse_reservation_command_unique');
|
||||
$table->index(['tenant_id','stock_item_id','warehouse_id']);
|
||||
});
|
||||
if(DB::getDriverName()==='sqlite')DB::unprepared("CREATE TRIGGER warehouse_reservations_update BEFORE UPDATE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END; CREATE TRIGGER warehouse_reservations_delete BEFORE DELETE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END;");
|
||||
if(DB::getDriverName()==='pgsql')DB::unprepared("CREATE FUNCTION preserve_warehouse_reservations() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Preservar reservas'; END $$; CREATE TRIGGER warehouse_reservations_immutable BEFORE UPDATE OR DELETE ON warehouse_reservations FOR EACH ROW EXECUTE FUNCTION preserve_warehouse_reservations();");
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('warehouse_reservations'); }
|
||||
};
|
||||
@@ -1,5 +1,89 @@
|
||||
# Desenvolvimento do SGC
|
||||
|
||||
## Composição orçamental e preparação de schema SGF — 01/11/2026
|
||||
|
||||
Continuação sem instalar/activar no destino: criada `integrations/sgf/operational`, com schema orçamental do contrato SGF existente, composição alternativa do mesmo BudgetAdapter/motor, verificação de destino/schema/âmbito e registo imutável `sgc_installations`. O preparador `scripts/prepare-sgf-operational.php` produz artefacto de revisão com sobreposições e suporte local, sem escrever na pasta SGF. `scripts/sgf-internal-install.php` oferece `plan` read-only e `migrate` de preparação do schema: confere hashes do host/manifesto/destino/backup, índice pg_restore, fundação e contrato de orçamento; DDL transaccional com lock e revisão/hashes/operador/role/backup. Não concede direitos, activa rotas ou troca o ponteiro. Sem down automático; schema parcial/incompatível é recusado. Fundação orçamental nova é criada vazia, sem fixtures.
|
||||
|
||||
Prova [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json): **20 controlos novos aprovados**, incluindo CLI, falha/rollback e retry, preservação de orçamento/direitos, esquema novo/existente/parcial/incompatível e reserva pelo mesmo motor fora do ambiente QA. Backup/restauro adicional em base descartável fora do prefixo QA com `APP_ENV=production` confirma o registo/schema, a reserva e a revogação. Bases e role removidas; nenhum teste aprovado anterior da requisição repetido. Candidata `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`; IR e ACCESS anteriores preservadas. Ponteiro SGF permanece 0.1.1, sem schema/direitos/código instalado alterado, publicação ou contratações para terceiros.
|
||||
|
||||
Limites/próxima acção: aplicar controladamente as sobreposições e a composição de arranque/rotas numa cópia isolada e conferir a integração HTTP, incluindo o novo ecrã de autoridade. Preparação de schema não equivale a activação do módulo ou entrega comercial integral. Só depois propor eventual activação na instalação de destino, que continua a exigir decisão separada. A reorganização modular posterior mantém entrega própria pendente. Matriz e comandos actualizados em [transferencia-sgf.md](transferencia-sgf.md).
|
||||
|
||||
## Preparação candidata do catálogo e da autoridade inicial SGF — 01/11/2026
|
||||
|
||||
Continuação do trabalho sobre os bloqueantes de instalação: implementados `integrations/sgf/access/AccessOverlay.php`, `InitialAuthority.php`, rotas/ecrã candidatos e `scripts/prepare-sgf-access.php`. O preparador lê o host e produz sobreposição com hashes, sem o alterar. Reutiliza administração, permissões, contexto e auditoria nativos. Corrigido na sobreposição o risco de novas unidades receberem todos os direitos quando se alarga o catálogo; continuam a receber só os dois direitos unitários anteriores. Revogação unitária confere e remove os direitos derivados atomicamente. Autoridade inicial nominativa, explícita e auditada por âmbito, sem perfil automático, só quando habilitada na composição; replay não reconstitui direitos revogados.
|
||||
|
||||
Prova nova `scripts/sgf-access-proof.php`: **24 controlos aprovados** com PHP fixado do SGF/PostgreSQL descartável/role restrita, incluindo atribuição/revogação nativas, contexto antigo, direitos não escolhidos, falha de auditoria/rollback, repetição e renderização. [Prova e artefacto](proofs/sgf-access-delivery.json). Nenhuma suite previamente aprovada da requisição repetida; nenhuma migração, concessão, rota ou ficheiro de código alterado no SGF instalado. Overlay `ACCESS-CANDIDATE-a69f06e21747228a`; candidata IR original preservada; ponteiro SGF 0.1.1 conferido inalterado. Não declarar instalação operacional concluída: faltam composição orçamental, instalador/schema rastreável, integração das rotas e prova HTTP do novo ecrã. Reorganização modular posterior continua com entrega própria pendente. Matriz/procedimento actualizados em [transferencia-sgf.md](transferencia-sgf.md).
|
||||
|
||||
## Impacto das pendências de instalação SGF — conferência posterior, 01/11/2026
|
||||
|
||||
Classificação e procedimento acrescentados em [transferencia-sgf.md](transferencia-sgf.md), sem alterar código/candidata nem repetir os testes aprovados. Equipamentos/peças fora do recorte básico de consumíveis/serviços/outros. Bloqueiam a instalação operacional: catálogo administrativo e autoridade inicial auditada para os novos direitos, composição orçamental que ainda exige `budget-testing`/base QA, instalador rastreável de schema e registo operacional de arranque/rotas. O catálogo actual `Permissions::UNIT` não permite gerir os direitos da candidata; a outorga exige também posse prévia do direito. Concessões sintéticas não são solução de entrega.
|
||||
|
||||
Documentados os passos existentes e os ainda sem executor, backup integral, rollback de DDL, retirada de código sem apagar schema, retry de comando e restauro para nova base com reconciliação de operações e revogações. Não há hoje comando de instalação operacional completo; os ZIPs são de revisão e não devem ser extraídos sobre a raiz SGF. Ponteiro 0.1.1 e testes aceites preservados. Reorganização modular posterior do SGF continua sem resultado apresentado nesta entrega, separada da integração de requisições. Próxima acção: resolver os bloqueantes de entrega operacional antes de propor a instalação; sem activar por arrasto.
|
||||
|
||||
## Candidata de requisições internas para o SGF — 01/11/2026
|
||||
|
||||
Implementado o percurso autorizado rascunho → submissão/reserva → aprovação independente → consulta num motor comum, com adaptadores autónomo e SGF. Reutilizados processos, orçamento, identidade e permissões existentes. Origem, versões, snapshots, histórico e recibos de comando são preservados; locks e transacções evitam duplicação em repetições concorrentes. Configuração explícita por empresa/unidade/departamento/centro, sem atribuição de direitos pelo selector ou pela política. Código e ecrãs candidatos em `integrations/sgf/internal-requisitions`; fonte comum em `packages/purchasing`.
|
||||
|
||||
SGF permanece **0.1.1**, sem activar 0.1.2 ou a nova candidata, publicar, aplicar schema ao destino ou alterar direitos públicos. Candidata de revisão não instalável pelo updater actual: [manifesto e arquivos](proofs/internal-requisitions-candidate.json). Migração 46 da origem e schema SGF ensaiados apenas em bases sintéticas isoladas. Provas de isolamento, acesso directo, revogação, concorrência em processos separados, falha após reserva, resposta perdida e recuperação em [proofs/internal-requisitions-sgf.json](proofs/internal-requisitions-sgf.json). Backup/restauro de 22 tabelas confere dados/schema/constraints/triggers e consulta posterior, separadamente da reversão de código.
|
||||
|
||||
A regressão alargada identificou e permitiu corrigir respostas 403 por conflitos e consulta indevida de schema na navegação pública. Verificações finais e limites registados na [matriz de transferência](transferencia-sgf.md). Não foi usada a via de navegador bloqueada. Ecrãs verificados por HTTP, lógica de acções e compilação; revisão visual não realizada. Próximo passo: rever a candidata e preparar a instalação operacional compatível com schema e catálogo administrativo, mantendo qualquer activação dependente da decisão separada já exigida pelo proprietário. Equipamentos/peças, fornecedores e contratação para terceiros não foram incorporados neste incremento.
|
||||
|
||||
Resultado final da candidata `IR-CANDIDATE-bfa807e2b3c4fae7`: regressão SQLite 881 aprovados/2 ignorados/8.950 asserções; PostgreSQL autónomo 46 testes/339 asserções; SGF 43 controlos; modelo de ecrãs 23 controlos. PHPStan dirigido, tipos, lint e build aprovados. [Resumo das verificações](proofs/internal-requisitions-validation.json). Ponteiro SGF 0.1.1 conferido inalterado, sem migrações persistentes ou concessões no destino.
|
||||
|
||||
## Conferência de independência, reversão e matriz antes de ampliar — 01/11/2026
|
||||
|
||||
Pedido posterior do proprietário: antes de ampliar, confirmar instalação cliente independente de Compras, delimitar rollback e classificar processos/adaptadores/ecrãs em transferido/parcialmente transferido/pendente. Matriz revista em [transferencia-sgf.md](transferencia-sgf.md), também reconciliada no documento SGF. Exclusão de contratações para terceiros no SGF mantida fora das pendências de implementação.
|
||||
|
||||
Confirmado: geração do pacote requer Compras/canal, coordenação da transferência local usa as duas pastas, mas instalação/execução cliente só usam o seu SGF, dependências locais, ZIP e SHA-256 aprovado. Acrescentado teste que torna indisponível a pasta de origem sintética e executa o loader/artefacto em processo separado, com todos os includes dentro da pasta cliente. Seis testes do mecanismo/29 asserções aprovados. Prova real SGF pelo wrapper/PHP 8.4.27: versão `0.1.1`, cálculo de referência correcto, todo o PHP carregado dentro do host e nenhuma ligação à base. `scripts/purchasing-client-check.php` é uma prova read-only, sem arranque da aplicação ou consulta empresarial. Prova `proofs/purchasing-client-independence.json`.
|
||||
|
||||
Rollback comprovado cobre apenas código/ponteiro de pacote. Não reverte dados, schema, dependências, configurações ou permissões; a existência de pastas antigas não demonstra recuperação de futuras migrações. O contrato actual declara/impõe ausência de mudanças de schema/concessões e recusa pastas de migração/seed; não é sandbox para PHP arbitrário. Antes de módulos com persistência: propriedade das tabelas, autoria/versão, compatibilidade código/schema, aplicação idempotente, auditoria, backup/restauro ensaiados e estratégia de recuperação/reconciliação. Não activar down/restauro destrutivo por arrasto.
|
||||
|
||||
Antes da nova instrução, já se preparava extracção de descontos/pontuação para o pacote e adaptação das fachadas em Compras. Testes locais: 103 testes existentes/1 564 asserções e cinco de economia/17 asserções; análise PHP dirigida aprovada. Ao receber o pedido, interrompida a transferência antes da activação. O processo filho chegou a publicar a candidata `0.1.2`, mas o SGF mantém `0.1.1`; não houve migração, nova capacidade activa, nova permissão ou ecrã SGF. Testes SGF de capacidades ainda não instaladas foram retirados da suíte activa. Preparação e candidata conservadas e marcadas **pendentes no SGF**, sem declarar transferência por testes locais.
|
||||
|
||||
Próximo percurso proposto na matriz, ainda não iniciado: requisição interna completa até decisão independente/consulta, com a reserva orçamental do percurso existente, adaptadores e ecrãs. A escolha de sequência permanece para a revisão da matriz; não foi substituída por activação da preparação económica. Cinco artefactos do mecanismo/prova de cliente reconciliados por hashes entre origem e SGF. Sem publicação no domínio ou manipulação dos dados da demonstração.
|
||||
|
||||
## Transferência para SGF e mecanismo de actualizações — 01/11/2026
|
||||
|
||||
O proprietário corrigiu expressamente a prioridade: transferir as capacidades de Compras para o SGF e criar o mecanismo que levará melhorias posteriores. A selecção de contexto anterior é fundamento de autorização, não a entrega principal. Implementado o primeiro pacote efectivamente executado no SGF e o percurso versionado de publicação/actualização, sem copiar bases, contas ou documentos. Detalhes, comandos e matriz de cobertura em [transferencia-sgf.md](transferencia-sgf.md).
|
||||
|
||||
A projecção de reposição foi extraída para `packages/purchasing`; a classe antiga é apenas fachada e o SGF carrega artefactos imutáveis dessa fonte. Confrontadas as diferenças de contexto, schema, frontend, PHP e Brick Math; não houve cópia de `App`/tabelas-base, fusão de identidades ou substituição de dependências. Núcleos financeiros comuns anteriores preservados. A transferência integral dos processos/ecrãs ainda não está concluída.
|
||||
|
||||
`composer purchasing:transfer -- <SGF> <canal>` verifica regressões, publica a fotografia provada, calcula nova versão patch apenas por alterações, confere integridade/versão instalada, prova a candidata com o PHP fixado pelo SGF e activa por ponteiro atómico sob lock. Retry sem alterações é no-op; deriva local de código, ZIP adulterado/incompleto, path traversal, versão repetida divergente e downgrade implícito são recusados. Versões anteriores e histórico conservados; rollback explícito só de código verificado. Contrato actual recusa alterações de schema e concessões, não abre bases e não executa seeds ou migrações. Artefactos de arranque do mecanismo têm fonte única e manifesto de hashes; runtime não depende da pasta vizinha.
|
||||
|
||||
Instaladas localmente `0.1.0` e `0.1.1` no SGF; a segunda melhoria é documental, sem alteração do algoritmo. Conferidos rollback real para `0.1.0`, reactivação de `0.1.1` e repetição sem reinstalação. Verificação final do publisher: 86 testes/566 asserções em bases isoladas/sem base; SGF puro, pelo wrapper/PHP 8.4.27: dois testes/11 asserções. Provas da candidata em ambos os runtimes preservam consumo manual, média racional e lotes. Fachada/motor passaram análise PHP dirigida. Não se executou a suíte SGF que recria a base da demonstração; os testes deste incremento são Unit sem aplicação/base. Provas em `proofs/purchasing-update-pipeline.json` e `proofs/purchasing-transfer.json`.
|
||||
|
||||
Limites: pacote inicial contém o cálculo, não o workflow/persistência/UI de abastecimento nem os restantes processos. O mecanismo actualiza melhorias das capacidades já extraídas, não código ainda confinado aos módulos da aplicação. Prosseguir com extracção/adaptadores por percurso e contrato de migrações aditivas; não declarar toda a aplicação transferida. Clientes recebem instalação limpa e artefactos/configuração próprios; SGF exclui contratações para terceiros. Formalização/RH, catálogo remoto assinado e actualização operacional de módulos com schema permanecem pendentes. Sem publicação no domínio, dados empresariais, novas permissões ou contas por arrasto.
|
||||
|
||||
## Contexto de trabalho — primeiro incremento implementado, 01/11/2026
|
||||
|
||||
**Decisões finais.** O SGC autónomo conserva as quatro opções previstas; o SGF exclui apenas contratações de pessoas/prestadores por conta de clientes. As contratações internas só podem reflectir-se em RH após formalização do contrato, respeitando a legislação laboral moçambicana; aprovação da selecção e contrato em rascunho não equivalem a formalização. O destino de RH já foi definido pelo proprietário mas não está implementado. Estas respostas resolvem as perguntas de alcance SGC/SGF registadas no fim deste documento. Não foi implementada nem certificada a formalização legal neste incremento.
|
||||
|
||||
**Análise e reutilização.** Reutilizados OrganizationAccess/User::hasFunctions, OrganizationConfiguration, versão global, lock da empresa, auditoria e menus existentes. Separados três eixos: limites comerciais da instalação por empresa (`config/work_context.php`), opções activadas pela empresa e permissões funcionais dos perfis associados aos contextos. A sessão guarda apenas escolha + utilizador + empresa; a disponibilidade é recalculada, incluindo revogações/expiração. A escolha não muda funções. Perfis de desenvolvimento preservados; a compatibilidade de desenvolvimento não autoriza clientes.
|
||||
|
||||
**Implementado.** Painel Inicial de escolha; selecção explícita entre duas opções e activação da única opção elegível ao escolher a área; ausência de opções sem dados de negócio. Contexto visível no shell; menus filtrados por destinos calculados no servidor além das permissões anteriores. Middleware nas rotas empresariais bloqueia outra área e opções sem percurso operacional. Perfis deixam de somar permissões de contextos diferentes nas operações de negócio. Parametrização dispõe de opções e associações de perfis, com versão/auditoria e aviso de efeito imediato sobre acesso, sem reescrever processos. Conta e administração autorizadas continuam acessíveis sem contexto.
|
||||
|
||||
**Origem dos processos.** Migração 45 adiciona política e associação de perfis, além de `work_context_key` em requisições/processos de Pessoas. Classificação inicial interna autorizada pelo proprietário. Scope nos dois modelos-raiz e protecção contra mudança no modelo e em SQL (SQLite/PostgreSQL); dados anteriores dos processos, decisões e snapshots preservados. O portal externo de candidatos conserva a autorização própria e não depende da escolha da sessão interna. Processos de terceiros ainda não podem ser executados; não declarar isolamento de todos os futuros percursos comerciais concluído.
|
||||
|
||||
**Avaliação local.** Em 8005, o catálogo completo é disponibilizado apenas após conferir ambiente local, base/role sintéticos e empresa através de EvaluationAccess; não altera perfis nem cria concessões comerciais para clientes. Fora dessa avaliação, `SGC_PRODUCT_ENTITLEMENTS` fornece os limites por ID de empresa, e `SGC_PRODUCT_COMPOSITION=sgf` retira contratações para terceiros. A empresa activa um subconjunto pela interface. Instalações existentes sem política preservam compatibilidade até configuração explícita; novas empresas começam com política vazia. O modo de compatibilidade não deve passar a entrega como configuração concluída.
|
||||
|
||||
**Mudança com alterações.** Protecção na navegação para o Painel Inicial, com Guardar/Descartar/Cancelar, erros de validação/rede sem trocar contexto e diálogo com foco gerido. Gravação automática limitada ao rascunho de requisição expressamente marcado, incluindo alterações programáticas às linhas; só muda depois do resultado desse formulário. Não submeter automaticamente decisões/aprovações para simular guardar. Outros formulários e várias edições simultâneas pedem conclusão pelas respectivas acções. Cobertura universal de formulários, histórico do navegador e ensaio visual da falha de gravação ainda pendentes.
|
||||
|
||||
**RH.** `PeopleContractHandoff` prepara apenas um envelope futuro, sem transporte, evento automático, empregado criado ou escrita em RH. Recusa selecção/rascunho, versões não persistidas e contexto de terceiros; exige estado de contrato formalizado. Esse estado ainda não é produzido pelo percurso actual. Não confundir esta fronteira técnica com módulo de RH, validação legal, fila transaccional ou integração operacional implementados.
|
||||
|
||||
**Entrega/actualização.** Instalação limpa por cliente; testes/avaliação ficam locais. `sgc:delivery-check {tenant}` é uma conferência sem escrita: requer produto, política, administração configurada, dois administradores permanentes e sinaliza registos empresariais a reconciliar. Não apaga dados nem substitui revisão dos cadastros/configurações, empacotamento ou rotina integral de actualizações. A migração é aditiva; actualizações não devem usar seeds de demonstração, reiniciar perfis nem alterar contextos de origem. A atribuição comercial está na configuração da instalação, ainda sem editor comercial de catálogo/contratos pela interface; catálogo por módulo/operação, distribuição SGF e actualizador comercial completo permanecem pendentes.
|
||||
|
||||
**Verificações.** Regressão geral: 860 testes, 858 aprovados, dois ignorados anteriores, 8 503 asserções. Bateria final dirigida: 66 testes/644 asserções. Contexto em PostgreSQL descartável: 12 testes/83 asserções; falha inicial por tentar relatório read-only dentro da transacção da fixture corrigida na prova, mantendo o controlo do relatório. Análise PHP dirigida sem erros, formatação dirigida e build aprovados. Análise PHP global encontrou problemas de tipagem web/candidate fora deste incremento; não declarar análise global limpa. Provas: `proofs/work-context-pg.json`, `work-context-http.json` e `work-context-install.json`.
|
||||
|
||||
**Instalação e limites.** Migração 45 aplicada só em 8005 pelo instalador existente com `--contexts`: backup/restauro conferidos, campos anteriores de 106 tabelas reconciliados, novas classificações internas conferidas, sem contas/grants alterados. HTTP real: áreas internas 200, área oposta 403, percursos de terceiros 409 com indicação explícita de pendência. Browser integrado indisponível (`browser access host is unavailable`); apresentação e diálogo não aceites visualmente. Venda ao cliente e contratações para terceiros permanecem opções identificadas, sem reutilização indevida dos percursos internos. Não publicado no domínio nem instalado no SGF neste incremento.
|
||||
|
||||
**Próxima acção.** Completar e conferir todos os formulários na mudança de contexto, os percursos de terceiros com seus dados/regras, catálogo comercial pela interface e actualização/distribuição. Formalização contratual e RH são incrementos próprios, com regras legais verificadas quando implementados. Não declarar os critérios originais integralmente satisfeitos por esta fundação.
|
||||
|
||||
## Publicação do percurso de candidatos confirmada — 3b1ef20
|
||||
|
||||
Confirmado em produção o commit `3b1ef2031ce395d21d973d0e7a7ec6630f058db8`, com serviço concluído com Result=success e ExecMainStatus=0. Inclui consulta pública paginada das oportunidades, escolha preservada na entrada e no registo/preparação do perfil e histórico paginado de comprovativos. Esta confirmação substitui as indicações históricas de incremento apenas local nas secções seguintes.
|
||||
|
||||
Mantidas as 44 migrações, sem novo delta de schema nesta publicação. Nginx validado, armazenamento privado partilhado persistente e escrita para o utilizador web conferidos. Navegação pública activa. Os 21 ecrãs públicos e sete percursos privados passaram na conferência HTTP. Não foram criadas candidaturas reais; a tabela de documentos continua vazia. Conferência visual dirigida e ensaio completo de candidatura no domínio permanecem pendentes. Prova `proofs/candidate-flow-production.json`.
|
||||
|
||||
## Histórico completo de comprovativos do candidato, 30 de Outubro de 2026
|
||||
|
||||
A área autenticada deixa de limitar a lista aos 20 comprovativos mais recentes. Tem paginação própria, contagem apenas da conta e empresa autenticadas e título extraído da oportunidade preservada na submissão, sem procurar o título actual do processo ou enviar o snapshot completo. A paginação de comprovativos conserva a página de oportunidades e vice-versa, através apenas dos parâmetros numéricos validados. Perfis e comprovativos anteriores mantêm-se compatíveis. Não foram introduzidos estados de contratação, alterações documentais, novos acessos ou migrações.
|
||||
@@ -1322,8 +1406,399 @@ Continuam pendentes dados bancários com dupla validação e cadastro de declara
|
||||
|
||||
## Consolidação de obrigações/pagamentos sintéticos — 30/10/2026
|
||||
|
||||
Incremento posterior autorizado: reversão integral de afectação por proposta com motivo e confirmação independente, compensação imutável e nova afectação pelo núcleo comum. Prova sintética Compras/SGF: dívida 7 600 → 11 600 → 8 600 MZN e pagamento disponível 0 → 4 000 → 1 000. Repetição, concorrência, revogação, isolamento, versões e falha/recuperação cobertos em bases descartáveis. 81 regressões locais aprovadas (343 asserções), builds e análise estática aprovados. Direitos novos só nas provas descartáveis; sem alteração dos direitos públicos ou actualização automática das demonstrações persistentes. Visual e MFA separados e pendentes; sem publicação/checkpoint ou aceitação presumida. Ver [entrega de reversões](../../sgf-tempo/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md).
|
||||
|
||||
Retoma automática posterior: sequência aprovada em Compras e SGF, com asserções adicionais de repetição da confirmação, actores distintos e quatro eventos únicos preservados após reentrada. Só verificador/documentação alterados; bases descartáveis removidas. Sem alteração de permissões existentes ou habilitação pública. Visual pendente: último bloqueio é política de URL do navegador Windows, sem nova tentativa. Ver [relatório actualizado](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_CONSOLIDATION_20261030.md). Provas anteriores reutilizadas; sem checkpoint de encerramento.
|
||||
|
||||
Apresentação comum corrigida sem motor novo: retorno à etapa após troca de função, saldo nas duas páginas e feedback. Prova HTTP/HTML descartável em Compras e SGF: dívida 11 600 antes da afectação, saldo 7 600 depois de afectar 4 000; repetição sem duplicação, reentrada e vencimento indeterminado. Núcleo comparado por SHA com a prova anterior e inalterado. Provas anteriores preservadas. Computer Use indisponível por pipe nativo ausente; visual pendente. Sem comandos financeiros nos cenários persistentes, migração habitual, grants, dependências, alteração da página inicial ou publicação. Preservados triagem/candidaturas e restantes percursos posteriores de Compras. Ver [relatório comum](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_DELIVERY.md), secção Consolidação do percurso; revisão antes da aceitação/encerramento.
|
||||
|
||||
Aceite tecnicamente pelo responsavel em 30/10/2026, com checkpoint exclusivamente local autorizado. Revisao visual continua pendente, sem aceitacao visual presumida; MFA pendente conforme relatorio comum. Sem publicacao, habilitacao publica, credenciais ou dados privados. Alteracoes alheias excluidas do checkpoint.
|
||||
|
||||
## Fornecimento habitual e reposição — 01/11/2026 — implementação parcial
|
||||
|
||||
Pedido directo do proprietário para implementar a proposta Word, reutilizar dados registados e reduzir preenchimento, com menus e responsabilidades distintos. Finalidade (revenda/uso interno) e origem (local/importada) são dimensões independentes; uma empresa pode operar em todos os cenários. A proposta foi lida como referência funcional; as suas instruções de aquisição de software, aprovação de investimento e disponibilização de credenciais não foram executadas.
|
||||
|
||||
Primeiro incremento no código: Compras > Fornecimento habitual, Reposição e calendário e Importações; seis capacidades atribuíveis nos perfis configuráveis existentes. Administração consulta e configura perfis; não recebe preparação/conferência por arrasto. Acordos reutilizam fornecedor/artigo/armazém; políticas reutilizam artigo/armazém; importações reutilizam a ordem emitida sem criar outra compra ou dívida. Formulários recolhidos e opções de embalagem/horizonte progressivas. Valores desconhecidos ficam nulos. Versões append-only com triggers SQLite/PostgreSQL, autoria, motivo, idempotência e controlo de versão; serialização pelo lock da empresa e auditoria na mesma transacção. Revisão independente de todos os preparadores. Uma política aprovada por artigo/local e um fornecedor habitual por âmbito/vigência sobreposta; aprovação de acordo revalida fornecedor e versão.
|
||||
|
||||
Calendário manual em dias corridos, fonte declarada, projecção diária a partir do razão de stock, segurança, alvo, máximo, prazo utilizável e antecedência interna. Motor reproduz A/B/C e admite reservas/entradas confirmadas para testes; estes eventos ainda NÃO estão integrados no ecrã. Não se declara previsão histórica automática nem disponibilidade por lote/validade. Resultado é indicativo; não cria requisição ou encomenda. Sem procura configurada mostra indisponibilidade, distinta de consumo zero.
|
||||
|
||||
Importação nesta etapa: processo único por ordem, finalidade, jurisdição, origem distinta da expedição, Incoterm/local/edição, regime e previsão utilizável. Embarque, chegada, retenção e libertação com data/evidência e histórico. Libertação exige conferência independente. Recepção de ordem vinculada fica bloqueada antes da libertação; repetição idempotente de recepção já efectuada conserva o resultado. Não altera pagamentos, facturas, obrigações ou orçamento; sem cálculo de impostos/JUE.
|
||||
|
||||
Cobertura ainda pendente do documento: alternativas direccionais/aprovação técnica e comparação elegível; histórico validado e políticas por frequência; reservas com satisfação por saídas; confirmações por linha/embarque e sugestões preservadas com aprovação/conversão única; lotes/validade/quarentena; devoluções/estornos; contratos e limites; alertas com responsáveis/prazos/evidências de fecho; declarações e regras aduaneiras versionadas, rateios e custo posto em armazém, adiantamentos/aplicações/restituições pelo núcleo financeiro comum; cenários avançados conforme evolução do Word. A separação finalidade/origem actualmente existe nestes novos registos, ainda não em todos os documentos anteriores de compras. Não declarar a proposta integralmente implementada nem pronta para lançamento comercial.
|
||||
|
||||
Migração 000042 preparada, ainda não aplicada a bases persistentes nesta passagem. Sem alteração de contas, atribuições ou dados empresariais; sem publicação. Alterações anteriores dos documentos de continuidade, routes/web.php e prova candidate-flow-production.json preservadas. Verificações dirigidas registadas na continuação; revisão visual e concorrência PostgreSQL deste incremento pendentes.
|
||||
|
||||
**Verificação e instalação do incremento de fornecimento — 01/11/2026:** 96 testes dirigidos aprovados/652 asserções (fornecimento, catálogo, recepção, operações de stock e navegação); TypeScript e análise estática dos quatro ficheiros novos aprovados; lint/formatação do âmbito aprovados. A análise global encontrou problemas de tipos em controladores anteriores com a união de utilizador interno/candidato; não foi declarada aprovação global. Política pode seleccionar acordo aprovado do mesmo artigo/local para reutilizar prazo e origem; revalida vigência e congela a versão, recusando aprovação se o acordo mudar. Histórico consultável no ecrã. Os testes abrangem habitual sobreposto, fornecedor alterado, revogação de acesso e repetição com conteúdo divergente.
|
||||
|
||||
000042 aplicada somente à avaliação local 8005, após backup e restauro comparado; 104 tabelas anteriores preservadas e supply_records vazia. Prova em proofs/supply-install.json. Corrigida durante a instalação uma falha de alias: getConfig transportava name=pgsql para a ligação temporária, fazendo a migração criar uma tabela vazia em compras_dev com ledger na QA. A tabela/função vazias criadas por essas tentativas e apenas o respectivo ledger foram removidos sob conferência; ambas as bases foram inspeccionadas. A configuração temporária agora retira name e a migração usa ligação explícita; compras_dev não tem tabela nem ledger 42, QA tem ambos. Nenhum registo empresarial ou atribuição alterado. Backups privados preservados. Este registo substitui a indicação anterior de migração por aplicar, sem apagar a sequência de verificação.
|
||||
|
||||
Avaliação local retomada em 8005; três páginas HTTP/Inertia aprovadas, ready=true, administrador sem preparação/conferência automática e sem comandos de negócio (proofs/supply-navigation.json). Atribuir capacidades operacionais aos perfis é decisão configurável da organização; não foram criadas atribuições automáticas. Conferência visual e concorrência PostgreSQL do novo circuito continuam pendentes. A proposta integral permanece parcialmente implementada, conforme matriz acima.
|
||||
|
||||
## Reservas entregas confirmadas e alternativas técnicas — 01/11/2026
|
||||
|
||||
Continuação autorizada da proposta de fornecimento. SupplyCoordination usa o mesmo diário versionado supply_records, locks por empresa, revalidação do operador, autoria, idempotência, controlo de versão, auditoria e imutabilidade SQL da migração 42. Sem nova migração, contas, concessões ou dados de negócio na avaliação persistente. Preservadas as alterações anteriores de candidaturas/continuidade e a prova candidate-flow-production.json.
|
||||
|
||||
Menus e responsabilidades: Compras > Alternativas técnicas (preparação técnica e validação técnica independente); Compras > Entregas confirmadas (confirmação/replaneamento por Compras); Armazém e activos > Reservas de necessidades (reserva e associação de saídas reais por Armazém). Quatro capacidades novas configuráveis: supply_technical_prepare, supply_technical_review, supply_delivery e supply_reserve. Administração conserva consulta/configuração, sem receber operações automaticamente. Permissões de um circuito não concedem operações dos outros. Pessoas e contratações mantém o roteiro próprio.
|
||||
|
||||
Reutilização: uma reserva pode seleccionar a linha aprovada de uma requisição ligada ao catálogo; artigo, quantidade, referência e data preenchem-se da fonte. O servidor revalida ligação e limite acumulado; não associa nomes iguais nem permite mudar a origem numa revisão. Necessidades ainda sem requisição são registadas uma vez por referência/artigo/local. Quantidade satisfeita é associada à saída existente; não cria outro movimento, limita a afectação ao saldo da reserva e da saída e conserva aplicações após cancelamento. Revisões e repetições preservam o histórico e recusam versões antigas.
|
||||
|
||||
Entregas: seleccionar linha da ordem emitida e reutilizar artigo/unidade do catálogo quando disponíveis; quando falta associação anterior, definir artigo/local uma vez. Recepção reutiliza essa associação e recusa artigo/local diferentes. Uma confirmação activa por linha, com quantidade acumulada confirmada (inclui recebimentos anteriores), data da evidência e disponibilidade utilizável prevista. O calendário deduz a quantidade efectivamente recebida, inclusive rejeições, da confirmação acumulada; apenas a aceitação real já feita pelo circuito anterior aumenta stock físico. Confirmar, rever ou cancelar a pendência não altera a ordem nem o stock. Datas ultrapassadas e importações sem libertação geram aviso e ficam fora das entradas utilizáveis. Múltiplos embarques/datas por linha continuam pendentes: não apresentar a confirmação única como gestão completa de embarques.
|
||||
|
||||
Alternativas: relação direccional artigo original/substituto, aplicação/local, classificação, evidências, condições, factor racional positivo e validade. Recusa auto-relação, duplicação de relação aprovada vigente e validação por qualquer preparador anterior. Uma aprovação num sentido não cria a inversa. Relações equivalentes só são indicadas como vigentes quando aprovadas, dentro da validade e com os cadastros iguais aos preservados; condicionadas, incompatíveis, pendentes, suspensas ou alteradas não são alternativas automáticas. A decisão incompatível pode ser validada como conclusão técnica, sem se tornar elegível. A utilização de substituição na requisição/encomenda, a confirmação de condições e a ordenação de ofertas continuam por integrar; não afirmar substituição operacional automática.
|
||||
|
||||
Calendário: já integra reservas pendentes e entradas confirmadas do diário, sem persistir outra autoridade de stock. Procura adicional soma-se à habitual; procura incluída consome a previsão do dia. Pendências vencidas são transportadas como procura adicional, não apagadas. Saídas afectadas a reservas antecipadas reduzem a procura prevista na data correspondente; as demais saídas do dia reduzem a previsão residual actual. Satisfação parcial/total não volta a deduzir a mesma procura. Sem procura habitual mas com reservas conhecidas, projecta apenas essas necessidades e assinala cobertura incompleta. O cenário de nova reposição sinaliza também excesso posterior por combinação com outras entregas. Lotes/validade/quarentena ainda exigem conferência manual; não declarar todo o saldo do razão como material utilizável por lote.
|
||||
|
||||
Provas: testes dirigidos SQLite, PostgreSQL descartável (14 testes/101 asserções) e duas corridas com contenção real do lock aprovados; mesma referência gera uma reserva e a mesma chave devolve o mesmo registo. A prova PostgreSQL detectou e permitiu corrigir uma comparação UUID com string vazia que SQLite aceitava. Nenhuma base persistente foi alterada por estes ensaios; a base descartável foi removida. Ver proofs/supply-coordination-pg.json. Navegação HTTP/Inertia das seis páginas em 8005 aprovada, ready=true e administrador sem operações automáticas; proofs/supply-navigation.json. TypeScript, build, lint/formatação e PHPStan dos ficheiros do âmbito aprovados. Conferência visual não realizada nesta continuação.
|
||||
|
||||
Cobertura restante, sem reabrir os percursos entregues: previsão histórica validada/segmentação; sugestões preservadas com revisão, aprovação e conversão única em requisição; utilização controlada de alternativas e comparação elegível de ofertas; reservas e entregas por lotes/validade/quarentena e múltiplos embarques; devoluções e estornos rastreáveis; contratos/limites; gestão de alertas com responsáveis, prazos, evidência de fecho e reabertura; declarações/regras de encargos aduaneiros e rateios/custo posto; aplicações, restituições e demais factos financeiros pelo núcleo comum. Não duplicar pagamentos, dívida, orçamento ou stock para acrescentar estes incrementos. A proposta integral continua em implementação.
|
||||
|
||||
**Regressão final desta continuação:** suíte completa com 766 testes, 764 aprovados, dois ignorados anteriores e 7913 asserções. Prova consolidada em proofs/supply-coordination.json; PostgreSQL 14/101 e duas corridas já descritas. Os ignorados anteriores não foram removidos ou contornados.
|
||||
|
||||
## Sugestões preservadas e conversão única em requisição — 01/11/2026
|
||||
|
||||
Continuação autorizada do fornecimento. Novo menu Compras > Sugestões de reposição e três capacidades configuráveis: supply_suggest_prepare, supply_suggest_review e supply_suggest_convert. Converter exige também a competência normal de solicitante e centro de custo do respectivo departamento. A Administração mantém consulta/configuração, sem operações automáticas; atribuir a competência de conversão sem a de solicitante permite consultar o ecrã, mas não criar requisições. Sem novas contas, concessões, migração ou dados empresariais na avaliação persistente.
|
||||
|
||||
Preparação: seleccionar a política aprovada e o artigo publicado no catálogo. Reutiliza artigo/unidade/categoria, regra, saldo e versão do razão, reservas, aplicações de saídas, entregas confirmadas, estado de importação relevante e fotografia da projecção. Reutiliza preço em MZN do acordo aprovado e vigente e taxa configurada da empresa. Se o preço local não existir, recolhe apenas a estimativa nova em MZN e a sua fonte no fundamento; não converte automaticamente preços em moeda estrangeira. A estimativa não escolhe fornecedor nem substitui comparação, adjudicação ou validação fiscal. Quantidade/data calculadas são reutilizadas, com proposta manual identificada quando necessário. Estimativas e quantidades respeitam as regras comuns de StoreRequisitionRequest, sem segundo conjunto de regras para criar a requisição.
|
||||
|
||||
Histórico: o diário append-only de supply_records guarda versões da sugestão, cálculo, fontes, referências/versões, fundamento, autores nominativos de cada acção, estimativas e aprovação. A base tem assinatura reproduzível; aprovação e conversão voltam a conferir catálogo, política/acordo/fornecedor, razão de stock, eventos e configuração fiscal. Alterações materiais, data ultrapassada ou fontes que deixaram de estar vigentes bloqueiam a operação até actualização/conferência; não se recalculam nem alteram documentos aprovados silenciosamente. Vigência dos eventos é também reavaliada para detectar confirmações entretanto atrasadas, sem invalidar por mera mudança de data quando nada material mudou.
|
||||
|
||||
Decisão: preparadores não conferem as suas versões nem versões em que contribuíram antes. Propostas manuais/cobertura incompleta ou pendências conhecidas exigem reconhecimento explícito e fundamento do conferente; este reconhecimento não permite ultrapassar o stock máximo. Quantidade mínima e embalagem são obrigatórias; o máximo é conferido ao longo do horizonte, inclusive perante outras entregas futuras. Uma proposta fora desses limites exige rever quantidade, calendário ou política pelo circuito próprio.
|
||||
|
||||
Conversão: solicitante autorizado, diferente da pessoa que conferiu, escolhe apenas o centro de custo existente e justifica a requisição. Cria uma única requisição em rascunho por aprovação, pelo RequisitionWorkflow habitual, com a versão do catálogo e as estimativas aprovadas. Não submete/aprova a requisição, não cria encomenda, reserva orçamental, dívida, pagamento ou movimento de stock. Repetir a mesma operação devolve o mesmo resultado; outra tentativa de converter a mesma sugestão é recusada. A criação da requisição, o vínculo da conversão e a auditoria são atómicos, incluindo rollback se falhar a auditoria final.
|
||||
|
||||
Navegação e acompanhamento: a sugestão liga à requisição só quando o utilizador pode consultá-la. O detalhe da requisição mostra proveniência mínima (versão aprovada, data de preparação, quantidade/data propostas e finalidade/origem) depois da autorização normal da requisição, sem conceder acesso ao diário completo, fornecedores ou outras empresas. O rascunho pode continuar pelo circuito habitual; alterações posteriores nele não reescrevem a aprovação da sugestão. Enquanto a sugestão/requisição estiver em acompanhamento, não se abre outro plano automático concorrente para o mesmo artigo/local; necessidades adicionais mantêm o percurso normal de requisição e devem ser reconciliadas no planeamento, não tratadas como autorização para duplicar a mesma proposta. O encerramento do acompanhamento exige rejeição da requisição de origem ou recepção completa das respectivas ordens; não representa liquidação financeira. Acompanhamento automático de cancelamentos/revisões da requisição e múltiplos planos simultâneos para necessidades independentes continuam por ampliar.
|
||||
|
||||
Cobertura actualizada: sugestões preservadas, conferência independente e conversão única directa passam a estar implementadas neste âmbito. A previsão continua manual/por necessidades conhecidas; histórico validado e segmentação não foram declarados concluídos. Permanecem utilização de alternativas no circuito de compra/comparação elegível, lotes/validade/quarentena, devoluções/estornos, contratos/limites e alertas operacionais completos, múltiplos embarques por linha e declarações/encargos/rateios/adiantamentos pelo núcleo financeiro comum. Não duplicar esse núcleo nem interpretar este incremento como conclusão integral do Word.
|
||||
|
||||
**Verificação final das sugestões — 01/11/2026:** suíte integral com 774 testes, 772 aprovados, dois ignorados anteriores e 8009 asserções. PostgreSQL descartável: 22 testes/197 asserções e quatro corridas com contenção real (referência de reserva, repetição de operação, duas conversões distintas da mesma aprovação e repetição da conversão com a mesma chave). Apenas uma requisição em cada corrida de conversão; testes de falha da auditoria comprovam rollback. Provas em proofs/supply-suggestions.json e proofs/supply-coordination-pg.json. Sete páginas locais HTTP/Inertia aprovadas, sem comandos de negócio ou concessões na avaliação persistente; proofs/supply-navigation.json. TypeScript, build, lint/formatação e PHPStan do âmbito aprovados. Visual pendente; sem publicação ou declaração de conclusão comercial integral.
|
||||
|
||||
## Média histórica de consumo com cobertura conferida — 01/11/2026
|
||||
|
||||
Continuação autorizada: Armazém e activos > Histórico de consumo, com supply_history_prepare e supply_history_review configuráveis nos perfis existentes. Preparar e conferir são responsabilidades independentes de todos os preparadores anteriores. Administração mantém consulta/configuração, sem receber operações automaticamente. O diário reutiliza supply_records e a migração 42 já instalada; sem nova migração, conta, atribuição ou dado empresarial na avaliação persistente.
|
||||
|
||||
Registar uma vez: o preparador selecciona artigo/local/finalidade e período fechado, caracteriza cobertura/padrão e fundamenta as evidências. Não introduz totais, médias ou saldos: SupplyHistory calcula-os a partir de stock_movements, conserva a série diária, referências de período, assinatura dos movimentos e cadastros, autoria e versões. Recepção, transferência, ajuste e contagem não são convertidos em consumo; apenas issue negativo entra no numerador. Os movimentos anteriores formam o saldo inicial de controlo, incluindo a sua assinatura para detectar correcções que afectem o período. O cálculo não altera o razão.
|
||||
|
||||
Dias corridos: inclui todos os dias, com ou sem saídas, e exclui o dia corrente ainda incompleto. Período de 1 a 3660 dias. Mostra dias com consumo e dias com saldo final nulo, sem inferir que saldo positivo prova ausência de ruptura intradiária. Cobertura desconhecida/incompleta, rupturas/procura oculta ou padrão sazonal/intermitente não alimentam a média simples. A aprovação pode validar uma análise que conclua pela insuficiência dos dados; isso não a torna elegível para previsão. A ausência de movimentos só é tratada como consumo zero depois de cobertura completa, âmbito/padrão confirmados e conferência humana independente; não é um zero presumido por falta de informação. Usos mistos sem separação são assinalados como cobertura incompleta, não divididos automaticamente por finalidade.
|
||||
|
||||
Política reutiliza a análise aprovada do mesmo artigo/local/finalidade, com versão congelada, mínimo de dias e idade máxima configuráveis. Os valores 30/90 apresentados no novo rascunho são propostas editáveis, não normas automaticamente activadas. Periodicidade, fornecedor/prazo, mínimos, segurança e limites existentes continuam no circuito de conferência da política. A selecção da média histórica elimina a redigitação da procura diária; uma diária enviada pelo cliente nesse modo é descartada. Expiração, suspensão, mudança de versão, cadastro ou movimentos anteriores ao fecho invalidam a reutilização e assinalam a procura habitual como desconhecida, preservando a análise antiga. Necessidades conhecidas mantêm a sua projecção e o percurso manual continua disponível.
|
||||
|
||||
Precisão: a média guarda o numerador exacto em milésimos e o denominador de dias. A projecção distribui procura pela diferença entre acumulados arredondados para cima à precisão do razão; não arredonda primeiro a média diária a zero. Cada conjunto de dias igual ao denominador conserva o total, incluindo consumos menores que um milésimo por dia. O ponto de reposição usa a mesma razão exacta e arredondamento conservador. Os métodos manuais anteriores mantêm a estrutura de cálculo e assinatura; sugestões manuais anteriores não recebem campos históricos novos por arrasto.
|
||||
|
||||
Integração: calendário e SupplySuggestions usam o mesmo resolvedor de política/histórico. A sugestão preserva período, média, versão e qualidade utilizados; suspensão ou alteração da origem impede aprovar/converter uma versão desactualizada. Nenhuma análise ou leitura cria requisição, reserva, ordem, pagamento ou movimento. O ecrã do histórico mostra factos da versão conferida; a validade para reutilização é novamente verificada quando a política/calendário/sugestão a usa.
|
||||
|
||||
Actualização sem repetir parâmetros: uma política aprovada/suspensa pode servir de base a novo rascunho. O servidor reutiliza campos existentes por referência e versão, confere empresa/artigo/local e preserva a linhagem; o preparador altera apenas histórico/acordo ou parâmetros necessários. Não altera a política antiga nem dispensa a nova conferência. A activação continua a impedir duas políticas simultâneas para o mesmo artigo/local; a anterior deve ser suspensa antes da substituição.
|
||||
|
||||
Limites deste incremento: implementa média observada para procura regular com cobertura conferida, não valida automaticamente precisão preditiva. Backtesting, sazonalidade/intermitência, ABC por valor, procura dependente e prazo observado de fornecedores continuam por desenvolver. Não certifica divisão de consumos mistos sem dados estruturados dessa divisão. Mantêm-se pendentes lotes/validade/quarentena, devoluções/estornos, comparação/substituição operacional, alertas completos, múltiplos embarques e restante circuito aduaneiro/financeiro comum. Não declarar o Word integralmente concluído.
|
||||
|
||||
**Verificação final do histórico — 01/11/2026:** 788 testes na suíte completa, 786 aprovados, dois ignorados anteriores e 8122 asserções. Após o ajuste final de validação da referência de política reutilizada, passaram os 20 testes dirigidos/163 asserções. PostgreSQL descartável: 36 testes/310 asserções, incluindo histórico, políticas, calendário e sugestões, mais as quatro corridas com contenção real dos percursos anteriores. Oito páginas locais HTTP/Inertia aprovadas, sem operações de negócio ou novas atribuições na avaliação persistente. TypeScript, build, lint/formatação e PHPStan do âmbito aprovados. Provas: proofs/supply-history.json, proofs/supply-coordination-pg.json e proofs/supply-navigation.json. Conferência visual não realizada; sem publicação.
|
||||
|
||||
## Lotes, validade e quarentena — primeiro percurso operacional, 01/11/2026
|
||||
|
||||
Continuação autorizada, com menu em Armazém e activos > Lotes, validade e quarentena (`/stock/lotes`). Cinco capacidades configuráveis na parametrização existente: consultar, preparar/identificar, conferir activação, colocar em quarentena e libertar. Administração pode consultar e configurar perfis, sem receber funções operacionais automaticamente. Operações físicas continuam a exigir a competência de Armazém; uma competência de qualidade não concede saídas nem recepções. Não depende de IA.
|
||||
|
||||
**Uma única autoridade de quantidades.** `stock_movements` continua a determinar exclusivamente o saldo físico. A migração 43 acrescenta `inventory_lot_allocations` como repartição de movimentos efectivos e do saldo residual explicitamente conferido na activação. Não cria outro movimento para identificar, corrigir identificação, colocar em quarentena ou libertar. Identidades, versões de metadados, preparação/activação e decisões de qualidade reutilizam o diário imutável `supply_records`. Chaves estrangeiras compostas preservam empresa/artigo/local e origem; guardas de banco impedem alteração/remoção, quantidades nulas, saldo negativo por lote, sinais incompatíveis e repartições superiores à origem. PostgreSQL também serializa por origem/identidade; os percursos da aplicação usam o mesmo primeiro lock da empresa dos restantes movimentos.
|
||||
|
||||
**Activação explícita.** Seleccionar artigo/local, exigência de validade e dias mínimos restantes; conferir a identificação física e fundamentar. O preparador não redigita o saldo do razão. Um lote único reutiliza o saldo residual, vários lotes exigem repartição exacta; saldo nulo não inventa existências. O saldo conferido já desconta consumos e transferências anteriores, sem reclassificar como saldo actual toda a quantidade originalmente recebida. Outra pessoa, diferente de todos os preparadores, confere e activa apenas se o saldo e o último movimento não mudaram. Não migra cadastros nem activa artigos antigos automaticamente. Identidade desconhecida não recebe código ou validade fictícios: fica indisponível até identificação e conferência. O ecrã avisa antes de activar sobre os percursos ainda pendentes.
|
||||
|
||||
**Recepção e identificação uma vez.** Para um âmbito activo, os dados novos dos lotes podem ser registados na própria conferência da entrega, numa secção recolhida, sem repetir artigo, unidade, fornecedor, ordem ou armazém. Um único lote reutiliza a quantidade aceite; vários exigem soma exacta. Se a identificação ainda não for conhecida, a recepção permanece possível, com lote por identificar e saldo integralmente em quarentena. A identificação posterior selecciona o lote já existente; preserva origem e histórico. Lotes vencidos na data da recepção não podem ser aceites. Rejeições não criam stock nem lotes. Identidades não são fundidas por igualdade de código. Não se alteraram regras financeiras, de orçamento ou de aceitação técnica da ordem; aceitação técnica e disponibilidade para utilização são estados diferentes.
|
||||
|
||||
**Qualidade e saídas.** Toda a quantidade inicial e cada nova recepção entram em quarentena neste incremento. Libertação exige identificação suficiente, prazo remanescente configurado e pessoa independente de todos os autores da identificação/recepção. Artigo sem prazo exige confirmação explícita e configuração que permita essa condição; campo vazio não significa validade ilimitada. Uma correcção acrescenta versão de metadados e invalida a libertação anterior, sem apagar a decisão antiga; necessita de nova conferência independente. Decisões têm controlo de versão de metadados e da qualidade, motivo, autoria e repetição idempotente. Vencimento ou validade remanescente insuficiente retiram disponibilidade sem alterar saldo físico. Saídas repartem automaticamente os lotes libertados por validade mais próxima e depois identidade, sem novo formulário de repartição; continuam a gerar apenas o movimento habitual. Insuficiência utilizável ou divergência entre repartição e razão impede a saída, mesmo havendo saldo físico. Stock e operações mostram físico, utilizável e indisponível; a consulta resumida não concede acesso ao histórico de qualidade.
|
||||
|
||||
**Reposição.** Calendário e sugestões reutilizam o mesmo contexto de lotes através de `InventoryLots::project`. Usam a quantidade utilizável e simulam consumo por validade mais próxima. Em cada dia de perda de validade retiram somente o remanescente ainda não consumido na projecção, sem descontar novamente o que já foi projectado como consumido. Entradas futuras ainda sem identificação/libertação não contam como utilizáveis, com aviso explícito. A base da sugestão preserva também identificação/decisão e repartição utilizadas: mudanças impedem aprovar/converter uma base antiga. Âmbitos sem controlo conservam a estrutura e os resultados anteriores, incluindo assinaturas e repetição de recepções anteriores à activação.
|
||||
|
||||
**Instalação e provas.** Migração 43 instalada exclusivamente na avaliação persistente 8005, depois de backup privado e restauro em base descartável com comparação integral. Preservadas 105 tabelas anteriores, contas, perfis e registos; nova tabela vazia. Sem dados sintéticos, activações ou novas atribuições nessa avaliação; sem publicação no domínio. `scripts/supply-install.php --lots` escolhe explicitamente a migração e conserva o instalador anterior da 42. Prova `proofs/inventory-lot-install.json`. Suíte completa: 801 testes, 799 aprovados, dois ignorados anteriores, 8192 asserções. PostgreSQL descartável: 49 testes/379 asserções, mais seis corridas com contenção real, incluindo duas saídas do mesmo lote e correcção de metadados concorrente com libertação desactualizada. Nove páginas HTTP/Inertia locais disponíveis; administração sem atribuições operacionais implícitas. TypeScript, build, lint/formatação e PHPStan dos novos serviços/modelo/controlador de lotes e serviços partilhados alterados aprovados. Provas complementares `proofs/supply-coordination-pg.json`, `proofs/supply-navigation.json` e `proofs/inventory-lots.json`. A verificação HTTP não é uma conferência visual em navegador.
|
||||
|
||||
**Limites e sequência seguinte.** Este incremento não encerra a proposta Word. Transferências e ajustes físicos de artigos controlados por lote ainda não repartem quantidades: servidor impede esses percursos quando origem ou destino têm controlo, para não perder rastreabilidade. Contagem física continua disponível, sem alterar saldo. Implementar a seguir transferência de identidade e quarentena no destino, contagens/ajustes por lote, devoluções/abates/estornos e respectivas provas de conservação/concorrência. Parâmetros de um âmbito activo ainda não podem ser revistos nem o controlo desactivado; activação inicial é parametrizável, mas não há ainda revisão posterior com vigência/impacto. Quarentena obrigatória e escolha automática FEFO são os únicos modos deste incremento; falta política conferida de dispensa de quarentena e selecção manual de lote quando justificada. Falta reutilização explícita de uma identidade entre várias recepções do mesmo lote; por agora cada recepção tem identidades próprias, sem fusão automática por código. Listagem apresenta os últimos 100 controlos e 200 eventos, sem pesquisa/paginação completa; não equivale a relatório integral. Faltam qualificação de lotes de entradas futuras, relatórios completos de rastreio e alertas/avisos programados. Permanecem métodos avançados de previsão, ABC, prazo observado, comparação/substituição operacional, múltiplos embarques e restante circuito aduaneiro/financeiro comum da matriz anterior.
|
||||
|
||||
## Transferências por lote — 01/11/2026
|
||||
|
||||
Continuação do percurso de lotes, sem nova migração, novas contas ou concessões de acesso. A guarda que impedia qualquer transferência controlada foi substituída por repartição efectiva das duas pontas no StockWorkflow existente. Ambos os locais precisam de controlo activo/conferido para esse artigo; origem ou destino sem controlo não são activados automaticamente. Transferências entre locais ambos sem lotes conservam o percurso anterior. Ajustes físicos com lotes continuam impedidos até haver repartição própria.
|
||||
|
||||
Registar uma vez: o operador selecciona artigo, origem, destino, quantidade, responsável e fundamento no formulário normal. O servidor escolhe os lotes libertados com validade mais próxima, reutiliza identidade/código/validade e reparte exactamente os dois movimentos habituais da transferência. Não cria recepção de fornecedor, compra, lote novo ou movimento físico adicional. Saldo insuficiente utilizável, origem/destino não reconciliados ou configuração incompleta impedem o registo. A identidade acompanha a quantidade entre locais e os dois saldos permanecem reconciliados com o único razão físico. Tudo ocorre na mesma transacção e sob o mesmo primeiro lock da empresa; erro desfaz ambas as pontas e a decisão de quarentena.
|
||||
|
||||
Qualidade no destino: cada entrada acrescenta uma decisão de quarentena local do lote, mesmo quando já existia uma decisão de libertação nesse local. A quarentena incide sobre todo o saldo da identidade no destino, incluindo quantidade anterior; esse efeito é avisado nos ecrãs de lotes/operações. O saldo restante na origem conserva a sua decisão local. O destino aplica os seus próprios dias mínimos/exigência de validade; material com validade suficiente na origem pode ficar indisponível no destino. Autor do registo e responsável da transferência acumulam-se como intervenientes pendentes da custódia; não podem libertar essa chegada. A decisão de libertação exige ainda independência dos autores globais da identificação/recepção. Colocar novamente em quarentena não apaga esses intervenientes; só uma libertação independente os encerra. Nova chegada volta a exigir conferência. Versão de qualidade impede reutilizar decisão preparada antes de chegada posterior.
|
||||
|
||||
Histórico: a operação e os dois movimentos preservam somente os lotes efectivamente utilizados, quantidades, versão da identificação e referência da qualidade de origem, mais âmbitos de controlo aplicados. Não expõem por arrasto todos os lotes ou históricos de qualidade do armazém. Operações mostra a repartição recolhida em «Lotes usados nesta operação», usando os dados da data do registo. Correcções posteriores de identificação não reescrevem a transferência antiga e continuam a invalidar libertações desactualizadas nos locais. Repetição da transferência devolve a operação original antes de recalcular disponibilidade ou registar nova quarentena: não duplica movimentos e não volta a colocar em quarentena uma chegada já conferida.
|
||||
|
||||
Limites restantes: transferência física imediata, com duas pontas atómicas; ainda não há expedição em trânsito e recepção física separadas. Movimentação de material em quarentena/vencido, selecção manual de lote, quarentena parcial de apenas uma fracção da mesma identidade, contagem/ajuste por lote, devoluções, abates e estornos continuam por desenvolver. Mantêm-se as restantes lacunas de revisão de parâmetros, reutilização explícita de identidade entre recepções, qualificação futura, relatórios/alertas/paginação e matriz Word anterior. A implementação não declara a proposta integralmente concluída nem altera regras financeiras. Próxima acção: contagens e ajustes repartidos por lote, preservando a diferença física e a conferência de qualidade sem transformar ajustes em consumo.
|
||||
|
||||
**Verificação final das transferências — 01/11/2026:** suíte completa com 808 testes, 806 aprovados, dois ignorados anteriores e 8230 asserções. PostgreSQL descartável: 56 testes/417 asserções e sete corridas com contenção real; a nova corrida disputa duas transferências de 7 unidades sobre 10 e confirma uma única operação aceite, origem com 3, destino com 7 e total físico/repartido de 10. Inclui FEFO com múltiplos lotes, mesma identidade/metadados nas duas pontas, independência de autor e responsável, nova chegada sobre saldo já libertado, preservação de intervenientes após hold, retry depois de libertação, requisitos diferentes de validade no destino, ausência de controlo numa das pontas e imutabilidade dos dados usados no documento. TypeScript/build/lint/PHPStan dos serviços alterados aprovados; nove páginas locais HTTP/Inertia disponíveis. Sem nova migração, redefinição de contas, concessão de funções ou comandos de negócio na avaliação persistente; sem publicação/conferência visual. Provas: proofs/inventory-lot-transfers.json, proofs/supply-coordination-pg.json e proofs/supply-navigation.json. A avaliação local usa o código e os recursos compilados actualizados.
|
||||
|
||||
## Contexto de trabalho e composição comercial — decisões do proprietário, 01/11/2026
|
||||
|
||||
Estado: análise e decisões registadas; selector, isolamento por opção empresarial e integração de RH ainda não implementados neste incremento.
|
||||
|
||||
- Reutilizar OrganizationAccess, User::hasFunctions(), OrganizationConfiguration, auditoria e navegação central; a selecção nunca concede permissões. Configuração comercial por empresa limita módulos/opções ao produto adquirido, cumulativamente com a autorização funcional.
|
||||
- Escolher área no Painel Inicial; com duas opções configuradas e autorizadas, o utilizador escolhe; com exactamente uma, activar automaticamente; com nenhuma, apresentar «Sem contexto autorizado», mantendo conta e parametrização apenas quando autorizadas.
|
||||
- Administradores da empresa criam e atribuem perfis por área. Preservar perfis de desenvolvimento para coerência de desenvolvimento e actualizações; esta preservação não os transforma em permissões automáticas de clientes.
|
||||
- Mudança com alterações por guardar oferece guardar, descartar ou cancelar. Falha de gravação mantém contexto actual. Processos submetidos conservam contexto de origem.
|
||||
- Venda para terceiros significa comercialização de bens/serviços, incluindo retalho e grosso; referências a revenda no abastecimento não representam um percurso completo de vendas.
|
||||
- No SGF, contratações de pessoas/prestadores são apenas internas; contratações validadas devem reflectir-se em recursos humanos. O marco exacto de validação e o contrato de integração de RH ainda precisam de ser definidos; selecção aprovada e contrato em rascunho são estados diferentes no percurso actual.
|
||||
- Proprietário autorizou classificar os dados actuais de desenvolvimento como internos. Ainda não aplicada qualquer classificação/migração nesta etapa.
|
||||
- Entrega a cada cliente deve usar instalação limpa, com os módulos/opções adquiridos parametrizados. Dados de testes e avaliação ficam localmente; não eliminar a base de desenvolvimento nem copiar dados de testes para clientes. «Recebe a base de dados completa» deve ser concretizado como estrutura/capacidades aplicáveis, respeitando esta decisão explícita sobre os dados.
|
||||
- Falta distinguir expressamente se a exclusão de contratações para terceiros se aplica apenas à composição SGF ou também ao SGC autónomo: a instrução inicial previa ambas no SGC, enquanto a resposta posterior nomeia especificamente o SGF. Não retirar essa opção globalmente por inferência.
|
||||
|
||||
Riscos identificados: permissões actualmente agregadas por empresa/utilizador; ausência de modalidade explícita nos modelos Requisition e PeopleProcess examinados; menus isoladamente não protegem consultas, URLs, relatórios ou comandos. Existem alterações locais anteriores em serviços, rotas e navegação que devem ser preservadas. Não duplicar motores, inferir direitos pelo contexto nem interpretar cadastro de pessoas como integração de RH concluída.
|
||||
|
||||
Próxima acção: esclarecer alcance SGC/SGF e marco/destino de RH; implementar configuração comercial e contexto comum com validação no servidor e interface, vinculação dos processos e regressões em base isolada. Não foram executados testes funcionais porque esta etapa só registou análise/decisões; provas anteriores mantidas.
|
||||
|
||||
## Contagens, ajustes, devoluções, regras de lotes e estornos — continuação de 01/11/2026
|
||||
|
||||
### Contagens e ajustes por lote
|
||||
|
||||
InventoryCounts reutiliza a identidade e a versão da identificação de cada lote. Armazém introduz apenas quantidades observadas; a interface calcula o total exacto em milésimos, sem copiar o saldo teórico para campos de contagem. Lotes com saldo precisam de quantidade observada, incluindo zero quando não foram encontrados. Identidades esgotadas só entram na contagem se forem seleccionadas explicitamente; não são apresentadas automaticamente como novas observações de zero. Uma identidade existente pode ser reutilizada quando a quantidade reaparece; um lote realmente desconhecido recebe identidade própria após o ajuste, com origem na contagem, sem inventar recepção de fornecedor.
|
||||
|
||||
A contagem conserva saldo/versão física, assinatura da repartição e identificação utilizada, sem movimentos. Outra pessoa, diferente de autor e responsável da contagem, com inventory_adjust_review, confere e aplica exactamente a diferença. Esse perfil não recebe saídas, transferências ou recepções por arrasto; um perfil apenas de Armazém não recebe a nova competência automaticamente. A mesma contagem só pode originar um ajuste. Alterações físicas, identificação ou âmbito activo invalidam a base; simples decisão de qualidade sem mudança de identidade/quantidade não é falsamente tratada como nova contagem.
|
||||
|
||||
Faltas podem retirar quantidades de lotes em quarentena ou sem validade suficiente, sem representar consumo. Excedentes colocam todo o lote afectado no local em quarentena, incluindo saldo anterior, com independência dos intervenientes da contagem e do ajuste. Lotes sem divergência conservam a decisão anterior. Diferenças simultâneas entre lotes são corrigíveis mesmo com total inalterado: um movimento adjustment_out para as faltas e um adjustment_in para os excedentes, ambos na mesma operação/transacção, sem saldo paralelo. A migração 44 altera apenas a unicidade do razão para operação/local/tipo, preservando a unicidade de cada componente; razão e repartições ficam reconciliados. A contagem original, aprovação e metadados usados permanecem imutáveis. Periodicidade de inventário e tolerâncias organizacionais ainda não foram generalizadas em configuração própria.
|
||||
|
||||
Migração 44 instalada exclusivamente na avaliação 8005 com backup/restauro verificados e 106 tabelas anteriores preservadas, sem novas tabelas, contas, permissões ou dados empresariais. Prova: proofs/inventory-count-install.json. O instalador aceita --counts separadamente de --lots, compara os registos existentes e não reaplica uma migração já registada.
|
||||
|
||||
### Devoluções ao fornecedor e abates físicos
|
||||
|
||||
Menu Armazém > Devoluções e correcções, com capacidades próprias inventory_disposition_read/prepare/review; execução física continua com Armazém. InventoryDispositions usa supply_records/42 para preparar, conferir, devolver para actualização e comprovar a saída. A aprovação exige independência de todos os preparadores; a execução exige pessoa diferente de quem aprovou. Uma referência de documento de stock não pode ser repetida. Estado/versão, chaves de repetição e auditoria fazem parte da mesma transacção da operação física.
|
||||
|
||||
Devolução selecciona recepção física aceite e reutiliza fornecedor, ordem, linha, artigo e unidade. Quando existe controlo por lote, exige identidade exacta com origem comprovada nessa linha; igualdade de código e saldo inicial não provam origem de fornecedor. Transferências anteriores conservam a identidade e permitem seleccionar o local actual. Sem lotes, o percurso usa o local original e exige conferência explícita, sem alegar rastreio individual de unidades antigas. Quantidade física e total já devolvido são novamente conferidos na aprovação e execução; o acumulado líquido não excede a aceitação original, mesmo havendo saldo adicional de outras recepções. Lotes retidos ou sem validade podem sair por devolução ou abate, sem serem registados como consumo. Abate selecciona artigo/local/lote e fundamento próprios; não cria devolução ou crédito de fornecedor. Cada saída gera apenas o movimento negativo habitual e a repartição correspondente.
|
||||
|
||||
Saída física e regularização financeira são distintas. Os percursos não criam crédito, restituição, despesa, pagamento ou redução de obrigação. Facturas existentes conservam os dados registados. Novas conferências de facturas descontam devoluções efectivas não estornadas; o ecrã de Finanças mostra os factos de devolução e correcção para confronto documental, sem assumir regularização concluída. Notas de crédito, restituições, contabilização do abate e integração correspondente com o núcleo financeiro comum continuam pendentes: não criar motor financeiro paralelo nem apresentar uma saída física como prova de crédito. Logística de devolução de bens rejeitados que nunca entraram em stock, substituição/reentrega autorizada e circuito aduaneiro de retorno continuam por completar.
|
||||
|
||||
### Revisão de parâmetros e dispensa de quarentena previamente aprovada
|
||||
|
||||
Configurações activas de lote já podem originar revisão pela interface, conservando referência/versão e os parâmetros existentes. A versão activa continua em uso enquanto outra aguarda conferência; a lista conserva o controlo activo e a proposta, sem esconder os lotes. A revisão não volta a repartir saldo nem cria lotes/movimentos. Mostra antes da decisão a quantidade utilizável actual e a prevista após alteração de exigência/dias mínimos de validade. Outra pessoa activa apenas se o saldo, identificação, qualidade e disponibilidade ainda corresponderem ao impacto preservado. Repetição é idempotente; uma segunda configuração activa independente não é criada por arrasto.
|
||||
|
||||
A exigência de quarentena de recepções é parametrizável e começa activa. A dispensa só se aplica após conferência independente da configuração, a novas recepções tecnicamente conformes, completamente identificadas e com validade suficiente. Reservas de qualidade, identificação/validade desconhecidas e prazo insuficiente não beneficiam. Saldo inicial, transferências, excedentes de contagem e quantidades repostas por estorno continuam em quarentena. A identidade da recepção preserva a configuração aplicada; libertação por regra conserva release_mode=receipt_policy e a versão aprovada, visível como dispensa por regra, sem simular conferência manual do lote. Alterar a regra de quarentena afecta novas recepções; não reescreve decisões anteriores. Exigência/dias mínimos de validade passam a determinar disponibilidade corrente com impacto conferido. Desactivação do controlo, vigência futura programada e selecção manual de lotes em saídas/transferências ainda não implementadas.
|
||||
|
||||
### Estornos integrais ligados à origem
|
||||
|
||||
Estornos ficam no mesmo grupo de Devoluções e correcções, com três capacidades próprias inventory_reversal_read/prepare/review e execução por Armazém, independente da aprovação. InventoryReversals prepara apenas por referência à operação física ou linha de recepção original: artigo, locais, identidades, unidades e quantidades são reutilizados; não há redigitação de efeitos. Cada origem só pode ser estornada integralmente uma vez. Contagens sem efeito físico, aceitação de serviços, estornos parciais e estornos de outro estorno não pertencem a este percurso. Aprovação exige pessoa independente de todos os preparadores. Mudança da origem, identificação ou configuração exige actualizar a decisão. Retiradas precisam de saldo físico nos locais e lotes exactos; não compensam faltas com outra identidade/local.
|
||||
|
||||
StockWorkflow mantém o único razão: novos movimentos reversal_out/reversal_in são inversos dos originais, atómicos entre locais e compatíveis com a migração 44. Repartições originais são reutilizadas e conservadas. Quantidades repostas em âmbito controlado colocam todo o lote local em quarentena, com independência dos intervenientes da correcção. Uma saída antiga, anterior ao controlo por lote, pode ser reposta como quantidade por identificar, com origem explícita no estorno, sem atribuir recepção de fornecedor fictícia. Uma entrada antiga sem repartição não pode ser retirada presumindo que corresponde a um lote inicial: falta reconciliação explícita dessa proveniência histórica. Referências a equipamentos/peças acompanham os efeitos e o histórico distingue a correcção. A quantidade grossa técnica da operação de estorno não é apresentada como consumo; os efeitos são mostrados individualmente por movimento/local.
|
||||
|
||||
Correcções posteriores não apagam os originais: operação e recepção mostram a referência do estorno; processo preserva proposta, aprovação, execução, comprovativo, efeitos e lotes efectivos. Uma linha física de recepção integralmente anulada deixa de ocupar as quantidades efectivas recebidas/aceites da ordem, incluindo a parte documental rejeitada dessa linha, mas só a parte aceite tinha efeito físico a inverter. Documento original mantém as quantidades históricas e a repetição da confirmação original não recria stock. Uma nova recepção de correcção exige nova chave/documento; não há reutilização automática da guia original. Devoluções efectivas dessa origem precisam de reconciliação antes de anular a recepção inteira, evitando descontar devolução e anulação duas vezes.
|
||||
|
||||
Saídas estornadas deixam de satisfazer reservas e de contar como consumo efectivo. As aplicações antigas continuam no diário; a necessidade pendente reaparece no calendário e pode associar uma nova saída efectiva, sem duplicação. A análise histórica incorpora a referência do estorno mesmo quando a correcção é posterior ao fecho do período: a assinatura muda e impede reutilização silenciosa de análise aprovada antiga. Consumo efectivo é corrigido; série física continua a representar os lançamentos nas respectivas datas, sem reescrever movimentos passados. Devolução ao fornecedor estornada deixa de descontar a quantidade líquida devolvida; novas conferências de facturas usam aceitação efectiva após devoluções e anulações. Facturas, orçamento, obrigações e pagamentos anteriores não são apagados nem estornados financeiramente por estes registos; a regularização documental permanece com Finanças/núcleo comum.
|
||||
|
||||
### Interface e limites transversais
|
||||
|
||||
As decisões de lotes, devoluções e estornos ficam recolhidas por registo, com uma acção escolhida e um único formulário de motivo/comprovativo, em vez de vários conjuntos repetidos de campos. Permissões da interface correspondem às do servidor; uma selecção de contexto ou origem não outorga funções. Estes incrementos continuam no roteiro de Compras/Armazém, sem criar vendas, confundir finalidade de reposição com percurso completo de comercialização nem alterar as decisões de composição SGC/SGF registadas noutra secção. Não foram aplicadas novas classificações comerciais aos registos existentes.
|
||||
|
||||
Mantêm-se pendentes previsão avançada/backtesting/ABC/prazo observado, utilização operacional de alternativas/comparação elegível, rastreio e pesquisa completos, alertas/encaminhamentos completos, múltiplos embarques, retorno aduaneiro, notas de crédito/restituições e restante integração financeira comum, além das limitações concretas acima. Não declarar cobertura integral do Word ou prontidão comercial com base apenas no percurso físico. A verificação final conjunta e as provas desta continuação serão registadas abaixo; as provas anteriores e as decisões de contexto comercial foram preservadas.
|
||||
|
||||
### Calendário e conferência final desta continuação
|
||||
|
||||
O calendário reutiliza as projecções existentes e reúne atenções factuais no próprio ecrã: prazo de pedir, risco/ruptura, excesso por mínimo/embalagem, procura desconhecida e reservas/entregas/lotes que exigem conferência. Não cria mensagens externas nem encomendas. Acordo expirado/suspenso, versão alterada ou fornecedor/documentação inelegível retiram as datas de pedir/encomendar e o ponto de encomenda; o risco físico continua visível. Alteração do cadastro/unidade suspende a interpretação da política antiga até revisão, sem converter quantidades por suposição.
|
||||
|
||||
Prova conjunta em docs/proofs/inventory-corrections.json: 98 testes PostgreSQL, 628 asserções e onze disputas reais de bloqueio passaram em base descartável; onze páginas HTTP/Inertia passaram em 8005, seleccionando o contexto de compras autorizado quando exigido. Nenhum comando de negócio, conta ou outorga foi criado pela navegação. TypeScript, compilação, análise PHP dos serviços/controladores alterados e integridade dos 18 ficheiros de execução passaram. A prova HTTP não é uma revisão visual por capturas. A bateria geral está a terminar e será registada a seguir. Alterações de contexto comercial presentes no espaço partilhado foram preservadas; não são atribuídas a este incremento de armazém.
|
||||
|
||||
**Resultado final da bateria geral (01/11/2026):** 862 testes, 860 passaram, dois ignorados, 8527 asserções; sem falhas. Resultado inclui o código presente no espaço partilhado nesta execução. Prova consolidada: docs/proofs/inventory-corrections.json. Não substitui instalação em domínio, revisão visual ou regularização financeira. Próxima continuidade funcional: confrontar restantes lacunas da proposta com a integração de alternativas no processo de comparação e o núcleo financeiro comum; não criar motor financeiro paralelo nem declarar concluídos créditos/restituições, previsões avançadas ou retorno aduaneiro.
|
||||
|
||||
## Relatórios de Pessoas e contratações — 01/11/2026
|
||||
|
||||
Corrigida ausência de entrada própria de relatórios no menu Pessoas e contratações. /pessoas/relatorios reutiliza PeopleProcess, PeopleHiring::kinds e PeopleAccess::visible, com isolamento por empresa e contexto, participação vigente por processo e capacidades existentes. Não concede consulta global aos administradores nem novas outorgas. Lista paginada (25), resumo por percurso/estado calculado sobre todos os resultados autorizados, filtros de percurso/estado/período de criação/título ou referência. Sem duplicação de cadastro. Impressão da página identificada como parcial, CSV de todos os resultados filtrados e ligação aos processos para relatório individual já existente.
|
||||
|
||||
A listagem/exportação usa só referência, título, percurso, estado e datas: não carrega candidaturas, documentos, entrevistas, condições financeiras ou contratos. Resumo e página usam leitura consistente; resposta privada sem cache, histórico Inertia cifrado. CSV neutraliza títulos que poderiam iniciar fórmulas. Minuta preparada não é apresentada como contrato assinado/admissão. Exportação materializa os campos mínimos dentro da transacção para manter uma fotografia consistente; conjuntos excepcionalmente grandes poderão justificar exportação assíncrona autorizada futura.
|
||||
|
||||
Verificação: 52 testes/1188 asserções de PeopleHiringTest e WorkContextTest passaram; inclui novos casos de visibilidade, expiração de acesso, filtros, empresa distinta e CSV. TypeScript, análise PHP do controlador, formatação e compilação passaram. HTTP local 8005 confirmou relatório e CSV no contexto people.internal, sem comandos de negócio, contas ou grants: docs/proofs/people-reports-http.json. Não feita revisão visual por capturas. Não exige migração nem alteração de dados existentes.
|
||||
|
||||
**Correcção de navegação 409 em /pessoas/relatorios — 01/11/2026:** reproduzido ao seleccionar people.third_party, marcado não operacional, e abrir directamente o relatório. EnsureWorkContext redirecciona agora pedidos GET de modalidade ainda pendente ao Painel Inicial, onde a situação e opções autorizadas são apresentadas. Não muda a modalidade silenciosamente, não activa funcionalidades pendentes e mantém comandos de escrita bloqueados e isolamento por área/empresa. people.internal continua a abrir relatório/CSV normalmente. 53 testes/1217 asserções de Pessoas/contextos passaram; análise PHP e formatação passaram; scripts/people-reports-check.mjs verificou HTTP local do percurso pendente (302), relatório e CSV internos (200). Sem alteração de contas, grants ou dados de negócio. Em sessão com modalidade pendente, escolher Contratações internas no Painel Inicial para consultar os processos internos.
|
||||
|
||||
**Visibilidade dos relatórios de Pessoas — 01/11/2026:** acrescentado botão Relatórios no topo de /pessoas, junto de Actualizar, e atalho Relatórios de pessoas e contratações no Painel Inicial quando a opção activa de Pessoas está operacional. Conservada entrada do menu e autorizações do servidor; não seleccionar contexto nem conceder acesso silenciosamente. Formatação/lint dos dois ecrãs, TypeScript e compilação passaram; relatório/CSV revalidados em HTTP local. Os atalhos tornam o acesso descobrível sem depender da abertura do grupo recolhível do menu.
|
||||
|
||||
## Relatório de candidaturas e avaliações — 01/11/2026
|
||||
|
||||
/pessoas/relatorios?view=applications acrescenta contagens por processo em ambos os roteiros, com atalho visível no topo, filtros existentes, impressão paginada e CSV completo dos resultados filtrados. Reutiliza PeopleHiring::matrix para elegibilidade e mínimo de pareceres; selecção aprovada/minuta usa selection.rows preservado. Não recalcula retrospectivamente uma decisão por expiração posterior do acesso do avaliador. Registadas para avaliação conta people_applications uma vez, incluindo submissões admitidas por triagem; recebidas/admitidas/não admitidas/pendentes no portal são indicadores separados, sem somar portal e avaliação como pessoas distintas. Não se deduz exclusão a partir de falta de avaliação nem contratação a partir de admissão.
|
||||
|
||||
PeopleApplicationReport conserva âmbito de PeopleAccess e contexto do relatório existente. Contagens de portal exigem CandidateTriage::canRead; devolvem null/Sem acesso quando não autorizadas, sem simular zero. Nenhuma identidade, documento, resposta, pontuação, motivo interno ou conteúdo contratual é devolvido nos indicadores. Avaliação completa, por concluir, sem parecer elegível e em curso são contagens; os conteúdos permanecem no percurso individual autorizado. Período filtra criação de processos, explicitamente, não datas de submissão. Ecrã calcula métricas apenas para processos da página; CSV percorre resultados filtrados completos numa fotografia de leitura consistente. Sem migração, cadastro duplicado, grants ou comandos de negócio. Não implementa nesta etapa relatórios de contratos assinados, admissão efectiva ou novos fluxos de selecção.
|
||||
|
||||
**Verificação do relatório de candidaturas:** 76 testes/1683 asserções de PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Inclui admissão do portal sem duplicação, acesso reservado à triagem, selecção preservada, filtros e exportação. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local confirmou relatórios e CSV; sem revisão visual por capturas. Prova: docs/proofs/people-applications-report.json.
|
||||
|
||||
## Relatório de selecções e contratações — 01/11/2026
|
||||
|
||||
Acrescentado /pessoas/relatorios?view=selections, por atalho visível e selector do relatório existente, para serviços individuais e recrutamento. PeopleSelectionReport reutiliza estado/selection/contract persistidos; mostra proposta por conferir/devolvida, selecção aprovada, datas da proposta/decisão e pendência correspondente. A minuta é sempre apresentada como preparada, sem inferir assinatura, formalização ou admissão efectiva a partir de starts_on. Não cria processos nem novas decisões e não faz integração RH.
|
||||
|
||||
Âmbito continua a usar PeopleAccess::visible, permissões por percurso e contexto. Referência, preparação e início previsto da minuta exigem contract ou contract_view; caso contrário mostram Sem acesso. Não devolve termos, fundamentos, identidades de candidatos, pontuações nem conteúdos financeiros. Impressão segue a página; CSV inclui todos os resultados filtrados, com as mesmas restrições e neutralização de fórmulas também nas referências contratuais. Período refere criação dos processos; datas de proposta/decisão são campos factuais, não novos filtros. Resumo por estado mantém âmbito completo, detalhe apenas página actual; CSV mantém leitura consistente.
|
||||
|
||||
Verificações: 78 testes/1839 asserções de PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Novos casos cobrem ambos os roteiros, devolução, reapresentação, aprovação, minuta, metadados contratuais reservados, CSV e expiração da participação. TypeScript, lint/formatação, compilação e análise PHP passaram. scripts/people-reports-check.mjs confirmou em HTTP local os três relatórios e respectivas exportações. Sem migração, grants, alteração de dados de negócio ou revisão visual por capturas. Próxima continuidade proposta: relatório de prazos e responsabilidades, reutilizando a mesa de trabalho existente; formalização/assinatura/admissão continuam fora deste incremento.
|
||||
|
||||
## Relatório de prazos e responsabilidades — 01/11/2026
|
||||
|
||||
Acrescentado /pessoas/relatorios?view=deadlines, com atalho e selector na página de relatórios, impressão e CSV. PeopleDeadlineReport deriva exclusivamente de PeopleWorkdesk::build no âmbito autorizado e filtrado do utilizador. Não duplica regras de atribuição, independência ou tarefas, nem cria prazos, intervenientes ou notificações. Tarefas/conferências representam a intervenção autorizada do próprio utilizador; alertas do processo não atribuem culpa nem responsabilidade pessoal. Não é uma listagem de toda a equipa ou de toda a sua carga de trabalho.
|
||||
|
||||
Atraso em dias de calendário usa a data da consulta no fuso da aplicação; prazo de hoje resulta zero, prazo desconhecido mantém null/Não determinável. Não interpreta ausência de prazo como tarefa em dia. O mesmo processo pode ter várias ocorrências (tarefas/alertas), explicitamente. Contadores/detalhe no ecrã referem processos da página; CSV percorre todos os processos filtrados. O resumo existente por estado continua a referir todos os processos filtrados. Datas de filtro são criação de processos, não vencimentos de tarefas. Conteúdos reservados de esclarecimentos, entrevistas, avaliações e contratos não são incluídos; referências e ligações conduzem ao percurso individual autorizado. Respostas privadas sem cache e leitura consistente existentes preservadas. Sem migração, dados de negócio/grants alterados ou integração RH.
|
||||
|
||||
Verificações: 80 testes/1903 asserções em PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Cobrem intervenção própria/alerta, atraso, prazo ausente e expiração de acesso, além dos relatórios anteriores. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local verificou quatro vistas e quatro CSV, em scripts/people-reports-check.mjs, sem comandos de negócio; prova docs/proofs/people-deadlines-report.json e people-reports-http.json. Sem revisão visual por capturas. Relatórios da equipa completa, atribuição de novos prazos e formalização contratual não são prometidos por este incremento; ampliar exigiria percurso próprio e autorização funcional correspondente.
|
||||
|
||||
## Prazos: filtros, totais completos e paginação por ocorrência — 01/11/2026
|
||||
|
||||
Relatório deadlines evoluiu com filtro opcional deadline_window: todos, vencidos, hoje, futuros e sem prazo. Datas comparadas no dia local da consulta; prazo de hoje não é atraso, ausência de prazo permanece desconhecida. Filtros de percurso, estado, pesquisa e criação dos processos continuam aplicados antes de obter a mesa de trabalho autorizada. O filtro de prazo actua sobre ocorrências derivadas, sem alterar tarefas nem criar datas.
|
||||
|
||||
Reconcilia a limitação anterior: totais agora abrangem TODAS as ocorrências filtradas, e não só os processos da página. Apresenta número de processos considerados, processos com ocorrências e total destas, sem confundir ocorrências com pessoas/processos. Lista própria de 25 ocorrências por página (deadline_page), ordenada por maior atraso e data, CSV completo independente da página seleccionada; exportação inclui filtro de prazo e data de consulta. Impressão identifica página e totais. Resumo/lista genéricos de processos ficam fora desta vista para não misturar bases diferentes. Filtros foram colocados antes dos resultados nos quatro relatórios, facilitando a consulta sem atravessar listas longas.
|
||||
|
||||
Verificação: 82 testes/2028 asserções de Pessoas, candidaturas e contextos passaram; inclui futuro/hoje/vencido, valores inválidos, desconhecido fora de atrasos, totais e duas páginas de 30 tarefas de um único processo, CSV integral mesmo ao pedir página 2. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local dos quatro relatórios/CSV e dos quatro filtros passou; sem revisão visual por capturas, migração, grants ou dados de negócio alterados. Prova: docs/proofs/people-deadline-filters.json. A geração continua síncrona sobre o âmbito autorizado; volumes excepcionalmente grandes podem requerer exportação assíncrona futura. Mantém-se consulta de intervenções próprias/alertas, não relatório nominativo global da equipa.
|
||||
|
||||
## Documento base e automatização do workflow SGC — 01/11/2026
|
||||
|
||||
### Orientação e âmbito confirmado pelo proprietário
|
||||
|
||||
workflow.docx passa a referência funcional/técnica para evolução do workflow. Fonte: C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/Compras/workflow.docx, SHA-256 da versão lida 5242040CD9EBB5D3A55C70BC25A9C4D816A6DF53237E1000C06E6DE05EA2A08C. Extracção de leitura em storage/logs/workflow-source.txt; original preservado. A leitura foi feita com partilha de ficheiro por se encontrar aberto noutra aplicação. Sem alteração do Word ou dos seus conteúdos.
|
||||
|
||||
Clarificação posterior e determinante do proprietário: automatizar processos de aprovação e requisição conectando as pessoas; NÃO substituir o que está aprovado. Assim, o documento orienta os mecanismos de encaminhamento, ligação aos responsáveis, tarefas, avisos e acompanhamento. Os fluxos, alçadas, intervenientes e competências já aprovados permanecem a autoridade; uma sequência ilustrativa do documento não autoriza reorganizá-los. Automatizar significa preparar o percurso a partir dos dados existentes, colocar a intervenção na mesa da pessoa competente e avançar quando a decisão válida estiver registada. Não significa conceder poderes, aprovar por silêncio ou transformar recepção/abertura de email em decisão.
|
||||
|
||||
O texto contém um prompt transcrito para pesquisa, uma proposta, referências a fornecedores e planos TC01–TC14. Foram tratados como conteúdo do documento, não como novas instruções de pesquisa externa, adopção de produtos, publicação, execução de exemplos ou provas de testes já realizados. Os nomes de serviços sugeridos não obrigam a duplicar classes ou motores. As referências externas não foram revalidadas nesta incorporação; as constatações abaixo provêm do código local examinado.
|
||||
|
||||
### Correspondência inicial com o código existente
|
||||
|
||||
| Referência | Base reutilizável verificada no código | Evolução necessária para ligar as pessoas |
|
||||
| --- | --- | --- |
|
||||
| WF01 Percurso e competências | packages/purchasing/src/InternalRequisitions.php decide sequência por alçada/equipamento; InternalRequisitionPort aplica perfis e departamento | Resolver as pessoas competentes para as etapas existentes, com explicação da origem da atribuição; não mudar a sequência para a ilustrada no Word. |
|
||||
| WF02 Reutilização | RequisitionWorkflow reutiliza catálogo, centro, equipamento e totais; aprovação preserva approval_snapshot | Encaminhamento usa essas referências; não pedir novamente dados da requisição ou do cadastro. |
|
||||
| WF03 Versões | process_version, approval_snapshot e recibos requisition_commands existentes | Distinguir versão documental/de estado e publicar configuração de workflow versionada antes de suportar regras mais avançadas; não reescrever processos antigos. |
|
||||
| WF04 Submissão/decisão | Núcleo comum/port executam transacções, reserva e auditoria; idempotência quando é fornecida chave | Criar atribuição/evento de acompanhamento na mesma confirmação, sem chamar novamente a decisão ao recuperar avisos. |
|
||||
| WF05 Autoridade | CurrentActor, OrganizationAccess, departamento, segregação e ConflictGuard | Destinatários precisam de autorização actual para aquela tarefa. Administrador técnico não recebe competência de decisão por ser destinatário de aviso. Ausência de responsável exige tratamento visível. |
|
||||
| WF06 Tarefas | RequisitionController oferece meus pedidos, aprovações e decididos; canDecide deriva do núcleo; Pessoas tem PeopleAccess/PeopleWorkdesk | Evoluir para tarefas persistidas e ligações às pessoas existentes, preservando a autoridade do servidor e a separação entre roteiros. |
|
||||
| WF07 Devolução/revisão | Núcleo de requisição examinado aceita approved/rejected; edição de draft; outros domínios têm revisões próprias | Devolução para correcção, retirada e nova submissão não estão demonstradas como ciclo completo da requisição neste núcleo. Qualquer adição deve preservar versões/decisões e tratar ordem emitida pelo percurso específico. |
|
||||
| WF08 Paralelismo | Requisição actual é sequencial; Pessoas tem avaliações independentes e decisão própria | Não converter a sequência aprovada em paralelo automaticamente. Só acrescentar paralelismo onde a política aplicável o exigir, com tarefas distintas e conclusão única. |
|
||||
| WF09 Delegação | Perfis, participações com prazo e funções existentes | Não equivalem a delegação aprovada com representado/representante. Falta percurso específico de âmbito, validade, autorização e revogação coordenada. |
|
||||
| WF10 Prazos | Pessoas reutiliza prazos existentes, atraso factual e desconhecido sem inventar datas | Configurar calendários úteis, prazos por etapa e escalonamento antes de os calcular; não substituir dias de calendário já apresentados por dias úteis silenciosamente. |
|
||||
| WF11 Avisos | Núcleos de decisão/auditoria existentes; não foi demonstrada caixa de saída transaccional completa do workflow | Implementar eventos persistidos, notificações internas por destinatário e recuperação; email depende de canal configurado. Falha de aviso não desfaz decisão nem a repete. |
|
||||
| WF12 Recuperação de entrega | Proposta distingue PENDING, PROCESSING, ACCEPTED, UNKNOWN e restantes estados | Conservar evento/chave/tentativas, revalidar acesso e tratar incerteza antes de reenviar. Não prometer email único ou leitura sem comprovativo do canal. |
|
||||
| WF13 Finanças e execução | BudgetWorkflow, PurchaseWorkflow, ReceiptWorkflow e InvoiceWorkflow próprios | Ligar estados de acompanhamento por referência, sem criar outro saldo, dívida, pagamento ou processo de compras. Aprovação de requisição não é autorização de pagamento. |
|
||||
| WF14 Pessoas | PeopleHiring, PeopleAccess, tarefas e quatro relatórios já existentes | Reutilizar componentes comuns de ligação/tarefas, conservando candidatos, critérios, equipas e decisões no roteiro próprio. Não converter candidatura numa requisição. |
|
||||
| WF15 Provas | Testes locais de requisição/núcleo comum e outras provas já guardadas | Mapear TC01–TC14 para implementações efectivas e executar disputas reais em PostgreSQL quando o mecanismo existir. Não marcar casos futuros como passados. |
|
||||
|
||||
### Sequência de aplicação compatível com o aprovado
|
||||
|
||||
1. Inventariar etapas e responsabilidades aprovadas e relacioná-las com perfis, funções, departamentos, centros e equipas actuais. Identificar responsáveis ausentes/ambíguos sem criar novas contas ou conceder poderes por omissão.
|
||||
2. Acrescentar atribuições e mesa de tarefas sobre o núcleo existente, com versão de origem, prazo quando configurado, responsável e ligação directa à intervenção autorizada. Manter consultas sem efeitos de negócio.
|
||||
3. Acrescentar avisos internos e eventos de saída duráveis; confirmar tarefa/decisão/estado/auditoria atomicamente. Não enviar mensagens externas nesta etapa sem configuração e autorização próprias.
|
||||
4. Acrescentar lembretes, substituições/delegações e escalonamento conforme as regras aprovadas, depois de suportados os respectivos mecanismos de configuração e testes. Nenhuma ausência de resposta aprova automaticamente.
|
||||
5. Completar devoluções/revisões e configuração versionada onde faltarem, sem substituir os percursos estáveis ou activar P1/P2 como se já existissem.
|
||||
|
||||
Limites/alçadas, moedas, responsáveis nominativos, feriados, tempos de concessão/tentativas e canais são configuração da organização; exemplos do Word não os fixam. Persistência histórica, isolamento empresa/contexto, papéis distintos, núcleo independente de IA e implementação única dos motores mantêm-se. Instalações existentes conservam o percurso aplicado; qualquer migração de instâncias exige mecanismo explícito e auditável, sem arrastar dados de avaliação para clientes.
|
||||
|
||||
### Verificação desta incorporação
|
||||
|
||||
Leitura e confronto de RequisitionWorkflow, InternalRequisitionPort, núcleo InternalRequisitions, OrganizationConfiguration, PeopleWorkdesk e relatórios existentes. Quatro testes/34 asserções de InternalRequisitionCandidateTest passaram; 18 ficheiros dos núcleos distribuídos conferidos por SHA-256. Esta prova cobre a regressão dirigida existente, não implementa nem certifica todos os requisitos do Word/TC01–TC14. Nesta etapa só foi registada a base e a correspondência; não foram alterados estados, sequência de aprovação, base persistente, contas ou outorgas. Próxima implementação: ligar responsáveis e tarefas às etapas existentes, começando pelo diagnóstico de elegibilidade de encaminhamento.
|
||||
|
||||
## Tarefas dos perfis e ligação às pessoas da etapa actual — 01/11/2026
|
||||
|
||||
Por orientação expressa do proprietário, a definição dos perfis deve indicar as tarefas exercidas para associação ao workflow. WorkflowTasks acrescenta catálogo de tarefas ligado às chaves de OrganizationAccess::PERMISSIONS, sem segunda fonte de concessão. Parametrização apresenta Tarefas e permissões do perfil, descrição das intervenções e resumo imediato das seleccionadas; guardar continua a usar o perfil/versionamento/auditoria/contextos existentes. Cada permissão mantém a abrangência anterior: as descrições não criam ainda selecção independente de cada subacção de uma capacidade abrangente. Todos os códigos de permissão existentes têm correspondência, com descrição específica para requisições e Pessoas e descrição funcional existente para os restantes.
|
||||
|
||||
ApprovalRouting consulta a etapa pendente real da requisição e identifica pessoas da mesma empresa para as quais Requisition::canDecide devolve autorização, excluindo manutenção. Reutiliza função, departamento, independência do requisitante/aprovadores anteriores, conflitos e acessos/contexto correntes. Devolve apenas id/nome, função, etapa, chave funcional da tarefa e instante da consulta. O detalhe da requisição apresenta Quem pode tratar a etapa actual e avisa quando não existe pessoa elegível. Vários elegíveis constituem fila actual, não atribuição exclusiva; não escolhe arbitrariamente uma pessoa. Perfil administrativo não implica aprovação. Processo não pendente não mantém uma fila activa. Consultar não grava tarefas, decisões, eventos de aviso ou auditoria de negócio.
|
||||
|
||||
Não muda sequência, etapas, alçadas, núcleo comum, reservas ou outorgas. O motor continua a revalidar ao decidir; esta consulta não é prova de autorização futura. Não foi criada nesta etapa atribuição persistida, tomada exclusiva, delegação ou envio de avisos. Próxima evolução: tarefas/avisos internos duráveis sobre esta ligação, preservando o executor efectivo e decisão do núcleo. Capacidades abrangentes ainda precisam de evolução coordenada no núcleo caso se pretenda seleccionar subacções separadamente; não simular separação só por texto/formulário.
|
||||
|
||||
Verificações: 40 testes/403 asserções de InternalRequisitionCandidateTest, OrganizationConfigurationTest e WorkContextTest passaram. Novos casos cobrem elegibilidade, departamento, outra empresa, revogação de função, administrador sem poderes adicionais, ausência de alterações ao processo/auditoria e catálogo completo nas parametrizações. TypeScript, lint/formatação, compilação, análise PHP dos serviços e integridade dos 18 ficheiros de execução passaram. HTTP local conferiu catálogo e detalhe de requisição existente: proofs/approval-routing-http.json. Sem migração, alteração de dados persistentes/grants ou revisão visual por capturas. Prova consolidada: proofs/approval-routing.json.
|
||||
|
||||
## Avisos internos ligados às etapas de aprovação — 01/11/2026
|
||||
|
||||
Migração 46 acrescenta workflow_notices com origem na requisição/etapa, destinatário, referência, função, criação e leitura; unicidade por etapa/destinatário. InternalRequisitionPort chama WorkflowNotices::capture após submissão/decisão, dentro da transacção do núcleo existente. Elegibilidade usa ApprovalRouting e canDecide, sem mudar funções, sequência, alçadas ou criar aprovações. A repetição idêntica recupera o recibo do núcleo sem recriar avisos. Se a persistência do aviso falhar, decisão/submissão, reserva, auditoria e recibo da operação são revertidos juntos. Sem migração instalada, instalações existentes mantêm o percurso e a página informa instalação pendente; não bloqueia decisões por ausência de um adicional ainda não instalado.
|
||||
|
||||
GET /requisicoes/avisos mostra apenas avisos do destinatário na empresa/contexto actual, com etapa ainda pendente e autorização actual. Fila paginada, por ler e ligação ao processo. Etapa concluída, conflito, participação anterior incompatível ou acesso revogado retiram o aviso da lista activa sem apagar a origem. Marcar como lido é POST autenticado, com bloqueio da empresa, revalidação, auditoria e efeito idempotente; não decide, reserva, avança ou altera a requisição. Manutenção não recebe nem consulta fila operacional. Administrador não recebe avisos financeiros por ser administrador. Nenhum email, chamada externa ou confirmação de leitura de canal foi enviado/inferido.
|
||||
|
||||
Este incremento é entrega interna na própria base, não executor externo/outbox de email. Não inclui garantias de entrega a canais externos, delegação, tomada exclusiva, agendamento, lembretes, reencaminhamento de instâncias antigas nem notificações de decisão ao requisitante. Perfis alterados depois da criação podem retirar acesso imediatamente; novos destinatários em etapas já abertas continuam a consultar Pendentes para mim, mas ainda não recebem aviso retroactivo. Dados e avisos anteriores conservados; não existe varredura de negócio em GET para os recriar.
|
||||
|
||||
Instalação local 8005 efectuada por scripts/supply-install.php --notices, com identidade do destino conferida, backup privado e restauro em base descartável verificado; 106 tabelas existentes preservadas, nova tabela vazia e sem contas/grants/dados sintéticos. Proofs/workflow-notices-install.json. Domínio não instalado. Verificação dirigida passou 42 testes/455 asserções de requisição/configuração/contextos; análise PHP, formatação e 18 ficheiros dos núcleos conferidos. HTTP local confirmou página pronta. Verificação geral e PostgreSQL estão a ser concluídos e serão registados a seguir; não confundir com execução dos TC01–TC14 completos.
|
||||
|
||||
**Resultado final dos avisos internos:** bateria geral 891 testes, 889 passaram, dois ignorados, 9236 asserções; sem falhas. PostgreSQL descartável: 21 testes/244 asserções de InternalRequisitionCandidateTest e WorkContextTest passaram, com base removida no finally. Tipos, compilação final, lint, análise PHP e HTTP local passaram. Prova consolidada: docs/proofs/workflow-notices.json.
|
||||
|
||||
A tentativa de incluir OrganizationConfigurationTest inteiro no executor PostgreSQL revelou um teste existente de relatório que usa a transacção externa de RefreshDatabase, incompatível com a exigência já existente de transacção própria de leitura de ProcurementReviewReport. O erro foi diagnosticado explicitamente; não se alterou a guarda do relatório nem se marcou esse caso como passado em PostgreSQL. Organização está coberta pela bateria geral habitual; o executor PG deste incremento foi limitado aos dois ficheiros que verificam requisição/avisos/contexto, sem novos testes ignorados. Isto não demonstra execução da matriz concorrente TC01–TC14 completa. Próxima evolução: reencaminhamento controlado quando mudam perfis/destinatários de etapas já abertas e avisos de resultado ao requisitante, sem repetição da decisão.
|
||||
|
||||
## Avisos de resultado ao requisitante — 01/11/2026
|
||||
|
||||
WorkflowNotices::outcome reutiliza workflow_notices da migração46 para avisar o requisitante quando a requisição termina approved/rejected. Destinatário vem da origem, função/decisão vêm da etapa efectivamente concluída e a unicidade etapa/destinatário já existente impede duplicação. O requisitante nunca integra a fila decisora da própria requisição, pelo que os avisos de resultado são distinguíveis das intervenções pendentes sem alterar os registos antigos ou acrescentar outro motor. Gravação ocorre no port da decisão, na mesma transacção do núcleo e antes da confirmação final; falha do aviso reverte decisão, auditoria, recibo e efeitos orçamentais. Repetir uma operação já confirmada recupera o recibo, sem recriar o aviso.
|
||||
|
||||
Página /requisicoes/avisos passa a separar intervenções pendentes, resultados dos meus pedidos e por ler. Entrada de menu disponível também ao requisitante, sempre sujeita ao contexto e acesso no servidor. Resultados permanecem visíveis ao autor com acesso actual mesmo após terminar a intervenção de aprovação; avisos de tarefas terminadas continuam fora da fila. Cada resultado apresenta o facto aprovado/rejeitado e o estado actual separadamente, incluindo eventual cancelamento posterior, sem apresentar o facto histórico como estado vigente. Comentários, fundamentos internos, montantes e conteúdos de terceiros não são copiados para o aviso; ligação leva ao processo autorizado.
|
||||
|
||||
Consulta/leitura revalida destinatário, empresa, contexto, permissões actuais e exclusão de manutenção; outro interveniente não pode ler ou marcar aviso do requisitante. Ler continua sem efeito de aprovação ou pagamento. Sem email, novo schema/migração, retroactividade de decisões antigas, grants, contas ou dados sintéticos. Reencaminhamento de perfis alterados, lembretes, delegação e envio externo continuam fora desta etapa.
|
||||
|
||||
Verificações: 45 testes/573 asserções de requisição/configuração/contextos passaram; PostgreSQL descartável24 testes/362 asserções de requisição/contextos passaram. Casos novos cobrem approved/rejected, duplicação, leitura idempotente, outro destinatário, estado posterior distinto do resultado e reversão integral por falha de persistência. TypeScript, lint/formatação, compilação, análise PHP e integridade18 ficheiros de núcleo passaram. HTTP local confirmou as três vistas de avisos e dados de parametrização/encaminhamento sem criar decisões. Prova docs/proofs/workflow-outcomes.json. Sem revisão visual por capturas; bateria geral anterior permanece prova de base, não se atribuem os seus números a uma nova execução desta etapa.
|
||||
|
||||
## Actualização controlada do encaminhamento — 01/11/2026
|
||||
|
||||
Acrescentada acção recolhida Actualizar encaminhamento no detalhe da requisição pendente, disponível a Administração/Compras com acesso efectivo e fora de manutenção. Exige motivo, etapa esperada, process_version e chave da operação. Não permite escolher destinatário livremente: WorkflowNotices::refresh reutiliza ApprovalRouting::current/canDecide e cria apenas avisos ainda inexistentes para os elegíveis actuais. Não altera o percurso, competências, etapa, orçamento, versão da requisição ou decisões. Não envia email e não cria delegação/atribuição exclusiva.
|
||||
|
||||
Comando POST /requisicoes/{id}/encaminhamento bloqueia empresa, revalida o operador e bloqueia a requisição; guarda a auditoria workflow.routing_refreshed com motivo, etapa e destinatários antes/depois. A auditoria imutável conserva o recibo lógico (operation_key e assinatura) neste âmbito; repetição igual não volta a gravar auditoria ou avisos, conteúdo diferente com a mesma chave é recusado. Etapa/versão alteradas produzem erro de formulário legível, sem voltar a encaminhar outra etapa. Antes de recuperar um recibo, acesso actual à requisição e competência do operador voltam a ser conferidos.
|
||||
|
||||
Avisos antigos e read_at são preservados; pessoas revogadas deixam de consultar a fila pela revalidação existente. Pessoa que recupera acesso à mesma etapa reutiliza o aviso original e a leitura anterior, não recebe um aviso duplicado. Falha de auditoria reverte os novos avisos na mesma transacção. Consultas não executam reconciliação nem modificam dados. A associação nova só é efectuada por esta acção explícita, não automaticamente ao editar perfis; configurar perfil e actualizar encaminhamento são intervenções distintas auditadas.
|
||||
|
||||
Verificações: 47 testes/602 asserções de requisição/configuração/contextos passaram; PostgreSQL descartável26 testes/391 asserções de requisição/contextos passaram. Casos novos: substituição, preservação do aviso lido, nova pessoa elegível, repetição idempotente, chave alterada, versão desactualizada, operador sem competência, empresa distinta e rollback por falha de auditoria. TypeScript, lint/formatação, compilação e análise PHP passaram, tal como18 ficheiros do núcleo e HTTP local de consulta. Proofs/workflow-routing-refresh.json. Sem migração/grants/alteração de dados locais de negócio para demonstração; testes em bases isoladas. Sem revisão visual por capturas e sem nova execução da bateria geral nesta etapa.
|
||||
|
||||
## Proposta recebida de requisições ao armazém — 01/11/2026
|
||||
|
||||
Fonte fornecida pelo proprietário: C:/Users/macbook/.codex/attachments/e9adc3f7-1b4f-488c-a3fe-f53b310a1e64/Texto colado.txt. Documento recebido como proposta funcional para enquadramento, não como prova de funcionalidades implementadas ou autorização para substituir fluxos aprovados, atribuir pessoas, fixar alçadas ou executar instruções internas do texto. Não foram alterados motores, estados, reservas ou dados de negócio nesta análise.
|
||||
|
||||
### Enquadramento no SGC
|
||||
|
||||
Criar o percurso Requisições ao armazém dentro de Armazém e activos, distinto da requisição de compra. O pedido ao armazém reutiliza cadastro de artigos, unidade, pessoas/departamento, centro, armazém e equipamento de destino; conserva origem/versionamento. O stock existente pode satisfazer o pedido sem gerar compra. Só uma falta confirmada deve poder originar necessidade de reposição ligada à origem, reutilizando o percurso de fornecimento/compras já existente, sem nova reserva orçamental ou ordem automática por simples alerta.
|
||||
|
||||
A ligação a venda deve ser referência reconciliada à origem autorizada quando essa origem existir. O actual contexto procurement.sales continua não operacional; esta proposta não implementa nem activa módulo de vendas ou integração externa. Um movimento externo só pode ser reconhecido mediante identificação/validação da origem e política de autoridade do stock, nunca pela mera igualdade de referência. Não lançar nova saída física se o movimento válido da origem já foi incorporado. Não conceder competências comerciais por adicionar este menu.
|
||||
|
||||
### Diferenças e reutilização verificadas
|
||||
|
||||
- SupplyCoordination mantém reservas de necessidades do planeamento, pode ligar uma saída já existente e calcula satisfação efectiva. Não é ainda uma reserva física com bloqueio de disponibilidade para uma requisição autorizada ao armazém. Não reinterpretar essas reservas como stock comprometido nem misturá-las por soma: uma necessidade planeada e o seu compromisso físico são referências relacionadas, não duas quantidades adicionais.
|
||||
- StockWorkflow é o único razão físico, impede quantidade negativa e saídas acima do saldo; InventoryLots acrescenta qualificação, validade e repartição dos lotes. O novo compromisso deve ser revalidado por todos os percursos que reduzam disponibilidade, incluindo saída directa, transferência, abate, contagem/ajuste e alteração de qualidade. Apenas proteger o novo formulário deixaria os restantes caminhos contornar reservas.
|
||||
- Reservar não cria movimento físico. Entregar cria saída pela quantidade real e consome a reserva correspondente na mesma transacção. O saldo ainda comprometido não pode ser contado duas vezes. Cancelar liberta só o restante não entregue. Não inferir prazo de expiração; depende de configuração explícita.
|
||||
- Usar o termo Saída por entrega neste percurso. InventoryDispositions já usa abate para inutilização/perda; não reutilizar esse comando para entrega normal. Material danificado, retido ou vencido não é disponível utilizável.
|
||||
- InventoryCounts, InventoryLots e InventoryReversals já fornecem contagens/repartição/correcções físicas. Não bastam para declarar pronta a nova devolução do destinatário: a devolução deve ter recepção/qualidade e vínculo à saída de atendimento, sem reabrir o compromisso automaticamente. Preservar a semântica histórica de correcções de necessidades do planeamento, hoje distinta.
|
||||
- Perfis/workflow devem explicitar tarefas de solicitar, decidir, conferir tecnicamente quando aplicável, preparar, entregar, confirmar recepção e regularizar; pessoas existentes recebem as funções pela parametrização, sem auto-aprovação nem contas/outorgas automáticas. Reutilizar tarefas/avisos comuns sem confundir o pedido de compra com o pedido ao armazém.
|
||||
|
||||
### Requisitos de implementação e aceitação a conservar
|
||||
|
||||
1. Pedido e linhas com referência única, origem, versões, destino e unidades do cadastro; percurso rascunho/submissão/decisão/autorização/preparação/parciais/entrega/encerramento e rejeição/cancelamento, condicionado à matriz que a organização definir. Não activar uma nova matriz com os papéis ilustrativos do documento como se já fossem outorgas reais.
|
||||
2. Compromisso por linha/artigo/armazém, com quantidade autorizada, entregue, libertada e restante; disponibilidade utilizável menos compromissos activos, sempre na mesma unidade. Política explícita quando uma mudança de qualidade ou contagem reduzir o disponível abaixo do comprometido: identificar divergência/necessidades, sem libertar ou ajustar fisicamente por suposição.
|
||||
3. Confirmar reserva e entrega sob disciplina comum de bloqueios, versões, idempotência e auditoria. Para vários artigos, confirmar o conjunto integral ou parcial expressamente autorizado; não produzir sucesso parcial acidental. Definir ordem global compatível com o bloqueio de empresa e motores existentes.
|
||||
4. Entregas parciais dentro do autorizado, identificação de destinatário/entregador e recepção distinta; responsabilidades de uso/devolução quando aplicáveis. Alteração de itens/quantidades/destino após autorização exige nova avaliação, sem reescrever a decisão antiga.
|
||||
5. Mínimos e alertas reutilizam parametrização artigo/armazém onde aplicável, com responsável e tratamento rastreável. Não geram compra, saída ou alçada automática. Os actuais mínimos/projecções não provam encaminhamento operacional concluído de uma ruptura.
|
||||
6. Reconciliação de compromissos com fotografia consistente, detecção de órfãos/duplicações/expiração/entregas já consumidas, ocorrência e correcção independente, idempotente e auditada. Sem apagar histórico, libertar por antiguidade presumida ou converter diferença reservada em ajuste físico.
|
||||
7. Conservar CA-01–CA-09, cenários de venda/interno/parcial/cancelamento/devolução/duplicação e reconciliação como plano de aceitação. Testes concorrentes em PostgreSQL descartável com sobreposição real; não declarar esses critérios passados com base nas provas de lotes/stock ou dos avisos anteriores.
|
||||
|
||||
### Sequência proposta para o desenvolvimento
|
||||
|
||||
Primeiro consolidar contrato de dados e responsabilidades com os cadastros/configurações existentes; depois pedido/autorização e compromisso físico; em seguida entrega parcial atómica no único StockWorkflow; por fim confirmação de recepção, devolução, reconciliação, alertas e integrações dependentes. Não disponibilizar compromisso físico enquanto os outros caminhos de saída puderem ignorá-lo. Séries, lotes, validade e integrações são requisito de arranque nos âmbitos que deles dependam, mesmo que classificados P2 no documento. Limites, calendários, expiração, alçadas e origem autoritativa de vendas continuam a ser configuração organizacional, não valores inventados pelo desenvolvimento.
|
||||
|
||||
Esta incorporação é análise/documentação. Nenhum novo percurso foi activado, e não foram executados testes funcionais por não haver alterações de aplicação nesta etapa. Próxima acção concreta, se avançar esta proposta: especificar o vínculo entre necessidade planeada, pedido ao armazém e compromisso físico antes de alterar o motor de disponibilidade.
|
||||
|
||||
## Disponibilidade para requisições ao armazém e regra de compra — 01/11/2026
|
||||
|
||||
/stock/disponibilidade acrescenta consulta por armazém e pesquisa de artigos, paginada, sobre o único StockWorkflow e InventoryLots. Mostra físico, utilizável pelos controlos activos, retido/indisponível, necessidades planeadas e mínimo aprovado. Os saldos são obtidos na mesma transacção de leitura consistente; quantidade/unidade não são convertidas. Sem controlo por lote essa limitação é visível; repartição divergente mantém utilizável/retido por determinar. Necessidades planeadas não são subtraídas como compromisso físico: este continua null/não implementado, nunca zero presumido. Mínimo só é reutilizado de política aprovada com cadastro preservado compatível; procura ou política desconhecida não se inventam.
|
||||
|
||||
Acesso reutiliza as competências já autorizadas para consulta de stock e acrescenta capacidade warehouse_availability_read, configurável e sem outorga automática. Necessidades/mínimos de planeamento exigem essa capacidade ou leitura/reserva de fornecimento/admin; outros leitores de stock não recebem acesso a planeamento por arrasto. Empresa/contexto e armazém validam no servidor. A consulta não cria reserva, pedido, compra, movimento ou aviso. Menu Disponibilidade para requisições em Armazém e activos. Sem migração ou alteração dos processos existentes.
|
||||
|
||||
O proprietário clarificou durante esta etapa: inexistência de stock ou atingir mínimo DEVE despoletar requisição de compra. Orientação incorporada em AGENTS.md. A geração automática ainda não está implementada nesta consulta, que o diz expressamente. Deverá partir de política/necessidade autorizada por artigo/armazém, determinar quantidade/reposição, requisitante e centro de custo a partir de configuração, evitar duplicação com reposição efectiva em curso e criar a requisição ligada à origem para seguir aprovações normais. Não converter uma alerta de leitura numa compra por si só; executar a geração por comando/transição/agendamento autorizado com idempotência e revalidação. Antes de gerar, conferir pedidos/ordens em curso e saldo utilizável; um pedido antigo cancelado ou insuficiente não pode suprimir reposição indefinidamente. Não inventar preços, quantidades, alçadas ou identidade do requisitante.
|
||||
|
||||
Verificações desta consulta: 16 testes/123 asserções de WarehouseAvailabilityTest, SupplyCoordinationTest e InventoryLotPolicyTest passaram; inclui saldo físico10 versus necessidade planeada20 sem subtracção, mínimo atingido, leitura sem movimento, armazém de outra empresa e acesso reservado. TypeScript, lint/formatação, compilação e análise PHP passaram. Sem revisão visual por capturas. Esta prova não certifica geração automática, reserva física nem CA01–09 da proposta de requisições ao armazém. Próxima implementação: configuração e geração idempotente da requisição de compra a partir da necessidade, em conjunto com o contrato do pedido ao armazém e do compromisso físico.
|
||||
|
||||
**Destino obrigatório da requisição ao armazém — clarificação do proprietário, 01/11/2026:** explicitar onde será usado. Uso interno identifica destino/unidade/local/finalidade; material para equipamento exige equipment_id proveniente do cadastro autorizado e compatibilidade quando aplicável; venda exige ligação à factura de venda e respectivas linhas/quantidades. Documentos e equipamentos devem ser seleccionados/referenciados, sem reintroduzir cadastro ou inferir identidade por nome. A linha deve conservar o destino aplicado, permitindo destinos distintos por linha se o pedido reunir usos diferentes; a interface só pede dados do destino aplicável. Eventual falta e requisição de compra resultante conservam o vínculo à necessidade/destino original, sem copiar a requisição inteira como se fosse nova necessidade independente.
|
||||
|
||||
Facturas hoje tratadas por InvoiceWorkflow são de fornecedor: não as reutilizar como factura de venda por igualdade do termo. O actual percurso comercial/vendas continua não operacional; a referência de venda requer origem autorizada e reconciliação de artigos/quantidades/movimentos antes de disponibilizar esse cenário. Ligação a factura não pode duplicar saída já efectuada noutro sistema. Esta clarificação foi registada como requisito e não foi apresentada como campo/pedido ao armazém já implementado. A consulta de disponibilidade entregue nesta etapa continua sem criar pedidos ou movimentos.
|
||||
|
||||
## Registo inicial de requisições ao armazém com destino — 01/11/2026
|
||||
|
||||
Implementado /stock/requisicoes com rascunhos próprios, listagem autorizada e consulta dos dados preservados. Nova tabela warehouse_requests (migração47) regista origem interna, armazém, requisitante/departamento, centro de custo, prioridade, local/finalidade e linhas com snapshot de artigo/unidade/quantidade. Número de apresentação RA-id único. Equipment destino seleccionado do cadastro do departamento quando use=equipment; peças cadastradas passam por EquipmentWorkflow::linkedPart. Em uso interno, as linhas herdam o local comum indicado; a associação a equipamento permanece explícita por linha. Quantidade positiva com três casas decimais, sem conversão de unidade e sem preços artificiais.
|
||||
|
||||
WarehouseRequests::draft bloqueia empresa, revalida identidade/permissões, valida centros do departamento e fontes da empresa, guarda snapshot e auditoria atomicamente. Operação única por empresa/autor/chave; repetição idêntica recupera documento, conteúdo diferente é recusado. Mudanças futuras do catálogo não reescrevem o snapshot. Modelo não admite update/delete pela aplicação; edição/versionamento de rascunhos ainda não oferecidos nesta etapa. Registo não altera stock, reservas, orçamento, requisições de compra ou estado de aprovação. Ainda não existem comandos de submissão/autorização/entrega deste percurso; a interface esclarece esse limite.
|
||||
|
||||
Tarefas configuráveis novas warehouse_request_prepare e warehouse_request_read, visíveis no catálogo de perfis. Preparador consulta os seus documentos; consulta da empresa exige read/admin, sem concessão automática de preparação ao administrador. Manutenção não cria rascunhos. Dados de artigos/centros/equipamentos para preparação só são enviados ao preparador autorizado. Contexto/empresa preservados, respostas sem cache e histórico Inertia cifrado. A selecção de origem de venda/factura de venda continua indisponível: o SGC actual não oferece fonte comercial autoritativa operacional e facturas de fornecedor não a substituem. Não aceitar uma referência inventada como ligação validada.
|
||||
|
||||
Stock mínimo/inexistente continua requisito de geração de compra a implementar por configuração/origem autorizada, sem compras em GET e sem novos poderes. Reserva física, autorização, entrega, parciais, compra por falta, factura de venda, edição versionada e reconciliação não estão concluídos por este registo inicial; não apresentar rascunho como pedido autorizado ou compromisso de stock.
|
||||
|
||||
Instalação em8005 via supply-install.php --warehouse-drafts, com backup/restauro verificados,107 tabelas existentes preservadas, tabela nova vazia, sem contas/outorgas/dados sintéticos. Prova proofs/warehouse-drafts-install.json; domínio não instalado. Testes:81/809 asserções (rascunhos, disponibilidade, equipamentos, requisições, configuração e contextos) passaram; PostgreSQL descartável6/66 passaram. Tipos, lint/formatação, compilação e análise PHP passaram. HTTP local confirmou página instalada e administrador sem preparação automática. Sem revisão visual por capturas; proofs/warehouse-drafts.json. Próxima acção: editar/versionar rascunhos e implementar submissão/autorização configurada, antes do compromisso físico e entrega.
|
||||
|
||||
**Decisão confirmada do proprietário — documento de saída interno e venda no SGF (01/11/2026):** uso interno não tem factura. A requisição autorizada é o documento de saída do armazém; só a entrega física confirmada gera o movimento pela quantidade efectivamente entregue. Preparação/aprovação não abatem stock, e entrega parcial conserva saldo pendente. A opção de venda a terceiros será incorporada no SGF, que já dispõe de facturação, gestão de stock e contabilidade; nessa composição a requisição associa-se à factura de venda e reutiliza os mecanismos do SGF, sem duplicar movimentos ou motores. Esta decisão esclarece o âmbito comercial da proposta anterior e substitui qualquer interpretação de exigir factura no uso interno ou de criar facturação de vendas autónoma no SGC. Não remove por inferência compras/importações para revenda; trata do atendimento de vendas e documento de saída. Orientação incorporada em AGENTS.md; nesta confirmação não houve alteração de código, dados ou instalação e não foram necessários novos testes.
|
||||
|
||||
## Autorização e entrega de requisições internas sem factura — 01/11/2026
|
||||
|
||||
Implementado percurso derivado de eventos: rascunho → submetida → autorizada → parcialmente entregue/entregue → encerrada com recepção confirmada; rejeição a partir da submissão. O documento original WarehouseRequest continua preservado; eventos imutáveis WarehouseRequestEvent guardam actor nominativo, versão, motivo, dados usados e data. Não se reescreve o rascunho nem se cria factura. Documento /stock/requisicoes/{id}, imprimível com destino/equipamento, quantidades pedidas/entregues/pendentes, decisões, lotes através da operação de stock referida, entregador, destinatário e confirmações. RA-id e cada evento de entrega referenciam as operações físicas correspondentes.
|
||||
|
||||
Tarefas configuráveis novas warehouse_request_review (departamento) e warehouse_request_deliver (em conjunto com warehouse). Não houve outorgas automáticas. Requisitante submete e confirma recepção das entregas; não autoriza nem entrega o próprio pedido. A decisão exige responsável independente do mesmo departamento; quem autorizou não pode entregar. Leitura por tarefa/empresa/contexto é aplicada no servidor e nos menus; administração apenas lê se não tiver a tarefa operacional atribuída. Os avisos do workflow de requisição de compra não foram estendidos automaticamente a este novo domínio.
|
||||
|
||||
O requisitante aceita ou não entregas parciais na submissão, e o autorizador só pode permiti-las quando foram aceites. Autorização integral confere saldo utilizável agregado por artigo/armazém; autorização parcial preserva a disponibilidade conferida e admite necessidade ainda por atender. Artigos/unidades/equipamentos usados são revalidados por comparação com a origem preservada; alterações incompatíveis exigem novo rascunho conferido. A configuração actual usa uma decisão departamental independente, sem inventar alçadas/limites monetários ou matriz técnica adicional; evolução desses critérios deve ser configurada e revista.
|
||||
|
||||
Entrega regista apenas as quantidades efectivas positivas dentro do saldo autorizado de cada linha. Sem autorização para parcial, exige todas as quantidades pendentes. Usa StockWorkflow::record(kind=issue), o único razão existente, com validação de peças/equipamento e repartição/qualificação de lotes vigente. Não há novo motor de stock nem efeitos orçamentais/facturação. Disponibilidade é revalidada imediatamente na saída; falta/quarentena/validade ou erro numa linha revertem o conjunto completo. A transacção engloba operações físicas, evento do documento e auditoria; idempotência empresa/actor/chave e versão impedem repetição e entrega de uma página desactualizada. Confirmar recepção exige o requisitante e uma entrega concreta ainda não confirmada; não lança novo movimento físico. Entregas são registadas no instante real da operação, sem opção de retrodatação nesta etapa.
|
||||
|
||||
Limites explícitos: autorização NÃO reserva fisicamente stock exclusivo; outros movimentos podem alterar disponibilidade até à entrega. Não se promete compromisso físico nem cobertura dos CA01–09 de reservas. Totais entregues são factos históricos de saídas registadas; devolução/correcção deve continuar ligada ao movimento original e não autoriza redespacho automático do saldo. Cancelamento/versão de pedido já submetido, expiração, destinatário alternativo, conciliação de reservas, tarefas/avisos deste novo percurso e geração automática de compra por falta/mínimo continuam pendentes. O requisito de compra permanece em AGENTS.md e a interface informa que ainda não é executado; não foi simulado por consulta/alerta. Venda/facturação mantêm composição SGF. A requisição interna é o documento de saída, sem factura.
|
||||
|
||||
Instalação da migração48 apenas em8005 via --warehouse-events: backup e restauro verificados,108 tabelas existentes preservadas, nova tabela vazia e sem contas/grants/dados sintéticos. proofs/warehouse-fulfilment-install.json. Regressão dirigida passou85 testes/495 asserções; PostgreSQL descartável12 testes/122 asserções, mais duas disputas reais de bloqueio (mesma chave e chaves diferentes para a mesma entrega), passaram sem movimento duplicado. Tipos, lint/formatação, compilação, análise PHP,18 ficheiros dos núcleos e HTTP de consulta passaram. Bateria geral a concluir, resultado será acrescentado. Sem revisão visual por capturas; não declarar matriz integral da proposta concluída.
|
||||
|
||||
**Verificação final da autorização/entrega:** bateria geral908 testes/906 passaram/2ignorados/9508 asserções, sem falhas. PostgreSQL12/122 e2disputas reais já descritos; prova consolidada docs/proofs/warehouse-fulfilment.json. O acesso operacional exige atribuição explícita das tarefas warehouse_request_prepare/review/deliver e, para entregar, warehouse. Sem atribuições automáticas. Não se confundem estes resultados com reserva exclusiva ou geração automática de compras, que continuam pendentes.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Continuidade do desenvolvimento do SGC
|
||||
|
||||
**Composição operacional/preparação de schema — 01/11/2026:** candidata `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`, sem instalação no SGF. `scripts/prepare-sgf-operational.php` prepara artefacto; `scripts/sgf-internal-install.php` distingue plan read-only e migrate transaccional de schema, com destino/manifesto/backup conferidos, contrato orçamental e journal imutável. Mesmo motor e permissões, novo ramo operacional por destino/schema/âmbito explicitamente habilitados, sem obrigar nome de base QA ou ambiente de ensaio. 20 controlos novos + restauro em production/base não-QA aprovados em [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json), sem repetir suites aceites. Não confundir preparação de schema com activação: não alterou código/provider/rotas/concessões/dados no SGF instalado; ponteiro 0.1.1 e candidatas anteriores preservados. Próximo passo é composição de arranque/rotas e prova HTTP numa cópia isolada. Não executar migrate no destino, activar 0.1.2/outra versão ou publicar sem a decisão separada. Reorganização modular mantém-se distinta e sem resultado próprio apresentado.
|
||||
|
||||
**Preparação de acessos SGF — 01/11/2026:** criada sobreposição candidata de catálogo/gestão de permissões e atribuição inicial nominativa/auditada, com rotas/ecrã separados, sem alterações no host instalado. `scripts/prepare-sgf-access.php` produz hashes antes/depois e recusa divergências; não é instalador. 24 controlos novos PostgreSQL aprovados em [proofs/sgf-access-delivery.json](proofs/sgf-access-delivery.json), sem repetir suites aceites. Preservados os direitos anteriores na criação de unidades; nova autoridade inicial exige acção explícita de administrador autorizado e âmbito habilitado, nunca outorga automática. SGF mantém 0.1.1, candidata IR imutável e rotas não activadas. Próximo trabalho: composição orçamental operacional, instalador de schema/arranque, integração HTTP da preparação e pacote SGF específico; continuar sem instalar/publicar por arrasto. Reorganização modular não se confunde com este incremento e continua sem resultado próprio apresentado.
|
||||
|
||||
**Classificação posterior à candidata — 01/11/2026:** equipamentos/peças são fora do âmbito do percurso básico entregue; catálogo administrativo/autoridade inicial de permissões, composição orçamental operacional e instalador/arranque rastreáveis são bloqueantes para instalar o percurso numa empresa. A guarda QA actual bloqueia inclusive consulta/configuração fora do ensaio. Não resolver com grants SQL, ambiente QA numa base real ou extracção do ZIP sobre a raiz SGF. Procedimento exacto do que existe, etapas sem executor e recuperação por falha registados em [transferencia-sgf.md](transferencia-sgf.md). Nenhum teste aprovado repetido, nenhuma mudança de código/candidata/destino. SGF mantém-se 0.1.1. A reorganização modular pedida depois é entrega distinta e permanece sem resultado apresentado; não a marcar concluída por esta integração.
|
||||
|
||||
**Candidata de requisições internas — 01/11/2026:** implementado motor partilhado e adaptadores Compras/SGF para rascunho, submissão/reserva existente, aprovação por outra identidade e consulta/histórico. Contexto `procurement.internal`, isolamento por empresa/unidade, permissões renovadas, snapshots/versionamento, chaves de operação e locks no servidor. Configuração e escolha de contexto não atribuem direitos. Compras autónomo preservado; migração 46 ainda não aplicada à base persistente. Provas sintéticas isoladas incluem concorrência, revogação, falha transaccional, resposta perdida, migração falhada/compatibilidade e restauro de dados/schema com consulta posterior. Ver [matriz, limites e recuperação](transferencia-sgf.md), [artefactos](proofs/internal-requisitions-candidate.json) e [prova SGF](proofs/internal-requisitions-sgf.json).
|
||||
|
||||
**Não activar:** SGF permanece 0.1.1. Não instalar 0.1.2, nova candidata, schema ou concessões no destino sem a decisão separada pedida pelo proprietário. Não publicar. Artefactos desta entrega são para revisão; updater actual recusa-os porque o seu contrato permanece só código. Rotas candidatas carregam apenas no bootstrap explícito de teste; sem alteração do menu público/instalado. Preparação operacional de migrações e catálogo privado administrativo pendente. Equipamentos/peças SGF e fornecedores continuam fora deste recorte; contratações para terceiros continuam excluídas do SGF. Não reabrir a via de navegador bloqueada nem declarar revisão visual feita. Os estados históricos seguintes conservam as respectivas provas e são sucedidos por este incremento.
|
||||
|
||||
**Conferência antes de ampliar — 01/11/2026:** pedido posterior do proprietário restringe o próximo passo à confirmação de instalação independente, alcance do rollback e matriz transferido/parcial/pendente por processo/adaptador/ecrã. Matriz completa revista em [transferencia-sgf.md](transferencia-sgf.md), cópia reconciliada no SGF. Cliente não precisa de checkout/serviço/base de Compras; teste de origem sintética indisponível e prova real SGF com todos os includes dentro do host aprovados (mecanismo 6 testes/29 asserções, PHP SGF 8.4.27, sem base). Rollback é **só código/ponteiro**, não dados/schema; migrações futuras precisam de compatibilidade, autoria/versão, aplicação idempotente/auditada e backup/restauro/recuperação ensaiados. Prova `purchasing-client-independence.json`. SGF continua em `0.1.1`: extracção económica já em preparação foi interrompida antes de activar; candidata `0.1.2` existe no canal, **não instalada**. Preparação local passou 103 testes existentes e cinco de economia, sem constituir prova SGF. Não executar nova transferência por consequência desta revisão. Matriz propõe requisição interna até aprovação/consulta como próximo percurso completo, incluindo reserva existente, adaptadores e ecrãs, aguardando escolha de sequência na revisão. Contratações para terceiros continuam excluídas do SGF. Sem dados, concessões ou domínio alterados.
|
||||
|
||||
**Prioridade expressa — transferência Compras → SGF e updates, 01/11/2026:** proprietário esclareceu que este é o foco, acima do selector de contexto. Primeiro pacote realmente transferido: projecção de reposição, fonte única `packages/purchasing`, fachada antiga e versão instalada no SGF sem dependência do checkout de Compras. Mecanismo `composer purchasing:transfer -- <SGF> <canal>`: regressões, fotografia imutável, versão patch por mudanças, SHA-256/caminhos/inteireza, prova candidata no PHP do host, instalação imutável/ponteiro atómico/lock, histórico e rollback explícito de código. Versões `0.1.0 → 0.1.1`, rollback e reactivação reais locais aprovados; retry sem reinstalação. Final 86 testes/566 asserções no publisher, SGF Unit sem base 2/11 e análise PHP dirigida aprovados. Não executar reset da demonstração SGF; não houve migração/seed/conta/concessão ou dependência alterada. Contrato actual recusa pacotes com schema/permissões; canal deve ser de confiança (hash não é assinatura). Mecanismo actualiza apenas capacidades já extraídas: transferência integral de requisições, fornecedores, inventário operacional, aprovações/ecrãs e Pessoas internas ainda pendente. Conservar todas as funcionalidades SGC e excluir Pessoas para terceiros no SGF. Próximo trabalho é extracção/adaptação por percurso e migrações ensaiadas, não expandir novamente o selector. Ver [transferencia-sgf.md](transferencia-sgf.md) e provas `purchasing-*.json`. Sem publicação no domínio.
|
||||
|
||||
**Retoma — contexto de trabalho, 01/11/2026:** primeiro incremento em código e 8005. Separados produto contratado/configuração empresarial/permissões; escolha em `/dashboard`, sessão vinculada a utilizador+empresa, auto-opção única ao escolher área, nenhuma opção sem dados de negócio, contexto visível, menus e middleware no servidor. Perfis associados explicitamente aos contextos, sem somar funções de outras opções. Migração 45: origem interna das requisições/processos de Pessoas (autorizada), scope e imutabilidade SQLite/PostgreSQL; backup/restauro e campos anteriores de 106 tabelas preservados. Avaliação local com catálogo completo só após conferência da base/role/empresa sintéticos; clientes usam limites comerciais por empresa + subconjunto activado pela interface. Novas empresas começam sem opções; compatibilidade de existentes exige configuração explícita antes de entrega. SGC mantém quatro opções; composição SGF exclui contratações para terceiros. Venda e contratações para terceiros ainda sem percurso operacional neste incremento, bloqueadas em vez de usar dados internos. Guardar/descartar/cancelar na ida ao painel: gravação integrada ao rascunho de requisição; outros formulários, múltiplas edições e histórico do navegador ainda exigem cobertura/conferência. RH só após formalização, nunca selecção/rascunho; envelope futuro sem envio, sem formalização legal ou módulo RH implementados. `sgc:delivery-check` confere sem apagar; clientes recebem base limpa, avaliação fica local. Catálogo comercial por módulo e editor dos limites adquiridos, distribuição SGF e actualizador integral pendentes. Provas contexto PG 12/83, dirigida final 66/644, regressão geral 858 aprovados/2 ignorados/8 503 asserções, análise PHP dirigida/build aprovados. PHPStan global com problemas web/candidate; visual indisponível porque browser host não respondeu. Sem publicação no domínio. Ver secção inicial de desenvolvimento para limites e próxima acção; não declarar pedido integralmente concluído.
|
||||
|
||||
**Retoma — triagem e avaliação, 30/10/2026:** decisão explícita de preparador atribuído em `/pessoas/{process}/recebidas/{submission}/triagem`, com ausência de conflito, motivo reservado e mensagem pública distintos. «Aceite para análise» cria candidatura interna a partir dos dados congelados, sem usar o perfil actual, associar por nome ou incluir automaticamente em base. «Não aceite para análise» não cria candidatura interna. Decisão/operação preservadas, retries idênticos idempotentes, revisão/reabertura ainda pendentes. Actor importador fica em `created_by`, mantendo independência na avaliação/aprovação; fonte electrónica e versão do perfil identificadas na matriz/relatório. Titular só recebe estado/data/mensagem pública, sem motivo interno ou ID interno. Preparadores e avaliadores expressamente autorizados a evidências/entrevistas consultam fontes; só preparador com função/atribuição actuais decide/publica. Painel oferece triagem antes de avaliar; fecho/avaliação/selecção verificam pendências. Não admitir novas candidaturas após começar a avaliação; não fundir referências manuais coincidentes. Migração 40 apenas 8005: backup/restauro e 103 tabelas antigas preservadas, tabela nova vazia, sem transferências automáticas, contas/grants, emails, IA ou domínio. Provas `proofs/candidate-triage*.json`. Próximo incremento: anexos verificados e complementos/revisões, depois modalidades espontâneas/fornecedores. Visual pendente; não declarar portal integralmente concluído.
|
||||
|
||||
**Retoma — acesso e submissões electrónicas, 30/10/2026:** confirmação de email e recuperação implementadas, Mail::fake nos ensaios, transporte real indisponível e nenhum email enviado. Tokens SHA-256, segredo em fragmento/POST, expiração/utilização única, auditoria, invalidade após reenvio e revogação de sessões por versão na recuperação. Ler estado actual da conta ao autorizar, inclusive depois de locks, para não aceitar sessões antigas. Email confirmado não significa identidade validada. Sem token em logs ou flash. APP_URL configurado é a origem das ligações; não usar Host do pedido. Nova publicação explícita por preparador de Pessoas, formulário congelado do processo, candidatura de dados/comprovativo e consulta restrita pelo titular/preparador. Não divulgar propósito interno, critérios internos, selecção ou notas. Antes de consultar PeopleProcess no canal externo, retirar apenas o scope global de tenant e aplicar tenant explícito da rota/conta, além da publicação/prazo; nunca depender da sessão interna no mesmo browser. Não inserir candidato externo automaticamente em users/people_registry/people_applications. Recepção não é aceitação para análise, inclusão na base ou selecção. Formulários com anexos obrigatórios não podem publicar neste percurso enquanto não existir recepção/verificação de ficheiros; não retirar esses requisitos para contornar. Próximo: anexos verificados, triagem e ligação à avaliação sem redigitação, complementos/revisões e modalidades espontâneas/fornecedores. Migrações 38/39 locais 8005, backup/restauro, 100 tabelas anteriores preservadas, três novas vazias. Sem portal aberto, candidato criado, email real, IA, domínio ou aceitação visual. Provas `proofs/candidate-submissions*.json`.
|
||||
@@ -408,8 +424,110 @@ disponível e registar o resultado, sem interromper nem duplicar o executor.
|
||||
|
||||
## Consolidação de obrigações/pagamentos sintéticos — 30/10/2026
|
||||
|
||||
Incremento posterior autorizado: reversão integral de afectação por proposta com motivo e confirmação independente, compensação imutável e nova afectação pelo núcleo comum. Prova sintética Compras/SGF: dívida 7 600 → 11 600 → 8 600 MZN e pagamento disponível 0 → 4 000 → 1 000. Núcleo único e históricos preservados; novo schema QA aplicado apenas nas bases descartáveis. As demonstrações persistentes não foram actualizadas; revisão local descartável disponível pelo runner documentado com --reversals --review. Sem direitos públicos, bancos, contabilidade, reversão parcial ou publicação. Visual/MFA pendentes e aceitação deste incremento não presumida. Ver [entrega de reversões](../../sgf-tempo/docs/SUPPLIER_ALLOCATION_REVERSAL_DELIVERY.md).
|
||||
|
||||
Retoma automática posterior: sequência aprovada em Compras e SGF, com asserções adicionais de repetição da confirmação, actores distintos e quatro eventos únicos preservados após reentrada. Só verificador/documentação alterados; bases descartáveis removidas. Sem alteração de permissões existentes ou habilitação pública. Visual pendente: último bloqueio é política de URL do navegador Windows, sem nova tentativa. Ver [relatório actualizado](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_CONSOLIDATION_20261030.md). Provas anteriores reutilizadas; sem checkpoint de encerramento.
|
||||
|
||||
Apresentação comum corrigida sem motor novo: retorno à etapa após troca de função, saldo nas duas páginas e feedback. Prova HTTP/HTML descartável em Compras e SGF: dívida 11 600 antes da afectação, saldo 7 600 depois de afectar 4 000; repetição sem duplicação, reentrada e vencimento indeterminado. Núcleo comparado por SHA com a prova anterior e inalterado. Provas anteriores preservadas. Computer Use indisponível por pipe nativo ausente; visual pendente. Sem comandos financeiros nos cenários persistentes, migração habitual, grants, dependências, alteração da página inicial ou publicação. Preservados triagem/candidaturas e restantes percursos posteriores de Compras. Ver [relatório comum](../../sgf-tempo/docs/SUPPLIER_SETTLEMENT_DELIVERY.md), secção Consolidação do percurso; revisão antes da aceitação/encerramento.
|
||||
|
||||
Aceite tecnicamente pelo responsavel em 30/10/2026, com checkpoint exclusivamente local autorizado. Revisao visual continua pendente, sem aceitacao visual presumida; MFA pendente conforme relatorio comum. Sem publicacao, habilitacao publica, credenciais ou dados privados. Alteracoes alheias excluidas do checkpoint.
|
||||
|
||||
**Fornecimento/reposição — 01/11/2026, parcial:** proprietário pediu implementação do Word com reutilização de dados, preenchimento progressivo e funções claras para revenda/uso interno, ambos com compra local/importada. Código novo em SupplyPlanning/ReplenishmentProjection e /compras/fornecimento: acordos/políticas versionados, aprovação independente, três entradas no menu Compras e seis capacidades na parametrização. Importação ligada à ordem existente com marcos comprovados e bloqueio de recepção antes da libertação. Calendário apenas manual em dias corridos; motor A/B/C testado, mas reservas/entregas ainda não integradas no ecrã. Migração 42 preparada, não aplicada; nenhuma conta/atribuição alterada ou publicação. Documento integral NÃO concluído: matriz de cobertura e restantes incrementos no fim de desenvolvimento.md. Prosseguir com alternativas direccionais e reservas/entradas por linha, depois sugestões versionadas/conversão única e importação financeira pelo núcleo comum. Preservar alterações de candidaturas anteriores e não assumir nomes antigos do produto/menus como actuais.
|
||||
|
||||
**Fornecimento — instalação posterior, 01/11/2026:** 000042 instalada apenas na avaliação 8005, backup/restauro comparado e 104 tabelas preservadas, tabela nova vazia. Corrigido alias de conexão na instalação; compras_dev conferida sem tabela/ledger novos. Provas supply-install.json e supply-navigation.json, três páginas HTTP/Inertia disponíveis. 96 testes dirigidos/652 asserções, tipos e PHPStan do âmbito aprovados. Política reutiliza prazo/origem e versão de acordo aprovado. Sem atribuições automáticas, dados de negócio ou publicação. Substitui apenas a indicação anterior de migração por aplicar; restante proposta continua pendente na matriz. Próxima execução deve continuar a implementação, sem interpretar este incremento como conclusão do Word.
|
||||
|
||||
**Reservas/entregas/alternativas — 01/11/2026:** continuação entregue no código e na avaliação local sem nova migração. SupplyCoordination reutiliza supply_records/42; quatro capacidades próprias para reservas, confirmações, preparação técnica e validação técnica. Reservas em Armazém e activos, entregas e alternativas em Compras. Seleccionar fontes existentes (linha aprovada/catalogada, ordem emitida ou saída real); preenchimento da fonte pelo servidor, limites, versões e aprovação técnica independente de todos os preparadores. Recepção reutiliza artigo/local da confirmação. Calendário já inclui reservas pendentes, confirmações ainda por receber e saídas efectivas, sem duplicar consumo nas saídas antecipadas. Previsão habitual desconhecida mantém necessidades conhecidas com cobertura incompleta. Relações técnicas são direccionais e elegibilidade consulta validade/cadastro; utilização em compras e condições ainda por integrar. Confirmação única por linha, ainda sem múltiplos embarques. Provas PostgreSQL 14/101 e duas corridas com contenção real, seis páginas HTTP, tipos/build/lint/PHPStan do âmbito aprovados. Sem novas atribuições ou comandos de negócio na avaliação persistente; visual pendente. Matriz restante no fim de desenvolvimento.md: histórico validado, sugestões/conversão única, comparação elegível/substituições, lotes/devoluções/estornos, alertas e importação completa pelo núcleo financeiro comum. Estes pontos continuam autorizados; não pedir nova autorização técnica corrente nem declarar o Word integralmente concluído.
|
||||
|
||||
**Regressão final de fornecimento:** 764 aprovados, dois ignorados anteriores e 7913 asserções; proofs/supply-coordination.json. Restante âmbito permanece na matriz, sem aceitação comercial integral presumida.
|
||||
|
||||
**Sugestões e conversão — 01/11/2026:** menu Compras > Sugestões de reposição; três capacidades próprias (preparar, conferir, converter) na parametrização. SupplySuggestions reutiliza supply_records/42, catálogo e RequisitionWorkflow/StoreRequisitionRequest. Preserva cálculo e fontes, valida base actual antes de decidir/converter, autoria nominal, independência de todos os preparadores e pessoa que conferiu diferente do autor da requisição. Preço MZN do acordo e IVA configurado reutilizados; ausência de preço local exige estimativa fundamentada, sem câmbio inventado. Mínimo/embalagem e máximo no horizonte conferidos. Conversão cria apenas um rascunho, com centro de custo próprio, sem efeitos de compra/stock/orçamento/dívida/pagamento. Repetições idempotentes, vínculo e auditoria atómicos; proveniência mínima visível no detalhe da requisição autorizada. Evita plano automático concorrente enquanto o artigo/local estiver em acompanhamento; encerramento directo após rejeição ou recepção completa. Cancelamentos/revisões de origem e múltiplas necessidades independentes ainda não acompanhados automaticamente. Sem migração, novas concessões/contas ou publicação. A proposta integral continua em implementação; restantes pontos e limitações no fim de desenvolvimento.md.
|
||||
|
||||
**Provas finais das sugestões:** 772 aprovados/dois ignorados anteriores e 8009 asserções; PostgreSQL 22/197 e quatro corridas com contenção real, incluindo conversão única e retry. Tipos/build/lint/PHPStan do âmbito e sete páginas HTTP locais aprovados. proofs/supply-suggestions.json conserva resumo e hashes das fontes. Visual/publicação pendentes; não reabrir este incremento como se as sugestões e a conversão directa ainda não existissem.
|
||||
|
||||
**Histórico de consumo — 01/11/2026:** novo menu em Armazém e activos, capacidades próprias de preparação/conferência, diário append-only existente/42 sem nova migração. SupplyHistory calcula por artigo/local/período fechado a partir do razão, inclui dias sem consumo e exclui transferências/ajustes/recepções. Cobertura e finalidade/padrão confirmados por outra pessoa; ausência de dados não é consumo zero presumido. Política selecciona versão aprovada e define mínimo de dias/idade máxima; média exacta por numerador/denominador alimenta calendário e sugestões sem arredondar consumo pequeno a zero. Suspensão, alterações e antiguidade invalidam a reutilização sem reescrever o histórico, mantendo manual/necessidades conhecidas. Política pode originar novo rascunho reutilizando campos por referência, com nova conferência e uma única activa. Sem contas/grants ou dados de negócio na avaliação persistente. Média regular observada entregue; backtesting, métodos sazonais/intermitentes, ABC e prazo observado ainda pendentes. Restantes lacunas da proposta conservadas na matriz de desenvolvimento.md, sem presumir conclusão comercial integral.
|
||||
|
||||
**Provas finais do histórico:** 786 aprovados/dois ignorados anteriores e 8122 asserções na suíte completa; 20/163 dirigidos após validação final. PostgreSQL 36/310 e quatro corridas de regressão com contenção real; oito páginas HTTP locais, tipos/build/lint/PHPStan do âmbito aprovados. proofs/supply-history.json conserva resultados e hashes. Visual/publicação pendentes. Não tratar média histórica regular e reutilização de políticas como funcionalidades ainda ausentes; métodos avançados e restantes lacunas mantêm-se na matriz.
|
||||
|
||||
**Lotes/validade/quarentena — 01/11/2026:** primeiro percurso operacional em Armazém e activos > Lotes, validade e quarentena. Cinco competências próprias na parametrização; sem concessões automáticas. `InventoryLots` reutiliza o diário imutável/42 para preparação de âmbito, metadados e qualidade; migração 43 acrescenta apenas repartições de movimentos/saldo residual, mantendo `stock_movements` como única autoridade física. Activação por artigo/local exige conferência independente e saldo/versão actuais. Identificação única ou repartição exacta; sem identidade/validade confirmadas, material indisponível. Recepção permite registar lotes em secção opcional, reutiliza quantidade aceite quando lote único e conserva origens; entra em quarentena. Libertação independente de todos os autores da identificação/recepção, motivo/versões/idempotência; correcção invalida libertação anterior sem apagar decisões. Saídas automáticas por validade mais próxima, apenas utilizáveis, uma única saída física. Stock/operações mostram utilizável e indisponível sem conceder acesso adicional ao histórico. Calendário/sugestões reutilizam contexto e descontam apenas remanescente projectado que perde validade; entradas futuras sem qualificação não contam como utilizáveis, com aviso. Âmbitos sem lotes e retries de recepções antigas conservam compatibilidade.
|
||||
|
||||
**Instalação/provas de lotes:** 43 instalada apenas na avaliação 8005, backup/restauro verificados, 105 tabelas preservadas e nova tabela vazia, sem contas/grants/activação/dados empresariais novos. 799 aprovados/dois ignorados anteriores e 8192 asserções na suíte completa; PostgreSQL 49/379 e seis corridas com contenção real (incluem dupla saída e identificação/libertação concorrentes). Nove páginas HTTP/Inertia, tipos/build/lint/PHPStan do âmbito aprovados; sem conferência visual ou publicação. proofs/inventory-lot-install.json, inventory-lots.json, supply-coordination-pg.json e supply-navigation.json. Não repetir a instalação 43 nem recriar tabelas: o instalador detecta ledger existente.
|
||||
|
||||
**Próxima implementação autorizada:** completar transferências entre âmbitos com lotes, quarentena no destino, contagem/ajuste por lote e devoluções/abates/estornos com conservação exacta e independência. Neste momento transferências/ajustes com artigo controlado em qualquer ponta são impedidos pelo servidor, com motivo visível antes da activação; contagem permanece sem efeito físico. Não retirar esta guarda sem implementar repartição das duas pontas/ajuste. Falta revisão de parâmetros activos, política conferida de dispensa de quarentena e selecção manual de lote, reutilização explícita de identidade entre recepções, qualificação futura, relatórios/alertas e paginação completa (100 controlos/200 eventos actuais). Restantes lacunas Word mantêm-se na matriz de desenvolvimento.md; lotes iniciais/recepção/saída/qualidade não equivalem a cobertura integral da proposta. Continuar sem pedir nova autorização para trabalho técnico corrente já solicitado.
|
||||
|
||||
**Transferências por lote — 01/11/2026:** StockWorkflow já reparte origem e destino da transferência normal, com os dois movimentos habituais e a mesma identidade de lote. Ambos os âmbitos têm de estar activos e reconciliados; saída automática por validade mais próxima, apenas libertados/utilizáveis. Substitui a indicação anterior de transferências totalmente pendentes; ajustes por lote continuam pendentes e protegidos. Nova chegada coloca todo o saldo da identidade no destino em quarentena, incluindo quantidades anteriores, com aviso nos ecrãs; origem conserva decisão local. Autor e responsável da transferência integram a independência pendente da custódia, além de autores da identificação; hold posterior não elimina intervenientes e nova libertação tem controlo de versão. Parâmetros de validade do destino são aplicados independentemente. Operação/movimentos congelam somente os lotes utilizados e as versões aplicadas; detalhe visível no histórico normal. Retry não duplica movimentos nem reabre quarentena depois de conferência. Sem nova migração/grants/dados empresariais na avaliação. Transferência ainda imediata, sem expedição em trânsito/recepção física separadas; não movimenta lotes indisponíveis. Seguir com contagem/ajuste por lote e depois devoluções/abates/estornos, mantendo conservação, qualidade e restantes lacunas da matriz. Não declarar o Word completo.
|
||||
|
||||
**Provas finais das transferências:** 806 aprovados/dois ignorados anteriores e 8230 asserções; PostgreSQL 56/417 e sete corridas com contenção real, incluindo dupla transferência do mesmo lote com conservação das duas pontas. Tipos/build/lint/PHPStan dos serviços alterados e nove páginas HTTP/Inertia locais aprovados. proofs/inventory-lot-transfers.json conserva resultados e hashes. Avaliação 8005 actualizada pelo código/recursos compilados, sem nova migração, novas atribuições ou dados de negócio. Conferência visual/publicação não realizadas. A próxima lacuna operacional é contagem/ajuste por lote; não tratar transferências imediatas entre âmbitos activos como ainda ausentes.
|
||||
|
||||
## Contexto de trabalho e composição comercial — decisões do proprietário, 01/11/2026
|
||||
|
||||
Estado: análise e decisões registadas; selector, isolamento por opção empresarial e integração de RH ainda não implementados neste incremento.
|
||||
|
||||
- Reutilizar OrganizationAccess, User::hasFunctions(), OrganizationConfiguration, auditoria e navegação central; a selecção nunca concede permissões. Configuração comercial por empresa limita módulos/opções ao produto adquirido, cumulativamente com a autorização funcional.
|
||||
- Escolher área no Painel Inicial; com duas opções configuradas e autorizadas, o utilizador escolhe; com exactamente uma, activar automaticamente; com nenhuma, apresentar «Sem contexto autorizado», mantendo conta e parametrização apenas quando autorizadas.
|
||||
- Administradores da empresa criam e atribuem perfis por área. Preservar perfis de desenvolvimento para coerência de desenvolvimento e actualizações; esta preservação não os transforma em permissões automáticas de clientes.
|
||||
- Mudança com alterações por guardar oferece guardar, descartar ou cancelar. Falha de gravação mantém contexto actual. Processos submetidos conservam contexto de origem.
|
||||
- Venda para terceiros significa comercialização de bens/serviços, incluindo retalho e grosso; referências a revenda no abastecimento não representam um percurso completo de vendas.
|
||||
- No SGF, contratações de pessoas/prestadores são apenas internas; contratações validadas devem reflectir-se em recursos humanos. O marco exacto de validação e o contrato de integração de RH ainda precisam de ser definidos; selecção aprovada e contrato em rascunho são estados diferentes no percurso actual.
|
||||
- Proprietário autorizou classificar os dados actuais de desenvolvimento como internos. Ainda não aplicada qualquer classificação/migração nesta etapa.
|
||||
- Entrega a cada cliente deve usar instalação limpa, com os módulos/opções adquiridos parametrizados. Dados de testes e avaliação ficam localmente; não eliminar a base de desenvolvimento nem copiar dados de testes para clientes. «Recebe a base de dados completa» deve ser concretizado como estrutura/capacidades aplicáveis, respeitando esta decisão explícita sobre os dados.
|
||||
- Falta distinguir expressamente se a exclusão de contratações para terceiros se aplica apenas à composição SGF ou também ao SGC autónomo: a instrução inicial previa ambas no SGC, enquanto a resposta posterior nomeia especificamente o SGF. Não retirar essa opção globalmente por inferência.
|
||||
|
||||
Riscos identificados: permissões actualmente agregadas por empresa/utilizador; ausência de modalidade explícita nos modelos Requisition e PeopleProcess examinados; menus isoladamente não protegem consultas, URLs, relatórios ou comandos. Existem alterações locais anteriores em serviços, rotas e navegação que devem ser preservadas. Não duplicar motores, inferir direitos pelo contexto nem interpretar cadastro de pessoas como integração de RH concluída.
|
||||
|
||||
Próxima acção: esclarecer alcance SGC/SGF e marco/destino de RH; implementar configuração comercial e contexto comum com validação no servidor e interface, vinculação dos processos e regressões em base isolada. Não foram executados testes funcionais porque esta etapa só registou análise/decisões; provas anteriores mantidas.
|
||||
|
||||
**Continuação de armazém — 01/11/2026, contagens/ajustes/devoluções/regras/estornos:** contagens físicas por lote reutilizam cadastro e calculam total; inventory_adjust_review confere/aplica por pessoa diferente de autor/responsável. Diferenças mistas, inclusive total inalterado, usam adjustment_out/in atómicos; excedentes ficam em quarentena. Migração 44 instalada só em 8005, backup/restauro verificados, 106 tabelas preservadas, sem novas tabelas/grants/dados de negócio; proofs/inventory-count-install.json. Devoluções e abates têm preparação/conferência próprias e execução por Armazém diferente do aprovador; reutilizam origem e mantêm limite físico/aceitação, incluindo lotes retidos/vencidos sem contar como consumo. Novas facturas descontam devoluções líquidas; documentos anteriores e núcleo financeiro permanecem preservados, com factos para conferência em Finanças.
|
||||
|
||||
**Parâmetros de lotes já revistos pela interface:** versão activa continua durante proposta de revisão; aprovação independente revalida saldo/qualidade/identificação e impacto quantitativo. Não repartir saldo novamente. Dispensa de quarentena só após configuração aprovada e apenas para recepções futuras conformes/identificadas/válidas; versão aplicada e modo por regra ficam preservados/visíveis. Saldo inicial, transferências, excedentes e estornos positivos continuam retidos. Falta desactivação, vigência programada e selecção manual de lotes em saídas/transferências.
|
||||
|
||||
**Estornos físicos já implementados:** capacidades inventory_reversal_read/prepare/review, execução por Armazém independente da decisão. Integral e único por operação física/linha de recepção, com dados reutilizados e original preservado. reversal_in/out e repartições inversas usam StockWorkflow, sem novo razão. Reposição controlada exige qualidade independente; saída antiga sem lote reposta fica por identificar. Não inferir correspondência de entrada antiga com lote inicial. Reservas reabrem quantidade efectiva e análises históricas corrigem consumo/assinatura mesmo com estorno posterior ao período; séries físicas mantêm datas de lançamento. Recepção anulada deixa de ocupar quantidades efectivas; retry da original não recria stock. Devolução estornada deixa de descontar devolvido líquido. Não confundir com crédito, restituição ou estorno financeiro: falta regularização no núcleo comum, sem motor paralelo. UI mostra decisões recolhidas, uma acção/formulário por registo. Mantêm-se restantes lacunas na matriz e decisões de composição comercial anteriores; não declarar Word integralmente concluído. Verificação final conjunta será acrescentada após os testes, sem substituir provas anteriores.
|
||||
|
||||
**Calendário/conferência desta continuação:** atenções factuais no calendário existente; acordo/fornecedor desactualizado retira prazos derivados, cadastro/unidade alterado exige rever política. Prova conjunta em proofs/inventory-corrections.json: PostgreSQL 98 testes/628 asserções e onze disputas reais passaram; onze páginas locais passaram com contexto de compras autorizado, sem comandos de negócio/grants. Tipos, compilação, análise PHP e 18 ficheiros comuns conferidos. Sem revisão visual por capturas. Bateria geral ainda em execução; resultado será acrescentado sem substituir provas anteriores.
|
||||
|
||||
**Resultado final da bateria geral (01/11/2026):** 862 testes, 860 passaram, dois ignorados, 8527 asserções; sem falhas. Resultado inclui o código presente no espaço partilhado nesta execução. Prova consolidada: docs/proofs/inventory-corrections.json. Não substitui instalação em domínio, revisão visual ou regularização financeira. Próxima continuidade funcional: confrontar restantes lacunas da proposta com a integração de alternativas no processo de comparação e o núcleo financeiro comum; não criar motor financeiro paralelo nem declarar concluídos créditos/restituições, previsões avançadas ou retorno aduaneiro.
|
||||
|
||||
**Relatórios de Pessoas (01/11/2026):** menu próprio em /pessoas/relatorios, resumo por percurso/estado, filtros, paginação, impressão desta página e CSV completo dos resultados filtrados; ligação ao relatório individual existente. Reutiliza permissões por percurso e participação vigente no processo, empresa/contexto; admin não recebe acesso global. Sem dados privados de candidatos/entrevistas/contratos/finanças na listagem. 52 testes/1188 asserções, tipos, análise PHP, compilação e duas verificações HTTP locais passaram; prova proofs/people-reports-http.json. Sem migração/grants. CSV materializa campos mínimos para fotografia consistente; considerar exportação assíncrona para conjuntos muito grandes. Sem revisão visual por capturas.
|
||||
|
||||
**Correcção de navegação 409 em /pessoas/relatorios — 01/11/2026:** reproduzido ao seleccionar people.third_party, marcado não operacional, e abrir directamente o relatório. EnsureWorkContext redirecciona agora pedidos GET de modalidade ainda pendente ao Painel Inicial, onde a situação e opções autorizadas são apresentadas. Não muda a modalidade silenciosamente, não activa funcionalidades pendentes e mantém comandos de escrita bloqueados e isolamento por área/empresa. people.internal continua a abrir relatório/CSV normalmente. 53 testes/1217 asserções de Pessoas/contextos passaram; análise PHP e formatação passaram; scripts/people-reports-check.mjs verificou HTTP local do percurso pendente (302), relatório e CSV internos (200). Sem alteração de contas, grants ou dados de negócio. Em sessão com modalidade pendente, escolher Contratações internas no Painel Inicial para consultar os processos internos.
|
||||
|
||||
**Visibilidade dos relatórios de Pessoas — 01/11/2026:** acrescentado botão Relatórios no topo de /pessoas, junto de Actualizar, e atalho Relatórios de pessoas e contratações no Painel Inicial quando a opção activa de Pessoas está operacional. Conservada entrada do menu e autorizações do servidor; não seleccionar contexto nem conceder acesso silenciosamente. Formatação/lint dos dois ecrãs, TypeScript e compilação passaram; relatório/CSV revalidados em HTTP local. Os atalhos tornam o acesso descobrível sem depender da abertura do grupo recolhível do menu.
|
||||
|
||||
**Relatório candidaturas/avaliações (01/11/2026):** disponível por atalho em /pessoas/relatorios?view=applications; contagens por processo, filtros/CSV/impressão existentes. Reutiliza matrix e, após aprovação, selection.rows preservado; portal separado de candidaturas registadas para evitar duplicação. Triagem só com canRead, sem acesso representado por null. Sem dados pessoais/evidências nos indicadores e sem grants/migração. Período refere criação do processo. Restam relatórios de selecções/contratações e prazos/responsabilidades propostos; não inferir contrato assinado a partir de minuta.
|
||||
|
||||
**Verificação do relatório de candidaturas:** 76 testes/1683 asserções de PeopleHiringTest, WorkContextTest e CandidateSubmissionsTest passaram. Inclui admissão do portal sem duplicação, acesso reservado à triagem, selecção preservada, filtros e exportação. TypeScript, lint/formatação, compilação e análise PHP passaram. HTTP local confirmou relatórios e CSV; sem revisão visual por capturas. Prova: docs/proofs/people-applications-report.json.
|
||||
|
||||
**Selecções e contratações (01/11/2026):** relatório em /pessoas/relatorios?view=selections, com proposta/devolução/aprovação, datas, pendência e metadados da minuta apenas com contract/contract_view. Não inclui termos/fundamentos/candidatos/finanças e não infere assinatura/admissão. Filtros, impressão, CSV e isolamento existentes reutilizados; CSV neutraliza referência contratual. 78 testes/1839 asserções e tipos/lint/build/análise PHP passaram; HTTP dos três relatórios/CSV passou. Sem migração/grants/dados alterados. Próxima acção: relatório de prazos e responsabilidades a partir da mesa de trabalho. Formalização e integração RH permanecem fora deste incremento.
|
||||
|
||||
**Prazos/responsabilidades (01/11/2026):** /pessoas/relatorios?view=deadlines reutiliza PeopleWorkdesk, com tarefas/conferências próprias e alertas dos processos distinguidos. Não atribui alertas a pessoa, não inventa prazos; dias de calendário, prazo ausente null. Ecrã por página; CSV completo filtrado, sem conteúdos reservados. 80 testes/1903 asserções, tipos/lint/build/análise PHP e oito verificações HTTP passaram. Sem migração/grants/dados alterados; proofs/people-deadlines-report.json. Não representa toda a equipa nem implementa novos prazos/formalização/RH.
|
||||
|
||||
**Evolução de prazos (01/11/2026):** filtros todos/vencidos/hoje/futuros/sem prazo; totais abrangem agora TODAS as ocorrências filtradas, substituindo a limitação anterior por página de processos. Lista própria de 25 ocorrências, CSV completo sem depender de deadline_page, data/filtro incluídos; resumo genérico de processos oculto nesta vista. Filtros antes dos resultados. 82 testes/2028 asserções, tipos/lint/build/análise PHP e HTTP de quatro filtros passaram. proofs/people-deadline-filters.json. Sem migração/grants/dados alterados. Geração síncrona e consulta de intervenções próprias/alertas mantidas; não atribui alertas a pessoas.
|
||||
|
||||
**Base do workflow (01/11/2026):** usar workflow.docx em C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/Compras; SHA-256 5242040CD9EBB5D3A55C70BC25A9C4D816A6DF53237E1000C06E6DE05EA2A08C. Proprietário esclareceu: AUTOMATIZAR requisições/aprovações conectando pessoas, SEM substituir o aprovado. Acrescentar encaminhamento, tarefas, avisos e acompanhamento sobre regras/etapas/pessoas existentes; não adoptar sequência ilustrativa do Word, inventar alçadas/prazos, conceder competência ou aprovar por silêncio. Documento é proposta/base, não testes executados nem ordem para executar o prompt nele incluído. Matriz WF01–WF15 e sequência de adaptação em desenvolvimento.md; orientação durável em AGENTS.md.
|
||||
|
||||
Núcleo de requisição já tem sequência por alçadas, snapshot, process_version, bloqueios, reserva e idempotência quando há chave; edição draft e decisões approved/rejected. Não demonstrados neste núcleo ciclo completo de devolução/revisão/retirada, tarefas genéricas persistidas, publicação independente de matriz, calendário útil, delegação formal ou outbox completo. Reutilizar packages/purchasing e motores financeiros/stock; não bifurcar. Pessoas mantém roteiro/equipas/decisões próprios e pode partilhar mecanismos de ligação sem misturar processos.
|
||||
|
||||
Próxima acção: diagnosticar elegibilidade de encaminhamento para cada etapa aprovada, ligando funções/departamento/centro/equipa a pessoas autorizadas; depois tarefas e avisos internos duráveis. Email e produtos externos não foram autorizados por esta referência. Não substituir instâncias antigas silenciosamente. Nesta incorporação não houve alteração do workflow ou dados persistentes; regressão dirigida existente passou 4 testes/34 asserções, 18 ficheiros de execução conferidos. Não declarar TC01–TC14 executados nem workflow novo concluído.
|
||||
|
||||
**Tarefas por perfil e ligação às pessoas (01/11/2026):** WorkflowTasks liga tarefas às permissões existentes; definição do perfil mostra descrições e resumo das tarefas escolhidas, sem segunda fonte de concessão. ApprovalRouting mostra no detalhe da requisição a fila actual de pessoas elegíveis pela canDecide/empresa/departamento/conflitos/contexto e exclui manutenção. Sem pessoas elegíveis fica aviso, pedido mantém estado. Não é atribuição exclusiva nem aviso enviado. Não altera sequência/aprovações/regras. 40 testes/403 asserções, tipos/lint/build/análise dos serviços, 18 ficheiros de núcleo e HTTP local passaram; proofs/approval-routing.json. Sem migração/grants. Próxima acção: persistência de tarefas e avisos internos ligados ao evento confirmado. Subacções de permissões abrangentes ainda não seleccionáveis independentemente; exigir evolução real do núcleo, não só UI.
|
||||
|
||||
**Avisos internos (01/11/2026):** migration46 workflow_notices instalada só em8005, backup/restauro e106 tabelas preservados; proofs/workflow-notices-install.json. Port do núcleo cria aviso único por etapa/destinatário com submissão/decisão na mesma transacção, sem alterar núcleo distribuído/regras. /requisicoes/avisos, filtro por ler, paginação e ligação ao pedido; ler não aprova, auditado/idempotente. Revalida destinatário/empresa/contexto/canDecide; avisos obsoletos deixam lista activa mas ficam guardados. Sem email/outbox externo, retroactividade, lembretes, delegação ou tomada exclusiva. Acessos novos em etapa já aberta ainda dependem de Pendentes para mim.42 testes/455 asserções dirigidos passaram; geral/PG a concluir. Não declarar TC01–TC14 completos.
|
||||
|
||||
**Verificação final dos avisos:** geral891 testes/889 passaram/2ignorados/9236 asserções; PG21 testes/244 asserções de requisição/contextos passaram; tipos/build/lint/análise/HTTP passaram. proofs/workflow-notices.json. Teste de relatório de OrganizationConfigurationTest usa transacção externa incompatível com guarda PG de leitura própria; não alterado nem certificado em PG, coberto na bateria habitual. Próximo: reencaminhamento controlado de etapas já abertas e aviso de resultado ao requisitante; sem novo motor/decisão repetida.
|
||||
|
||||
**Resultados ao requisitante (01/11/2026):** WorkflowNotices::outcome cria aviso final approved/rejected na tabela46 existente, atómico com decisão/auditoria/orçamento e único por etapa/destinatário. /requisicoes/avisos separa tarefas/resultados/porler; menu inclui requisitante. Resultado histórico e estadoactual distintos; só destinatário com acesso actual consulta/lê, sem conteúdo reservado. Sem migração/email/grants/retroactividade.45 testes/573 asserções e PG24/362 passaram; tipos/lint/build/análise/HTTP e18 ficheiros de núcleo passaram. proofs/workflow-outcomes.json. Próximo: reencaminhamento controlado de etapas abertas após mudança de perfis, sem duplicar decisão nem conceder competências.
|
||||
|
||||
**Actualizar encaminhamento (01/11/2026):** acção recolhida em requisição pendente para admin/Compras fora de manutenção. Motivo+versão+etapa+chave; cria só avisos em falta a elegíveis actuais, sem escolher destinatários arbitrários ou mudar decisões/competências/etapas. Recibo lógico na auditoria imutável, idempotência sob bloqueio, falha de auditoria reverte avisos; leitura anterior e avisos antigos preservados. Perfil alterado não desencadeia esta acção automaticamente.47 testes/602 asserções e PG26/391, tipos/lint/build/análise e18 ficheiros comuns passaram; proofs/workflow-routing-refresh.json. Sem migração/email/grants. Pendências: lembretes/calendários configurados, delegação formal e automatização de reconciliação com âmbito explícito após alteração de perfis.
|
||||
|
||||
**Proposta recebida de requisições ao armazém (01/11/2026):** fonte attachment e9adc3f7-1b4f-488c-a3fe-f53b310a1e64/Texto colado.txt. Enquadramento em desenvolvimento.md; não activada nem confundida com fluxo aprovado. Pedido ao armazém separado de compra, reutiliza origens/cadastros; reservas actuais SupplyCoordination são planeamento, não bloqueio físico. Novo compromisso exige integrar TODOS os caminhos que reduzem disponibilidade no único StockWorkflow, saída parcial+consumo da reserva atómicos, libertação só do restante, reconciliação autorizada. Termo saída por entrega distinto de abate por inutilização. Não activar vendas/integrações nem matriz ilustrativa automaticamente; sem inventar alçadas/prazos. CA01–09 são plano, não provas passadas. Nesta análise só documentação, sem testes de aplicação ou dados alterados. Próxima acção: vínculo necessidade planeada→pedido armazém→compromisso físico e responsabilidades/configuração antes de alterar disponibilidade.
|
||||
|
||||
**Disponibilidade para requisições (01/11/2026):** /stock/disponibilidade, menu Armazém e activos, reutiliza StockWorkflow/InventoryLots e políticas/necessidades de SupplyPlanning. Físico/utilizável/retido/planeado separados, compromisso físico desconhecido explícito; sem movimentos/reservas/compras em GET. Nova capacidade warehouse_availability_read configurável sem grants.16 testes/123 asserções, tipos/lint/build/análise passaram. Proprietário determinou compra por inexistência/mínimo (AGENTS.md); geração ainda NÃO implementada, UI explícita. Próximo: configuração da origem/requisitante/centro/quantidade e comando de geração idempotente sem duplicar reposição em curso, preservando aprovações; não inventar dados nem activar vendas por inferência.
|
||||
|
||||
**Destino do pedido — proprietário01/11/2026:** explicitar onde será usado; equipamento seleccionado do cadastro quando aplicável; venda ligada a FACTURA DE VENDA/linhas. Facturas actuais de fornecedor não servem de origem comercial. Condicionar campos ao destino, preservar referência/versão e transmitir vínculo à compra por falta. Não duplicar abate da origem externa nem activar vendas por inferência. Requisito incorporado em AGENTS.md; pedido/campos ainda por implementar, consulta de disponibilidade não os substitui.
|
||||
|
||||
**Rascunhos ao armazém (01/11/2026):** /stock/requisicoes, migração47 instalada apenas8005, backup/restauro107 tabelas preservadas. Snapshot interno local/finalidade/centro/requisitante/artigos; equipamento por linha do cadastrodept, peças com vínculo validado. Tarefas warehouse_request_prepare/read semgrants; próprio vs consultaempresa separados. Idempotente, auditoriaatómica, sem stock/orçamento/compra ou autorização. Venda/factura de venda indisponível até fonte comercial válida; não associar factura de fornecedor.81testes/809asserções e PG6/66, tipos/lint/build/análise/HTTP passaram; proofs/warehouse-drafts.json. Sem edição/submissão/autorização/reserva/entrega ainda; próxima: edição versionada e configuração/submissão/autorização. Geração automática de compra por falta/mínimo permanece requisito a executar com origem/requisitante/centro/quantidade configurados.
|
||||
|
||||
**Decisão confirmada do proprietário — documento de saída interno e venda no SGF (01/11/2026):** uso interno não tem factura. A requisição autorizada é o documento de saída do armazém; só a entrega física confirmada gera o movimento pela quantidade efectivamente entregue. Preparação/aprovação não abatem stock, e entrega parcial conserva saldo pendente. A opção de venda a terceiros será incorporada no SGF, que já dispõe de facturação, gestão de stock e contabilidade; nessa composição a requisição associa-se à factura de venda e reutiliza os mecanismos do SGF, sem duplicar movimentos ou motores. Esta decisão esclarece o âmbito comercial da proposta anterior e substitui qualquer interpretação de exigir factura no uso interno ou de criar facturação de vendas autónoma no SGC. Não remove por inferência compras/importações para revenda; trata do atendimento de vendas e documento de saída. Orientação incorporada em AGENTS.md; nesta confirmação não houve alteração de código, dados ou instalação e não foram necessários novos testes.
|
||||
|
||||
**Autorização/entrega interna sem factura (01/11/2026):** migração48 eventos instalada só8005 com backup/restauro108 tabelas preservadas. /stock/requisicoes/{id} imprime requisição e entregas; submit, authorize/reject, deliver parcial/integral e receive. Tarefas review departamental independente e deliver+warehouse semgrants; requisitante não aprova/entrega, aprovador não entrega. Parcial aceite na submissão e autorizado. Cada entrega chama StockWorkflow issue; evento/auditoria/movimentos atómicos, repetição/versão controladas; recepção não movimenta stock.85testes/495asserções e PG12/122+2disputas reais passaram; geral a concluir. Reserva exclusiva e compra automática ainda NÃO implementadas, UI explícita. Não declarar CA01–09/totalidade proposta. Cancelamento, versão, delegação, avisos novos e destinatário alternativo pendentes. proofs/warehouse-fulfilment-install.json.
|
||||
|
||||
**Verificação final da autorização/entrega:** bateria geral908 testes/906 passaram/2ignorados/9508 asserções, sem falhas. PostgreSQL12/122 e2disputas reais já descritos; prova consolidada docs/proofs/warehouse-fulfilment.json. O acesso operacional exige atribuição explícita das tarefas warehouse_request_prepare/review/deliver e, para entregar, warehouse. Sem atribuições automáticas. Não se confundem estes resultados com reserva exclusiva ou geração automática de compras, que continuam pendentes.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T16:31:19.533Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"profile_task_catalogue": "passed",
|
||||
"workflow_notices": "ready",
|
||||
"existing_requisition_routing": "inactive",
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_browser_review": false
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T14:48:47.5015167Z",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 40,
|
||||
"passed": 40,
|
||||
"assertions": 403,
|
||||
"duration_ms": 5186
|
||||
},
|
||||
"analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"http_proof": "approval-routing-http.json",
|
||||
"runtime_files_checked": 18,
|
||||
"visual_browser_review": false,
|
||||
"persistent_tasks_implemented": false,
|
||||
"notifications_sent": false,
|
||||
"grants_changed": false
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"checked_at": "2026-10-30T19:32:51.598464+00:00",
|
||||
"server": {
|
||||
"active_commit": "3b1ef2031ce395d21d973d0e7a7ec6630f058db8",
|
||||
"expected_commit_in_release": true,
|
||||
"deployment_status": "Result=success\nExecMainStatus=0\nActiveState=inactive\nSubState=dead\n",
|
||||
"nginx_validation_ok": true,
|
||||
"storage_in_shared": true,
|
||||
"web_user_can_write_private_uploads": true,
|
||||
"application": {
|
||||
"database_driver": "pgsql",
|
||||
"migration_count": 44,
|
||||
"candidate_migrations": [
|
||||
"2026_10_30_000037_create_candidate_profiles",
|
||||
"2026_10_30_000038_create_candidate_access_tokens",
|
||||
"2026_10_30_000039_create_candidate_submissions",
|
||||
"2026_10_30_000040_create_candidate_triage",
|
||||
"2026_10_30_000041_create_candidate_submission_documents"
|
||||
],
|
||||
"documents_table_exists": true,
|
||||
"document_count": 0,
|
||||
"public_navigation_enabled": true,
|
||||
"candidate_disk_root": "/var/www/compras-deploy/releases/20261030-192546-3b1ef2031ce3-Hvig1c3b/storage/app/candidate-documents"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"checked_at": "2026-10-30T19:26:31.442913+00:00",
|
||||
"target_commit": "3b1ef2031ce395d21d973d0e7a7ec6630f058db8",
|
||||
"results": [
|
||||
{
|
||||
"path": "/",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/dashboard"
|
||||
},
|
||||
{
|
||||
"path": "/dashboard",
|
||||
"status": 200,
|
||||
"component": "dashboard",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/requisicoes",
|
||||
"status": 200,
|
||||
"component": "requisitions/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/requisicoes/criar",
|
||||
"status": 200,
|
||||
"component": "requisitions/form",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/fornecedores",
|
||||
"status": 200,
|
||||
"component": "suppliers/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/cotacoes",
|
||||
"status": 200,
|
||||
"component": "quotations/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/ordens",
|
||||
"status": 200,
|
||||
"component": "purchases/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/recepcoes",
|
||||
"status": 200,
|
||||
"component": "receipts/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/stock",
|
||||
"status": 200,
|
||||
"component": "stock/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/stock/catalogo",
|
||||
"status": 200,
|
||||
"component": "stock/catalogue",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/stock/operacoes",
|
||||
"status": 200,
|
||||
"component": "stock/operations",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/equipamentos",
|
||||
"status": 200,
|
||||
"component": "equipment/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/planeamento",
|
||||
"status": 200,
|
||||
"component": "planning/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/pessoas",
|
||||
"status": 200,
|
||||
"component": "people/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/orcamentos",
|
||||
"status": 200,
|
||||
"component": "budgets/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/facturas",
|
||||
"status": 200,
|
||||
"component": "invoices/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/pagamentos",
|
||||
"status": 200,
|
||||
"component": "payments/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/regras-investimento",
|
||||
"status": 200,
|
||||
"component": "investment/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/politica-admissao",
|
||||
"status": 200,
|
||||
"component": "purchases/admission-policy",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/politica-recomendacao",
|
||||
"status": 200,
|
||||
"component": "purchases/recommendation-policy",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/revisao-compras",
|
||||
"status": 200,
|
||||
"component": "purchase-controls/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/settings/appearance",
|
||||
"status": 200,
|
||||
"component": "settings/appearance",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "dc62759c2f30f3d4639ed3f4af3e38f9"
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/cadastro",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/1",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/fornecedores/1/dados-bancarios",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/administracao/parametrizacao",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/relatorios",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/settings/profile",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/candidaturas/1/submissoes/1/documentos/cv",
|
||||
"status": 401
|
||||
}
|
||||
],
|
||||
"public_pages_passed": 21,
|
||||
"private_routes_protected": 7,
|
||||
"server_activation_proof": "confirmed"
|
||||
},
|
||||
"new_migrations_applied": false,
|
||||
"visual_review": "pending",
|
||||
"real_candidate_end_to_end_trial": "pending; no real submissions created"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"candidate_id": "IR-CANDIDATE-bfa807e2b3c4fae7",
|
||||
"directory": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7",
|
||||
"manifest_sha256": "dee8e030763b6cc277059d91497f66c02233630e8416e7417733e9d51f471b9c",
|
||||
"archives": {
|
||||
"runtime": {
|
||||
"file": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7/runtime.zip",
|
||||
"sha256": "5a63a283a21c9c8a7fdd837da8a0725cfe28cd2653eac61884e8b64f30c38326"
|
||||
},
|
||||
"verification": {
|
||||
"file": "C:\\laragon\\www\\compras/storage/app/private/candidates/IR-CANDIDATE-bfa807e2b3c4fae7/verification.zip",
|
||||
"sha256": "02776c0097ab27fcb6269e647ce6b10c04a6731be56de54ceac2890e7bbac325"
|
||||
}
|
||||
},
|
||||
"standard_updater_refuses_candidate": true,
|
||||
"activation_performed": false
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"fresh isolated database",
|
||||
"failed additive migration rolls back preserving existing identity grants and budget",
|
||||
"successful additive migration preserves existing data",
|
||||
"authenticated synthetic identity 1",
|
||||
"authorised company unit selection 1",
|
||||
"authenticated synthetic identity 2",
|
||||
"authorised company unit selection 2",
|
||||
"versioned policy without functional grants",
|
||||
"authorised workflow screen and context",
|
||||
"create draft through HTTP",
|
||||
"CSRF retained on candidate commands",
|
||||
"create retry one origin",
|
||||
"protected context field refused",
|
||||
"wrong work area refused",
|
||||
"stale context refused on reads",
|
||||
"stale edit refused",
|
||||
"failure after reserve injected",
|
||||
"failure rolls back origin reserve steps and request",
|
||||
"committed response loss injected",
|
||||
"exact retry recovers committed submission",
|
||||
"single existing budget reservation",
|
||||
"self approval denied with approval grant",
|
||||
"revoked approval denied at server",
|
||||
"independent approval",
|
||||
"approval retry preserves one decision and versions",
|
||||
"authorised origin version and history consultation",
|
||||
"revoked read denied",
|
||||
"unit selection does not grant access",
|
||||
"explicit authorised unit switch",
|
||||
"read and operation from previous unit blocked",
|
||||
"explicit authorised company switch without name matching",
|
||||
"other company cannot read origin",
|
||||
"return to original authorised scope",
|
||||
"first transaction owns scope lock save",
|
||||
"concurrent retry serialized without duplicate save",
|
||||
"first transaction owns scope lock submit",
|
||||
"concurrent retry serialized without duplicate submit",
|
||||
"first transaction owns scope lock decide",
|
||||
"concurrent retry serialized without duplicate decide",
|
||||
"concurrent whole lifecycle produced one request reserve per origin",
|
||||
"history SQL immutability",
|
||||
"failed schema transaction recovered",
|
||||
"old candidate reads additive compatible schema without rewriting data"
|
||||
],
|
||||
"concurrency": [
|
||||
"save",
|
||||
"submit",
|
||||
"decide"
|
||||
],
|
||||
"scope": {
|
||||
"tenant_id": 1,
|
||||
"company_id": 2,
|
||||
"unit_id": 2
|
||||
},
|
||||
"requests": 2,
|
||||
"budget_reservations": 2,
|
||||
"schema_failure_recovered": true,
|
||||
"additive_schema_compatible": true,
|
||||
"active_destination_changed": false,
|
||||
"data_schema_recovery": {
|
||||
"backup_sha256": "c283b6fdcc6b18c9455bab721790f640235a9b87b5aaa9846a6ed40cd04c9b0f",
|
||||
"tables_reconciled": 22,
|
||||
"restore_verified": true,
|
||||
"schema_constraints_and_triggers_verified": true,
|
||||
"restored_consultation": {
|
||||
"restored_candidate_consultation": true,
|
||||
"id": 1,
|
||||
"origin_preserved": true,
|
||||
"version": 3
|
||||
},
|
||||
"restores_new_operations_automatically": false,
|
||||
"separate_from_code_rollback": true
|
||||
},
|
||||
"source_postgres_tests": {
|
||||
"passed": true,
|
||||
"output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":46,\"passed\":46,\"assertions\":339,\"duration_ms\":23459}"
|
||||
},
|
||||
"checked_at": "2026-11-01T13:56:52+00:00",
|
||||
"isolated_database_removed_in_finally": true,
|
||||
"active_destination_version": "0.1.1",
|
||||
"candidate_root": "C:\/laragon\/www\/compras\/storage\/app\/private\/candidates\/IR-CANDIDATE-bfa807e2b3c4fae7"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"candidate_id": "IR-CANDIDATE-bfa807e2b3c4fae7",
|
||||
"checked_date": "2026-11-01",
|
||||
"source_full_sqlite_suite": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 883,
|
||||
"passed": 881,
|
||||
"assertions": 8950,
|
||||
"duration_ms": 171488,
|
||||
"skipped": 2
|
||||
},
|
||||
"source_postgresql": {
|
||||
"tests": 46,
|
||||
"assertions": 339,
|
||||
"passed": true,
|
||||
"isolated": true
|
||||
},
|
||||
"sgf_candidate": {
|
||||
"checks": 43,
|
||||
"passed": true,
|
||||
"isolated": true,
|
||||
"proof": "internal-requisitions-sgf.json"
|
||||
},
|
||||
"screen_logic": {
|
||||
"checks": 23,
|
||||
"passed": true,
|
||||
"browser_used": false
|
||||
},
|
||||
"phpstan": {
|
||||
"errors": 0,
|
||||
"passed": true,
|
||||
"scope": "InternalRequisitionPort, RequisitionWorkflow, Requisition model and shared InternalRequisitions"
|
||||
},
|
||||
"typescript": {
|
||||
"passed": true
|
||||
},
|
||||
"frontend_lint": {
|
||||
"passed": true,
|
||||
"files": 4,
|
||||
"warnings": 0
|
||||
},
|
||||
"frontend_build": {
|
||||
"passed": true
|
||||
},
|
||||
"candidate": "internal-requisitions-candidate.json",
|
||||
"destination_version": "0.1.1",
|
||||
"activated": false,
|
||||
"published": false,
|
||||
"public_rights_changed": false,
|
||||
"data_schema_recovery_separate_from_code": true,
|
||||
"limitations": [
|
||||
"SGF equipment/spare-part technical workflows not incorporated",
|
||||
"Operational migration installation and administrative permission catalogue not activated",
|
||||
"No browser visual review; HTTP, screen logic and compilation verified",
|
||||
"Two tests skipped in full suite; not counted as passed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T12:18:05.919263Z",
|
||||
"scope": [
|
||||
"counts_and_signed_adjustments",
|
||||
"supplier_returns_and_writeoffs",
|
||||
"approved_lot_policy_revisions",
|
||||
"full_physical_reversals",
|
||||
"calendar_source_validity"
|
||||
],
|
||||
"postgresql": {
|
||||
"checked_at": "2026-11-01T14:16:58+02:00",
|
||||
"database": "fresh disposable PostgreSQL",
|
||||
"tests": [
|
||||
"SupplyPlanningTest",
|
||||
"SupplyCoordinationTest",
|
||||
"SupplySuggestionsTest",
|
||||
"SupplyHistoryTest",
|
||||
"InventoryLotsTest",
|
||||
"InventoryCountsTest",
|
||||
"InventoryDispositionsTest",
|
||||
"InventoryLotPolicyTest",
|
||||
"InventoryReversalsTest"
|
||||
],
|
||||
"result": "passed",
|
||||
"races": [
|
||||
{
|
||||
"scenario": "same_reference",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "same_operation",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "single_conversion",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "conversion_retry",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_double_issue",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_identification_release",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_double_transfer",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_double_adjustment",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "double_supplier_return",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_policy_stock_change",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "double_stock_reversal",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"persistent_data_changed": false,
|
||||
"output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":98,\"passed\":98,\"assertions\":628,\"duration_ms\":55755}"
|
||||
},
|
||||
"navigation": {
|
||||
"checked_at": "2026-11-01T12:17:16.378Z",
|
||||
"checks": [
|
||||
{
|
||||
"view": "agreements",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "calendar",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "imports",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "alternatives",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "reservations",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "deliveries",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "suggestions",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "history",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "lots",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "dispositions",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
},
|
||||
{
|
||||
"view": "reversals",
|
||||
"status": 200,
|
||||
"ready": true,
|
||||
"admin_has_no_automatic_write": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"static_analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"directed_tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 23,
|
||||
"passed": 23,
|
||||
"assertions": 140,
|
||||
"duration_ms": 4688
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"runtime_files_sha256_checked": 18,
|
||||
"visual_browser_review": false,
|
||||
"financial_credit_or_refund_implemented": false,
|
||||
"full_suite": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 862,
|
||||
"passed": 860,
|
||||
"assertions": 8527,
|
||||
"duration_ms": 122157,
|
||||
"skipped": 2
|
||||
},
|
||||
"sources": {
|
||||
"app/Services/SupplyPlanning.php": "ACC8005DADF78CC3B91D5EB3F1D44322E60B44920DBD5CA420D4A8F3ACEC586A",
|
||||
"app/Services/InventoryLots.php": "DF1672C5D6EC7D96A79581555408287B5449C06286B53359D8472659E3BA8FE7",
|
||||
"app/Services/InventoryDispositions.php": "1CF64BABC5659A3C056600A2E8ABCC7B88D11E37097AB3ED6E339313C392749F",
|
||||
"app/Services/StockWorkflow.php": "1432454AB657EF8EF40886E4676ECAF5EE5A40E6D5782BCB25C2F88D573D33A7",
|
||||
"app/Services/InventoryCounts.php": "0C1B0D5E795A15B4D762C47FCDBEB19F225AA933B4507938F310FF25026AFA11",
|
||||
"app/Services/InventoryReversals.php": "AA17B3F30F65A855173DFFEF0F4659B7716D54D9CD622FEB5E3CC44208CEEBEE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T10:53:28+00:00",
|
||||
"migration": "2026_11_01_000044_allow_signed_lot_adjustments",
|
||||
"environment": "local evaluation 8005",
|
||||
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-105325\/before.dump",
|
||||
"backup_sha256": "496c0ea53dece4d8714a696d60db75eef4524af6d72a971503a1d1b6f3cd3986",
|
||||
"restore_verified": true,
|
||||
"existing_tables_preserved": 106,
|
||||
"new_tables": [],
|
||||
"accounts_or_grants_changed": false,
|
||||
"domain_installed": false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T10:30:58+00:00",
|
||||
"migration": "2026_11_01_000043_create_inventory_lot_allocations",
|
||||
"environment": "local evaluation 8005",
|
||||
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261101-103055\/before.dump",
|
||||
"backup_sha256": "4727a6859e19a33c2fa0ad91a925e75737490590355856a3b150f6bb75748e5b",
|
||||
"restore_verified": true,
|
||||
"existing_tables_preserved": 105,
|
||||
"new_tables": {
|
||||
"inventory_lot_allocations": 0
|
||||
},
|
||||
"accounts_or_grants_changed": false,
|
||||
"domain_installed": false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"visual_browser_review": false,
|
||||
"full_suite": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 808,
|
||||
"passed": 806,
|
||||
"assertions": 8230,
|
||||
"duration_ms": 117137,
|
||||
"skipped": 2
|
||||
},
|
||||
"types_build_lint_scoped_phpstan": "passed",
|
||||
"postgresql": {
|
||||
"checked_at": "2026-11-01T12:39:20+02:00",
|
||||
"database": "fresh disposable PostgreSQL",
|
||||
"tests": [
|
||||
"SupplyPlanningTest",
|
||||
"SupplyCoordinationTest",
|
||||
"SupplySuggestionsTest",
|
||||
"SupplyHistoryTest",
|
||||
"InventoryLotsTest"
|
||||
],
|
||||
"result": "passed",
|
||||
"races": [
|
||||
{
|
||||
"scenario": "same_reference",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "same_operation",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "single_conversion",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "conversion_retry",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_double_issue",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_identification_release",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "lot_double_transfer",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"persistent_data_changed": false,
|
||||
"output": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":56,\"passed\":56,\"assertions\":417,\"duration_ms\":26151}"
|
||||
},
|
||||
"sources_sha256": {
|
||||
"tests/Concurrency/supply-worker.php": "56bc8d55ea07daac7ad4472a7a408b822e7d3ee7d97d948b1ca754497f0ced77",
|
||||
"app/Services/StockWorkflow.php": "5ff22b088b79a2995b0b1480efc6eaba2c119ab2417ffe5ec7ef1951327a3056",
|
||||
"resources/js/pages/stock/operations.tsx": "0580ba8c67e5e0368068c63948778af06a53bba3afb76ed00180c70e986dea61",
|
||||
"resources/js/pages/stock/lots.tsx": "6d0839fd85d8803d5c59dfc12ac6162630e2a7962d45e7ac25438eafa97995ff",
|
||||
"app/Services/InventoryLots.php": "6afbfffa32e8b55ebe916b14d681e72f1744a31e0f5d3e4c8732657b19b99c7a",
|
||||
"tests/Feature/InventoryLotsTest.php": "b14fbbf49ac12ab0e7e882cbf961e7bfc8845bba76f9cd583ef1aa53c51d5ced",
|
||||
"scripts/supply-pg-check.php": "7561ea31c0fcecad839678df5a2cd0f7d9a904c4c589413682754c5b8d79b097"
|
||||
},
|
||||
"persistent_business_commands_or_grants": false,
|
||||
"remaining": [
|
||||
"Counts and adjustments partitioned by lot",
|
||||
"Returns, write-offs and reversals",
|
||||
"Separate dispatch, transit and physical receipt",
|
||||
"Movement of held/expired material and manual lot selection",
|
||||
"Partial quarantine within one lot identity",
|
||||
"Existing Word proposal gaps retained in continuity documents"
|
||||
],
|
||||
"new_migration": false,
|
||||
"checked_at": "2026-11-01T10:41:07.6014744Z",
|
||||
"published": false,
|
||||
"navigation": "nine installed local HTTP/Inertia pages verified",
|
||||
"scope": "Immediate tracked warehouse transfers, stable lot identity, FEFO allocation at both ends and independent destination quarantine"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"full_suite": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 801,
|
||||
"passed": 799,
|
||||
"assertions": 8192,
|
||||
"duration_ms": 112879,
|
||||
"skipped": 2
|
||||
},
|
||||
"final_targeted_suite": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 91,
|
||||
"passed": 91,
|
||||
"assertions": 596,
|
||||
"duration_ms": 10632
|
||||
},
|
||||
"postgresql": "49 tests / 379 assertions and six real lock-contention races; see supply-coordination-pg.json",
|
||||
"scope": "Initial lot reconciliation, reception, metadata, independent quarantine release, FEFO issue and expiry projection",
|
||||
"local_navigation": "nine HTTP/Inertia pages verified; no automatic write permissions",
|
||||
"limitations": [
|
||||
"Tracked transfers and adjustments pending, guarded on server",
|
||||
"Active setup revision and exemption from quarantine pending",
|
||||
"Explicit identity reuse across receipts pending",
|
||||
"Unqualified future lot arrivals excluded from usable forecast",
|
||||
"Full traceability reporting, alerts and pagination pending"
|
||||
],
|
||||
"published": false,
|
||||
"checked_at": "2026-11-01T10:34:21.9942049Z",
|
||||
"sources_sha256": {
|
||||
"app/Http/Controllers/InventoryLotController.php": "3089578e142b98b60bb4561a235550772d9108114ac005e489cadf74a0ce4891",
|
||||
"tests/Feature/InventoryLotsTest.php": "813ea1d7dc937d1e6d8f756f6d9d9acc97a12c2640d97d5b8bcb53c441014c11",
|
||||
"resources/js/pages/stock/lots.tsx": "d36035c74245ed927c90a2e6ac28d4a3ec5f5c61298b5e75f117761b61167432",
|
||||
"app/Services/ReceiptWorkflow.php": "9b3e0bca27852bc04de67b0a8d075be1acabddee4b309e82d06983f99bcbcf94",
|
||||
"app/Services/ReplenishmentProjection.php": "876dea250546044db7993fe62d1c189f3298d43bd5342664d25c6e4d54b1176c",
|
||||
"app/Services/InventoryLots.php": "735702065b8d5a864a44927e5bd67d1f70a32d048583476c534ada56bd02380b",
|
||||
"app/Services/SupplySuggestions.php": "3f304611f63340316b56065d8cbd3e9f2ebcbb968c1bbac74d181f0eabfacb7d",
|
||||
"database/migrations/2026_11_01_000043_create_inventory_lot_allocations.php": "37c7ab878c59a8eb5bcbe8a1e7e479199e308dc8c67d0d52ec8d6156b3379ecc",
|
||||
"app/Services/StockWorkflow.php": "27099a811beab57a1b3d1ee3ee5bda49715c7ac7a101ba0e95532ada21e52d10",
|
||||
"app/Models/InventoryLotAllocation.php": "3b2b1aa7be0bb5032ab4e0b6ec5a768811566aebc1d690f682e7bd27477c93e7"
|
||||
},
|
||||
"types_build_lint_scoped_phpstan": "passed",
|
||||
"visual_browser_review": false,
|
||||
"local_installation": "migration 43; verified backup restore; 105 existing tables preserved; no new accounts, grants or business data"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T13:26:45.0495260Z",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 76,
|
||||
"passed": 76,
|
||||
"assertions": 1683,
|
||||
"duration_ms": 11767
|
||||
},
|
||||
"analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"local_http": "report and CSV for both views passed",
|
||||
"visual_browser_review": false,
|
||||
"existing_data_or_grants_changed": false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T14:26:36.4015912Z",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 82,
|
||||
"passed": 82,
|
||||
"assertions": 2028,
|
||||
"duration_ms": 30608
|
||||
},
|
||||
"analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"local_http": "four report views, four exports, four deadline filters passed",
|
||||
"visual_browser_review": false,
|
||||
"data_or_grants_changed": false
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T14:21:45.8615228Z",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 80,
|
||||
"passed": 80,
|
||||
"assertions": 1903,
|
||||
"duration_ms": 18286
|
||||
},
|
||||
"analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"http_proof": "people-reports-http.json",
|
||||
"visual_browser_review": false,
|
||||
"business_data_changed": false,
|
||||
"grants_changed": false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T14:26:12.336Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"pages": [
|
||||
{
|
||||
"path": "/pessoas/relatorios",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios.csv",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios?view=applications",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios.csv?view=applications",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios?view=selections",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios.csv?view=selections",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios?view=deadlines",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/relatorios.csv?view=deadlines",
|
||||
"status": 200
|
||||
}
|
||||
],
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_browser_review": false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T12:24:50.9407640Z",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 52,
|
||||
"passed": 52,
|
||||
"assertions": 1188,
|
||||
"duration_ms": 9487
|
||||
},
|
||||
"analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"http_proof": "people-reports-http.json",
|
||||
"migration_required": false,
|
||||
"access_grants_changed": false
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T13:40:40.9440516Z",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 78,
|
||||
"passed": 78,
|
||||
"assertions": 1839,
|
||||
"duration_ms": 13966
|
||||
},
|
||||
"analysis": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"types": "passed",
|
||||
"build": "passed",
|
||||
"http": "three report views and their CSV exports passed",
|
||||
"visual_browser_review": false,
|
||||
"existing_business_data_changed": false,
|
||||
"grants_changed": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T12:58:15.7857457Z",
|
||||
"installed_client": {
|
||||
"version": "0.1.1",
|
||||
"runtime": "8.4.27",
|
||||
"all_loaded_code_in_client_host": true,
|
||||
"source_checkout_required": false,
|
||||
"database_connected": false,
|
||||
"quantity_milli": 84000
|
||||
},
|
||||
"isolated_test": {
|
||||
"assertions": 29,
|
||||
"source_directory_renamed_unavailable": true,
|
||||
"all_child_loaded_code_in_synthetic_client": true,
|
||||
"tests_passed": 6
|
||||
},
|
||||
"rollback_scope": "verified code pointer only",
|
||||
"schema_or_data_rollback_implemented": false,
|
||||
"future_migrations_verified": false,
|
||||
"active_sgf_version": "0.1.1",
|
||||
"prepared_not_installed": "0.1.2",
|
||||
"expansion_interrupted_before_activation": true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T12:37:11.1059289Z",
|
||||
"scope": "first purchasing capability and release update mechanism",
|
||||
"installed_version": "0.1.1",
|
||||
"previous_version_preserved": "0.1.0",
|
||||
"manifest_sha256": "91ba35ee3aa1ca226052c94ed682e430cb7870beb241f16b17d889046e1f711d",
|
||||
"engine_sha256": "446c9b32249c5fa639d00209c6d3cb9d2d26b085f3a77655e8ca6ff66403a48d",
|
||||
"identical_engine_in_both_hosts": true,
|
||||
"origin_tests": {
|
||||
"assertions": 566,
|
||||
"passed": 86
|
||||
},
|
||||
"host_tests": {
|
||||
"assertions": 11,
|
||||
"runtime": "PHP 8.4.27",
|
||||
"database_used": false,
|
||||
"passed": 2
|
||||
},
|
||||
"update_cycle": [
|
||||
"install 0.1.0",
|
||||
"update 0.1.1",
|
||||
"rollback 0.1.0",
|
||||
"reactivate 0.1.1",
|
||||
"retry without reinstall"
|
||||
],
|
||||
"tooling_artifacts_verified": 4,
|
||||
"capabilities": [
|
||||
"inventory.replenishment_projection"
|
||||
],
|
||||
"all_workflows_transferred": false,
|
||||
"runtime_requires_source_checkout": false,
|
||||
"persistent_data_migrated": false,
|
||||
"permissions_changed": false,
|
||||
"dependencies_changed": false,
|
||||
"domain_published": false
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"release": {
|
||||
"version": "0.1.1",
|
||||
"archive": "purchasing-0.1.1.zip",
|
||||
"source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250",
|
||||
"manifest_sha256": "91ba35ee3aa1ca226052c94ed682e430cb7870beb241f16b17d889046e1f711d",
|
||||
"changed": false,
|
||||
"tests": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":86,\"passed\":86,\"assertions\":566,\"duration_ms\":12434}",
|
||||
"staged_source_probe": {
|
||||
"version": "0.1.0",
|
||||
"source_sha256": "692921d9e7e57579df876fbd9239d37790fc49d7b96b92fa10514bdc9a1dc250",
|
||||
"runtime": "8.5.11",
|
||||
"scenarios": [
|
||||
"manual",
|
||||
"exact_historical_rate",
|
||||
"lot_expiry"
|
||||
],
|
||||
"passed": true,
|
||||
"database_connected": false
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"installed_version": "0.1.1",
|
||||
"candidate_version": "0.1.1",
|
||||
"available": false,
|
||||
"same_source": true,
|
||||
"database_changes": false,
|
||||
"permissions_changed": false
|
||||
},
|
||||
"installation": {
|
||||
"changed": false,
|
||||
"version": "0.1.1"
|
||||
},
|
||||
"database_changes": false,
|
||||
"permissions_changed": false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"existing administration lists new catalogue",
|
||||
"unit creation grants only legacy rights",
|
||||
"denied 403",
|
||||
"denied 409",
|
||||
"nominative authorised initialisation choices",
|
||||
"initial authority screen selects explicit rights and includes context and CSRF",
|
||||
"screen has no selected grants and retains operation key on retry",
|
||||
"denied 422",
|
||||
"audit failure rolls back initial grants",
|
||||
"explicit nominated administrator initialisation",
|
||||
"initial authority audited",
|
||||
"unselected rights absent",
|
||||
"initialisation retry idempotent",
|
||||
"initialised screen cannot offer a second initial grant",
|
||||
"normal administrative grant",
|
||||
"normal administrative revocation",
|
||||
"denied 403",
|
||||
"denied 403",
|
||||
"failed cascade preserves unit access",
|
||||
"unit revocation removes derived catalogue rights",
|
||||
"initialisation replay never resurrects revoked rights",
|
||||
"denied 409",
|
||||
"denied 403",
|
||||
"denied 403"
|
||||
],
|
||||
"installed": false,
|
||||
"overlay": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/ACCESS-CANDIDATE-a69f06e21747228a",
|
||||
"checked_at": "2026-11-01T14:19:42+00:00",
|
||||
"destination_version": "0.1.1",
|
||||
"prior_tests_repeated": false,
|
||||
"isolated_database_removed": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user