commit14
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReplenishmentTick extends Command
|
||||
{
|
||||
protected $signature = 'sgc:replenishment-tick {--limit= : Máximo de artigos/armazéns por execução}';
|
||||
|
||||
protected $description = 'Recuperar reposições pendentes e reavaliar mínimos e validade, sem aprovar compras';
|
||||
|
||||
public function handle(ReplenishmentQueue $queue): int
|
||||
{
|
||||
if (! $queue->ready()) {
|
||||
$this->warn('Reposição durável não instalada nesta composição.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$limit = $this->option('limit') ?? config('replenishment.batch_size');
|
||||
if (! filter_var($limit, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 500]])) {
|
||||
$this->error('Limite entre 1 e 500.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$queue->discover();
|
||||
$result = $queue->drain((int) $limit);
|
||||
$this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.');
|
||||
|
||||
return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -3,50 +3,82 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AutomaticPurchaseController extends Controller
|
||||
{
|
||||
public function index(Request $request, AutomaticPurchases $service)
|
||||
public function index(Request $request, AutomaticPurchases $service): Response
|
||||
{
|
||||
$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);
|
||||
$filters = $request->validate(['need_state' => 'nullable|in:all,pending,created,covered,satisfied', 'rules_page' => 'nullable|integer|min:1|max:1000000', 'needs_page' => 'nullable|integer|min:1|max:1000000', 'history_page' => 'nullable|integer|min:1|max:1000000', 'work_page' => 'nullable|integer|min:1|max:1000000']);
|
||||
$needs = $planning->latest('auto_need');
|
||||
if (($filters['need_state'] ?? 'all') !== 'all') {
|
||||
$needs->where('state', $filters['need_state']);
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
|
||||
return Inertia::render('stock/replenishment', [
|
||||
$response = 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() : [],
|
||||
'filters' => ['need_state' => $filters['need_state'] ?? 'all'],
|
||||
'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString() : null,
|
||||
'needs' => $ready ? $needs->orderByRaw("CASE WHEN state = 'pending' THEN 0 ELSE 1 END")->oldest('id')->paginate(20, ['*'], 'needs_page')->withQueryString() : null,
|
||||
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->paginate(30, ['*'], 'history_page')->withQueryString() : null,
|
||||
...app(ReplenishmentQueue::class)->status($actor),
|
||||
'workItems' => app(ReplenishmentQueue::class)->ready() ? DB::table('replenishment_work as w')->join('stock_items as i', 'i.id', '=', 'w.stock_item_id')->join('warehouses as a', 'a.id', '=', 'w.warehouse_id')->where('w.tenant_id', $actor->tenant_id)->where('i.tenant_id', $actor->tenant_id)->where('a.tenant_id', $actor->tenant_id)->where(fn ($q) => $q->whereColumn('w.generation', '>', 'w.processed_generation')->orWhere('w.failures', '>', 0))->orderBy('w.due_at')->orderBy('w.id')->paginate(20, ['w.id', 'i.code', 'i.name', 'a.name as warehouse', 'w.last_error', 'w.due_at', 'w.failures'], 'work_page')->withQueryString() : null,
|
||||
'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) => $service->eligibleRequester($u))->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']);
|
||||
'equipment' => $prepare ? Equipment::query()->orderBy('code')->get(['id', 'code', 'description', 'department_id']) : [],
|
||||
'catalogueTypes' => $prepare && Schema::hasTable('requisition_catalogue') ? DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->pluck('purchase_type', 'stock_item_id') : [],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, AutomaticPurchases $service)
|
||||
public function save(Request $request, AutomaticPurchases $service): RedirectResponse
|
||||
{
|
||||
$service->save($request->user('web'), $request->all());
|
||||
|
||||
return redirect('/stock/reposicao');
|
||||
}
|
||||
|
||||
public function evaluate(Request $request, int $id, AutomaticPurchases $service)
|
||||
public function evaluate(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
|
||||
{
|
||||
$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);
|
||||
if (app(ReplenishmentQueue::class)->ready()) {
|
||||
$service->signal($actor, $need->stock_item_id, $need->warehouse_id);
|
||||
} else {
|
||||
$service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
|
||||
}
|
||||
|
||||
return redirect('/stock/reposicao');
|
||||
}
|
||||
|
||||
public function recover(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['supply_prepare', 'supply_review']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$work = DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->where('id', $id)->firstOrFail();
|
||||
$service->signal($actor, $work->stock_item_id, $work->warehouse_id);
|
||||
|
||||
return redirect('/stock/reposicao');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CommonStockReadController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// Native authority, reloaded on every request; no SGF grants or identity assumptions.
|
||||
$user=User::query()->findOrFail($request->user()->id);
|
||||
abort_unless($user->tenant_id!==null && $user->hasFunctions([PurchasingRole::Admin,PurchasingRole::Warehouse,PurchasingRole::Procurement,PurchasingRole::Finance])
|
||||
&& !app(OrganizationAccess::class)->maintenanceOnly($user),403);
|
||||
$d=$request->validate(['view'=>'sometimes|in:items,warehouses,balances,movements','search'=>'nullable|string|max:100',
|
||||
'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|min:1|max:100','snapshot'=>'sometimes|string|max:8192']);
|
||||
abort_if(array_diff(array_keys($request->query()),array_keys($d))!==[],422);
|
||||
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'','warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,
|
||||
'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
|
||||
$binding=config('stock_read.synthetic_sources.'.$user->tenant_id.':'.$user->id);
|
||||
if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Fonte autorizada de stock ainda não ligada a este contexto.'],503)->header('Cache-Control','no-store');
|
||||
abort_unless(app()->environment('testing')&&DB::connection()->getDriverName()==='pgsql',403);
|
||||
$identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
abort_unless(preg_match('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name,$m)
|
||||
&& $identity->role==='stock_read_qa_'.$m[1] && (int)$identity->port===55439
|
||||
&& preg_match('/^[a-f0-9]{12}$/D',(string)config('stock_read.qa_run'))
|
||||
&& $identity->cluster==='sgf_modular_regression_'.config('stock_read.qa_run'),403);
|
||||
abort_unless(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$binding['schema']??'')&&($binding['append_only']??false)===true
|
||||
&& ($binding['source_tenant_id']??null)===(int)$user->tenant_id,503,'Fonte de stock incompatível.');
|
||||
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
|
||||
$root=base_path('packages/inventory-read/releases/0.1.0');
|
||||
abort_unless(is_file($root.'/manifest.json')&&hash_equals(config('stock_read.package_manifest_sha256'),hash_file('sha256',$root.'/manifest.json')),503,'Pacote de consulta incompatível.');
|
||||
$manifest=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
foreach(['bootstrap.php','src/InventoryReader.php','src/InventoryReadSession.php'] as $file)
|
||||
abort_unless(is_file($root.'/'.$file)&&isset($manifest['files'][$file])&&hash_equals($manifest['files'][$file],hash_file('sha256',$root.'/'.$file)),503,'Pacote de consulta incompatível.');
|
||||
require_once $root.'/bootstrap.php';
|
||||
$fingerprint=hash('sha256',json_encode(['compras',$user->tenant_id,$user->id,$request->session()->get('work_context'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
|
||||
try { $result=(new \Tempo\Inventory\InventoryReadSession(DB::connection()))->read($binding,$fingerprint,$filters,$d['snapshot']??null); }
|
||||
catch(\Illuminate\Database\QueryException $e){
|
||||
if(!in_array($e->errorInfo[0]??null,['42P01','42703','42883','57014'],true))throw $e;
|
||||
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
|
||||
}
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -7,22 +7,30 @@ use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentaryQualificationWorkflow;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DocumentaryQualificationController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function access(Request $request, int $id): Requisition
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $opening): Response
|
||||
@@ -32,11 +40,13 @@ class DocumentaryQualificationController extends Controller
|
||||
$sets = QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->get(['id', 'revision', 'reason', 'created_at']);
|
||||
$latest = $sets->first() ? QualificationSet::query()->findOrFail($sets->first()->id) : null;
|
||||
$selected = isset($filters['version']) ? QualificationSet::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filters['version']) : $latest;
|
||||
$focusInput = $request->validate(['quotation_id' => ['nullable', 'integer', 'min:1', Rule::exists('quotations', 'id')->where('tenant_id', $requisition->tenant_id)->where('requisition_id', $requisition->id), Rule::in(array_column($selected->snapshot['proposals'] ?? [], 'id'))]]);
|
||||
$focus = isset($focusInput['quotation_id']) ? (int) $focusInput['quotation_id'] : null;
|
||||
$proposals = $opening->proposals($requisition);
|
||||
$basis = $opening->basis($proposals);
|
||||
$current = $selected && $selected->id === $latest?->id && $selected->basis_hash === $basis;
|
||||
$workflow = app(DocumentaryQualificationWorkflow::class);
|
||||
$current = $selected !== null && $workflow->currentSet($requisition, $selected);
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$query = QualificationAssessment::query()->where('qualification_set_id', $selected->id ?? 0);
|
||||
$recordIds = (clone $query)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id');
|
||||
$records = QualificationAssessment::query()->whereIn('id', $recordIds)->orderBy('quotation_id')->orderBy('analyst_id')->get();
|
||||
@@ -46,9 +56,10 @@ class DocumentaryQualificationController extends Controller
|
||||
'latestId' => $latest->id ?? 0, 'latestCriteria' => $latest->criteria ?? [], 'basisHash' => $basis, 'isCurrent' => (bool) $current,
|
||||
'proposals' => $proposals,
|
||||
'closedForChanges' => $closed,
|
||||
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
|
||||
'canDefine' => $open && $proposals !== [] && $request->user()->hasFunctions([PurchasingRole::Procurement]),
|
||||
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
|
||||
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $this->actor($request)->id,
|
||||
'focusQuotationId' => $focus,
|
||||
'canDefine' => $workflow->canDefine($this->actor($request), $requisition),
|
||||
'canAssess' => $selected !== null && $workflow->canAssess($this->actor($request), $requisition, $selected),
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
@@ -64,7 +75,7 @@ class DocumentaryQualificationController extends Controller
|
||||
'criteria.*.label' => 'required|string|min:3|max:500', 'criteria.*.category' => 'required|in:legal,financial,fiscal,technical,administrative', 'criteria.*.required' => 'required|boolean',
|
||||
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
|
||||
]);
|
||||
$workflow->define($request->user(), $requisition, $data);
|
||||
$workflow->define($this->actor($request), $requisition, $data);
|
||||
|
||||
return to_route('qualifications.show', $requisition);
|
||||
}
|
||||
@@ -80,7 +91,7 @@ class DocumentaryQualificationController extends Controller
|
||||
'snapshot' => 'prohibited', 'analyst_id' => 'prohibited', 'tenant_id' => 'prohibited', 'revision' => 'prohibited',
|
||||
]);
|
||||
$data['answers'] = array_map(fn ($answer) => [...$answer, 'criterion_id' => (int) $answer['criterion_id']], $data['answers']);
|
||||
$workflow->assess($request->user(), $set, $data);
|
||||
$workflow->assess($this->actor($request), $set, $data);
|
||||
|
||||
return to_route('qualifications.show', $set->requisition_id);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierBankDetail;
|
||||
use App\Models\User;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierBankWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -19,20 +23,27 @@ class ExternalPaymentController extends Controller
|
||||
{
|
||||
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$canManage = $request->user()->hasFunctions([PurchasingRole::Finance]);
|
||||
$payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$filter = $request->validate(['payment_id' => ['nullable', 'integer', 'min:1', Rule::exists('external_payments', 'id')->where('tenant_id', $actor->tenant_id)], 'invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)]]);
|
||||
$focus = isset($filter['payment_id']) ? (int) $filter['payment_id'] : null;
|
||||
$focusInvoice = isset($filter['invoice_id']) ? (int) $filter['invoice_id'] : null;
|
||||
$canManage = $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
$visibleInvoices = Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'));
|
||||
$payments = ExternalPayment::query()->whereIn('invoice_id', (clone $visibleInvoices)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusInvoice !== null, fn ($q) => $q->where('invoice_id', $focusInvoice))->latest('id')->paginate(15)->withQueryString()
|
||||
->through(fn (ExternalPayment $payment) => [...$payment->toArray(), 'canReview' => $workflow->reviewerEligible($actor, $payment), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
|
||||
if ($canManage && $payments->count() > 0) {
|
||||
app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
|
||||
}
|
||||
$invoices = Invoice::query()->where('status', 'validated')->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
|
||||
$invoices = $visibleInvoices->where('status', 'validated')->when($focus !== null, fn ($q) => $q->whereIn('id', $payments->pluck('invoice_id')))->when($focusInvoice !== null, fn ($q) => $q->whereKey($focusInvoice))->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
|
||||
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
|
||||
$bank = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
|
||||
|
||||
return ['id' => $invoice->id, 'number' => $invoice->number, 'supplier_id' => $supplier->id, 'supplier_name' => $supplier->name, 'total_minor' => $invoice->total_minor, 'outstanding_minor' => $workflow->outstanding($invoice), 'bank_id' => $bank->id ?? 0, 'bank_revision' => $bank->revision ?? 0, 'bank_state' => app(SupplierBankWorkflow::class)->state($supplier)];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('payments/index', ['invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
|
||||
$response = Inertia::render('payments/index', ['focusPaymentId' => $focus, 'focusInvoiceId' => $focusInvoice, 'invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
@@ -40,17 +51,20 @@ class ExternalPaymentController extends Controller
|
||||
|
||||
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
$data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']);
|
||||
$workflow->register($request->user(), $invoice, $data);
|
||||
$workflow->register($actor, $invoice, $data);
|
||||
|
||||
return to_route('payments.index');
|
||||
}
|
||||
|
||||
public function review(Request $request, ExternalPayment $payment, ExternalPaymentWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$data = $request->validate(['decision' => 'required|in:confirmed,rejected', 'reason' => 'required|string|max:2000', 'independently_verified' => 'accepted']);
|
||||
$workflow->review($request->user(), $payment, $data['decision'], $data['reason']);
|
||||
$workflow->review($actor, $payment, $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@@ -9,26 +9,35 @@ use App\Models\AdmissionPolicy;
|
||||
use App\Models\AdmissionReview;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Models\User;
|
||||
use App\Services\FormalAdmission;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FormalAdmissionController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function access(Request $request, int $id): Requisition
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
|
||||
}
|
||||
|
||||
public function policy(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
@@ -40,7 +49,7 @@ class FormalAdmissionController extends Controller
|
||||
{
|
||||
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
|
||||
$data['enabled'] = (bool) $data['enabled'];
|
||||
$workflow->policy($request->user(), $data);
|
||||
$workflow->policy($this->actor($request), $data);
|
||||
|
||||
return to_route('admissions.policy');
|
||||
}
|
||||
@@ -51,27 +60,27 @@ class FormalAdmissionController extends Controller
|
||||
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||
$adoption = $workflow->adoption($requisition);
|
||||
$selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
|
||||
$focusInput = $request->validate(['decision_id' => ['nullable', 'integer', 'min:1', Rule::exists('admission_decisions', 'id')->where('tenant_id', $requisition->tenant_id)->where('admission_adoption_id', $selected->id ?? 0)]]);
|
||||
$focus = isset($focusInput['decision_id']) ? (int) $focusInput['decision_id'] : null;
|
||||
$current = $selected && $selected->id === $adoption?->id;
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$open = $workflow->canParticipate($this->actor($request), $requisition);
|
||||
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get();
|
||||
$latestIds = $decisions->unique('quotation_id')->pluck('id');
|
||||
$proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) {
|
||||
$basis = $workflow->basis($requisition, $quote['id']);
|
||||
|
||||
return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0];
|
||||
});
|
||||
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString();
|
||||
$history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) {
|
||||
$basis = $workflow->basis($requisition, $decision->quotation_id);
|
||||
$fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash;
|
||||
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('id')->paginate(15)->withQueryString();
|
||||
$history->through(function ($decision) use ($workflow, $selected, $current, $requisition, $request) {
|
||||
$fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $decision);
|
||||
|
||||
return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh,
|
||||
'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(),
|
||||
'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
|
||||
'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $decision)];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
|
||||
$response = Inertia::render('purchases/admission', ['focusDecisionId' => $focus, 'requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
@@ -81,7 +90,7 @@ class FormalAdmissionController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
|
||||
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
$workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
|
||||
return to_route('admissions.show', $requisition);
|
||||
}
|
||||
@@ -90,7 +99,7 @@ class FormalAdmissionController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'outcome' => 'required|in:admit,exclude,clarify', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
|
||||
$workflow->propose($request->user(), $requisition, $data);
|
||||
$workflow->propose($this->actor($request), $requisition, $data);
|
||||
|
||||
return to_route('admissions.show', $requisition);
|
||||
}
|
||||
@@ -100,7 +109,7 @@ class FormalAdmissionController extends Controller
|
||||
$adoption = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
|
||||
$this->access($request, $adoption->requisition_id);
|
||||
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
|
||||
$workflow->review($request->user(), $decision, $data['verdict'], $data['reason']);
|
||||
$workflow->review($this->actor($request), $decision, $data['verdict'], $data['reason']);
|
||||
|
||||
return to_route('admissions.show', $adoption->requisition_id);
|
||||
}
|
||||
|
||||
@@ -10,27 +10,35 @@ use App\Models\RecommendationPolicy;
|
||||
use App\Models\RecommendationReview;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\FormalAdmission;
|
||||
use App\Services\FormalRecommendation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FormalRecommendationController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function access(Request $request, int $id): Requisition
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
|
||||
}
|
||||
|
||||
public function policy(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
@@ -42,7 +50,7 @@ class FormalRecommendationController extends Controller
|
||||
{
|
||||
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
|
||||
$data['enabled'] = (bool) $data['enabled'];
|
||||
$workflow->policy($request->user(), $data);
|
||||
$workflow->policy($this->actor($request), $data);
|
||||
|
||||
return to_route('recommendations.policy');
|
||||
}
|
||||
@@ -53,22 +61,24 @@ class FormalRecommendationController extends Controller
|
||||
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||
$adoption = $workflow->adoption($requisition);
|
||||
$selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
|
||||
$focusInput = $request->validate(['attempt_id' => ['nullable', 'integer', 'min:1', Rule::exists('recommendation_attempts', 'id')->where('tenant_id', $requisition->tenant_id)->where('recommendation_adoption_id', $selected->id ?? 0)]]);
|
||||
$focus = isset($focusInput['attempt_id']) ? (int) $focusInput['attempt_id'] : null;
|
||||
$current = $selected && $selected->id === $adoption?->id;
|
||||
$basis = $workflow->basis($requisition);
|
||||
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first();
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString();
|
||||
$history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) {
|
||||
$fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
|
||||
$open = $workflow->canParticipate($this->actor($request), $requisition);
|
||||
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('revision')->paginate(10)->withQueryString();
|
||||
$history->through(function ($attempt) use ($workflow, $selected, $current, $requisition, $request) {
|
||||
$fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $attempt);
|
||||
|
||||
return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(),
|
||||
'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()];
|
||||
'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $attempt)];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
|
||||
$response = Inertia::render('purchases/recommendation', ['focusAttemptId' => $focus, 'requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
|
||||
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
|
||||
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id])->toResponse($request);
|
||||
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
@@ -78,7 +88,7 @@ class FormalRecommendationController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
|
||||
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
$workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
|
||||
return to_route('recommendations.show', $requisition);
|
||||
}
|
||||
@@ -87,7 +97,7 @@ class FormalRecommendationController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
|
||||
$workflow->propose($request->user(), $requisition, $data);
|
||||
$workflow->propose($this->actor($request), $requisition, $data);
|
||||
|
||||
return to_route('recommendations.show', $requisition);
|
||||
}
|
||||
@@ -97,7 +107,7 @@ class FormalRecommendationController extends Controller
|
||||
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||
$this->access($request, $adoption->requisition_id);
|
||||
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
|
||||
$workflow->review($request->user(), $attempt, $data['verdict'], $data['reason']);
|
||||
$workflow->review($this->actor($request), $attempt, $data['verdict'], $data['reason']);
|
||||
|
||||
return to_route('recommendations.show', $adoption->requisition_id);
|
||||
}
|
||||
@@ -108,7 +118,7 @@ class FormalRecommendationController extends Controller
|
||||
if (DB::connection()->getDriverName() === 'pgsql') {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
}
|
||||
$actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id);
|
||||
$actor = User::query()->where('tenant_id', $this->actor($request)->tenant_id)->findOrFail($this->actor($request)->id);
|
||||
$request->setUserResolver(fn () => $actor);
|
||||
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||
$requisition = $this->access($request, $adoption->requisition_id);
|
||||
@@ -118,7 +128,7 @@ class FormalRecommendationController extends Controller
|
||||
|
||||
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]);
|
||||
|
||||
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $this->actor($request)->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,37 +5,57 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\User;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\InvoiceWorkflow;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\SupplierPayables;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$filters = $request->validate(['invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'purchase_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)]]);
|
||||
$focus = isset($filters['invoice_id']) ? (int) $filters['invoice_id'] : null;
|
||||
$focusOrder = isset($filters['purchase_order_id']) ? (int) $filters['purchase_order_id'] : null;
|
||||
Inertia::encryptHistory();
|
||||
|
||||
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])]);
|
||||
$response = Inertia::render('invoices/index', ['focusInvoiceId' => $focus, 'focusOrderId' => $focusOrder, 'stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(),
|
||||
'invoices' => Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusOrder !== null, fn ($q) => $q->where('purchase_order_id', $focusOrder))->with('items')->latest('id')->paginate(15)->withQueryString()
|
||||
->through(fn (Invoice $invoice) => [...$invoice->toArray(), 'canValidate' => app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)]),
|
||||
'orders' => PurchaseOrder::query()->visibleTo($actor)->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($actor, 'view'), 'canManage' => $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor)])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
$decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
|
||||
$data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
|
||||
$workflow->register($request->user(), $order, $data);
|
||||
$data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'sometimes|required|string|max:30', 'items.*.unit_price' => ['sometimes', ...$decimal], 'items.*.tax_rate' => ['sometimes', 'required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
|
||||
$workflow->register($actor, $order, $data);
|
||||
|
||||
return to_route('invoices.index');
|
||||
}
|
||||
|
||||
public function validateInvoice(Request $request, Invoice $invoice, InvoiceWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->validate($request->user(), $invoice);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$workflow->validate($actor, $invoice);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\AiPreparationPlan;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\IntelligencePolicy;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\OrganizationActivationPreview;
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\WorkContext;
|
||||
use App\Services\WorkflowTasks;
|
||||
@@ -22,35 +24,45 @@ class OrganizationConfigurationController extends Controller
|
||||
{
|
||||
public function index(Request $request, OrganizationConfiguration $service): Response
|
||||
{
|
||||
$actor = $service->administrator($request->user());
|
||||
$ready = Schema::hasTable('organization_access');
|
||||
$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)),
|
||||
'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
|
||||
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
|
||||
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
|
||||
'permissions' => OrganizationAccess::PERMISSIONS,
|
||||
'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']),
|
||||
'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
|
||||
'notice' => $request->session()->get('organization_notice')])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
return DB::transaction(function () use ($request, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$user = $request->user('web');
|
||||
abort_unless($user instanceof User, 403);
|
||||
$actor = $service->administrator($user);
|
||||
$ready = Schema::hasTable('organization_access');
|
||||
$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(),
|
||||
'activationPreview' => app(OrganizationActivationPreview::class)->build($actor),
|
||||
'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)),
|
||||
'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
|
||||
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
|
||||
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
|
||||
'permissions' => OrganizationAccess::PERMISSIONS,
|
||||
'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']),
|
||||
'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
|
||||
'notice' => $request->session()->get('organization_notice')])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function command(Request $request, string $command, OrganizationConfiguration $service): Response
|
||||
{
|
||||
$actor = $service->administrator($request->user());
|
||||
$user = $request->user('web');
|
||||
abort_unless($user instanceof User, 403);
|
||||
$actor = $service->administrator($user);
|
||||
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') {
|
||||
|
||||
@@ -9,7 +9,9 @@ use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\RecommendationAttempt;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\FormalRecommendation;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\PurchaseWorkflow;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -22,15 +24,19 @@ use Inertia\Response;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
private function access(Request $request): User
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$actor = $this->access($request);
|
||||
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
|
||||
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
|
||||
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
|
||||
$recommendation = app(FormalRecommendation::class);
|
||||
@@ -50,17 +56,17 @@ class PurchaseController extends Controller
|
||||
|
||||
return Inertia::render('purchases/award', [
|
||||
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
|
||||
'canSelect' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
|
||||
'canSelect' => ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions([PurchasingRole::Procurement]) && $award === null && $actor->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
|
||||
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
|
||||
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
|
||||
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->hasFunctions([PurchasingRole::Procurement]),
|
||||
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($actor, $award, $requisition),
|
||||
'canIssue' => $award !== null && $workflow->canIssue($actor, $award, $requisition),
|
||||
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
$data = $request->validate([
|
||||
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
|
||||
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
|
||||
@@ -68,46 +74,46 @@ class PurchaseController extends Controller
|
||||
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
|
||||
'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$workflow->select($request->user(), $requisition, $data);
|
||||
$workflow->select($actor, $requisition, $data);
|
||||
|
||||
return to_route('purchases.award', $requisition);
|
||||
}
|
||||
|
||||
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
|
||||
$workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
|
||||
$workflow->decide($actor, $award, $data['decision'], $data['comment'] ?? null);
|
||||
|
||||
return to_route('purchases.award', $award->requisition_id);
|
||||
}
|
||||
|
||||
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
$request->validate([
|
||||
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
|
||||
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$order = $workflow->issue($request->user(), $award);
|
||||
$order = $workflow->issue($actor, $award);
|
||||
|
||||
return to_route('orders.show', $order);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
|
||||
return Inertia::render('purchases/index', [
|
||||
'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
|
||||
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
|
||||
'orders' => PurchaseOrder::query()->visibleTo($actor)->latest('id')->paginate(15),
|
||||
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($actor)->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, PurchaseOrder $order): Response
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
$actor = $this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
|
||||
|
||||
return Inertia::render('purchases/show', [
|
||||
'order' => $order->load('items'),
|
||||
@@ -117,8 +123,8 @@ class PurchaseController extends Controller
|
||||
|
||||
public function print(Request $request, PurchaseOrder $order): View
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
$actor = $this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
|
||||
|
||||
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ class PurchaseProcessController extends Controller
|
||||
{
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
|
||||
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
|
||||
$purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
@@ -90,10 +91,13 @@ class PurchaseProcessController extends Controller
|
||||
}
|
||||
$invoices = $finance && $order ? Invoice::query()->where('purchase_order_id', $order->id)->get(['id', 'number', 'status']) : collect();
|
||||
$payments = $finance ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->get(['id', 'status']) : collect();
|
||||
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/facturas' : null];
|
||||
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance && $order ? '/facturas?purchase_order_id='.$order->id : null];
|
||||
$stages[] = ['label' => 'Pagamentos externos', 'state' => $finance ? ($payments->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $payments->count().' registos; '.$payments->where('status', 'confirmed')->count().' confirmados. Não comprova liquidação integral e não executa transferências.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/pagamentos' : null];
|
||||
foreach ($invoices as $invoice) {
|
||||
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas'];
|
||||
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
|
||||
}
|
||||
foreach ($payments as $payment) {
|
||||
$documents[] = ['label' => 'Registo externo #'.$payment->id, 'kind' => 'Pagamento externo', 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
|
||||
}
|
||||
$cancellation = $award ? AwardCancellation::query()->where('purchase_award_id', $award->id)->first(['id', 'status']) : null;
|
||||
$actors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', array_column($decisions, 'actor_id'))->pluck('name', 'id');
|
||||
|
||||
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\CurrentActor;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\QuotationParticipation;
|
||||
use App\Services\QuotationPricing;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
@@ -18,47 +20,65 @@ use Brick\Math\RoundingMode;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as HttpResponse;
|
||||
|
||||
class QuotationController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
private function access(Request $request): User
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
|
||||
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
|
||||
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($actor)->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
public function show(Request $request, Requisition $requisition): HttpResponse
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$actor = $this->access($request);
|
||||
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
|
||||
$filters = $request->validate(['quotation_id' => ['nullable', 'integer', Rule::exists('quotations', 'id')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $requisition->id)], 'prepare' => 'nullable|boolean']);
|
||||
$access = app(QuotationParticipation::class);
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->with('supplier')->get()->keyBy('id');
|
||||
$guard = app(ConflictGuard::class);
|
||||
$result = app(ProposalComparison::class)->inspect($requisition);
|
||||
$scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
|
||||
$closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$scores = array_map(function ($row) use ($quotes, $actor, $access, $requisition, $guard) {
|
||||
$quote = $quotes->get($row['id']);
|
||||
|
||||
return Inertia::render('quotations/show', [
|
||||
return [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $actor), 'canEvaluate' => $row['eligible'] && $quote !== null && $access->canEvaluate($actor, $requisition, $quote)];
|
||||
}, $result['rows']);
|
||||
Inertia::encryptHistory();
|
||||
|
||||
$response = Inertia::render('quotations/show', [
|
||||
'requisition' => $requisition->load('items'), 'quotations' => $scores,
|
||||
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
|
||||
'canCreate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'canEvaluate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]) && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'canCreate' => $access->canPrepare($actor, $requisition),
|
||||
'canEvaluate' => $access->canEvaluateProcess($actor, $requisition),
|
||||
'focusQuotationId' => isset($filters['quotation_id']) ? (int) $filters['quotation_id'] : null,
|
||||
'prepare' => (bool) ($filters['prepare'] ?? false),
|
||||
'ready' => $result['ready'],
|
||||
'topIds' => $result['top_ids'],
|
||||
'comparisonPolicy' => $result['policy'],
|
||||
]);
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
$actor = $this->access($request);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
$data = $request->validate([
|
||||
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
|
||||
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
|
||||
@@ -68,17 +88,14 @@ class QuotationController extends Controller
|
||||
'items.*.discount_type' => 'sometimes|required|in:none,percent,amount',
|
||||
'items.*.discount_value' => 'required_with:items.*.discount_type|numeric|decimal:0,2|min:0|max:1000000000',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $requisition, $data, $workflow) {
|
||||
DB::transaction(function () use ($actor, $requisition, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$guard->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->status === 'approved', 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
app(QuotationParticipation::class)->assertPrepare($actor, $requisition);
|
||||
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
|
||||
$guard->assertProcess($requisition, $supplier->id, $request->user());
|
||||
$guard->assertProcess($requisition, $supplier->id, $actor);
|
||||
if (! $supplier->eligible()) {
|
||||
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
|
||||
}
|
||||
@@ -108,20 +125,20 @@ class QuotationController extends Controller
|
||||
if ($subtotal + $tax > 100000000000000) {
|
||||
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
|
||||
}
|
||||
$items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
|
||||
$items[] = ['tenant_id' => $actor->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
|
||||
}
|
||||
if ($subtotal <= 0) {
|
||||
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
|
||||
}
|
||||
$quote = Quotation::query()->create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
|
||||
'tenant_id' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $actor->id,
|
||||
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
|
||||
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
|
||||
'weights' => app(ComparisonPolicy::class)->forRequest($requisition)['weights'],
|
||||
'commercial_terms' => array_filter($terms, fn ($term) => $term['discount_type'] !== 'none') === [] ? null : ['version' => 1, 'discount_basis' => 'unit_before_vat', 'rounding' => 'net_unit_half_up_cent', 'lines' => $terms],
|
||||
]);
|
||||
$quote->items()->createMany($items);
|
||||
$workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
|
||||
$workflow->audit($actor, 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
|
||||
});
|
||||
|
||||
return back();
|
||||
@@ -129,28 +146,18 @@ class QuotationController extends Controller
|
||||
|
||||
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$actor = $this->access($request);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
|
||||
DB::transaction(function () use ($request, $quotation, $data, $workflow) {
|
||||
DB::transaction(function () use ($actor, $quotation, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
|
||||
abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
$guard->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$requisition = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($quotation->requisition_id);
|
||||
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
|
||||
$guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
|
||||
if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
|
||||
}
|
||||
if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
|
||||
}
|
||||
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
|
||||
$workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
|
||||
app(QuotationParticipation::class)->assertEvaluate($actor, $requisition, $quotation);
|
||||
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $actor->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $actor->id]);
|
||||
$workflow->audit($actor, 'quotation.evaluated', $requisition, null, $evaluation->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Services\ApprovalRouting;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\EquipmentWorkflow;
|
||||
use App\Services\InternalSupplyTrace;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
@@ -31,18 +32,26 @@ use Inertia\Response;
|
||||
|
||||
class RequisitionController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]);
|
||||
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
|
||||
$query = Requisition::query()->visibleTo($this->actor($request))->with(['requester:id,name', 'department:id,name', 'steps']);
|
||||
if (($filters['view'] ?? 'all') === 'mine') {
|
||||
$query->where('requester_id', $request->user()->id);
|
||||
$query->where('requester_id', $this->actor($request)->id);
|
||||
} elseif (($filters['view'] ?? 'all') === 'approvals') {
|
||||
$eligible = (clone $query)->where('status', 'pending')->get()
|
||||
->filter(fn (Requisition $requisition) => $requisition->canDecide($request->user()))->modelKeys();
|
||||
->filter(fn (Requisition $requisition) => $requisition->canDecide($this->actor($request)))->modelKeys();
|
||||
$query->whereIn('id', $eligible);
|
||||
} elseif (($filters['view'] ?? 'all') === 'decided') {
|
||||
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $request->user()->id)->whereIn('decision', ['approved', 'rejected']));
|
||||
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $this->actor($request)->id)->whereIn('decision', ['approved', 'rejected']));
|
||||
}
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where('number', 'like', '%'.$filters['search'].'%');
|
||||
@@ -59,12 +68,12 @@ class RequisitionController extends Controller
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
|
||||
|
||||
return Inertia::render('requisitions/form', [
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
|
||||
'requisition' => null,
|
||||
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($request->user()->tenant_id)->default_tax_rate ?? '16.00'),
|
||||
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
|
||||
...$this->equipmentOptions($request),
|
||||
'catalogue' => $this->catalogueOptions($request),
|
||||
]);
|
||||
@@ -72,28 +81,29 @@ class RequisitionController extends Controller
|
||||
|
||||
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$requisition = $workflow->save($request->user(), $request->validated());
|
||||
$requisition = $workflow->save($this->actor($request), $request->validated());
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
|
||||
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
|
||||
|
||||
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'),
|
||||
'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $this->actor($request)),
|
||||
'canRefreshRouting' => $requisition->status === 'pending' && $this->actor($request)->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($this->actor($request)) && Schema::hasTable('workflow_notices'),
|
||||
'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition),
|
||||
'automaticSource' => app(InternalSupplyTrace::class)->purchaseOrigin($this->actor($request), $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()),
|
||||
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
|
||||
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
|
||||
'canEdit' => app(OrganizationAccess::class)->canRequest($this->actor($request)) && $requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft',
|
||||
'canDecide' => $requisition->canDecide($this->actor($request)),
|
||||
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($this->actor($request), $requisition),
|
||||
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at', 'after'])->map(fn ($a) => ['id' => $a->id, 'action' => $a->action, 'user_id' => $a->user_id, 'created_at' => $a->created_at, 'execution' => $a->after['execution']['mode'] ?? null]),
|
||||
'technical' => [
|
||||
'canReview' => app(EquipmentWorkflow::class)->canReview($request->user(), $requisition),
|
||||
'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition),
|
||||
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
|
||||
'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(),
|
||||
],
|
||||
@@ -102,13 +112,13 @@ class RequisitionController extends Controller
|
||||
|
||||
public function edit(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
|
||||
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
|
||||
abort_unless($requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft', 403);
|
||||
|
||||
return Inertia::render('requisitions/form', [
|
||||
'requisition' => $requisition->load('items'),
|
||||
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($request->user()->tenant_id)->default_tax_rate ?? '16.00'),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
|
||||
...$this->equipmentOptions($request),
|
||||
'catalogue' => $this->catalogueOptions($request),
|
||||
]);
|
||||
@@ -117,7 +127,7 @@ class RequisitionController extends Controller
|
||||
/** @return array<string,mixed> */
|
||||
private function equipmentOptions(Request $request): array
|
||||
{
|
||||
$equipment = Equipment::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(['id', 'code', 'description']);
|
||||
$equipment = Equipment::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(['id', 'code', 'description']);
|
||||
$parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']);
|
||||
|
||||
return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])];
|
||||
@@ -128,20 +138,20 @@ class RequisitionController extends Controller
|
||||
{
|
||||
$search = $request->validate(['catalogo' => 'nullable|string|max:100'])['catalogo'] ?? '';
|
||||
|
||||
return app(RequisitionCatalogue::class)->options($request->user(), $search);
|
||||
return app(RequisitionCatalogue::class)->options($this->actor($request), $search);
|
||||
}
|
||||
|
||||
public function technical(Request $request, Requisition $requisition): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']);
|
||||
app(EquipmentWorkflow::class)->review($request->user(), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
|
||||
app(EquipmentWorkflow::class)->review($this->actor($request), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->save($request->user(), $request->validated(), $requisition);
|
||||
$workflow->save($this->actor($request), $request->validated(), $requisition);
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
@@ -149,7 +159,7 @@ class RequisitionController extends Controller
|
||||
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$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);
|
||||
$workflow->submit($this->actor($request), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
|
||||
|
||||
return back();
|
||||
}
|
||||
@@ -161,7 +171,7 @@ class RequisitionController extends Controller
|
||||
'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, $data['operation_key'] ?? null, $data['process_version'] ?? null);
|
||||
$workflow->decide($this->actor($request), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
|
||||
|
||||
return back();
|
||||
}
|
||||
@@ -174,20 +184,20 @@ class RequisitionController extends Controller
|
||||
if ($contexts->enforced($actor)) {
|
||||
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
|
||||
}
|
||||
$visible = Requisition::query()->visibleTo($request->user());
|
||||
$visible = Requisition::query()->visibleTo($this->actor($request));
|
||||
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->tenant_id) : null,
|
||||
'company' => $this->actor($request)->tenant_id ? Tenant::query()->find($this->actor($request)->tenant_id) : null,
|
||||
'stats' => [
|
||||
'total' => (clone $visible)->count(),
|
||||
'pending' => $pending->count(),
|
||||
'approved' => (clone $visible)->where('status', 'approved')->count(),
|
||||
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
|
||||
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->count(),
|
||||
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->count(),
|
||||
],
|
||||
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
|
||||
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
|
||||
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->take(5)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class StockOperationController extends Controller
|
||||
}
|
||||
|
||||
return Inertia::render('stock/operations', [
|
||||
'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && ! app(\App\Services\OrganizationAccess::class)->maintenanceOnly($actor),
|
||||
'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', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WarehouseExchange;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WarehouseExchangeController extends Controller
|
||||
{
|
||||
public function index(Request $request, WarehouseExchange $exchange): JsonResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$input = $request->validate(['cursor' => 'nullable|string|max:4096', 'limit' => 'sometimes|integer|min:1|max:100']);
|
||||
|
||||
return response()->json($exchange->page($actor, $input['cursor'] ?? null, (int) ($input['limit'] ?? 50)))
|
||||
->header('Cache-Control', 'private, no-store, max-age=0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WarehousePilotReadiness;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WarehousePilotController extends Controller
|
||||
{
|
||||
public function export(Request $request, WarehousePilotReadiness $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
|
||||
$report = DB::transaction(function () use ($actor, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $service->inspect($actor);
|
||||
});
|
||||
$rows = [['Conferido em', 'Âmbito', 'Conferência', 'Resultado', 'Observação', 'Onde configurar']];
|
||||
foreach ($report['checks'] as $check) {
|
||||
$rows[] = [$report['checked_at'], 'SGC autónomo · diagnóstico de configuração', $check['label'], $check['ok'] ? 'Conferido' : 'A preparar', $check['detail'], $check['path'] ?? ''];
|
||||
}
|
||||
|
||||
return response()->streamDownload(static function () use ($rows) {
|
||||
$stream = fopen('php://output', 'wb');
|
||||
if ($stream === false) {
|
||||
throw new \RuntimeException('Não foi possível preparar a exportação.');
|
||||
}
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
foreach ($rows as $row) {
|
||||
$safe = array_map(static fn (string $value) => preg_match('/^[\s\x{FEFF}]*[=+@-]/u', $value) ? "'".$value : $value, $row);
|
||||
fputcsv($stream, $safe, ';', '"', '');
|
||||
}
|
||||
fclose($stream);
|
||||
}, 'preparacao-piloto-sgc-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
|
||||
public function index(Request $request, WarehousePilotReadiness $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
|
||||
|
||||
return DB::transaction(function () use ($actor, $request, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/pilot', ['report' => $service->inspect($actor), 'syntheticEvaluation' => app()->environment(['testing', 'payable-testing'])])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,41 +8,66 @@ use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Services\InternalSupplyTrace;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use App\Services\WarehouseWorkdesk;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WarehouseRequestController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
public function index(Request $request, WarehouseWorkdesk $desk): 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;
|
||||
return DB::transaction(function () use ($request, $actor, $desk) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$filters = $request->validate(['view' => 'nullable|in:all,review,reserve,delivery,receipt', 'state' => 'nullable|in:all,draft,submitted,authorized,partial,delivered,closed,rejected,cancelled', 'search' => 'nullable|string|max:100', 'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], 'page' => 'nullable|integer|min:1|max:1000000']);
|
||||
$prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
$ready = Schema::hasTable('warehouse_requests');
|
||||
$base = $desk->visible($actor);
|
||||
if (! empty($filters['warehouse_id'])) {
|
||||
$base->where('warehouse_id', $filters['warehouse_id']);
|
||||
}
|
||||
if (! empty($filters['search'])) {
|
||||
$search = trim($filters['search']);
|
||||
if (preg_match('/^(?:RA-)?([1-9][0-9]{0,9})$/iD', $search, $match)) {
|
||||
$base->whereKey($match[1]);
|
||||
} else {
|
||||
$base->where(fn ($q) => $q->whereLike('snapshot->requester->name', '%'.$search.'%')->orWhereLike('snapshot->destination', '%'.$search.'%'));
|
||||
}
|
||||
}
|
||||
$views = $ready ? $desk->views(clone $base, $actor) : [];
|
||||
$query = $desk->forView(clone $base, $actor, $filters['view'] ?? 'all');
|
||||
$states = $ready ? DB::query()->fromSub((clone $query)->selectRaw($desk->stateExpression().' as projected_state')->toBase(), 'request_states')->selectRaw('projected_state, COUNT(*) as total')->groupBy('projected_state')->pluck('total', 'projected_state') : [];
|
||||
if (($filters['state'] ?? 'all') !== 'all') {
|
||||
$query->whereRaw($desk->stateExpression().' = ?', [$filters['state']]);
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
|
||||
'filters' => ['view' => $filters['view'] ?? 'all', 'state' => $filters['state'] ?? 'all', 'search' => $filters['search'] ?? '', 'warehouse_id' => $filters['warehouse_id'] ?? null], 'views' => $views, 'states' => $states, 'checkedAt' => now()->toIso8601String(),
|
||||
'records' => $ready ? $query->oldest('id')->paginate(15, ['id', 'tenant_id', 'warehouse_id', 'created_by', 'status', 'snapshot', 'created_at'])->withQueryString()->through(function ($r) use ($desk, $actor) {
|
||||
$p = app(WarehouseFulfilment::class)->progress($r);
|
||||
|
||||
return ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => $p, 'health' => $desk->health($r, $p), 'actions' => $desk->actions($actor, $r, $p)];
|
||||
}) : null,
|
||||
'warehouses' => Warehouse::query()->when(! $prepare, fn ($q) => $q->whereIn('id', $ready ? $desk->visible($actor)->select('warehouse_id') : []))->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
|
||||
@@ -58,22 +83,24 @@ class WarehouseRequestController extends Controller
|
||||
{
|
||||
$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;
|
||||
return DB::transaction(function () use ($request, $id, $service, $actor) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$r = WarehouseRequest::query()->findOrFail($id);
|
||||
$desk = app(WarehouseWorkdesk::class);
|
||||
abort_unless($desk->canView($actor, $r), 403);
|
||||
$p = $service->progress($r);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
|
||||
'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && !app(OrganizationAccess::class)->maintenanceOnly($actor),
|
||||
'health' => $desk->health($r, $p), 'people' => $desk->people($r, $p),
|
||||
'supplyTrace' => app(InternalSupplyTrace::class)->forRequest($actor, $r, $p), ...$desk->actions($actor, $r, $p)])->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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\WarehouseReturns;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class WarehouseReturnController extends Controller
|
||||
{
|
||||
private function actor(Request $r): User
|
||||
{
|
||||
$actor = $r->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function page(Request $r, WarehouseReturns $flow): Response
|
||||
{
|
||||
$flow->authorize($this->actor($r), 'read');
|
||||
$d = $r->validate(['request_id' => 'sometimes|integer|min:1']);
|
||||
$actor = $this->actor($r);
|
||||
$source = isset($d['request_id']) ? $flow->availableDeliveries($actor, (int) $d['request_id'], 1) : null;
|
||||
$response = Inertia::render('stock/returns', ['requestId' => isset($d['request_id']) ? (int) $d['request_id'] : null, 'canPrepare' => $source['can_prepare'] ?? false, 'canWithdraw' => $flow->allows($actor, 'prepare'), 'canConfirm' => $flow->allows($actor, 'confirm'), 'actorId' => $actor->id])->toResponse($r);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function sources(Request $r, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['request_id' => 'required|integer|min:1', 'source_page' => 'sometimes|integer|min:1|max:100000']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
|
||||
return $this->response(fn () => $flow->availableDeliveries($this->actor($r), (int) $d['request_id'], (int) ($d['source_page'] ?? 1)), true);
|
||||
}
|
||||
|
||||
/** @param callable():array<string,mixed> $run */
|
||||
private function response(callable $run,bool $readOnly=false): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result=$readOnly?DB::transaction(function()use($run){if(DB::getDriverName()==='pgsql'&&DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $run();}):$run();
|
||||
return response()->json($result)->header('Cache-Control', 'no-store');
|
||||
} catch (QueryException $e) {
|
||||
if (($e->errorInfo[0] ?? null) === '55P03') {
|
||||
return response()->json(['code'=>'warehouse_return_busy','message' => 'Devolução ocupada. Aguarde e repita manualmente a conferência.'], 423)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function index(Request $r, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'state' => 'sometimes|in:all,prepared,confirmed,withdrawn,rejected']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
|
||||
return $this->response(fn () => $flow->listing($this->actor($r), (int) ($d['page'] ?? 1), $d['state'] ?? 'all'), true);
|
||||
}
|
||||
|
||||
public function prepare(Request $r, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['operation_key' => 'required|uuid', 'delivery_event_id' => 'required|integer|min:1', 'line_index' => 'required|integer|min:0|max:49', 'quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'declared_unused' => 'required|accepted', 'evidence' => 'required|string|min:10|max:1000', 'reason' => 'required|string|min:10|max:1000']);
|
||||
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
|
||||
$d['quantity_milli'] = ReceiptWorkflow::milli($d['quantity']);
|
||||
$d['line_index'] = (int) $d['line_index'];
|
||||
$d['delivery_event_id'] = (int) $d['delivery_event_id'];
|
||||
$d['declared_unused'] = true;
|
||||
|
||||
return $this->response(fn () => $flow->prepare($this->actor($r), $d));
|
||||
}
|
||||
|
||||
public function confirm(Request $r, int $id, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['version' => 'required|integer|min:1', 'received_in_warehouse' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000']);
|
||||
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
|
||||
$d['version'] = (int) $d['version'];
|
||||
$d['received_in_warehouse'] = true;
|
||||
|
||||
return $this->response(fn () => $flow->confirm($this->actor($r),$id,$d));
|
||||
}
|
||||
public function withdraw(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'withdrawn');}
|
||||
public function reject(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'rejected');}
|
||||
private function close(Request $r,int $id,WarehouseReturns $flow,string $state): JsonResponse
|
||||
{
|
||||
$d=$r->validate(['version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000']);abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);$d['version']=(int)$d['version'];return $this->response(fn()=>$flow->close($this->actor($r),$id,$d,$state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
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 WorkflowTaskInboxController extends Controller
|
||||
{
|
||||
public function procurement(Request $request, WorkflowTaskInbox $inbox): Response
|
||||
{
|
||||
return $this->page($request, $inbox, 'procurement');
|
||||
}
|
||||
|
||||
public function people(Request $request, WorkflowTaskInbox $inbox): Response
|
||||
{
|
||||
return $this->page($request, $inbox, 'people');
|
||||
}
|
||||
|
||||
private function page(Request $request, WorkflowTaskInbox $inbox, string $area): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$modules = $area === 'people' ? ['people' => 'Pessoas e contratações'] : ['purchases' => 'Compras', 'warehouse' => 'Armazém', 'supply' => 'Fornecimentos e reposição', 'finance' => 'Finanças'];
|
||||
$filters = $request->validate(['module' => ['nullable', Rule::in(['all', ...array_keys($modules)])],
|
||||
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000']);
|
||||
|
||||
return DB::transaction(function () use ($request, $actor, $area, $inbox, $modules, $filters) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$all = collect($inbox->build($actor, $area));
|
||||
$counts = [];
|
||||
foreach ($modules as $key => $label) {
|
||||
$counts[$key] = $all->where('module', $key)->count();
|
||||
}
|
||||
$rows = $all->filter(fn ($row) => (($filters['module'] ?? 'all') === 'all' || $row['module'] === $filters['module'])
|
||||
&& (($filters['search'] ?? '') === '' || mb_stripos($row['reference'].' '.$row['label'], trim($filters['search'])) !== false))->values();
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('workflow/tasks', ['area' => $area, 'modules' => $modules, 'counts' => $counts,
|
||||
'checkedAt' => now()->toIso8601String(), 'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? ''],
|
||||
'tasks' => (new LengthAwarePaginator($rows->slice(($page - 1) * 25, 25)->values(), $rows->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ trait BelongsToTenant
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $query) {
|
||||
$tenantId = auth()->check() ? auth()->user()->tenant_id : null;
|
||||
$tenantId = auth()->check() ? auth()->user()->tenant_id : app(\App\Services\ReplenishmentRun::class)->tenantId();
|
||||
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(\App\Services\ReplenishmentRun::class);
|
||||
require_once base_path('packages/purchasing/bootstrap.php');
|
||||
$package = config('common-budget.path');
|
||||
require_once $package.'/bootstrap.php';
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Requests\StoreRequisitionRequest;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\WarehouseRequest;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -17,23 +22,70 @@ use Illuminate\Validation\ValidationException;
|
||||
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
|
||||
class AutomaticPurchases
|
||||
{
|
||||
private function independentReviewer(User $actor, SupplyRecord $rule): bool
|
||||
{
|
||||
return ! in_array($actor->id, $rule->snapshot['contributors'] ?? [$rule->created_by], true);
|
||||
}
|
||||
|
||||
public function canReview(User $actor, SupplyRecord $rule, string $action = 'approve'): bool
|
||||
{
|
||||
if ($actor->tenant_id !== $rule->tenant_id || $rule->kind !== 'auto_rule' || ! in_array($action, ['approve', 'suspend'], true)
|
||||
|| ! app(SupplyPlanning::class)->allows($actor, 'supply_review')) {
|
||||
return false;
|
||||
}
|
||||
$latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
|
||||
|
||||
return $latest?->id === $rule->id && $rule->state === ($action === 'approve' ? 'draft' : 'approved')
|
||||
&& ($action !== 'approve' || $this->independentReviewer($actor, $rule));
|
||||
}
|
||||
|
||||
public function eligibleRequester(User $user): bool
|
||||
{
|
||||
return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && !app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function origin(Requisition $requisition): ?array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
return null;
|
||||
}
|
||||
$record = SupplyRecord::query()->where('kind', 'auto_need')->whereJsonContains('data->requisition_ids', $requisition->id)->oldest('id')->first();
|
||||
|
||||
return $record === null ? null : ['need_id' => $record->id, 'rule_id' => $record->snapshot['rule_id'], 'item' => $record->snapshot['item'], 'warehouse' => $record->snapshot['warehouse'], 'origins' => $record->snapshot['origins'], 'created_at' => $record->created_at->toDateTimeString()];
|
||||
}
|
||||
|
||||
public function requisitionChanged(User $actor, Requisition $requisition): void
|
||||
{
|
||||
$origin = $this->origin($requisition);
|
||||
if ($origin !== null) {
|
||||
$this->signal($actor, $origin['item']['id'], $origin['warehouse']['id']);
|
||||
}
|
||||
}
|
||||
|
||||
public function requester(User $executor, SupplyRecord $rule): User
|
||||
{
|
||||
$executor = app(CurrentActor::class)->lock($executor);
|
||||
$executor = $this->lockExecutor($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 && $this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
|
||||
$this->check(app(ReplenishmentRun::class)->tenantId() !== null || ! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
|
||||
|
||||
return $this->configuredRequester($rule, true);
|
||||
}
|
||||
|
||||
private function configuredRequester(SupplyRecord $rule, bool $lock): User
|
||||
{
|
||||
$query = User::query()->where('tenant_id', $rule->tenant_id)->whereKey($rule->data['requester_id']);
|
||||
if ($lock) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
$user = $query->firstOrFail();
|
||||
$this->check($this->eligibleRequester($user), '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']);
|
||||
@@ -42,6 +94,30 @@ class AutomaticPurchases
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** Inspect the same sources used by generation, without actor substitution, locks or commands.
|
||||
* @return array{valid:bool,issues:list<string>}
|
||||
*/
|
||||
public function inspectRule(User $viewer, SupplyRecord $rule): array
|
||||
{
|
||||
abort_unless(auth()->id() === $viewer->id && $viewer->tenant_id === $rule->tenant_id && $viewer->hasFunctions(['admin']), 403);
|
||||
try {
|
||||
$latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
|
||||
$this->check($latest !== null && $latest->id === $rule->id && $latest->state === 'approved', 'A regra já não está activa.');
|
||||
$requester = $this->configuredRequester($rule, false);
|
||||
$form = new StoreRequisitionRequest;
|
||||
$form->setUserResolver(fn () => $requester);
|
||||
Validator::make(['items' => [['unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]], array_intersect_key($form->rules(), array_flip(['items.*.unit_price', 'items.*.tax_rate'])), $form->messages())->validate();
|
||||
$this->basis($rule);
|
||||
|
||||
return ['valid' => true, 'issues' => []];
|
||||
} catch (ValidationException $error) {
|
||||
return ['valid' => false, 'issues' => array_values(array_merge(...array_values($error->errors())))];
|
||||
} catch (ModelNotFoundException) {
|
||||
return ['valid' => false, 'issues' => ['Uma origem deixou de estar disponível; reveja a parametrização.']];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, array $input): SupplyRecord
|
||||
{
|
||||
abort_unless($this->ready(), 503, 'Instalação da reserva e reposição pendente.');
|
||||
@@ -59,24 +135,37 @@ class AutomaticPurchases
|
||||
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 ?? [];
|
||||
$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();
|
||||
'equipment_id' => 'nullable|integer|min:1', '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'];
|
||||
$values['tax_rate'] = $values['tax_rate'] ?? $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($values['tax_rate'] !== null, 'A empresa não tem imposto configurado. Registe uma estimativa fundamentada.');
|
||||
$values['tax_rate'] = (string) $values['tax_rate'];
|
||||
$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($basis['catalogue']['purchase_type'], ['consumable', 'other', 'spare_part'], true), 'Seleccione um artigo de stock publicado. Equipamento novo e serviços seguem o seu processo próprio.');
|
||||
$user = User::query()->where('tenant_id', $actor->tenant_id)->whereKey($values['requester_id'])->firstOrFail();
|
||||
$this->check($this->eligibleRequester($user), '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,
|
||||
$snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])),
|
||||
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
|
||||
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
|
||||
$this->check(! empty($values['equipment_id']), 'Seleccione o equipamento de destino da peça.');
|
||||
$part = SparePart::query()->where('stock_item_id', $basis['stock_item_id'])->firstOrFail();
|
||||
app(EquipmentWorkflow::class)->linkedPart($part->id, (int) $values['equipment_id'], $user->department_id);
|
||||
$equipment = Equipment::query()->whereKey($values['equipment_id'])->firstOrFail();
|
||||
$values['spare_part_id'] = $part->id;
|
||||
$snapshot['equipment'] = $equipment->only(['id', 'code', 'description', 'department_id']);
|
||||
$snapshot['spare_part'] = $part->toArray();
|
||||
} else {
|
||||
$this->check(empty($values['equipment_id']), 'O equipamento na parametrização aplica-se apenas a peças. Os destinos dos pedidos são preservados na origem.');
|
||||
}
|
||||
$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);
|
||||
@@ -84,7 +173,7 @@ class AutomaticPurchases
|
||||
$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.');
|
||||
$this->check($data['action'] !== 'approve' || $this->independentReviewer($actor, $old), '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') {
|
||||
@@ -97,11 +186,17 @@ class AutomaticPurchases
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
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.');
|
||||
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
|
||||
$part = app(EquipmentWorkflow::class)->linkedPart($rule->data['spare_part_id'], (int) $rule->data['equipment_id'], $rule->snapshot['requester']['department_id']);
|
||||
$equipment = Equipment::query()->whereKey($rule->data['equipment_id'])->firstOrFail();
|
||||
$this->check($part->toArray() === $rule->snapshot['spare_part'] && $equipment->only(['id', 'code', 'description', 'department_id']) === $rule->snapshot['equipment'], 'O destino técnico mudou. Reveja a parametrização da peça.');
|
||||
}
|
||||
|
||||
return $basis;
|
||||
}
|
||||
@@ -112,9 +207,20 @@ class AutomaticPurchases
|
||||
if (! $this->ready()) {
|
||||
return;
|
||||
}
|
||||
if (app(ReplenishmentQueue::class)->ready()) {
|
||||
$id = app(ReplenishmentQueue::class)->enqueue($actor, $item, $warehouse);
|
||||
DB::afterCommit(fn () => app(ReplenishmentQueue::class)->interactive($actor, $id));
|
||||
|
||||
return;
|
||||
}
|
||||
DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse));
|
||||
}
|
||||
|
||||
public function lockExecutor(User $actor): User
|
||||
{
|
||||
return app(ReplenishmentRun::class)->tenantId() === null ? app(CurrentActor::class)->lock($actor) : app(ReplenishmentRun::class)->lockResponsible($actor);
|
||||
}
|
||||
|
||||
public function evaluate(User $actor, int $item, int $warehouse): void
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
@@ -122,15 +228,19 @@ class AutomaticPurchases
|
||||
}
|
||||
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);
|
||||
$actor = $this->lockExecutor($actor);
|
||||
abort_if(app(ReplenishmentRun::class)->tenantId() === null && 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();
|
||||
$policies = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->where('data->purpose', 'internal');
|
||||
if ($rule !== null) {
|
||||
$policies->where('record_key', $rule->data['policy_key']);
|
||||
}
|
||||
$policy = $policies->latest('id')->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) {
|
||||
foreach (WarehouseRequest::query()->where('work_context_key', 'procurement.internal')->where('warehouse_id', $warehouse)->get() as $request) {
|
||||
$p = app(WarehouseFulfilment::class)->progress($request);
|
||||
if (! in_array($p['state'], ['submitted', 'authorized', 'partial'], true)) {
|
||||
continue;
|
||||
@@ -142,14 +252,14 @@ class AutomaticPurchases
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($policy === null && $unreserved === 0 && $old === null) {
|
||||
if ($policy === null && $origins === [] && $old === null) {
|
||||
return;
|
||||
}
|
||||
$purchases = $old?->data['purchases'] ?? [];
|
||||
$snapshot = ['item' => ['id' => $item], 'warehouse' => ['id' => $warehouse], 'origins' => $origins, 'rule_id' => $rule?->id];
|
||||
$snapshot = ['item' => StockItem::query()->whereKey($item)->firstOrFail()->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->whereKey($warehouse)->firstOrFail()->only(['id', 'code', 'name']), '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];
|
||||
$values = ['purchases' => $purchases, 'requisition_ids' => array_column($purchases, 'id'), 'message' => $message];
|
||||
try {
|
||||
$free = app(WarehouseReservations::class)->free($item, $warehouse);
|
||||
$minimum = $policy?->data['minimum_stock_milli'] ?? 0;
|
||||
@@ -162,20 +272,31 @@ class AutomaticPurchases
|
||||
$this->check($rule !== null, $message);
|
||||
$requester = $this->requester($actor, $rule);
|
||||
$basis = $this->basis($rule);
|
||||
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
|
||||
foreach ($origins as $origin) {
|
||||
$this->check(($origin['equipment']['id'] ?? null) === (int) $rule->data['equipment_id'], 'O equipamento do pedido não coincide com o destino técnico da parametrização. Reconciliar os destinos antes de gerar a compra.');
|
||||
}
|
||||
}
|
||||
$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.');
|
||||
$tracked = $purchases;
|
||||
foreach ($planning->latest('suggestion')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->whereIn('state', ['converted', 'closed'])->get() as $suggestion) {
|
||||
if (! in_array($suggestion->data['requisition_id'], array_column($tracked, 'id'), true)) {
|
||||
$tracked[] = ['id' => $suggestion->data['requisition_id'], 'rule_id' => null];
|
||||
}
|
||||
}
|
||||
foreach ($tracked as $purchase) {
|
||||
$r = Requisition::query()->whereKey($purchase['id'])->firstOrFail();
|
||||
if ($r->status === 'rejected') {
|
||||
if ($purchase['rule_id'] === $rule->id) {
|
||||
$blocked = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
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');
|
||||
$this->check($r->items->count() === 1, 'Uma reposição anterior foi repartida ou alterada; reconcilie as linhas antes de gerar nova compra.');
|
||||
$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;
|
||||
@@ -185,11 +306,13 @@ continue;
|
||||
$received += (int) $receipt->accepted_milli;
|
||||
}
|
||||
}
|
||||
$coverage += max(0, ReceiptWorkflow::milli($line->quantity) - $received);
|
||||
$orderLines = DB::table('purchase_order_items')->where('tenant_id', $actor->tenant_id)->where('requisition_item_id', $line->id)->get(['quantity']);
|
||||
$expected = $orderLines->isEmpty() ? ReceiptWorkflow::milli($line->quantity) : $orderLines->sum(fn ($ordered) => ReceiptWorkflow::milli($ordered->quantity));
|
||||
$coverage += max(0, $expected - $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');
|
||||
$automaticIds = array_column($tracked, '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)) {
|
||||
@@ -203,13 +326,19 @@ continue;
|
||||
$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);
|
||||
$quantity = intdiv(max($demand, $basis['policy']['minimum_milli']) + $pack - 1, $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(),
|
||||
$payload = ['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']]]]);
|
||||
'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'purchase_type' => $basis['catalogue']['purchase_type'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate'],
|
||||
...($basis['catalogue']['purchase_type'] === 'spare_part' ? ['equipment_id' => $rule->data['equipment_id'], 'spare_part_id' => $rule->data['spare_part_id']] : [])]]];
|
||||
$form = new StoreRequisitionRequest;
|
||||
$form->setUserResolver(fn () => $requester);
|
||||
$payload = Validator::make($payload, $form->rules(), $form->messages())->validate();
|
||||
$r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, $payload);
|
||||
$values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->id];
|
||||
$values['requisition_ids'][] = $r->id;
|
||||
$state = 'created';
|
||||
$values['message'] = 'Rascunho criado; o requisitante deve conferir e submeter às aprovações habituais.';
|
||||
}
|
||||
@@ -222,21 +351,24 @@ continue;
|
||||
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.');
|
||||
$this->append($actor, 'auto_need', $old, $state, $values, $snapshot, app(ReplenishmentRun::class)->tenantId() === null ? 'Avaliação automática após alteração da disponibilidade ou parametrização.' : 'Reavaliação periódica ou recuperação da reposição, sem decisão automática de compra.');
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param array<string,mixed> $snapshot
|
||||
*/
|
||||
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,
|
||||
$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]);
|
||||
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
|
||||
private function check(bool $valid, string $message): void
|
||||
{
|
||||
if (! $valid) {
|
||||
throw ValidationException::withMessages(['replenishment' => $message]);
|
||||
|
||||
@@ -24,6 +24,7 @@ class DocumentaryQualificationWorkflow
|
||||
/** Call after the company/actor locks; coordinates with award and quote transactions. */
|
||||
private function process(User $actor, int $requisitionId): Requisition
|
||||
{
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($requisitionId);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
|
||||
$this->invalid('qualification', 'Registe a análise num pedido aprovado, antes da adjudicação. As versões anteriores continuam disponíveis.');
|
||||
@@ -36,6 +37,36 @@ class DocumentaryQualificationWorkflow
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function canParticipate(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
|
||||
&& ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
||||
}
|
||||
|
||||
public function canDefine(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->hasFunctions([PurchasingRole::Procurement]) && $this->canParticipate($actor, $request)
|
||||
&& Quotation::query()->where('requisition_id', $request->id)->exists();
|
||||
}
|
||||
|
||||
public function currentSet(Requisition $request, QualificationSet $set): bool
|
||||
{
|
||||
$latest = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
|
||||
return $set->tenant_id === $request->tenant_id && $set->requisition_id === $request->id && $latest?->id === $set->id
|
||||
&& $set->basis_hash === $opening->basis($opening->proposals($request));
|
||||
}
|
||||
|
||||
public function canAssess(User $actor, Requisition $request, QualificationSet $set): bool
|
||||
{
|
||||
return $actor->id !== $request->requester_id && app(ProposalComparison::class)->eligibleRole($actor, $request)
|
||||
&& $this->canParticipate($actor, $request) && $this->currentSet($request, $set);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function define(User $actor, Requisition $request, array $data): QualificationSet
|
||||
{
|
||||
@@ -80,11 +111,10 @@ class DocumentaryQualificationWorkflow
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$request = $this->process($actor, $set->requisition_id);
|
||||
abort_unless(app(ProposalComparison::class)->eligibleRole($actor, $request), 403);
|
||||
abort_if($actor->id === $request->requester_id, 403);
|
||||
$set = QualificationSet::query()->lockForUpdate()->findOrFail($set->id);
|
||||
$latestSet = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->firstOrFail();
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
if ($set->id !== $latestSet->id || $set->basis_hash !== $opening->basis($opening->proposals($request))) {
|
||||
if (! $this->currentSet($request, $set)) {
|
||||
$this->invalid('qualification', 'Os requisitos ou a base mudaram. As análises anteriores são históricas; registe uma versão actual dos requisitos.');
|
||||
}
|
||||
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
|
||||
|
||||
@@ -15,12 +15,47 @@ use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
class ExternalPaymentWorkflow
|
||||
{
|
||||
private function actor(User $user, int $tenantId): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
}
|
||||
|
||||
private function reviewerBaseEligible(User $user, ExternalPayment $payment): bool
|
||||
{
|
||||
return $user->tenant_id === $payment->tenant_id && $payment->created_by !== $user->id
|
||||
&& $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
}
|
||||
|
||||
/** The confirmation still rechecks bank, balance, author and evidence in its transaction. */
|
||||
public function reviewerEligible(User $user, ExternalPayment $payment): bool
|
||||
{
|
||||
if ($payment->status !== 'pending' || ! $this->reviewerBaseEligible($user, $payment)) {
|
||||
return false;
|
||||
}
|
||||
$invoice = Invoice::query()->whereKey($payment->invoice_id)->first();
|
||||
$order = $invoice === null ? null : PurchaseOrder::query()->whereKey($invoice->purchase_order_id)->first();
|
||||
if ($invoice === null || $order === null) {
|
||||
return false;
|
||||
}
|
||||
$request = Requisition::query()->visibleTo($user)->whereKey($order->requisition_id)->first();
|
||||
if ($request === null || app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $user)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
app(SupplierPayables::class)->assertHistoricalReview($user, $invoice);
|
||||
} catch (HttpExceptionInterface $error) {
|
||||
if (! in_array($error->getStatusCode(), [403, 409], true)) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
@@ -100,7 +135,7 @@ class ExternalPaymentWorkflow
|
||||
public function review(User $user, ExternalPayment $payment, string $decision, string $reason): void
|
||||
{
|
||||
$this->actor($user, $payment->tenant_id);
|
||||
abort_if($payment->created_by === $user->id, 403);
|
||||
abort_unless($this->reviewerBaseEligible($user, $payment), 403);
|
||||
if (! in_array($decision, ['confirmed', 'rejected']) || trim($reason) === '') {
|
||||
$this->invalid('Decisão e fundamento obrigatórios.');
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class FormalAdmission
|
||||
|
||||
private function process(User $actor, int $id): Requisition
|
||||
{
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
|
||||
$this->invalid('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.');
|
||||
@@ -174,7 +175,40 @@ class FormalAdmission
|
||||
|
||||
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $decision->tenant_id === $request->tenant_id
|
||||
&& $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function canParticipate(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
|
||||
&& ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
||||
}
|
||||
|
||||
/** Same immutable basis check used before a decision is recorded. */
|
||||
public function currentReviewBasis(Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
|
||||
{
|
||||
if ($adoption->requisition_id !== $request->id || $decision->admission_adoption_id !== $adoption->id) {
|
||||
return false;
|
||||
}
|
||||
$latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
|
||||
$basis = $this->basis($request, $decision->quotation_id);
|
||||
|
||||
return $latest?->id === $decision->id && $basis !== null && $basis['hash'] === $decision->basis_hash;
|
||||
}
|
||||
|
||||
public function canReview(User $actor, Requisition $request, AdmissionDecision $decision): bool
|
||||
{
|
||||
$adoption = $this->adoption($request);
|
||||
|
||||
return $adoption !== null && $this->eligible($actor, $request, $adoption, $decision) && $this->canParticipate($actor, $request)
|
||||
&& $this->currentReviewBasis($request, $adoption, $decision) && $this->status($decision, $adoption) === 'pending'
|
||||
&& ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->exists();
|
||||
}
|
||||
|
||||
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
|
||||
@@ -189,9 +223,7 @@ class FormalAdmission
|
||||
$request = $this->process($actor, $original->requisition_id);
|
||||
$adoption = $this->adoption($request);
|
||||
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403);
|
||||
$latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
|
||||
$basis = $this->basis($request, $decision->quotation_id);
|
||||
if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) {
|
||||
if (! $this->currentReviewBasis($request, $adoption, $decision)) {
|
||||
$this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.');
|
||||
}
|
||||
$existing = AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->first();
|
||||
|
||||
@@ -34,6 +34,7 @@ class FormalRecommendation
|
||||
|
||||
private function process(User $actor, int $id): Requisition
|
||||
{
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
|
||||
$this->invalid('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.');
|
||||
@@ -152,7 +153,40 @@ class FormalRecommendation
|
||||
|
||||
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $attempt->tenant_id === $request->tenant_id
|
||||
&& $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function canParticipate(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
|
||||
&& ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
||||
}
|
||||
|
||||
/** Same immutable basis check used before a decision is recorded. */
|
||||
public function currentReviewBasis(Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
if ($adoption->requisition_id !== $request->id || $attempt->recommendation_adoption_id !== $adoption->id) {
|
||||
return false;
|
||||
}
|
||||
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||
$basis = $this->basis($request);
|
||||
|
||||
return $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
|
||||
}
|
||||
|
||||
public function canReview(User $actor, Requisition $request, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
$adoption = $this->adoption($request);
|
||||
|
||||
return $adoption !== null && $this->eligible($actor, $request, $adoption, $attempt) && $this->canParticipate($actor, $request)
|
||||
&& $this->currentReviewBasis($request, $adoption, $attempt) && $this->status($attempt, $adoption) === 'pending'
|
||||
&& ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->exists();
|
||||
}
|
||||
|
||||
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
|
||||
@@ -167,9 +201,7 @@ class FormalRecommendation
|
||||
$request = $this->process($actor, $original->requisition_id);
|
||||
$adoption = $this->adoption($request);
|
||||
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403);
|
||||
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||
$basis = $this->basis($request);
|
||||
if ($attempt->recommendation_adoption_id !== $adoption->id || $latest?->id !== $attempt->id || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash) {
|
||||
if (! $this->currentReviewBasis($request, $adoption, $attempt)) {
|
||||
$this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.');
|
||||
}
|
||||
$existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->first();
|
||||
|
||||
@@ -30,6 +30,12 @@ final class InternalRequisitionPort implements RequisitionPort
|
||||
{
|
||||
return DB::transaction(function () use ($work) {
|
||||
app(ConflictGuard::class)->lockCompany($this->actor);
|
||||
if (app(ReplenishmentRun::class)->tenantId() !== null) {
|
||||
abort_unless($this->automaticRule !== null, 403);
|
||||
$this->actor = app(ReplenishmentRun::class)->lockResponsible($this->actor);
|
||||
|
||||
return $work();
|
||||
}
|
||||
$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);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Internal links over authoritative SGC documents; no copy or workflow transition. */
|
||||
class InternalSupplyTrace
|
||||
{
|
||||
/** @param array<string,mixed> $progress
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function forRequest(User $actor, WarehouseRequest $request, array $progress): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && app(WarehouseWorkdesk::class)->canView($actor, $request), 403);
|
||||
if (! Schema::hasTable('supply_records')) {
|
||||
return ['groups' => [], 'ready' => false];
|
||||
}
|
||||
// Last version that actually mentioned this request: future shared purchases
|
||||
// must not be retroactively attributed to a request already fulfilled.
|
||||
$matched = SupplyRecord::query()->where('kind', 'auto_need')->where('warehouse_id', $request->warehouse_id);
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
$matched->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(supply_records.snapshot::jsonb->'origins') o WHERE o->>'warehouse_request_id' = ?)", [(string) $request->id]);
|
||||
} else {
|
||||
$matched->whereRaw("EXISTS (SELECT 1 FROM json_each(supply_records.snapshot, '$.origins') o WHERE json_extract(o.value, '$.warehouse_request_id') = ?)", [$request->id]);
|
||||
}
|
||||
$records = SupplyRecord::query()->whereIn('id', $matched->selectRaw('MAX(id)')->groupBy('record_key'))->orderBy('stock_item_id')->get();
|
||||
$current = app(SupplyPlanning::class)->latest('auto_need')->whereIn('record_key', $records->pluck('record_key'))->get()->keyBy('record_key');
|
||||
$corrections = app(InventoryReversals::class)->corrections('receipt');
|
||||
$groups = [];
|
||||
foreach ($records as $record) {
|
||||
$indexes = [];
|
||||
foreach ($record->snapshot['origins'] as $origin) {
|
||||
$index = $origin['line_index'];
|
||||
if ($origin['warehouse_request_id'] === $request->id && isset($request->snapshot['lines'][$index])
|
||||
&& $request->snapshot['lines'][$index]['item']['id'] === $record->stock_item_id) {
|
||||
$indexes[] = $index;
|
||||
}
|
||||
}
|
||||
if ($indexes === []) {
|
||||
continue;
|
||||
}
|
||||
$latest = $current->get($record->record_key) ?? $record;
|
||||
$ids = array_values(array_unique($record->data['requisition_ids'] ?? []));
|
||||
$purchases = Requisition::query()->visibleTo($actor)->whereIn('id', $ids)->with('steps')->orderBy('id')->get();
|
||||
$rows = [];
|
||||
foreach ($purchases as $purchase) {
|
||||
$orders = [];
|
||||
foreach (PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $purchase->id)->orderBy('id')->get() as $order) {
|
||||
$receipts = [];
|
||||
foreach (DB::table('goods_receipt_items as i')->join('goods_receipts as g', 'g.id', '=', 'i.goods_receipt_id')
|
||||
->where('i.tenant_id', $actor->tenant_id)->where('g.tenant_id', $actor->tenant_id)->where('g.purchase_order_id', $order->id)
|
||||
->where('g.warehouse_id', $request->warehouse_id)->where('i.stock_item_id', $record->stock_item_id)
|
||||
->orderBy('i.id')->get(['i.id', 'g.id as receipt_id', 'g.received_on', 'i.accepted_milli']) as $receipt) {
|
||||
$receipts[] = ['id' => $receipt->receipt_id, 'item_id' => $receipt->id, 'received_on' => $receipt->received_on,
|
||||
'accepted_milli' => (int) $receipt->accepted_milli, 'reversed' => isset($corrections[$receipt->id])];
|
||||
}
|
||||
$orders[] = ['id' => $order->id, 'number' => $order->number,
|
||||
'href' => $actor->hasFunctions(['admin', 'procurement', 'finance', 'department_head']) ? '/ordens/'.$order->id : null,
|
||||
'receipts' => $receipts, 'effective_received_milli' => array_sum(array_map(fn ($row) => $row['reversed'] ? 0 : $row['accepted_milli'], $receipts))];
|
||||
}
|
||||
$next = $purchase->status === 'pending' ? $purchase->steps->firstWhere('decision', 'pending') : null;
|
||||
$rows[] = ['id' => $purchase->id, 'number' => $purchase->number, 'status' => $purchase->status, 'href' => '/requisicoes/'.$purchase->id,
|
||||
'next_task' => $next === null ? null : (OrganizationAccess::PERMISSIONS[$next->role] ?? $next->role), 'orders' => $orders];
|
||||
}
|
||||
$unreserved = array_sum(array_map(fn ($index) => max(0, $progress['remaining'][$index] - $progress['reserved'][$index]), $indexes));
|
||||
$groups[] = ['item' => $request->snapshot['lines'][$indexes[0]]['item'], 'line_indexes' => array_values(array_unique($indexes)),
|
||||
'origin_need_id' => $record->id, 'current_need_id' => $latest->id, 'need_state' => $latest->state,
|
||||
'message' => $latest->data['message'] ?? '', 'unreserved_milli' => $unreserved,
|
||||
'purchases' => $rows, 'restricted' => $purchases->count() !== count($ids)];
|
||||
}
|
||||
|
||||
return ['ready' => true, 'groups' => $groups];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function purchaseOrigin(User $actor, Requisition $purchase): ?array
|
||||
{
|
||||
$origin = app(AutomaticPurchases::class)->origin($purchase);
|
||||
if ($origin === null) {
|
||||
return null;
|
||||
}
|
||||
$sources = WarehouseRequest::query()->whereIn('id', array_column($origin['origins'], 'warehouse_request_id'))->get()->keyBy('id');
|
||||
$origin['origins'] = array_map(function ($row) use ($sources, $actor) {
|
||||
$request = $sources->get($row['warehouse_request_id']);
|
||||
|
||||
return [...$row, 'href' => $request !== null && app(WarehouseWorkdesk::class)->canView($actor, $request) ? '/stock/requisicoes/'.$request->id : null];
|
||||
}, $origin['origins']);
|
||||
|
||||
return $origin;
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,8 @@ class InventoryReversals
|
||||
$receiptSource = null;
|
||||
if ($data['source_kind'] === 'operation') {
|
||||
$operation = StockOperation::query()->whereKey($data['source_operation_id'])->firstOrFail();
|
||||
$sourceMovementIds=StockMovement::query()->where('stock_operation_id',$operation->id)->pluck('id');
|
||||
$this->check(!StockMovement::query()->where('movement_type','workshop_return_in')->whereIn('snapshot->delivery_movement_id',$sourceMovementIds)->exists(),'action','A entrega já tem devolução física de excedente. Preserve o original e confira a correcção própria.');
|
||||
$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();
|
||||
|
||||
@@ -19,7 +19,28 @@ class InvoiceWorkflow
|
||||
{
|
||||
private function access(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
}
|
||||
|
||||
private function reviewerBaseEligible(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $user->tenant_id === $invoice->tenant_id && $invoice->created_by !== $user->id
|
||||
&& $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
}
|
||||
|
||||
/** Pending intervention, not a promise of a matching invoice or budget availability. */
|
||||
public function reviewerEligible(User $user, Invoice $invoice): bool
|
||||
{
|
||||
if ($invoice->status !== 'pending' || ! $this->reviewerBaseEligible($user, $invoice)) {
|
||||
return false;
|
||||
}
|
||||
$order = PurchaseOrder::query()->whereKey($invoice->purchase_order_id)->first();
|
||||
if ($order === null) {
|
||||
return false;
|
||||
}
|
||||
$request = Requisition::query()->visibleTo($user)->whereKey($order->requisition_id)->first();
|
||||
|
||||
return $request !== null && ! app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $user);
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
@@ -49,7 +70,10 @@ class InvoiceWorkflow
|
||||
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.');
|
||||
}
|
||||
if (BigDecimal::of((string) $input['unit_price'])->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $input['tax_rate'])->compareTo((string) $source->tax_rate) !== 0 || trim((string) $input['unit']) !== $source->unit) {
|
||||
$unit = array_key_exists('unit', $input) ? $input['unit'] : $source->unit;
|
||||
$unitPrice = array_key_exists('unit_price', $input) ? $input['unit_price'] : (string) BigDecimal::of($source->unit_price_minor)->dividedBy(100, 2);
|
||||
$taxRate = array_key_exists('tax_rate', $input) ? $input['tax_rate'] : $source->tax_rate;
|
||||
if (BigDecimal::of((string) $unitPrice)->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $taxRate)->compareTo((string) $source->tax_rate) !== 0 || trim((string) $unit) !== $source->unit) {
|
||||
$this->invalid('Preço, unidade e IVA devem coincidir exactamente com a ordem. Não há tolerância ou excepção configurada.');
|
||||
}
|
||||
// Cumulative rounding ensures partial invoices sum exactly to the frozen order.
|
||||
@@ -75,6 +99,7 @@ class InvoiceWorkflow
|
||||
return DB::transaction(function () use ($user, $order, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||
$this->access($user);
|
||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
||||
$request = Requisition::query()->findOrFail($order->requisition_id);
|
||||
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
|
||||
@@ -104,10 +129,11 @@ class InvoiceWorkflow
|
||||
public function validate(User $user, Invoice $invoice): void
|
||||
{
|
||||
$this->access($user);
|
||||
abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403);
|
||||
abort_unless($this->reviewerBaseEligible($user, $invoice), 403);
|
||||
DB::transaction(function () use ($user, $invoice) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||
$this->access($user);
|
||||
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
|
||||
if ($invoice->status === 'validated') {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,9 @@ use Illuminate\Support\Facades\Schema;
|
||||
class OrganizationAccess
|
||||
{
|
||||
public const PERMISSIONS = [
|
||||
'warehouse_return_read' => 'Consultar devoluções de excedentes de entregas internas',
|
||||
'warehouse_return_prepare' => 'Declarar excedente não consumido de entrega interna própria',
|
||||
'warehouse_return_confirm' => 'Conferir ou recusar recepção independente do excedente no armazém (exige função de armazém)',
|
||||
'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',
|
||||
@@ -70,6 +73,18 @@ class OrganizationAccess
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
|
||||
}
|
||||
|
||||
return $this->configuredPermissions($user, $context);
|
||||
}
|
||||
|
||||
/** Project the saved profiles without activating them or granting authority.
|
||||
* @return list<string>
|
||||
*/
|
||||
public function configuredPermissions(User $user, ?string $context = null): array
|
||||
{
|
||||
if ($user->tenant_id === null || ! Schema::hasTable('organization_profiles') || ! Schema::hasTable('organization_memberships')) {
|
||||
return [];
|
||||
}
|
||||
$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());
|
||||
@@ -108,6 +123,15 @@ class OrganizationAccess
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->configuredMaintenanceOnly($user);
|
||||
}
|
||||
|
||||
public function configuredMaintenanceOnly(User $user): bool
|
||||
{
|
||||
if ($user->tenant_id === null || ! Schema::hasTable('organization_memberships')) {
|
||||
return false;
|
||||
}
|
||||
$valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
||||
|
||||
return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Diagnostic only: reads exactly the authority sources used by activation. */
|
||||
class OrganizationActivationPreview
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function build(User $actor): array
|
||||
{
|
||||
$configuration = app(OrganizationConfiguration::class);
|
||||
$actor = $configuration->administrator($actor);
|
||||
$ready = Schema::hasTable('organization_access') && Schema::hasTable('organization_profiles') && Schema::hasTable('organization_memberships');
|
||||
if (! $ready) {
|
||||
return ['ready' => false, 'users' => []];
|
||||
}
|
||||
$access = app(OrganizationAccess::class);
|
||||
$context = app(WorkContext::class);
|
||||
$company = Tenant::query()->findOrFail($actor->tenant_id);
|
||||
$admins = $configuration->permanentAdministrators($actor->tenant_id);
|
||||
$active = $access->active($actor->tenant_id);
|
||||
$users = [];
|
||||
foreach (User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->orderBy('id')->get() as $user) {
|
||||
$current = $access->permissions($user);
|
||||
$configured = $access->configuredPermissions($user);
|
||||
$enforced = $context->enforced($user);
|
||||
$users[] = ['id' => $user->id, 'name' => $user->name, 'current_permissions' => $current, 'configured_permissions' => $configured,
|
||||
'added' => array_values(array_diff($configured, $current)), 'removed' => array_values(array_diff($current, $configured)),
|
||||
'current_can_request' => $access->canRequest($user), 'configured_can_request' => in_array('requester', $configured, true),
|
||||
'configured_maintenance_only' => $access->configuredMaintenanceOnly($user), 'permanent_admin' => in_array($user->id, $admins, true),
|
||||
'current_contexts' => $enforced ? $context->available($user) : null,
|
||||
'configured_contexts' => $enforced ? $context->configuredAvailable($user) : null,
|
||||
'loses_access' => $current !== [] && $configured === []];
|
||||
}
|
||||
|
||||
return ['ready' => true, 'active' => $active, 'version' => DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->value('version') ?? 1,
|
||||
'checked_at' => now()->toIso8601String(), 'users' => $users, 'permanent_administrators' => count($admins),
|
||||
'provisional_rules' => $company->rules_provisional, 'can_activate' => ! $active && count($admins) >= 2 && ! $company->rules_provisional,
|
||||
'losing_access' => count(array_filter($users, fn ($row) => $row['loses_access'])),
|
||||
'context_labels' => array_map(fn ($option) => $option['areaLabel'].' · '.$option['label'], WorkContext::OPTIONS)];
|
||||
}
|
||||
}
|
||||
@@ -161,16 +161,25 @@ class OrganizationConfiguration
|
||||
|
||||
private function twoAdministrators(int $tenant): void
|
||||
{
|
||||
$count = 0;
|
||||
if (count($this->permanentAdministrators($tenant)) < 2) {
|
||||
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Same permanent administrator rule for activation, updates and their preview.
|
||||
* @return list<int>
|
||||
*/
|
||||
public function permanentAdministrators(int $tenant): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
|
||||
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->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))) {
|
||||
$count++;
|
||||
$ids[] = $user->id;
|
||||
}
|
||||
}
|
||||
if ($count < 2) {
|
||||
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public function initialise(User $first, User $second): void
|
||||
|
||||
@@ -28,6 +28,7 @@ class PurchaseWorkflow
|
||||
return DB::transaction(function () use ($user, $requisition, $data) {
|
||||
$this->guard->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
@@ -64,7 +65,8 @@ class PurchaseWorkflow
|
||||
|
||||
public function canDecide(User $user, PurchaseAward $award, Requisition $requisition): bool
|
||||
{
|
||||
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || in_array($user->id, [$requisition->requester_id, $award->created_by])) {
|
||||
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || $requisition->tenant_id !== $award->tenant_id || $requisition->id !== $award->requisition_id
|
||||
|| in_array($user->id, [$requisition->requester_id, $award->created_by]) || app(OrganizationAccess::class)->maintenanceOnly($user) || $this->pendingCancellation($award)) {
|
||||
return false;
|
||||
}
|
||||
$steps = $award->approvals()->get();
|
||||
@@ -75,6 +77,17 @@ class PurchaseWorkflow
|
||||
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
/** Eligibility to confer issuance; currentBasis and all final checks still run on issue. */
|
||||
public function canIssue(User $user, PurchaseAward $award, Requisition $requisition): bool
|
||||
{
|
||||
return $user->tenant_id === $award->tenant_id && $requisition->tenant_id === $award->tenant_id && $requisition->id === $award->requisition_id
|
||||
&& $award->status === 'approved' && $user->hasFunctions([PurchasingRole::Procurement])
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($user) && ! $this->pendingCancellation($award)
|
||||
&& ! PurchaseOrder::query()->where('requisition_id', $requisition->id)->exists()
|
||||
&& Requisition::query()->visibleTo($user)->whereKey($requisition->id)->exists()
|
||||
&& ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
public function decide(User $user, PurchaseAward $award, string $decision, ?string $comment): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $award, $decision, $comment) {
|
||||
@@ -103,6 +116,7 @@ class PurchaseWorkflow
|
||||
return DB::transaction(function () use ($user, $award) {
|
||||
$this->guard->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
@@ -313,8 +327,13 @@ class PurchaseWorkflow
|
||||
|
||||
private function assertNoPendingCancellation(PurchaseAward $award): void
|
||||
{
|
||||
if (AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists()) {
|
||||
if ($this->pendingCancellation($award)) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Existe pedido de cancelamento pendente. Aguarde a decisão antes de aprovar ou emitir.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function pendingCancellation(PurchaseAward $award): bool
|
||||
{
|
||||
return AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/** Shared read-only authority for quotation forms, tasks and commands. */
|
||||
class QuotationParticipation
|
||||
{
|
||||
private function assertActor(User $actor, Requisition $request, bool $evaluation): void
|
||||
{
|
||||
$roles = $evaluation ? [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead] : [PurchasingRole::Procurement];
|
||||
abort_unless($actor->tenant_id === $request->tenant_id && $request->status === 'approved' && $actor->hasFunctions($roles)
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& (! $evaluation || $actor->id !== $request->requester_id)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists(), 403);
|
||||
}
|
||||
|
||||
private function assertOpen(Requisition $request, bool $evaluation): void
|
||||
{
|
||||
if (PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => $evaluation
|
||||
? 'As avaliações estão fechadas pela adjudicação deste processo.'
|
||||
: 'As propostas estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertPrepare(User $actor, Requisition $request): void
|
||||
{
|
||||
$this->assertActor($actor, $request, false);
|
||||
$this->assertOpen($request, false);
|
||||
app(ConflictGuard::class)->assertParticipation($actor, $request);
|
||||
}
|
||||
|
||||
public function assertEvaluateProcess(User $actor, Requisition $request): void
|
||||
{
|
||||
$this->assertActor($actor, $request, true);
|
||||
$this->assertOpen($request, true);
|
||||
app(ConflictGuard::class)->assertParticipation($actor, $request);
|
||||
}
|
||||
|
||||
public function assertEvaluate(User $actor, Requisition $request, Quotation $quote): void
|
||||
{
|
||||
$this->assertEvaluateProcess($actor, $request);
|
||||
abort_unless($quote->tenant_id === $request->tenant_id && $quote->requisition_id === $request->id, 403);
|
||||
app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor);
|
||||
if ($quote->valid_until < today() || ! $quote->supplier?->eligible()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
|
||||
}
|
||||
if ($quote->evaluations()->where('evaluator_id', $actor->id)->exists()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param callable():void $check */
|
||||
private function eligible(callable $check): bool
|
||||
{
|
||||
try {
|
||||
$check();
|
||||
|
||||
return true;
|
||||
} catch (ValidationException) {
|
||||
return false;
|
||||
} catch (HttpException $error) {
|
||||
if ($error->getStatusCode() !== 403) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function canPrepare(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $this->eligible(fn () => $this->assertPrepare($actor, $request));
|
||||
}
|
||||
|
||||
public function canEvaluateProcess(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $this->eligible(fn () => $this->assertEvaluateProcess($actor, $request));
|
||||
}
|
||||
|
||||
public function canEvaluate(User $actor, Requisition $request, Quotation $quote): bool
|
||||
{
|
||||
return $this->eligible(fn () => $this->assertEvaluate($actor, $request, $quote));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Called only within ReceiptWorkflow's company-locked transaction. */
|
||||
final class ReceiptStockMovement
|
||||
{
|
||||
public function for(User $actor): \Tempo\Inventory\ReceiptMovementWriter
|
||||
{
|
||||
$root=base_path('packages/inventory-write/releases/0.1.0');
|
||||
if(!is_file($root.'/manifest.json')||!hash_equals('f0b99e591f9a4df135e685f64ceffad77bed259222debe0f34c20f95e3cad800',hash_file('sha256',$root.'/manifest.json')))
|
||||
throw new \LogicException('Pacote de recepção incompatível; não executar outro escritor.');
|
||||
$manifest=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
foreach(['bootstrap.php','src/ReceiptMovementWriter.php'] as $file)
|
||||
if(!is_file($root.'/'.$file)||!hash_equals($manifest['files'][$file]??'',hash_file('sha256',$root.'/'.$file)))throw new \LogicException('Pacote de recepção alterado.');
|
||||
require_once $root.'/bootstrap.php';
|
||||
$schema=match(DB::connection()->getDriverName()){
|
||||
'pgsql'=>'public','sqlite'=>'main','mysql','mariadb'=>DB::connection()->getDatabaseName(),
|
||||
default=>throw new \LogicException('Driver de inventário não suportado.'),
|
||||
};
|
||||
return new \Tempo\Inventory\ReceiptMovementWriter(DB::connection(),$schema,(int)$actor->tenant_id,(int)$actor->id,
|
||||
function () use ($actor) {app(CurrentActor::class)->lock($actor,[PurchasingRole::Warehouse]);});
|
||||
}
|
||||
}
|
||||
@@ -216,12 +216,10 @@ class ReceiptWorkflow
|
||||
foreach ($lines as $line) {
|
||||
$item = $receipt->items()->create($line);
|
||||
if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) {
|
||||
$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],
|
||||
]);
|
||||
$writer = app(ReceiptStockMovement::class)->for($user);
|
||||
$basis = $writer->preview($item->id);
|
||||
$result = $writer->write($item->id, $basis['source_hash']);
|
||||
$movement = StockMovement::query()->findOrFail($result['movement_id']);
|
||||
$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);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
/** One durable, coalescing work item per tenant/article/warehouse. No leased human identity. */
|
||||
class ReplenishmentQueue
|
||||
{
|
||||
/** Read-only shared status for the workdesk and pilot diagnostics.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function status(User $actor): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id, 403);
|
||||
$scheduler = null;
|
||||
$recent = false;
|
||||
$path = storage_path('framework/replenishment-status.json');
|
||||
if (app()->environment('payable-testing') && is_file($path)) {
|
||||
$text = file_get_contents($path);
|
||||
$data = $text === false ? null : json_decode($text, true);
|
||||
if (is_array($data) && is_string($data['finished_at'] ?? null) && in_array($data['result'] ?? null, ['passed', 'retry_pending'], true)) {
|
||||
try {
|
||||
$finished = CarbonImmutable::parse($data['finished_at']);
|
||||
$recent = $finished->greaterThanOrEqualTo(now()->subMinutes(3)) && $finished->lessThanOrEqualTo(now()->addMinute());
|
||||
$scheduler = array_intersect_key($data, array_flip(['started_at', 'finished_at', 'pid', 'scope', 'result', 'exit_code']));
|
||||
} catch (InvalidFormatException) {
|
||||
} // Optional telemetry must not prevent operational consultation.
|
||||
}
|
||||
}
|
||||
|
||||
return ['recoveryReady' => $this->ready(), 'scanMinutes' => config('replenishment.scan_minutes'), 'schedulerStatus' => $scheduler, 'schedulerRecent' => $recent,
|
||||
'work' => $this->ready() ? DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->selectRaw('COUNT(*) as total, SUM(CASE WHEN generation > processed_generation THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN failures > 0 THEN 1 ELSE 0 END) as failed, MAX(last_run_at) as last_run_at')->first() : null];
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return config('work_context.composition') === 'sgc' && Schema::hasTable('replenishment_work');
|
||||
}
|
||||
|
||||
public function enqueue(User $actor, int $item, int $warehouse): int
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $item, $warehouse) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
StockItem::query()->whereKey($item)->firstOrFail();
|
||||
Warehouse::query()->whereKey($warehouse)->firstOrFail();
|
||||
|
||||
return $this->touch($actor->tenant_id, $item, $warehouse, $actor->id, true);
|
||||
});
|
||||
}
|
||||
|
||||
private function touch(int $tenant, int $item, int $warehouse, int $responsible, bool $signal): int
|
||||
{
|
||||
$row = DB::table('replenishment_work')->where('tenant_id', $tenant)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
|
||||
if ($row === null) {
|
||||
return DB::table('replenishment_work')->insertGetId(['tenant_id' => $tenant, 'stock_item_id' => $item, 'warehouse_id' => $warehouse, 'responsible_id' => $responsible, 'generation' => 1, 'processed_generation' => 0, 'due_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
if ($signal) {
|
||||
DB::table('replenishment_work')->where('id', $row->id)->update(['generation' => $row->generation + 1, 'responsible_id' => $responsible, 'due_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
return $row->id;
|
||||
}
|
||||
|
||||
/** CLI discovery recovers pre-installation interruptions and adds approved policies without stock events. */
|
||||
public function discover(): void
|
||||
{
|
||||
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
||||
if (! $this->ready()) {
|
||||
return;
|
||||
}
|
||||
$latest = DB::table('supply_records')->selectRaw('MAX(id)')->whereIn('kind', ['policy', 'auto_rule', 'auto_need'])->groupBy('tenant_id', 'kind', 'record_key');
|
||||
DB::table('supply_records')->whereIn('id', $latest)->whereNotNull('stock_item_id')->whereNotNull('warehouse_id')->orderBy('id')->chunkById(100, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$data = json_decode($row->data, true, 64, JSON_THROW_ON_ERROR);
|
||||
if ($row->kind === 'policy' && ($row->state !== 'approved' || ($data['purpose'] ?? null) !== 'internal')) {
|
||||
continue;
|
||||
}
|
||||
if ($row->kind === 'auto_rule' && $row->state !== 'approved') {
|
||||
continue;
|
||||
}
|
||||
$this->discoverPair($row->tenant_id, $row->stock_item_id, $row->warehouse_id, $row->created_by);
|
||||
}
|
||||
});
|
||||
DB::table('warehouse_requests')->where('work_context_key', 'procurement.internal')->orderBy('id')->chunkById(100, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
foreach (json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)['lines'] as $line) {
|
||||
$this->discoverPair($row->tenant_id, $line['item']['id'], $row->warehouse_id, $row->created_by);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function enabled(int $tenant): bool
|
||||
{
|
||||
$company = Tenant::query()->findOrFail($tenant);
|
||||
|
||||
return $company->work_context_policy === null || (in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$tenant, []), true));
|
||||
}
|
||||
|
||||
private function discoverPair(int $tenant, int $item, int $warehouse, int $responsible): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $item, $warehouse, $responsible) {
|
||||
Tenant::query()->whereKey($tenant)->lockForUpdate()->firstOrFail();
|
||||
if (! $this->enabled($tenant)) {
|
||||
return;
|
||||
}
|
||||
if (! DB::table('users')->where('tenant_id', $tenant)->where('id', $responsible)->exists() || ! DB::table('stock_items')->where('tenant_id', $tenant)->where('id', $item)->exists() || ! DB::table('warehouses')->where('tenant_id', $tenant)->where('id', $warehouse)->exists()) {
|
||||
return;
|
||||
}
|
||||
$this->touch($tenant, $item, $warehouse, $responsible, false);
|
||||
});
|
||||
}
|
||||
|
||||
public function interactive(User $actor, int $id): bool
|
||||
{
|
||||
return $this->process($id, $actor);
|
||||
}
|
||||
|
||||
public function scheduled(int $id): bool
|
||||
{
|
||||
return app(ReplenishmentRun::class)->execute($id, fn () => $this->process($id, null));
|
||||
}
|
||||
|
||||
private function process(int $id, ?User $actor): bool
|
||||
{
|
||||
$work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
||||
if ($actor !== null) {
|
||||
abort_unless($actor->tenant_id === $work->tenant_id && auth()->id() === $actor->id, 403);
|
||||
}
|
||||
$generation = null;
|
||||
try {
|
||||
DB::transaction(function () use ($id, $actor, &$generation) {
|
||||
$first = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
||||
Tenant::query()->whereKey($first->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
if (CarbonImmutable::parse($row->due_at)->isFuture() && $row->generation === $row->processed_generation) {
|
||||
return;
|
||||
}
|
||||
$generation = $row->generation;
|
||||
if ($actor === null) {
|
||||
$actor = User::query()->where('tenant_id', $row->tenant_id)->whereKey($row->responsible_id)->firstOrFail();
|
||||
}
|
||||
if ($this->enabled($row->tenant_id)) {
|
||||
app(AutomaticPurchases::class)->evaluate($actor, $row->stock_item_id, $row->warehouse_id);
|
||||
}
|
||||
$latest = app(SupplyPlanning::class)->latest('auto_need')->where('stock_item_id', $row->stock_item_id)->where('warehouse_id', $row->warehouse_id)->first();
|
||||
DB::table('replenishment_work')->where('id', $id)->update(['processed_generation' => $generation, 'attempts' => $row->attempts + 1, 'failures' => 0, 'last_outcome' => $this->enabled($row->tenant_id) ? ($latest->state ?? 'no_need') : 'context_disabled', 'last_error' => null, 'last_run_at' => now(), 'due_at' => now()->addMinutes(config('replenishment.scan_minutes')), 'updated_at' => now()]);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (Throwable $error) {
|
||||
report($error);
|
||||
// The failed transaction rolled back both draft and checkpoint. Keep the durable signal.
|
||||
DB::transaction(function () use ($id, $work, $generation) {
|
||||
Tenant::query()->whereKey($work->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
$fields = ['attempts' => $row->attempts + 1, 'failures' => $row->failures + 1, 'last_outcome' => 'error', 'last_error' => 'Avaliação interrompida. Recuperação automática pendente; consulte o registo técnico.', 'updated_at' => now()];
|
||||
if ($generation !== null && $generation === $row->generation) {
|
||||
$fields['due_at'] = now()->addSeconds(min(3600, 60 * (2 ** min($row->failures, 6))));
|
||||
}
|
||||
DB::table('replenishment_work')->where('id', $id)->update($fields);
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{processed:int,failed:int} */
|
||||
public function drain(int $limit): array
|
||||
{
|
||||
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
||||
$result = ['processed' => 0, 'failed' => 0];
|
||||
foreach (DB::table('replenishment_work')->where('due_at', '<=', now())->orderBy('due_at')->orderBy('id')->limit($limit)->pluck('id') as $id) {
|
||||
$result[$this->scheduled($id) ? 'processed' : 'failed']++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** A narrow CLI execution scope, never a session, account or general authority bypass. */
|
||||
final class ReplenishmentRun
|
||||
{
|
||||
private ?int $tenant = null;
|
||||
|
||||
private ?int $work = null;
|
||||
|
||||
private ?int $responsible = null;
|
||||
|
||||
public function tenantId(): ?int
|
||||
{
|
||||
return $this->tenant;
|
||||
}
|
||||
|
||||
public function lockResponsible(User $user): User
|
||||
{
|
||||
abort_unless($this->tenant !== null && $user->tenant_id === $this->tenant && DB::transactionLevel() > 0 && ! auth()->check(), 403);
|
||||
// A stock writer may have updated the signal while this worker waited for the company lock.
|
||||
$responsible = DB::table('replenishment_work')->where('tenant_id', $this->tenant)->where('id', $this->work)->value('responsible_id');
|
||||
abort_unless($user->id === $responsible, 403);
|
||||
$this->responsible = $responsible;
|
||||
|
||||
return User::query()->where('tenant_id', $this->tenant)->whereKey($user->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function evidence(): array
|
||||
{
|
||||
return ['mode' => 'scheduled_replenishment', 'work_id' => $this->work, 'tenant_id' => $this->tenant, 'responsible_user_id' => $this->responsible, 'attribution' => 'responsible_for_trigger_or_rule; not_an_interactive_action'];
|
||||
}
|
||||
|
||||
public function execute(int $id, callable $callback): mixed
|
||||
{
|
||||
abort_unless(config('work_context.composition') === 'sgc' && app()->runningInConsole() && ! auth()->check() && $this->tenant === null && (! request()->hasSession()), 403);
|
||||
$work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
||||
$this->tenant = $work->tenant_id;
|
||||
$this->work = $work->id;
|
||||
$this->responsible = $work->responsible_id;
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->tenant = null;
|
||||
$this->work = null;
|
||||
$this->responsible = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\RequisitionItem;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -37,8 +38,11 @@ class RequisitionCatalogue
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
abort_unless($this->canManage($actor), 403);
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'stock_basis' => 'required|string|size:64', 'category' => 'required|string|max:100',
|
||||
'purchase_type' => 'required|in:consumable,other', 'published' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
'purchase_type' => 'required|in:consumable,other,spare_part', 'published' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
$stock = StockItem::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($stockId);
|
||||
if ($data['purchase_type'] === 'spare_part' && ! SparePart::query()->where('stock_item_id', $stock->id)->exists()) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'Registe a peça e os equipamentos compatíveis antes de a publicar como peça.']);
|
||||
}
|
||||
$entry = DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('stock_item_id', $stock->id)->lockForUpdate()->first();
|
||||
if ((int) $data['version'] !== (int) ($entry->version ?? 0) || $data['stock_basis'] !== $this->basis($stock)) {
|
||||
throw ValidationException::withMessages(['version' => 'O artigo ou a publicação mudou. Actualize e confira antes de guardar.']);
|
||||
@@ -119,6 +123,10 @@ class RequisitionCatalogue
|
||||
$snapshot = ['entry_id' => $entry->id, 'version' => $entry->version, ...$this->sourceDescriptor($entry)];
|
||||
}
|
||||
|
||||
if ($snapshot['purchase_type'] === 'spare_part' && ! SparePart::query()->whereKey($line['spare_part_id'] ?? 0)->where('stock_item_id', $snapshot['stock_item_id'])->exists()) {
|
||||
throw ValidationException::withMessages(['items' => 'A peça seleccionada não corresponde ao artigo publicado no catálogo.']);
|
||||
}
|
||||
|
||||
return [...$line, 'catalogue_entry_id' => $snapshot['entry_id'], 'catalogue_snapshot' => $snapshot,
|
||||
'description' => $snapshot['description'], 'unit' => $snapshot['unit'], 'category' => $snapshot['category'], 'purchase_type' => $snapshot['purchase_type']];
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ class RequisitionWorkflow
|
||||
unset($data['operation_key'], $data['process_version']);
|
||||
$result = $this->execute($user, 'save', $existing?->id, $version, $key, $data);
|
||||
|
||||
return Requisition::query()->whereKey($result['id'])->firstOrFail();
|
||||
$record = Requisition::query()->whereKey($result['id'])->firstOrFail();
|
||||
if ($existing !== null) {
|
||||
app(AutomaticPurchases::class)->requisitionChanged($user, $record);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +56,7 @@ class RequisitionWorkflow
|
||||
{
|
||||
return DB::transaction(function () use ($user, $data, $existing, $automaticRule) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user);
|
||||
$user = $automaticRule === null ? app(CurrentActor::class)->lock($user) : app(AutomaticPurchases::class)->lockExecutor($user);
|
||||
$executor = $user;
|
||||
if ($automaticRule !== null) {
|
||||
abort_unless($existing === null, 403);
|
||||
@@ -116,23 +121,27 @@ class RequisitionWorkflow
|
||||
});
|
||||
}
|
||||
|
||||
/** Only an independently activated rule may create a draft on behalf of its designated requester. */
|
||||
/** Only an independently activated rule may create a draft on behalf of its designated requester.
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public function automaticDraft(User $executor, SupplyRecord $rule, array $data): Requisition
|
||||
{
|
||||
$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']);
|
||||
return Requisition::query()->whereKey($result['id'])->firstOrFail();
|
||||
}
|
||||
|
||||
public function submit(User $user, Requisition $requisition, ?string $operationKey = null, ?int $version = null): void
|
||||
{
|
||||
$this->execute($user, 'submit', $requisition->id, $version, $operationKey);
|
||||
app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
|
||||
}
|
||||
|
||||
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]);
|
||||
app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $before
|
||||
@@ -143,7 +152,7 @@ class RequisitionWorkflow
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'user_id' => $user->id,
|
||||
'requisition_id' => $requisition?->id, 'action' => $action,
|
||||
'ip_address' => request()->ip(), 'before' => $before, 'after' => $after,
|
||||
'ip_address' => app(ReplenishmentRun::class)->tenantId() === null ? request()->ip() : null, 'before' => $before, 'after' => $after + (app(ReplenishmentRun::class)->tenantId() === null ? [] : ['execution' => app(ReplenishmentRun::class)->evidence()]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class StockIssueMovement
|
||||
{
|
||||
public function for(User $actor): \Tempo\Inventory\IssueMovementWriter
|
||||
{
|
||||
$root=base_path('packages/inventory-issue/releases/0.1.0');
|
||||
if(!is_file($root.'/manifest.json')||!hash_equals('3c24d9e9fb99bfbb274077495c3a42ddb7eccab06b5c6a956cd739b2bc9b56f0',hash_file('sha256',$root.'/manifest.json')))throw new \LogicException('Pacote de saída incompatível.');
|
||||
$m=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
foreach(['bootstrap.php','src/IssueMovementWriter.php'] as $f)if(!is_file($root.'/'.$f)||!hash_equals($m['files'][$f]??'',hash_file('sha256',$root.'/'.$f)))throw new \LogicException('Pacote de saída alterado.');
|
||||
require_once $root.'/bootstrap.php';
|
||||
$schema=match(DB::connection()->getDriverName()){'pgsql'=>'public','sqlite'=>'main','mysql','mariadb'=>DB::connection()->getDatabaseName(),default=>throw new \LogicException('Driver não suportado.')};
|
||||
return new \Tempo\Inventory\IssueMovementWriter(DB::connection(),$schema,(int)$actor->tenant_id,(int)$actor->id,function()use($actor){app(CurrentActor::class)->lock($actor,[PurchasingRole::Warehouse]);});
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,10 @@ class StockWorkflow
|
||||
|
||||
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): StockMovement
|
||||
{
|
||||
if($type==='issue'&&$operation->kind==='issue'){
|
||||
$writer=app(StockIssueMovement::class)->for($actor);$result=$writer->write($operation->id,$writer->preview($operation->id));
|
||||
return StockMovement::query()->findOrFail($result['movement_id']);
|
||||
}
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,24 @@ class SupplyPlanning
|
||||
return $actor->hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
}
|
||||
|
||||
public function independentReviewer(User $actor, SupplyRecord $record): bool
|
||||
{
|
||||
return $actor->tenant_id === $record->tenant_id && $this->allows($actor, 'supply_review')
|
||||
&& ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true);
|
||||
}
|
||||
|
||||
/** Eligibility to confer, not a promise that changed sources permit approval. */
|
||||
public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool
|
||||
{
|
||||
if (! in_array($record->kind, ['agreement', 'policy'], true) || ! in_array($action, ['approve', 'return', 'suspend'], true)
|
||||
|| ! $this->independentReviewer($actor, $record)) {
|
||||
return false;
|
||||
}
|
||||
$latest = $this->latest($record->kind)->where('record_key', $record->record_key)->first();
|
||||
|
||||
return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
|
||||
}
|
||||
|
||||
/** @return Builder<SupplyRecord> */
|
||||
public function latest(string $kind)
|
||||
{
|
||||
@@ -115,7 +133,7 @@ class SupplyPlanning
|
||||
$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($this->independentReviewer($actor, $old), '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 {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** Pull boundary over committed facts; never posts another stock movement. */
|
||||
class WarehouseExchange
|
||||
{
|
||||
public const SCHEMA = 'sgc.warehouse.fulfilment-feed.v1';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function page(User $actor, ?string $cursor = null, int $limit = 50): array
|
||||
{
|
||||
abort_unless(auth('web')->id() === $actor->id && $actor->tenant_id !== null
|
||||
&& $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
|
||||
$contexts = app(WorkContext::class);
|
||||
$enforced = $contexts->enforced($actor);
|
||||
abort_unless(! $enforced || $contexts->active(request()) === 'procurement.internal', 403);
|
||||
abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Instalação das entregas pendente.');
|
||||
abort_unless($limit >= 1 && $limit <= 100, 422);
|
||||
$permissions = app(OrganizationAccess::class)->permissions($actor);
|
||||
sort($permissions);
|
||||
$permissionFingerprint = hash('sha256', json_encode($permissions, JSON_THROW_ON_ERROR));
|
||||
$scope = [self::SCHEMA, $actor->tenant_id, $actor->id, $enforced ? 'procurement.internal' : 'legacy', $actor->department_id, $permissionFingerprint];
|
||||
$after = 0;
|
||||
if ($cursor !== null) {
|
||||
try {
|
||||
$decoded = json_decode(Crypt::decryptString($cursor), true, 8, JSON_THROW_ON_ERROR);
|
||||
} catch (DecryptException|\JsonException $e) {
|
||||
throw ValidationException::withMessages(['cursor' => 'Cursor inválido. Reinicie a leitura.']);
|
||||
}
|
||||
if (! is_array($decoded) || ($decoded['scope'] ?? null) !== $scope
|
||||
|| ! is_int($decoded['after'] ?? null) || $decoded['after'] < 0) {
|
||||
throw ValidationException::withMessages(['cursor' => 'O cursor pertence a outro âmbito. Reinicie a leitura.']);
|
||||
}
|
||||
$after = $decoded['after'];
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $enforced, $scope, $after, $limit) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$requests = app(WarehouseWorkdesk::class)->visible($actor)->where('tenant_id', $actor->tenant_id);
|
||||
$requests->where(fn ($q) => $q->where('work_context_key', 'procurement.internal')
|
||||
->when(! $enforced, fn ($q) => $q->orWhereNull('work_context_key')));
|
||||
$events = WarehouseRequestEvent::query()->where('tenant_id', $actor->tenant_id)
|
||||
->whereIn('warehouse_request_id', (clone $requests)->select('id'))
|
||||
->whereIn('action', ['deliver', 'receive'])->where('id', '>', $after)->orderBy('id')->limit($limit + 1)->get();
|
||||
$more = $events->count() > $limit;
|
||||
$events = $events->take($limit);
|
||||
$sources = $requests->whereIn('id', $events->pluck('warehouse_request_id'))->get()->keyBy('id');
|
||||
$items = [];
|
||||
foreach ($events as $event) {
|
||||
$source = $sources->get($event->getAttribute('warehouse_request_id'));
|
||||
abort_unless($source instanceof WarehouseRequest, 409, 'Origem da entrega indisponível.');
|
||||
$items[] = $this->envelope($source, $event);
|
||||
}
|
||||
|
||||
return ['schema' => self::SCHEMA, 'items' => $items, 'has_more' => $more,
|
||||
'next_cursor' => Crypt::encryptString(json_encode(['scope' => $scope, 'after' => $events->max('id') ?? $after], JSON_THROW_ON_ERROR))];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function envelope(WarehouseRequest $request, WarehouseRequestEvent $event): array
|
||||
{
|
||||
$key = 'sgc:'.$request->tenant_id.':warehouse-event:'.$event->id;
|
||||
$snapshot = $request->snapshot;
|
||||
$lines = [];
|
||||
if ($event->getAttribute('action') === 'deliver') {
|
||||
foreach ($event->data['lines'] as $line) {
|
||||
$source = $snapshot['lines'][$line['index']];
|
||||
$lines[] = ['request_line_index' => $line['index'], 'item' => $source['item'],
|
||||
'quantity_milli' => $line['quantity_milli'], 'use' => $source['use'],
|
||||
'destination' => $source['destination'], 'equipment' => $source['equipment'],
|
||||
'stock_operation_id' => $line['stock_operation_id']];
|
||||
}
|
||||
}
|
||||
$payload = ['schema' => 'sgc.warehouse.fulfilment-event.v1', 'idempotency_key' => $key,
|
||||
'tenant_id' => $request->tenant_id, 'event_id' => $event->id,
|
||||
'event_version' => $event->getAttribute('version'), 'action' => $event->getAttribute('action'),
|
||||
'occurred_at' => CarbonImmutable::parse($event->getAttribute('created_at'))->utc()->toIso8601String(), 'actor' => $event->data['actor'],
|
||||
'request' => ['id' => $request->id, 'reference' => 'RA-'.$request->id, 'document_type' => 'internal_warehouse_request',
|
||||
'work_context_key' => $request->getAttribute('work_context_key'),
|
||||
'requester' => $snapshot['requester'], 'warehouse' => $snapshot['warehouse'],
|
||||
'cost_center' => $snapshot['cost_center'], 'destination' => $snapshot['destination']],
|
||||
'lines' => $lines, 'delivery_event_key' => $event->getAttribute('action') === 'receive'
|
||||
? 'sgc:'.$request->tenant_id.':warehouse-event:'.$event->data['delivery_event_id'] : null];
|
||||
|
||||
return [...$payload, 'payload_sha256' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))];
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ class WarehouseFulfilment
|
||||
$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']);
|
||||
app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $data['partial_allowed'] ?? false, $data['operation_key']);
|
||||
}
|
||||
if ($action === 'reserve') {
|
||||
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'authorize')->firstOrFail();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** A diagnostic, never an activation, grant, business command or production certificate. */
|
||||
class WarehousePilotReadiness
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function inspect(User $actor): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && $actor->hasFunctions(['admin']), 403);
|
||||
$users = User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']);
|
||||
$centers = CostCenter::query()->get(['id', 'department_id']);
|
||||
$accessReady = Schema::hasTable('organization_memberships') && Schema::hasTable('organization_profiles');
|
||||
$groups = ['prepare' => [], 'review' => [], 'deliver' => [], 'purchase_requester' => [], 'supply_prepare' => [], 'supply_review' => []];
|
||||
$permanentAdmins = 0;
|
||||
foreach ($users as $user) {
|
||||
$grants = $accessReady ? DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $actor->tenant_id)->where('p.tenant_id', $actor->tenant_id)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions') : collect();
|
||||
if ($grants->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
|
||||
$permanentAdmins++;
|
||||
}
|
||||
if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
|
||||
continue;
|
||||
}
|
||||
$context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
|
||||
if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
|
||||
continue;
|
||||
}
|
||||
$permissions = app(OrganizationAccess::class)->permissions($user, $context);
|
||||
$person = $user->only(['id', 'name', 'department_id']) + ['has_cost_center' => $user->department_id !== null && $centers->contains('department_id', $user->department_id)];
|
||||
foreach (['prepare' => 'warehouse_request_prepare', 'review' => 'warehouse_request_review', 'purchase_requester' => 'requester', 'supply_prepare' => 'supply_prepare', 'supply_review' => 'supply_review'] as $key => $permission) {
|
||||
if (in_array($permission, $permissions, true)) {
|
||||
$groups[$key][] = $person;
|
||||
}
|
||||
}
|
||||
if (in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
|
||||
$groups['deliver'][] = $person;
|
||||
}
|
||||
}
|
||||
$independent = false;
|
||||
$deliveryIds = array_fill_keys(array_column($groups['deliver'], 'id'), true);
|
||||
foreach ($groups['prepare'] as $requester) {
|
||||
foreach ($groups['review'] as $reviewer) {
|
||||
if ($requester['id'] === $reviewer['id'] || $requester['department_id'] === null || $requester['department_id'] !== $reviewer['department_id'] || ! $requester['has_cost_center']) {
|
||||
continue;
|
||||
}
|
||||
if (count($deliveryIds) > (isset($deliveryIds[$requester['id']]) ? 1 : 0) + (isset($deliveryIds[$reviewer['id']]) ? 1 : 0)) {
|
||||
$independent = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$tables = [];
|
||||
foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue'] as $table) {
|
||||
$tables[$table] = Schema::hasTable($table);
|
||||
}
|
||||
$warehouses = Warehouse::query()->count();
|
||||
$items = StockItem::query()->get(['id', 'code', 'name', 'unit'])->keyBy('id');
|
||||
$published = 0;
|
||||
$stale = 0;
|
||||
if ($tables['requisition_catalogue']) {
|
||||
foreach (DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->get(['stock_item_id', 'source_snapshot']) as $entry) {
|
||||
$item = $items->get($entry->stock_item_id);
|
||||
if ($item !== null && app(RequisitionCatalogue::class)->source($item) === json_decode($entry->source_snapshot, true, 32, JSON_THROW_ON_ERROR)) {
|
||||
$published++;
|
||||
} else {
|
||||
$stale++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$planning = app(SupplyPlanning::class);
|
||||
$policies = $tables['supply_records'] ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->count() : 0;
|
||||
$validRules = 0;
|
||||
$invalidRules = 0;
|
||||
if (! in_array(false, $tables, true)) {
|
||||
foreach ($planning->latest('auto_rule')->where('state', 'approved')->cursor() as $rule) {
|
||||
$status = app(AutomaticPurchases::class)->inspectRule($actor, $rule);
|
||||
if ($status['valid']) {
|
||||
$validRules++;
|
||||
} else {
|
||||
$invalidRules++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$runtime = app(ReplenishmentQueue::class)->status($actor);
|
||||
$pending = (int) ($runtime['work']->pending ?? 0);
|
||||
$failed = (int) ($runtime['work']->failed ?? 0);
|
||||
$recoveryClear = $runtime['recoveryReady'] && $pending === 0 && $failed === 0;
|
||||
$active = app(OrganizationAccess::class)->active($actor->tenant_id);
|
||||
$company = Tenant::query()->findOrFail($actor->tenant_id);
|
||||
$internalConfigured = $company->work_context_policy !== null && in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$actor->tenant_id, []), true);
|
||||
$checks = [
|
||||
['key' => 'installation', 'label' => 'Instalação dos percursos', 'ok' => ! in_array(false, $tables, true), 'detail' => in_array(false, $tables, true) ? 'Existem componentes por instalar.' : 'Requisições, reservas, catálogo e recuperação durável instalados.', 'path' => null],
|
||||
['key' => 'participants', 'label' => 'Tarefas e independência no armazém', 'ok' => $independent, 'detail' => $independent ? 'Há combinação elegível de requisitante, autorizador e entregador distintos, com centro de custo do departamento.' : 'Faltam tarefas, centro de custo ou uma combinação de três intervenientes independentes. Não são atribuídas competências nesta consulta.', 'path' => '/administracao/parametrizacao'],
|
||||
['key' => 'catalogue', 'label' => 'Artigos e armazéns', 'ok' => $warehouses > 0 && $published > 0, 'detail' => $warehouses.' armazém(ns), '.$published.' artigo(s) publicado(s) com origem actual e '.$stale.' publicação(ões) a rever.', 'path' => '/stock/catalogo'],
|
||||
['key' => 'replenishment', 'label' => 'Reposição parametrizada e válida', 'ok' => $validRules > 0, 'detail' => $policies.' política(s) interna(s) aprovada(s), '.$validRules.' regra(s) activa(s) válida(s) e '.$invalidRules.' a rever. Falta de stock não é, por si, erro de configuração.', 'path' => '/stock/reposicao'],
|
||||
['key' => 'scheduler', 'label' => 'Verificação periódica observada', 'ok' => $runtime['recoveryReady'] && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed', 'detail' => $runtime['schedulerRecent'] ? 'Há uma passagem recente conhecida do serviço.' : 'Sem passagem recente confirmada nesta interface; instalação não prova execução.', 'path' => '/stock/reposicao'],
|
||||
['key' => 'administration', 'label' => 'Configuração própria da empresa', 'ok' => $active && $permanentAdmins >= 2 && $internalConfigured, 'detail' => $active ? 'Perfis próprios activos; '.$permanentAdmins.' administrador(es) permanente(s). Conferir também o contexto interno contratado/activado.' : 'Acesso ainda em compatibilidade com funções de desenvolvimento. Isto não é configuração de cliente concluída.', 'path' => '/administracao/parametrizacao'],
|
||||
['key' => 'recovery', 'label' => 'Trabalhos técnicos de reposição', 'ok' => $recoveryClear, 'detail' => $pending.' sinal(is) por processar e '.$failed.' trabalho(s) com recuperação pendente. Necessidades funcionais sem configuração válida continuam visíveis na reposição.', 'path' => '/stock/reposicao'],
|
||||
];
|
||||
|
||||
return ['checked_at' => now()->toIso8601String(), 'checks' => $checks, 'participants' => $groups, 'registered' => $users->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->all(), 'tables' => $tables, 'runtime' => $runtime,
|
||||
'warehouse_scenario_ready' => ! in_array(false, $tables, true) && $independent && $warehouses > 0 && $published > 0,
|
||||
'replenishment_scenario_ready' => ! in_array(false, $tables, true) && $recoveryClear && $validRules > 0 && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed',
|
||||
'client_configuration_complete' => $active && $permanentAdmins >= 2 && $internalConfigured];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class WarehouseReservations
|
||||
|
||||
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 $this->ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id ?? app(ReplenishmentRun::class)->tenantId())->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0;
|
||||
}
|
||||
|
||||
/** @return array<int,int> */
|
||||
@@ -114,10 +114,10 @@ class WarehouseReservations
|
||||
/** 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) {
|
||||
if (! $this->ready() || $this->committed($item, $warehouse) === 0) {
|
||||
return;
|
||||
}
|
||||
if ($this->free($item,$warehouse) < min(0,$before)) {
|
||||
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,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Inventory\DeliveryReturnWriter;
|
||||
|
||||
final class WarehouseReturnMovement
|
||||
{
|
||||
public const MANIFEST_SHA256='2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c';
|
||||
private function fileHash(string $file): string
|
||||
{
|
||||
$hash = is_file($file) ? hash_file('sha256', $file) : false;
|
||||
abort_unless(is_string($hash), 503, 'Ficheiro do pacote de devolução indisponível.');
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
public function for(User $actor): DeliveryReturnWriter
|
||||
{
|
||||
$root = base_path('packages/inventory-return/releases/0.1.0');
|
||||
if (! hash_equals(self::MANIFEST_SHA256, $this->fileHash($root.'/manifest.json'))) {
|
||||
abort(503, 'Pacote de devolução incompatível.');
|
||||
}
|
||||
$raw = file_get_contents($root.'/manifest.json');
|
||||
if ($raw === false) {
|
||||
abort(503, 'Manifesto de devolução indisponível.');
|
||||
}
|
||||
$manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach (['bootstrap.php', 'src/DeliveryReturnWriter.php'] as $file) {
|
||||
if (! hash_equals($manifest['files'][$file] ?? '', $this->fileHash($root.'/'.$file))) {
|
||||
abort(503, 'Pacote de devolução alterado.');
|
||||
}
|
||||
}
|
||||
require_once $root.'/bootstrap.php';
|
||||
|
||||
return new DeliveryReturnWriter(DB::connection(), 'public', (int) $actor->tenant_id, (int) $actor->id, function () use ($actor) {
|
||||
app(WarehouseReturns::class)->authorize($actor, 'confirm');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
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 App\Models\WarehouseRequest;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Native internal issue -> unused material declaration -> independently received physical return. */
|
||||
final class WarehouseReturns
|
||||
{
|
||||
public function allows(User $actor, string $action): bool
|
||||
{
|
||||
$access = app(OrganizationAccess::class);
|
||||
$scope = app(WorkContext::class)->enforced($actor) ? $this->context() : null;
|
||||
$permissions = $access->permissions($actor, $scope);
|
||||
|
||||
return ! $access->maintenanceOnly($actor) && in_array('warehouse_return_'.$action, $permissions, true) && ($action !== 'confirm' || in_array('warehouse', $permissions, true));
|
||||
}
|
||||
|
||||
public function authorize(User $actor, string $action): User
|
||||
{
|
||||
abort_unless(config('stock_returns.enabled'), 503, 'Devoluções de excedentes ainda não activadas.');
|
||||
abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Percurso de devoluções ainda não validado neste driver.');
|
||||
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
|
||||
$fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
|
||||
abort_unless($this->allows($fresh, $action), 403);
|
||||
abort_unless(Schema::hasTable('supply_records'), 503, 'Histórico administrativo ainda não instalado.');
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
/** @param array<array-key,mixed> $v */
|
||||
private function hash(array $v): string
|
||||
{
|
||||
return hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function lock(User $actor, string $action): User
|
||||
{
|
||||
$timeout=config('stock_returns.lock_timeout_ms',2000);
|
||||
abort_unless(is_int($timeout)&&$timeout>=100&&$timeout<=10000,503,'Tempo de espera de conferência inválido.');
|
||||
DB::select("SELECT set_config('lock_timeout',?,true)",[$timeout.'ms']);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$fresh = app(CurrentActor::class)->lock($actor);
|
||||
|
||||
return $this->authorize($fresh, $action);
|
||||
}
|
||||
|
||||
private function context(): string
|
||||
{
|
||||
$actor = auth()->user();
|
||||
abort_unless($actor instanceof User, 403);
|
||||
if (! app(WorkContext::class)->enforced($actor)) {
|
||||
return 'procurement.internal';
|
||||
}
|
||||
abort_unless(request()->hasSession(), 403);
|
||||
$key = app(WorkContext::class)->active(request());
|
||||
abort_unless($key === 'procurement.internal', 403);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function request(int $id): WarehouseRequest
|
||||
{
|
||||
$r = WarehouseRequest::query()->findOrFail($id);
|
||||
abort_unless(($r->work_context_key ?? 'procurement.internal') === $this->context(), 403);
|
||||
|
||||
return $r;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function availableDeliveries(User $actor, int $requestId, int $page): array
|
||||
{
|
||||
$this->authorize($actor, 'read');
|
||||
$r = $this->request($requestId);
|
||||
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $r), 403);
|
||||
$events = WarehouseRequestEvent::query()->where('warehouse_request_id', $r->id)->where('action', 'deliver')->whereExists(function ($q) use ($actor, $r) {
|
||||
$q->selectRaw('1')->from('warehouse_request_events AS ack')->where('ack.tenant_id', $actor->tenant_id)->where('ack.warehouse_request_id', $r->id)->where('ack.action', 'receive')->whereRaw("(ack.data->>'delivery_event_id')::bigint=warehouse_request_events.id");
|
||||
})->orderByDesc('id')->paginate(25, ['id', 'data'], 'source_page', $page);
|
||||
$options = [];
|
||||
foreach ($events as $event) {
|
||||
foreach ($event->data['lines'] as $line) {
|
||||
$basis = $r->snapshot['lines'][$line['index']] ?? null;
|
||||
if ($basis && $basis['use'] === 'internal' && $basis['equipment'] === null) {
|
||||
$options[] = ['delivery_event_id' => $event->id, 'line_index' => $line['index'], 'item' => $basis['item'], 'delivered_milli' => (string) $line['quantity_milli']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['request_id' => $r->id, 'can_prepare' => $r->created_by === $actor->id && $this->allows($actor, 'prepare'), 'options' => $options, 'pagination' => ['page' => $events->currentPage(), 'last_page' => $events->lastPage(), 'total' => $events->total()]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $lines
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function deliveryLine(array $lines, int $index): ?array
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
if ($line['index'] === $index) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function source(User $actor, array $d): array
|
||||
{
|
||||
$event = WarehouseRequestEvent::query()->where('action', 'deliver')->whereKey($d['delivery_event_id'])->lockForUpdate()->firstOrFail();
|
||||
$request = $this->request($event->warehouse_request_id);
|
||||
$line = $this->deliveryLine($event->data['lines'], $d['line_index']);
|
||||
$basis = $request->snapshot['lines'][$d['line_index']] ?? null;
|
||||
abort_unless($line && $basis && $basis['use'] === 'internal' && $basis['equipment'] === null, 409, 'Apenas excedente de entrega interna simples, sem equipamento ou venda.');
|
||||
$acknowledgement = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'receive')->where('data->delivery_event_id', $event->id)->lockForUpdate()->first();
|
||||
abort_unless($acknowledgement !== null, 409, 'A entrega original ainda não foi recebida pelo destinatário.');
|
||||
$operation = DB::table('stock_operations')->where(['tenant_id' => $actor->tenant_id, 'id' => $line['stock_operation_id'], 'kind' => 'issue'])->lockForUpdate()->first();
|
||||
abort_unless($operation && (int) $operation->stock_item_id === $basis['item']['id'] && (int) $operation->warehouse_id === $request->warehouse_id && $operation->equipment_id === null && $operation->spare_part_id === null, 409, 'Entrega incompatível com o documento interno.');
|
||||
$movement = DB::table('stock_movements')->where(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'movement_type' => 'issue'])->lockForUpdate()->first();
|
||||
abort_unless($movement && (int) $movement->quantity_milli === -(int) $line['quantity_milli'], 409, 'Quantidade da entrega incompatível.');
|
||||
$snapshot = json_decode($movement->snapshot, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(($snapshot['inventory_operation_hash'] ?? null) === $this->hash((array) $operation), 409, 'Entrega sem fotografia original íntegra.');
|
||||
abort_if(SupplyRecord::query()->where('kind', 'stock_reversal')->where('state', 'executed')->where('data->source_kind', 'operation')->where('data->source_operation_id', $operation->id)->exists(), 409, 'Entrega estornada; exige reconciliação própria.');
|
||||
abort_if(app(InventoryLots::class)->setup($operation->stock_item_id, $operation->warehouse_id) !== null || app(WarehouseReservations::class)->committed($operation->stock_item_id, $operation->warehouse_id) > 0, 409, 'Lotes ou reservas exigem percurso próprio.');
|
||||
$item = StockItem::query()->whereKey($operation->stock_item_id)->firstOrFail();
|
||||
$warehouse = Warehouse::query()->whereKey($operation->warehouse_id)->firstOrFail();
|
||||
abort_unless($item->unit === $basis['item']['unit'], 409, 'Unidade do artigo mudou; exige reconciliação própria, sem conversão automática.');
|
||||
$returned = (int) StockMovement::query()->where('movement_type', 'workshop_return_in')->where('snapshot->delivery_movement_id', $movement->id)->sum('quantity_milli');
|
||||
$remaining = (int) $line['quantity_milli'] - $returned;
|
||||
abort_unless($d['quantity_milli'] > 0 && $d['quantity_milli'] <= $remaining, 409, 'Quantidade excede a entrega ainda não devolvida.');
|
||||
$book = app(StockWorkflow::class)->balance($operation->stock_item_id, $operation->warehouse_id);
|
||||
|
||||
return ['contract_version' => 'WAREHOUSE-UNUSED-RETURN-v1', 'writer_manifest_sha256' => WarehouseReturnMovement::MANIFEST_SHA256, 'context_revision' => app(WorkContext::class)->policy($actor)['revision'] ?? null, 'request_id' => $request->id, 'requester_id' => $request->created_by, 'work_context_key' => $request->work_context_key ?? 'procurement.internal', 'delivery' => (array) $event->getAttributes(), 'acknowledgement' => (array) $acknowledgement->getAttributes(), 'line_index' => $d['line_index'], 'operation' => (array) $operation, 'movement' => (array) $movement, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'original_item' => $basis['item'], 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'quantity_milli' => $d['quantity_milli'], 'remaining_milli' => $remaining, 'book' => $book];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $s
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function impact(array $s): array
|
||||
{
|
||||
return ['physical_before' => (string) $s['book']['quantity'], 'physical_after' => (string) ($s['book']['quantity'] + $s['quantity_milli']), 'return_quantity_milli' => (string) $s['quantity_milli'], 'not_returned_before' => (string) $s['remaining_milli'], 'not_returned_after' => (string) ($s['remaining_milli'] - $s['quantity_milli']), 'original_movement_id' => (int) $s['movement']['id'], 'item' => $s['item'], 'warehouse' => $s['warehouse']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function prepare(User $actor, array $d): array
|
||||
{
|
||||
abort_unless(($d['declared_unused'] ?? false) === true, 422, 'Declare expressamente que o material não foi consumido.');
|
||||
$this->authorize($actor, 'prepare');
|
||||
|
||||
return DB::transaction(function () use ($actor, $d) {
|
||||
$actor = $this->lock($actor, 'prepare');
|
||||
$hash = $this->hash($d);
|
||||
$old = SupplyRecord::query()->where('kind', 'warehouse_return')->where('operation_key', $d['operation_key'])->first();
|
||||
if ($old) {
|
||||
abort_unless($old->created_by === $actor->id && hash_equals($old->input_hash, $hash), 409, 'Chave utilizada com outros dados.');
|
||||
$this->request($old->snapshot['source']['request_id']);
|
||||
|
||||
$state=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$old->record_key)->orderByDesc('version')->value('state');
|
||||
return ['id' => $old->id, 'state'=>$state, 'impact' => $this->impact($old->snapshot['source']), 'replayed' => true];
|
||||
}
|
||||
$source = $this->source($actor, $d);
|
||||
abort_unless($source['requester_id'] === $actor->id, 403, 'A declaração de excedente pertence ao destinatário original.');
|
||||
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $d['operation_key'], 'version' => 1, 'state' => 'prepared', 'stock_item_id' => $source['item']['id'], 'warehouse_id' => $source['warehouse']['id'], 'created_by' => $actor->id, 'data' => $d, 'snapshot' => ['source' => $source, 'source_hash' => $this->hash($source), 'declared_unused' => true], 'reason' => $d['reason'], 'operation_key' => $d['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.prepared', null, null, ['record_id' => $record->id, 'request_id' => $source['request_id'], 'impact' => $this->impact($source), 'declared_unused' => true]);
|
||||
|
||||
return ['id' => $record->id, 'state'=>'prepared', 'impact' => $this->impact($source), 'replayed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function confirm(User $actor, int $id, array $d): array
|
||||
{
|
||||
$this->authorize($actor, 'confirm');
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $d) {
|
||||
$actor = $this->lock($actor, 'confirm');
|
||||
$p = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->lockForUpdate()->findOrFail($id);
|
||||
$this->request($p->snapshot['source']['request_id']);
|
||||
abort_if($p->created_by === $actor->id, 403, 'Recepção deve ser independente da declaração.');
|
||||
$done = SupplyRecord::query()->where('kind', 'warehouse_return')->where('record_key', $p->record_key)->where('version', 2)->first();
|
||||
if ($done) {
|
||||
abort_unless($done->state==='confirmed',409,'Proposta encerrada sem movimento. Prepare uma nova proposta se necessário.');
|
||||
return ['id' => $done->id, 'movement_id' => $done->data['movement_id'], 'replayed' => true];
|
||||
}
|
||||
abort_unless($d['version'] === 1 && $d['received_in_warehouse'] === true, 422, 'Confirme a recepção física da versão conferida.');
|
||||
$source = $this->source($actor, $p->data);
|
||||
abort_unless(hash_equals($p->snapshot['source_hash'], $this->hash($source)), 409, 'A origem, configuração ou livro mudou. Prepare nova conferência.');
|
||||
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'warehouse_id' => $source['warehouse']['id'], 'stock_item_id' => $source['item']['id'], 'kind' => 'workshop_return', 'reference' => 'EXCEDENTE-'.$p->record_key, 'idempotency_key' => $p->record_key, 'payload_hash' => $p->snapshot['source_hash'], 'destination' => 'Devolução interna RA-'.$source['request_id'], 'reason' => $d['reason'], 'quantity_milli' => $source['quantity_milli'], 'theoretical_milli' => $source['book']['quantity'], 'ledger_version' => $source['book']['version'], 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'], 'warehouse' => $source['warehouse'], 'destination_warehouse' => null, 'equipment' => null, 'spare_part' => null, 'difference_milli' => $source['quantity_milli'], 'budget_checked' => false, 'delivery_movement_id' => (int) $source['movement']['id'], 'delivery_movement_hash' => $this->hash($source['movement']), 'received_in_warehouse' => true, 'native_request_id' => $source['request_id'], 'declaration_id' => $p->id, 'declared_unused_by' => $p->created_by, 'evidence' => $p->data['evidence']], 'recorded_at' => now()]);
|
||||
$writer = app(WarehouseReturnMovement::class)->for($actor);
|
||||
$out = $writer->write($operation->id, $writer->preview($operation->id));
|
||||
$done = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $p->record_key, 'version' => 2, 'state' => 'confirmed', 'stock_item_id' => $p->stock_item_id, 'warehouse_id' => $p->warehouse_id, 'created_by' => $actor->id, 'data' => ['movement_id' => $out['movement_id'], 'operation_id' => $operation->id, 'prepared_id' => $p->id, 'received_in_warehouse' => true, 'reason' => $d['reason']], 'snapshot' => ['source' => $source, 'before' => $source['book']['quantity'], 'after' => $source['book']['quantity'] + $source['quantity_milli'], 'preparer_id' => $p->created_by], 'reason' => $d['reason'], 'operation_key' => (string) Str::uuid(), 'input_hash' => $this->hash($d), 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.confirmed', null, null, ['record_id' => $done->id, 'declaration_id' => $p->id, 'movement_id' => $out['movement_id'], 'impact' => $this->impact($source), 'reason' => $d['reason']]);
|
||||
// Reuse the existing configured replenishment queue; replay never signals again.
|
||||
app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $source['warehouse']['id']);
|
||||
|
||||
return ['id' => $done->id, 'movement_id' => $out['movement_id'], 'replayed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function close(User $actor,int $id,array $d,string $state): array
|
||||
{
|
||||
abort_unless(in_array($state,['withdrawn','rejected'],true),422);
|
||||
$action=$state==='withdrawn'?'prepare':'confirm';$this->authorize($actor,$action);
|
||||
return DB::transaction(function()use($actor,$id,$d,$state,$action){
|
||||
$actor=$this->lock($actor,$action);$p=SupplyRecord::query()->where('kind','warehouse_return')->where('version',1)->lockForUpdate()->findOrFail($id);$this->request($p->snapshot['source']['request_id']);
|
||||
abort_unless($state==='withdrawn'?$p->created_by===$actor->id:$p->created_by!==$actor->id,403,'Retirada pertence ao preparador; recusa exige conferência independente.');
|
||||
$done=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$p->record_key)->where('version',2)->first();
|
||||
if($done){abort_unless($done->state===$state,409,'A proposta já tem outra decisão terminal.');return ['id'=>$done->id,'state'=>$done->state,'replayed'=>true];}
|
||||
abort_unless($d['version']===1,409,'Versão de proposta desactualizada.');$book=app(StockWorkflow::class)->balance($p->stock_item_id,$p->warehouse_id);
|
||||
$closed=SupplyRecord::query()->create(['tenant_id'=>$actor->tenant_id,'kind'=>'warehouse_return','record_key'=>$p->record_key,'version'=>2,'state'=>$state,'stock_item_id'=>$p->stock_item_id,'warehouse_id'=>$p->warehouse_id,'created_by'=>$actor->id,'data'=>['prepared_id'=>$p->id,'action'=>$state,'reason'=>$d['reason']],'snapshot'=>['source'=>$p->snapshot['source'],'before'=>$book['quantity'],'after'=>$book['quantity'],'preparer_id'=>$p->created_by],'reason'=>$d['reason'],'operation_key'=>(string)Str::uuid(),'input_hash'=>$this->hash($d+['action'=>$state]),'created_at'=>now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor,'warehouse_return.'.$state,null,null,['record_id'=>$closed->id,'declaration_id'=>$p->id,'reason'=>$d['reason'],'before'=>$book['quantity'],'after'=>$book['quantity'],'physical_effect'=>0]);
|
||||
return ['id'=>$closed->id,'state'=>$state,'replayed'=>false];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function listing(User $actor, int $page, string $state): array
|
||||
{
|
||||
$this->authorize($actor, 'read');
|
||||
$query = app(SupplyPlanning::class)->latest('warehouse_return');
|
||||
if ($state !== 'all') {
|
||||
$query->where('state', $state);
|
||||
}
|
||||
// Legacy and explicitly selected contexts are never mixed in this new history.
|
||||
$context = $this->context();
|
||||
$query->where('snapshot->source->work_context_key', $context);
|
||||
$rows = $query->orderByDesc('id')->paginate(25, ['id', 'record_key', 'version', 'state', 'reason', 'created_by', 'created_at', 'snapshot']);
|
||||
$prepared = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->whereIn('record_key', $rows->getCollection()->pluck('record_key'))->get()->keyBy('record_key');
|
||||
$items = $rows->getCollection()->map(function ($r) use ($prepared) {
|
||||
$p = $prepared->get($r->record_key);
|
||||
|
||||
return ['id' => $r->id, 'prepared_id' => $p->id, 'version' => $r->version, 'state' => $r->state, 'reason' => $r->reason, 'evidence' => $p->data['evidence'], 'preparer_id' => $p->created_by, 'author_id' => $r->created_by, 'created_at' => $r->created_at->toIso8601String(), 'impact' => $this->impact($r->snapshot['source'])];
|
||||
});
|
||||
|
||||
return ['rows' => $items, 'pagination' => ['page' => $rows->currentPage(), 'last_page' => $rows->lastPage(), 'total' => $rows->total()]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Read-only workdesk over the existing request, event and reservation journals. */
|
||||
class WarehouseWorkdesk
|
||||
{
|
||||
public function canView(User $actor, WarehouseRequest $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && (
|
||||
($request->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']))
|
||||
|| ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $request->snapshot['requester']['department_id'])
|
||||
|| ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))
|
||||
|| $actor->hasFunctions(['admin', 'warehouse_request_read']));
|
||||
}
|
||||
|
||||
/** @return Builder<WarehouseRequest> */
|
||||
public function visible(User $actor): Builder
|
||||
{
|
||||
$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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param literal-string $action
|
||||
* @return literal-string
|
||||
*/
|
||||
private function eventCount(string $action): string
|
||||
{
|
||||
if (! Schema::hasTable('warehouse_request_events')) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return "(SELECT COUNT(*) FROM warehouse_request_events e WHERE e.tenant_id=warehouse_requests.tenant_id AND e.warehouse_request_id=warehouse_requests.id AND e.action='$action')";
|
||||
}
|
||||
|
||||
/** SQL built solely from fixed identifiers/actions; filters use bound parameters.
|
||||
* @return literal-string
|
||||
*/
|
||||
public function stateExpression(): string
|
||||
{
|
||||
if (! Schema::hasTable('warehouse_request_events')) {
|
||||
return "'draft'";
|
||||
}
|
||||
$requested = $this->quantityExpression(false);
|
||||
$delivered = $this->quantityExpression(true);
|
||||
$deliveries = $this->eventCount('deliver');
|
||||
$receipts = $this->eventCount('receive');
|
||||
|
||||
return "CASE WHEN {$this->eventCount('cancel')}>0 THEN 'cancelled' WHEN {$this->eventCount('reject')}>0 THEN 'rejected' WHEN {$this->eventCount('authorize')}>0 THEN CASE WHEN $delivered >= $requested THEN CASE WHEN $deliveries=$receipts THEN 'closed' ELSE 'delivered' END WHEN $deliveries>0 THEN 'partial' ELSE 'authorized' END WHEN {$this->eventCount('submit')}>0 THEN 'submitted' ELSE 'draft' END";
|
||||
}
|
||||
|
||||
/** @return literal-string */
|
||||
private function quantityExpression(bool $delivery): string
|
||||
{
|
||||
$source = $delivery ? 'e.data' : 'warehouse_requests.snapshot';
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
$json = "json_array_elements($source->'lines') j(value)";
|
||||
$quantity = "CAST(j.value->>'quantity_milli' AS BIGINT)";
|
||||
} elseif (DB::getDriverName() === 'sqlite') {
|
||||
$json = "json_each(json_extract($source, '$.lines')) j";
|
||||
$quantity = "CAST(json_extract(j.value, '$.quantity_milli') AS INTEGER)";
|
||||
} else {
|
||||
throw new \LogicException('A projecção de requisições suporta PostgreSQL e SQLite.');
|
||||
}
|
||||
|
||||
return $delivery ? "(SELECT COALESCE(SUM($quantity),0) FROM warehouse_request_events e CROSS JOIN $json WHERE e.tenant_id=warehouse_requests.tenant_id AND e.warehouse_request_id=warehouse_requests.id AND e.action='deliver')" : "(SELECT COALESCE(SUM($quantity),0) FROM $json)";
|
||||
}
|
||||
|
||||
/** @param Builder<WarehouseRequest> $query
|
||||
* @return Builder<WarehouseRequest>
|
||||
*/
|
||||
public function forView(Builder $query, User $actor, string $view): Builder
|
||||
{
|
||||
if ($view === 'all') {
|
||||
return $query;
|
||||
}
|
||||
if (! Schema::hasTable('warehouse_request_events')) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
$maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
if ($maintenance) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
$state = $this->stateExpression();
|
||||
if (in_array($view, ['review', 'reserve'], true)) {
|
||||
if (! $actor->hasFunctions(['warehouse_request_review']) || $actor->department_id === null) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
|
||||
return $query->where('created_by', '!=', $actor->id)->where('snapshot->requester->department_id', $actor->department_id)->whereRaw($state.($view === 'review' ? " = 'submitted'" : " IN ('authorized','partial')"));
|
||||
}
|
||||
if ($view === 'delivery') {
|
||||
if (! $actor->hasFunctions(['warehouse_request_deliver']) || ! $actor->hasFunctions(['warehouse'])) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
|
||||
return $query->where('created_by', '!=', $actor->id)->whereRaw("$state IN ('authorized','partial')")->whereNotExists(fn ($q) => $q->selectRaw('1')->from('warehouse_request_events as decision')->whereColumn('decision.tenant_id', 'warehouse_requests.tenant_id')->whereColumn('decision.warehouse_request_id', 'warehouse_requests.id')->where('decision.action', 'authorize')->where('decision.actor_id', $actor->id));
|
||||
}
|
||||
if (! $actor->hasFunctions(['warehouse_request_prepare'])) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
|
||||
return $query->where('created_by', $actor->id)->whereRaw($this->eventCount('deliver').'>'.$this->eventCount('receive'));
|
||||
}
|
||||
|
||||
/** @param Builder<WarehouseRequest> $base
|
||||
* @return list<array{key:string,label:string,total:int}>
|
||||
*/
|
||||
public function views(Builder $base, User $actor): array
|
||||
{
|
||||
$views = ['all' => 'Todos no meu âmbito'];
|
||||
if (! app(OrganizationAccess::class)->maintenanceOnly($actor)) {
|
||||
if ($actor->hasFunctions(['warehouse_request_review'])) {
|
||||
$views += ['review' => 'Para autorizar', 'reserve' => 'Reservas a acompanhar'];
|
||||
}
|
||||
if ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse'])) {
|
||||
$views['delivery'] = 'Entregas pendentes';
|
||||
}
|
||||
if ($actor->hasFunctions(['warehouse_request_prepare'])) {
|
||||
$views['receipt'] = 'Recepções por confirmar';
|
||||
}
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($views as $key => $label) {
|
||||
$rows[] = ['key' => $key, 'label' => $label, 'total' => $this->forView(clone $base, $actor, $key)->count()];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function actions(User $actor, WarehouseRequest $r, array $p): array
|
||||
{
|
||||
$active = ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
$owner = $r->created_by === $actor->id;
|
||||
$prepare = $actor->hasFunctions(['warehouse_request_prepare']);
|
||||
$review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id'] && ! $owner;
|
||||
$open = in_array($p['state'], ['authorized', 'partial'], true);
|
||||
|
||||
return ['canSubmit' => $active && $owner && $prepare && $p['state'] === 'draft', 'canReview' => $active && $review && $p['state'] === 'submitted',
|
||||
'canDeliver' => $active && ! $owner && $p['reviewer_id'] !== $actor->id && $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']) && $open,
|
||||
'canManageReservation' => $active && $review && $open, 'canReceive' => $active && $owner && $prepare];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function health(WarehouseRequest $r, array $p): array
|
||||
{
|
||||
$rows = [];
|
||||
$needsReserve = false;
|
||||
$needsCheck = false;
|
||||
$readyLines = 0;
|
||||
$open = in_array($p['state'], ['authorized', 'partial'], true);
|
||||
foreach ($r->snapshot['lines'] as $index => $line) {
|
||||
$item = StockItem::query()->whereKey($line['item']['id'])->first();
|
||||
$sourceChanged = $item === null || $item->only(['id', 'code', 'name', 'unit']) !== $line['item'];
|
||||
if ($line['equipment'] !== null) {
|
||||
$equipment = Equipment::query()->whereKey($line['equipment']['id'])->first();
|
||||
$sourceChanged = $sourceChanged || $equipment === null || $equipment->only(['id', 'code', 'description']) !== $line['equipment'] || $equipment->department_id !== $r->snapshot['requester']['department_id'];
|
||||
}
|
||||
$part = SparePart::query()->where('stock_item_id', $line['item']['id'])->first();
|
||||
if ($part !== null && ($line['equipment'] === null || ! EquipmentSparePart::query()->where('spare_part_id', $part->id)->where('equipment_id', $line['equipment']['id'])->exists())) {
|
||||
$sourceChanged = true;
|
||||
}
|
||||
$gap = $open ? max(0, $p['remaining'][$index] - $p['reserved'][$index]) : 0;
|
||||
$issue = null;
|
||||
$ready = false;
|
||||
if ($open) {
|
||||
$lots = app(InventoryLots::class)->context($line['item']['id'], $r->warehouse_id);
|
||||
$usable = $lots === null ? app(StockWorkflow::class)->balance($line['item']['id'], $r->warehouse_id)['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null);
|
||||
$commitment = app(WarehouseReservations::class)->committed($line['item']['id'], $r->warehouse_id);
|
||||
if ($sourceChanged) {
|
||||
$issue = 'O cadastro ou destino mudou; é necessária revisão da origem.';
|
||||
} elseif ($usable === null) {
|
||||
$issue = 'Reconciliar lotes antes de actualizar a reserva ou entregar.';
|
||||
} elseif ($p['reserved'][$index] > 0 && $usable < $commitment) {
|
||||
$issue = 'O artigo tem material comprometido indisponível; conferir qualidade, validade e reservas.';
|
||||
} elseif (! $p['reservation_ready']) {
|
||||
$issue = 'Reserva física por instalar.';
|
||||
} elseif ($gap > 0) {
|
||||
$issue = 'Saldo ainda sem reserva; rever reposição e actualizar a reserva autorizadamente.';
|
||||
}
|
||||
$ready = ! $sourceChanged && $usable !== null && $usable >= $commitment && $p['reserved'][$index] > 0;
|
||||
$needsReserve = $needsReserve || $gap > 0;
|
||||
$needsCheck = $needsCheck || $sourceChanged || $usable === null || ($p['reserved'][$index] > 0 && $usable < $commitment);
|
||||
}
|
||||
if ($ready) {
|
||||
$readyLines++;
|
||||
}
|
||||
$rows[] = ['index' => $index, 'unreserved_milli' => $gap, 'attention' => $issue, 'source_changed' => $sourceChanged, 'ready' => $ready];
|
||||
}
|
||||
$unreceived = count(array_filter($p['events'], fn ($e) => $e['action'] === 'deliver' && ! in_array($e['id'], $p['received_deliveries'], true)));
|
||||
|
||||
return ['lines' => $rows, 'needs_reservation' => $needsReserve, 'needs_check' => $needsCheck, 'unconfirmed_deliveries' => $unreceived,
|
||||
'delivery_ready' => $open && ! in_array(true, array_column($rows, 'source_changed'), true) && ($p['partial_allowed'] ? $readyLines > 0 : ($readyLines === count($rows) && ! $needsReserve))];
|
||||
}
|
||||
|
||||
/** Current eligible people, not assignments or new grants.
|
||||
* @param array<string,mixed> $p
|
||||
* @return array<string,list<array{id:int,name:string}>>
|
||||
*/
|
||||
public function people(WarehouseRequest $r, array $p): array
|
||||
{
|
||||
$result = ['requester' => [], 'review' => [], 'delivery' => []];
|
||||
foreach (User::query()->where('tenant_id', $r->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']) as $user) {
|
||||
if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
|
||||
continue;
|
||||
}
|
||||
$context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
|
||||
$permissions = app(OrganizationAccess::class)->permissions($user, $context);
|
||||
if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
|
||||
continue;
|
||||
}
|
||||
$person = $user->only(['id', 'name']);
|
||||
if ($user->id === $r->created_by && in_array('warehouse_request_prepare', $permissions, true)) {
|
||||
$result['requester'][] = $person;
|
||||
}
|
||||
if ($user->id !== $r->created_by && $user->department_id === $r->snapshot['requester']['department_id'] && in_array('warehouse_request_review', $permissions, true)) {
|
||||
$result['review'][] = $person;
|
||||
}
|
||||
if ($user->id !== $r->created_by && $user->id !== $p['reviewer_id'] && in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
|
||||
$result['delivery'][] = $person;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -63,21 +63,11 @@ class WorkContext
|
||||
/** @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'])) {
|
||||
return $this->configuredAvailable($user);
|
||||
}
|
||||
$granted = [];
|
||||
if (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)) {
|
||||
@@ -86,7 +76,31 @@ class WorkContext
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_intersect($this->purchased($user), $enabled, array_unique($granted)));
|
||||
return array_values(array_intersect($this->purchased($user), $this->policy($user)['enabled'] ?? [], array_unique($granted)));
|
||||
}
|
||||
|
||||
/** Same configured intersection used after activation, without enabling it.
|
||||
* @return list<string>
|
||||
*/
|
||||
public function configuredAvailable(User $user): array
|
||||
{
|
||||
if (! Schema::hasTable('organization_memberships') || ! Schema::hasColumn('organization_profiles', 'work_contexts')) {
|
||||
return [];
|
||||
}
|
||||
$granted = [];
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_intersect($this->purchased($user), $this->policy($user)['enabled'] ?? [], array_unique($granted)));
|
||||
}
|
||||
|
||||
/** @param list<string> $permissions */
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AdmissionDecision;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\RecommendationAttempt;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Current tasks delegated to the existing module authorities, never another workflow. */
|
||||
class WorkflowTaskInbox
|
||||
{
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function build(User $actor, string $area): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && in_array($area, ['procurement', 'people'], true), 403);
|
||||
abort_unless($area !== 'people' || app(PeopleHiring::class)->kinds($actor) !== [], 403);
|
||||
if (app(OrganizationAccess::class)->maintenanceOnly($actor)) {
|
||||
return [];
|
||||
}
|
||||
$rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->finance($actor)];
|
||||
usort($rows, fn ($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '') ?: strnatcmp($a['id'], $b['id']));
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function purchases(User $actor): array
|
||||
{
|
||||
$rows = [];
|
||||
$prepare = app(OrganizationAccess::class)->canRequest($actor);
|
||||
$query = Requisition::query()->visibleTo($actor)->where(function ($q) use ($actor, $prepare) {
|
||||
$q->where('status', 'pending');
|
||||
if ($prepare) {
|
||||
$q->orWhere(fn ($draft) => $draft->where('status', 'draft')->where('requester_id', $actor->id));
|
||||
}
|
||||
});
|
||||
foreach ($query->with('steps')->lazyById(100) as $record) {
|
||||
$draft = $record->status === 'draft' && $prepare && $record->requester_id === $actor->id;
|
||||
if (! $draft && ! $record->canDecide($actor)) {
|
||||
continue;
|
||||
}
|
||||
$step = $draft ? null : $record->steps->firstWhere('decision', 'pending');
|
||||
$rows[] = ['id' => 'purchase:'.$record->id.':'.($step->id ?? 'draft'), 'module' => 'purchases',
|
||||
'reference' => $record->number, 'label' => $draft ? 'Conferir e submeter requisição de compra' : 'Decidir a etapa de aprovação',
|
||||
'task' => $draft ? 'prepare' : 'approve', 'status' => $record->status, 'blocked' => false,
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$record->id];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function warehouse(User $actor): array
|
||||
{
|
||||
if (! Schema::hasTable('warehouse_requests') || ! Schema::hasTable('warehouse_request_events')) {
|
||||
return [];
|
||||
}
|
||||
$desk = app(WarehouseWorkdesk::class);
|
||||
$rows = [];
|
||||
foreach (['review', 'reserve', 'delivery', 'receipt'] as $view) {
|
||||
$query = $desk->forView($desk->visible($actor), $actor, $view);
|
||||
foreach ($query->lazyById(100) as $record) {
|
||||
$progress = app(WarehouseFulfilment::class)->progress($record);
|
||||
$health = in_array($view, ['reserve', 'delivery'], true) ? $desk->health($record, $progress) : null;
|
||||
if ($view === 'reserve' && ! $health['needs_reservation'] && ! $health['needs_check']) {
|
||||
continue;
|
||||
}
|
||||
$base = ['module' => 'warehouse', 'reference' => 'RA-'.$record->id, 'status' => $progress['state'],
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null];
|
||||
if ($view === 'receipt') {
|
||||
foreach ($progress['events'] as $event) {
|
||||
if ($event['action'] === 'deliver' && ! in_array($event['id'], $progress['received_deliveries'], true)) {
|
||||
$rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':receive:'.$event['id'], 'task' => 'receive',
|
||||
'label' => 'Confirmar recepção da entrega #'.$event['id'], 'blocked' => false,
|
||||
'created_at' => $this->date($event['created_at']), 'href' => '/stock/requisicoes/'.$record->id.'#warehouse-command-receive-'.$event['id']];
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
$action = $view === 'review' ? 'authorize' : ($view === 'delivery' ? 'deliver' : 'reserve');
|
||||
$blocked = $view === 'delivery' && ! $health['delivery_ready'];
|
||||
$rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':'.$view, 'task' => $view,
|
||||
'label' => match ($view) {
|
||||
'review' => 'Autorizar ou recusar requisição interna',
|
||||
'reserve' => 'Conferir reposição e actualizar reserva',
|
||||
default => $blocked ? 'Conferir stock antes da entrega' : 'Confirmar a entrega física',
|
||||
}, 'blocked' => $blocked,
|
||||
'href' => '/stock/requisicoes/'.$record->id.($blocked ? '' : '#warehouse-command-'.$action)];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function people(User $actor): array
|
||||
{
|
||||
$kinds = app(PeopleHiring::class)->kinds($actor);
|
||||
abort_unless($kinds !== [], 403);
|
||||
if (! Schema::hasTable('people_processes') || ! Schema::hasTable('people_process_access')) {
|
||||
return [];
|
||||
}
|
||||
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
|
||||
$desk = app(PeopleWorkdesk::class)->build(clone $query, $actor);
|
||||
$dates = (clone $query)->pluck('created_at', 'id');
|
||||
$rows = [];
|
||||
foreach ([...$desk['tasks'], ...$desk['approvals']] as $task) {
|
||||
$rows[] = ['id' => 'people:'.$task['id'], 'module' => 'people', 'reference' => $task['reference'], 'label' => $task['label'],
|
||||
'task' => $task['action'], 'status' => $task['status'], 'blocked' => false,
|
||||
'created_at' => $this->date($dates->get($task['process_id'])), 'due_on' => $task['due_on'], 'href' => $task['href']];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function finance(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['finance'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
foreach (Invoice::query()->where('status', 'pending')->lazyById(100) as $invoice) {
|
||||
if (! app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['id' => 'invoice:'.$invoice->id, 'module' => 'finance', 'reference' => $invoice->number,
|
||||
'label' => 'Conferir factura de fornecedor', 'task' => 'invoice_review', 'status' => $invoice->status, 'blocked' => false,
|
||||
'created_at' => $this->date($invoice->created_at), 'due_on' => null, 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
|
||||
}
|
||||
foreach (ExternalPayment::query()->where('status', 'pending')->lazyById(100) as $payment) {
|
||||
if (! app(ExternalPaymentWorkflow::class)->reviewerEligible($actor, $payment)) {
|
||||
continue;
|
||||
}
|
||||
$invoice = Invoice::query()->whereKey($payment->invoice_id)->firstOrFail();
|
||||
$rows[] = ['id' => 'payment:'.$payment->id, 'module' => 'finance', 'reference' => $invoice->number,
|
||||
'label' => 'Conferir registo de pagamento externo', 'task' => 'payment_review', 'status' => $payment->status, 'blocked' => false,
|
||||
'created_at' => $this->date($payment->created_at), 'due_on' => null, 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function awards(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$workflow = app(PurchaseWorkflow::class);
|
||||
$rows = [];
|
||||
$requests = Requisition::query()->visibleTo($actor);
|
||||
$query = PurchaseAward::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereIn('status', ['pending', 'approved']);
|
||||
foreach ($query->lazyById(100) as $award) {
|
||||
$request = (clone $requests)->whereKey($award->requisition_id)->firstOrFail();
|
||||
$decide = $workflow->canDecide($actor, $award, $request);
|
||||
if (! $decide && ! $workflow->canIssue($actor, $award, $request)) {
|
||||
continue;
|
||||
}
|
||||
$step = $decide ? $award->approvals()->where('decision', 'pending')->orderBy('position')->first() : null;
|
||||
$rows[] = ['id' => 'award:'.$award->id.':'.($step->id ?? 'issue'), 'module' => 'purchases', 'reference' => $request->number,
|
||||
'label' => $decide ? 'Decidir a etapa da adjudicação' : 'Conferir adjudicação para emitir ordem de compra',
|
||||
'task' => $decide ? 'award_approve' : 'order_issue_review', 'status' => $award->status, 'blocked' => false,
|
||||
'created_at' => $this->date($award->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$request->id.'/adjudicacao'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
return $value === null ? null : CarbonImmutable::parse($value)->utc()->toIso8601String();
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function formalReviews(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
$admission = app(FormalAdmission::class);
|
||||
$recommendation = app(FormalRecommendation::class);
|
||||
$requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
|
||||
foreach ($requests->lazyById(100) as $request) {
|
||||
$base = ['module' => 'purchases', 'reference' => $request->number, 'blocked' => false, 'status' => 'pending', 'due_on' => null];
|
||||
$adoption = $admission->adoption($request);
|
||||
if ($adoption !== null) {
|
||||
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->whereNotExists(fn ($query) => $query->selectRaw('1')->from('admission_decisions as newer')
|
||||
->whereColumn('newer.tenant_id', 'admission_decisions.tenant_id')->whereColumn('newer.admission_adoption_id', 'admission_decisions.admission_adoption_id')
|
||||
->whereColumn('newer.quotation_id', 'admission_decisions.quotation_id')->whereColumn('newer.revision', '>', 'admission_decisions.revision'));
|
||||
foreach ($decisions->lazyById(100) as $decision) {
|
||||
if ($admission->canReview($actor, $request, $decision)) {
|
||||
$rows[] = [...$base, 'id' => 'admission:'.$decision->id.':review', 'task' => 'admission_review',
|
||||
'label' => 'Conferir proposta de admissão formal', 'created_at' => $this->date($decision->created_at),
|
||||
'href' => '/requisicoes/'.$request->id.'/admissao?version='.$adoption->id.'&decision_id='.$decision->id.'#admission-'.$decision->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
$adoption = $recommendation->adoption($request);
|
||||
$attempt = $adoption === null ? null : RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||
if ($adoption !== null && $attempt !== null && $recommendation->canReview($actor, $request, $attempt)) {
|
||||
$rows[] = [...$base, 'id' => 'recommendation:'.$attempt->id.':review', 'task' => 'recommendation_review',
|
||||
'label' => 'Conferir recomendação formal de adjudicação', 'created_at' => $this->date($attempt->created_at),
|
||||
'href' => '/requisicoes/'.$request->id.'/recomendacao?version='.$adoption->id.'&attempt_id='.$attempt->id.'#recommendation-'.$attempt->id];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** Only a person's own unfinished analysis, never a new mandatory analyst.
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function documentary(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
$workflow = app(DocumentaryQualificationWorkflow::class);
|
||||
$requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
|
||||
$query = QualificationSet::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_sets as newer')
|
||||
->whereColumn('newer.tenant_id', 'qualification_sets.tenant_id')->whereColumn('newer.requisition_id', 'qualification_sets.requisition_id')->whereColumn('newer.revision', '>', 'qualification_sets.revision'));
|
||||
foreach ($query->lazyById(100) as $set) {
|
||||
$request = (clone $requests)->whereKey($set->requisition_id)->firstOrFail();
|
||||
if (! $workflow->canAssess($actor, $request, $set)) {
|
||||
continue;
|
||||
}
|
||||
$records = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('analyst_id', $actor->id)->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_assessments as newer')
|
||||
->whereColumn('newer.tenant_id', 'qualification_assessments.tenant_id')->whereColumn('newer.qualification_set_id', 'qualification_assessments.qualification_set_id')
|
||||
->whereColumn('newer.analyst_id', 'qualification_assessments.analyst_id')->whereColumn('newer.quotation_id', 'qualification_assessments.quotation_id')->whereColumn('newer.revision', '>', 'qualification_assessments.revision'));
|
||||
foreach ($records->lazyById(100) as $record) {
|
||||
if (! collect($record->answers)->contains('result', 'pending')) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['id' => 'qualification:'.$record->id.':complete', 'module' => 'purchases', 'reference' => $request->number,
|
||||
'label' => 'Completar a minha análise documental', 'task' => 'qualification_complete', 'status' => 'pending', 'blocked' => false,
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null,
|
||||
'href' => '/requisicoes/'.$request->id.'/qualificacao?version='.$set->id.'"ation_id='.$record->quotation_id.'#qualification-assess'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function supply(User $actor): array
|
||||
{
|
||||
$planning = app(SupplyPlanning::class);
|
||||
if (! Schema::hasTable('supply_records') || ! $planning->allows($actor, 'supply_review')) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
foreach (['agreement', 'policy', 'auto_rule'] as $kind) {
|
||||
foreach ($planning->latest($kind)->whereIn('state', ['draft', 'returned'])->lazyById(100) as $record) {
|
||||
$eligible = $kind === 'auto_rule' ? app(AutomaticPurchases::class)->canReview($actor, $record) : $planning->canReview($actor, $record);
|
||||
if (! $eligible) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['id' => 'supply:'.$record->id.':review', 'module' => 'supply',
|
||||
'reference' => trim(($record->snapshot['item']['code'] ?? 'Fornecimento').' '.($record->snapshot['warehouse']['code'] ?? '')),
|
||||
'label' => match ($kind) {
|
||||
'agreement' => 'Conferir acordo de fornecimento', 'policy' => 'Conferir política de reposição', default => 'Conferir parametrização de compra automática',
|
||||
}, 'task' => $kind.'_review', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null,
|
||||
'href' => $kind === 'auto_rule' ? '/stock/reposicao?rule_id='.$record->id.'#supply-rule-'.$record->id
|
||||
: '/compras/fornecimento?view='.($kind === 'agreement' ? 'agreements' : 'calendar').'&record_id='.$record->id.'#supply-record-'.$record->id];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function quotations(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$access = app(QuotationParticipation::class);
|
||||
$rows = [];
|
||||
$requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')
|
||||
->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
|
||||
foreach ($requests->lazyById(100) as $request) {
|
||||
$prepare = $access->canPrepare($actor, $request);
|
||||
if (! $prepare && ! $access->canEvaluateProcess($actor, $request)) {
|
||||
continue;
|
||||
}
|
||||
$comparison = app(ProposalComparison::class)->inspect($request);
|
||||
$path = '/requisicoes/'.$request->id.'/cotacoes';
|
||||
$base = ['module' => 'purchases', 'reference' => $request->number, 'status' => $request->status, 'blocked' => false,
|
||||
'created_at' => $this->date($request->created_at), 'due_on' => null];
|
||||
if ($prepare) {
|
||||
if (count($comparison['rows']) < $comparison['policy']['minimum_proposals']) {
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$request->id.':prepare', 'task' => 'quotation_prepare',
|
||||
'label' => 'Completar consulta ao mercado', 'href' => $path.'?prepare=1#quotation-create'];
|
||||
} elseif (! $comparison['ready'] && collect($comparison['rows'])->contains('eligible', false)) {
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$request->id.':check', 'task' => 'quotation_check',
|
||||
'label' => 'Conferir propostas ou impedimentos da comparação', 'href' => $path];
|
||||
} elseif ($comparison['ready'] && $actor->id !== $request->requester_id) {
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$request->id.':result', 'task' => 'comparison_review',
|
||||
'label' => 'Conferir comparação e requisitos da adjudicação', 'href' => '/requisicoes/'.$request->id.'/adjudicacao'];
|
||||
}
|
||||
}
|
||||
$quotes = Quotation::query()->where('requisition_id', $request->id)->with('supplier')->get()->keyBy('id');
|
||||
foreach ($comparison['rows'] as $row) {
|
||||
$quote = $quotes->get($row['id']);
|
||||
if (! $row['eligible'] || $row['evaluation_count'] >= $comparison['policy']['minimum_evaluators'] || $quote === null || ! $access->canEvaluate($actor, $request, $quote)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$quote->id.':evaluate', 'task' => 'quotation_evaluate',
|
||||
'label' => 'Avaliar proposta '.$quote->reference, 'created_at' => $this->date($quote->created_at),
|
||||
'href' => $path.'?quotation_id='.$quote->id.'#quotation-'.$quote->id];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,15 @@ class WorkflowTasks
|
||||
public function catalogue(): array
|
||||
{
|
||||
$specific = [
|
||||
'supply_prepare' => ['supply.prepare' => 'Preparar acordos, políticas e parametrização da compra automática; reavaliar necessidades'],
|
||||
'supply_review' => ['supply.review' => 'Conferir políticas e activar ou suspender parametrizações preparadas por outra pessoa'],
|
||||
'warehouse_request_review' => ['warehouse.request.review' => 'Decidir requisições internas do departamento, actualizar reservas e cancelar saldos pendentes'],
|
||||
'warehouse_request_prepare' => ['warehouse.request.prepare' => 'Preparar e submeter requisições internas próprias; acompanhar reservas e confirmar a recepção das entregas'],
|
||||
'warehouse_request_deliver' => ['warehouse.request.deliver' => 'Acompanhar entregas pendentes e confirmar a saída efectiva, em conjunto com a tarefa de armazém e com independência do requisitante e autorizador'],
|
||||
'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'],
|
||||
'department_head' => ['requisition.approve.department_head' => 'Decidir as etapas previstas de requisição e adjudicação do próprio departamento; analisar requisitos documentais e conferir admissão/recomendação quando previsto no regime adoptado, com independência'],
|
||||
'procurement' => ['requisition.approve.procurement' => 'Decidir as etapas previstas de Compras na requisição e adjudicação', 'procurement.prepare' => 'Preparar consulta, requisitos documentais e propostas formais conforme os regimes adoptados; avaliar propostas elegíveis e conferir a emissão da ordem após as aprovações exigidas'],
|
||||
'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Analisar requisitos documentais e conferir admissão/recomendação conforme o regime adoptado; conferir facturas e registos de pagamentos externos de outra pessoa, com revalidação dos conflitos e competências específicas'],
|
||||
'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'],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Technical polling interval, not a business approval deadline or permission.
|
||||
'scan_minutes' => max(1, (int) env('SGC_REPLENISHMENT_SCAN_MINUTES', 5)),
|
||||
'batch_size' => max(1, min(500, (int) env('SGC_REPLENISHMENT_BATCH_SIZE', 100))),
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'synthetic_sources' => [],
|
||||
'qa_run' => env('SGF_ISOLATED_REGRESSION_RUN'),
|
||||
'package_manifest_sha256' => 'bbd97f63b0eeb671bd3ad57bbd62bd34fcf67b8e943320423715d514a2a2c19c',
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
return ['enabled' => false, 'lock_timeout_ms' => 2000];
|
||||
@@ -35,6 +35,6 @@ return new class extends Migration
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('warehouse_reservations');
|
||||
throw new LogicException('Preservar o histórico de reservas e entregas.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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('replenishment_work', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('stock_item_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('warehouse_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('responsible_id')->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedBigInteger('generation')->default(1);
|
||||
$table->unsignedBigInteger('processed_generation')->default(0);
|
||||
$table->unsignedInteger('attempts')->default(0);
|
||||
$table->unsignedInteger('failures')->default(0);
|
||||
$table->string('last_outcome')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestampTz('due_at');
|
||||
$table->timestampTz('last_run_at')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->unique(['tenant_id', 'stock_item_id', 'warehouse_id']);
|
||||
$table->index(['due_at', 'id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar trabalhos de reposição pendentes e histórico de recuperação.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
# Saída física comum e ligação sintética de Oficina
|
||||
|
||||
02/11/2026. `StockWorkflow` delega a saída normal em `StockIssueMovement` e no artefacto local verificado `packages/inventory-issue/releases/0.1.0`, fonte `sgf-tempo/packages/inventory-issue`. Manifesto SHA-256 `3c24d9e9fb99bfbb274077495c3a42ddb7eccab06b5c6a956cd739b2bc9b56f0`.
|
||||
|
||||
O núcleo exige transacção do host, autorização, fotografia/revisão actual, bloqueio de armazém/artigo e quantidade física suficiente; repetições recuperam o movimento. Não substitui políticas de Compras, reservas, FEFO/lotes, equipamentos, sinais ou auditoria. Transferências, contagens e estornos permanecem no circuito existente. Não há dependência da pasta SGF em runtime.
|
||||
|
||||
Provas afectadas em bases PostgreSQL sintéticas novas: **25 testes/125 asserções**, mais **2 testes/10 asserções** seleccionados de lotes (FEFO e bloqueios). Provas de recepção anteriores reutilizadas, sem repetir suites alheias. Ver relatório SGF `docs/WORKSHOP_STOCK_DELIVERY.md` e prova `docs/proofs/workshop-stock-20261102.json` no repositório SGF para a ligação com conferência independente, repetição, falha/retoma, versões e concorrência real.
|
||||
|
||||
O SGF usa um livro novo QA sem lotes/reservas/peças e apenas ordens explicitamente elegíveis. Uma entrega já debitada sem reconciliação impede novo abatimento. Não foi entregue reconciliação de entregas, devoluções ou compensações. A demonstração de recepção anterior é separada do cenário de consumo; não se declara um ERP integral instalado ou capacidade de implantação.
|
||||
|
||||
Sem alteração das demos, candidata activada, dados/direitos reais, publicação ou checkpoint. Os três direitos SGF `workshop_stock.view/prepare/confirm` são apenas propostos para implantação; as concessões da regressão são temporárias e restritas a actores QA novos.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Recepção → escritor comum de Stock
|
||||
|
||||
02/11/2026. `ReceiptWorkflow` passou a delegar a gravação de entradas físicas no adaptador `ReceiptStockMovement` e no artefacto local verificado `packages/inventory-write/releases/0.1.0`, fonte única `sgf-tempo/packages/inventory-write`. Nenhum fallback cria uma segunda entrada. Não se alteraram as validações de origem/quantidade/qualidade/unidade, separação do recebedor, lotes, sinais e auditoria do workflow.
|
||||
|
||||
Hash de origem congelado/revalidado, linha bloqueada, unicidade existente por `goods_receipt_item_id` e participação na mesma transacção; repetição recupera o movimento e falha desfaz os efeitos. A fotografia conserva os campos e acrescenta referência/hash. Não reescreve movimentos anteriores nem executa importação histórica. O escritor não constitui uma API independente para fabricar recepções confirmadas.
|
||||
|
||||
Manifesto SHA-256 `f0b99e591f9a4df135e685f64ceffad77bed259222debe0f34c20f95e3cad800`. Artefacto e fonte comparados; sem dependência da pasta SGF no runtime. A versão é do writer, não uma candidata Purchasing activada.
|
||||
|
||||
Provas PostgreSQL isoladas: fluxo novo **1/33**, recepções afectadas **31/193**, lotes seleccionados **3/20**, replay com adaptador final **1/8**. O SGF consultou o mesmo movimento através de role QA SELECT-only: **1/31**, mais os seus casos funcionais **2/77**. Exemplo: receber 6, aceitar 4, rejeitar 2; posterior recepção de 2 totalmente rejeitada; saldo 4, um movimento e duas auditorias de recepção. Concorrência recuperou o mesmo ID; falha após INSERT fez rollback e recuperou sem duplicação.
|
||||
|
||||
Sem dados/regras/demos reais alterados, migração ou direito público, activação, publicação ou checkpoint. Bases QA retidas; credenciais/tokens privados. O fluxo de preparação/confirmação de recepções no ERP SGF ainda não foi transferido; a observação de um livro QA não é implantação ou conector remoto obrigatório. Escritas em volume, outros drivers, revisão visual e capacidade de implantação permanecem pendentes.
|
||||
|
||||
Relatório e prova no SGF: `docs/STOCK_RECEIPT_LINK_DELIVERY.md` e `docs/proofs/stock-receipt-link-20261102.json`. Os restantes movimentos nativos permanecem nos seus serviços; próximo ponto é consumo de oficina por origem, sem duplicar saída.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Artefacto comum de entrada por devolução de excedente
|
||||
|
||||
Continuação posterior: [percurso nativo de devolução](warehouse-unused-return.md) implementado e comprovado em base isolada, reutilizando este mesmo artefacto sem alteração. A descrição inicial abaixo permanece como registo da preparação; não representa o estado posterior da integração nativa.
|
||||
|
||||
02/11/2026. Preparado localmente `packages/inventory-return/releases/0.1.0`, fonte canónica SGF `packages/inventory-return`. Manifesto SHA-256 `2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c`.
|
||||
|
||||
O escritor devolve quantidade ao mesmo livro, com transacção/autorização do host, operação e entrega original íntegras, revisão, bloqueios, quantidade cumulativa e recepção física expressa. É exercitado pelo adaptador sintético Oficina–Stock do SGF. A regra de excedente não consumido permanece no host, que conhece as associações de consumo.
|
||||
|
||||
**O comando nativo de devolução de Compras não está integrado por este artefacto.** Os circuitos existentes de devolução a fornecedor, abate e estorno têm efeitos e autoridades diferentes e não foram reutilizados indevidamente para gerar entradas de excedente de Oficina. A existência do pacote não activa menus, direitos, tabelas ou candidatas.
|
||||
|
||||
Ver `sgf-tempo/docs/WORKSHOP_MATERIAL_CYCLE.md` e `docs/proofs/workshop-material-cycle-20261102.json` nesse repositório: repartição, devolução, histórico paginado, concorrência, recuperação e limitações. Código nativo de saída e escritor anterior preservados; não se repetiram suites nativas sem alteração. Sem dados/demos reais, publicação ou checkpoint.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Consultas, cotações e adjudicação — entrega para revisão
|
||||
|
||||
02/11/2026. **Percurso implementado e confirmado em cópias isoladas; demonstração 8039 habilitada para preparação/avaliação/leitura, com adjudicação bloqueada pela composição de identidades autorizada.** O proprietário escolheu preparar sem criar quarta conta e autorizou o mapa exacto de novos direitos. Essa escolha foi respeitada; não se reduziu o mínimo de avaliadores nem se tornou Consulta num decisor.
|
||||
|
||||
## Destino, estado e candidata
|
||||
|
||||
| Elemento | Estado conferido |
|
||||
| --- | --- |
|
||||
| Demonstração existente | `http://127.0.0.1:8039`, pasta `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\development-app` |
|
||||
| Base/contexto | `sgf_modules_dev_2576427d1939`, porta **55439**, **tenant 1 / empresa 1 / unidade 1**, módulo Compras, opção `procurement.internal` |
|
||||
| Origem reutilizada | Requisição **ID 1, 100 MZN, aprovada, versão 3**, três eventos originais e **reserva única** preservados |
|
||||
| Dados preparados | Três fornecedores exclusivamente sintéticos, três cotações de 100/110/120 MZN e uma avaliação elegível por cotação; sem contactos reais |
|
||||
| Estado em 8039 | Comparação factual legível, classificação/seleccionabilidade incompletas: faltam segundo avaliador elegível e confirmador independente; **zero propostas de adjudicação e zero confirmações** |
|
||||
| Candidata final | `OPERATIONAL-CANDIDATE-040fb0cb67a77d61` |
|
||||
| ZIP privado | [040fb0cb67a77d61.zip](../storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-040fb0cb67a77d61.zip) |
|
||||
| SHA-256 ZIP | `dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a` |
|
||||
| SHA-256 manifesto | `040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58` |
|
||||
| Implantação final de código na demonstração | `040fb0cb67a77d618011bc7a`; integra a correcção de leitura da candidata 166e397e3ca63f02 |
|
||||
| SGF real | **0.1.1**, sem instalação, configuração, acessos ou base alterados; não existe implantação 8041 |
|
||||
|
||||
O ecrã é acessível pelo detalhe da requisição aprovada e directamente em `/purchasing/sourcing/1`, depois de entrar com uma das contas anteriores e seleccionar Compras no contexto autorizado. Os campos de data/local de entrega da proposta ficam por preencher: são requisitos da adjudicação existente de Compras, não foram presumidos para a empresa e não foi criada proposta na demonstração. Valores logísticos de testes aparecem apenas nas cópias sintéticas e em pedidos recusados, nunca numa decisão real.
|
||||
|
||||
## Mapeamento e reutilização de Compras
|
||||
|
||||
| Processo existente | Reutilização e adaptação |
|
||||
| --- | --- |
|
||||
| `QuotationController::store` e `QuotationPricing` | Cobertura de todas as linhas da origem, quantidade e imposto preservados, preços/condições/validade/prazo, desconto antes do imposto. O motor comum `Tempo\Purchasing\Economic\QuotationPricing` é utilizado directamente; não se criou outro motor monetário. |
|
||||
| `ComparisonPolicy` e `ProposalComparison` | A candidata transporta os defaults existentes: **3 fornecedores, 2 avaliadores**, pesos preço/qualidade/entrega/histórico/pagamento **40/20/15/15/10**. Não são uma nova política presumida para a Tempo. O processo guarda a política aplicada; avaliações de versões antigas ou de intervenientes cuja elegibilidade foi revogada não qualificam a comparação actual. |
|
||||
| `ProposalScoring` | A mesma classe partilhada calcula pontuações e melhores propostas. Sem IA, rede ou consumo de serviços externos. |
|
||||
| `PurchaseWorkflow::select/canDecide/decide` | Preservadas origem aprovada, selecção entre melhores propostas, justificação, data/local previstos, exclusão do requerente e confirmação por outra identidade. Proposta guarda comparação completa, fingerprint e versão. Nesta entrega só está integrado o primeiro nível; acima desse limite é sinalizada cadeia pendente. |
|
||||
| `BudgetWorkflow` / `BudgetControl` e reserva da requisição | Usada a origem financeira já mapeada, dotação aprovada e a base com/sem imposto existente. Nenhuma chamada nova a reserve/award/commit/consume/release. Valor divergente é sinalizado para adaptação orçamental ainda não integrada; não se inventa reforço ou libertação. |
|
||||
| Autorizações/transacções SGF | Reutilizados sessão, CSRF, `CompanyContext`, `Permissions`, `RuntimeContext` e `Port::atomic`: empresa/utilizador/origem bloqueados, contexto e direitos revalidados também em repetições. |
|
||||
|
||||
O novo núcleo é `packages/purchasing/src/Sourcing.php`. O adaptador, rotas e ecrã estão em `integrations/sgf/sourcing`. Os serviços/modelos activos de Compras autónomo não foram alterados por este incremento; a incorporação não transporta a base de Compras. O novo catálogo sintético é uma fonte local autorizada de selecção, não uma importação/reconciliação de fornecedores por igualdade de nome. Cadastro completo, revisão documental, conflitos/admissões/recomendações formais e ligação a cadastros reais continuam fora da cobertura declarada deste recorte sintético.
|
||||
|
||||
## Acessos efectivamente acrescentados
|
||||
|
||||
Somente após a resposta explícita do proprietário, em 1/1/1:
|
||||
|
||||
| Conta existente | Acréscimos |
|
||||
| --- | --- |
|
||||
| Preparador ID 1 | `purchasing_sourcing.prepare` |
|
||||
| Revisor ID 2 | `purchasing_sourcing.evaluate`, `purchasing_sourcing.propose` |
|
||||
| Consulta ID 3 | **Nenhum**; conserva a leitura anteriormente autorizada |
|
||||
|
||||
Nenhuma conta persistente foi criada. `purchasing_sourcing.confirm` foi acrescentada ao catálogo, mas **não concedida a qualquer conta da demonstração**. A quarta identidade sugerida foi recusada pelo proprietário e não foi criada. A confirmação usa ainda a atribuição ao departamento da origem; não basta acrescentar uma permissão isolada.
|
||||
|
||||
A outorga inicial específica foi auditada com a identidade administradora existente e motivo da confirmação do proprietário; não houve herança automática de perfis ou mudança de direitos anteriores. A administração nativa continua a exigir que o gestor detenha a permissão que outorga/revoga. Como ID 1 não recebeu evaluate/propose, a gestão desses direitos pelo seu ecrã administrativo mantém essa restrição; não se concederam direitos operacionais adicionais para a contornar. A revogação nos testes foi exercitada por fixtures privilegiadas **somente nas cópias**, e a sua eficácia nos pedidos/repetições foi conferida no servidor.
|
||||
|
||||
## Schema e actualizações
|
||||
|
||||
Migração aditiva própria **sourcing revisão 1**:
|
||||
|
||||
- Quatro tabelas: `ss_suppliers`, `ss_processes`, `ss_events`, `ss_commands`.
|
||||
- FKs de empresa/unidade/origem, unicidade por contexto/requisição e contexto/actor/chave; eventos/recibos imutáveis; origem e decisão final protegidas; versões avançam uma vez.
|
||||
- CHECK do catálogo de `permission_grants` ampliado com os quatro nomes unitários. Isso não concede acessos.
|
||||
- Nova entrada imutável `sourcing` em `sgc_installations`; a entrada original `internal-requisitions` revisão 2 não é reescrita. A guarda orçamental aceita a extensão apenas com assinatura e hash conhecidos e válidos.
|
||||
|
||||
O pacote inclui `scripts/sgf-sourcing-install.php`: `plan` só lê; `migrate` exige destino, manifesto, backup/hash, operador e manutenção conferidos. A migração não cria perfis, fornecedores ou contas, não concede direitos e não habilita o módulo. Escreve recibo privado da transição exacta, que o actualizador exige ao mudar os drivers de schema. Receitas de outro host/manifesto ou ausentes não autorizam transição. O conteúdo sintético e as credenciais não fazem parte do ZIP.
|
||||
|
||||
Não foram executados **GRANT/REVOKE SQL**, criadas roles ou promovida a ligação normal. A demonstração reutiliza a role proprietária que já tinha; essa limitação permanece explícita. O plano SQL do SGF real não foi aplicado, nem este incremento declara pronto o mapa SQL mínimo dos quatro novos objectos para uma instalação cliente restrita.
|
||||
|
||||
## Resultados e provas
|
||||
|
||||
| Prova | Resultado |
|
||||
| --- | --- |
|
||||
| [Núcleo](proofs/sgf-sourcing-core.json) | 12 controlos: mínimos, classificação comum, exclusão do requerente, proponente diferente do confirmador, cotação alterada/expirada, revogação, base exacta e fecho imutável. |
|
||||
| [Cópia isolada final](proofs/sgf-sourcing-isolated.json) | 6 grupos de instalação/migração, 11 de HTTP/concorrência e 8 de integridade/recuperação. Inclui falha de migração com rollback, replay, CLI empacotada, recibo exigido, dois processos concorrentes reais, versões conflituantes e confirmações concorrentes com só um resultado final. |
|
||||
| Falha depois de gravar o evento | Estado, evento e recibo revertidos na mesma transacção; nova tentativa prossegue sem duplicação. |
|
||||
| Versões e revogação | Alterar cotação invalida avaliações anteriores e impede confirmação de proposta obsoleta; revogação afecta sessões existentes e replay. |
|
||||
| Recuperação | Reversão de código preservou a adjudicação na base da cópia; estado pós-confirmação arquivado. Separadamente, restauro PostgreSQL do backup anterior reconciliou requisição/reserva/contas/direitos e ausência das tabelas/ledger da extensão. Base temporária eliminada depois dos testes. |
|
||||
| [8039 — HTTP](proofs/sgf-sourcing-demo-http.json) | Preparação/leitura das três cotações e avaliações; Consulta recusado em todas as mutações; falta do segundo avaliador sinalizada; valor diferente da reserva sinalizado; nenhum adjudicatário criado. |
|
||||
| [8039 — preservação](proofs/sgf-sourcing-demo.json) | Contas, requisição, reserva/consumo e direitos anteriores preservados; exactamente três acréscimos autorizados. |
|
||||
| [Relatório agregado](proofs/sgf-sourcing-delivery.json) | Candidata, hashes, ficheiros afectados, implantação final e estado parcial da demonstração. |
|
||||
|
||||
A confirmação completa ocorreu **apenas em cópias descartáveis**, com identidade e atribuição departamental de fixture. Não se afirma confirmação completa em 8039. Não se executaram cenários destrutivos/concorrentes na demonstração persistente. A cópia foi adaptada à sua identidade física exacta sem desligar as guardas; dependências e autoloader foram copiados. Regressões integrais aceites de Compras/navegação/requisições não foram repetidas; as provas anteriores permanecem válidas e referenciadas. Revisão visual continua separada; nenhum navegador utilizado.
|
||||
|
||||
## Recuperação concreta da demonstração
|
||||
|
||||
Antes da primeira escrita, identidade física e contexto foram conferidos. Backup privado em `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\private\sourcing-recovery\before.dump`, SHA-256 **`cb9d55db5fee08cb36e3af4c962556b465be9e14071b5c3505c834d2ef4d7bcd`**, com índice conferido. `baseline.json` conserva hashes/contas/direitos anteriores; `applied.json` a primeira aplicação da extensão; `final-code.json` a aplicação final. Os journals de todas as versões/cópias de ficheiros e bundles anteriores estão em `development-app/storage/app/private/module-deployments`.
|
||||
|
||||
1. Fechar só o servidor 8039, confirmar processo/pasta; nunca parar o cluster 55432 nem usar `sgf.ps1 stop`.
|
||||
2. Falha da migração antes do commit: rollback transaccional, conferir catálogo/tabelas/ledger antes de repetir; resultado incerto exige inspecção, não `down`.
|
||||
3. Falha de código: conservar manutenção e usar o ID/journal correcto da CLI de rollback. A actualização de refinamentos finais foi apenas de código, com hashes dos dados conferidos antes/depois. Não estreitar CHECK nem apagar ledger por arrasto.
|
||||
4. **Voltar ao bundle 166 depois da extensão de schema não reabre o percurso sozinho**: a sua guarda conhece apenas a assinatura anterior. Manter fechado até recuperar compatibilidade/schema; código e dados são recuperações distintas. Esse comportamento foi ensaiado nas cópias.
|
||||
5. Para dados/schema, preservar primeiro o estado actual, restaurar o backup numa base alternativa e reconciliar eventos/cotações/direitos posteriores ao corte antes de qualquer troca de ligação. Os resets usados nos testes atingiram só cópias descartáveis. Não restaurar por cima de 8039 ou do SGF real como simples operação de código.
|
||||
|
||||
## Pendências para revisão
|
||||
|
||||
- **Identidades:** mantendo três contas, requerente excluído e Consulta sem decisão, não existe segunda avaliação/confirmador elegível. A opção do proprietário foi preparar sem criar outra conta; não se pede nem presume nova autorização por continuidade.
|
||||
- **Orçamento:** resize da reserva, reforço, libertação, compromisso, consumo, rejeição/cancelamento com efeito financeiro não integrados neste incremento. Valores divergentes são sinalizados.
|
||||
- **Aprovação:** a cadeia acima do primeiro limite é bloqueada até integrar os níveis existentes; não é reduzida a uma confirmação simples.
|
||||
- **Configuração/cadastros:** defaults de comparação são compatibilidade da origem, preservados no processo; falta parametrização comercial desse recorte no SGF, cadastro/reconciliação real e procedimentos formais adicionais da origem. Não é entrega comercial integral.
|
||||
- **Administração/SQL:** delegação nativa restrita e owner/runtime da demonstração continuam como descrito; implantação cliente exige desenho/revisão próprios, sem reutilizar as credenciais/contas/dados sintéticos.
|
||||
- **Fora do incremento:** encomendas, recepção, pagamentos, contactos externos, contratação para terceiros, implantação real e revisão visual. Não foram introduzidos.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Decisão de instalação da integração de requisições no SGF
|
||||
|
||||
**Estado posterior de desenvolvimento:** candidata **040fb0cb67a77d61** acrescenta consultas/cotações/adjudicação ao módulo, com testes completos em cópias e preparação parcial em 8039. [Relatório e hashes actuais](consultas-adjudicacao-sgf.md). Existe extensão de schema própria; os planos anteriores de instalação real abaixo não se aplicam automaticamente à nova candidata. Não há autorização de instalação real, DCL, contas ou configuração Tempo. SGF real mantém **0.1.1**. A confirmação da adjudicação na demonstração permanece pendente por decisão de não criar quarta identidade, sem alterar mínimos ou independência.
|
||||
|
||||
**Estado posterior — apenas demonstração 8039:** correcção de leitura incorporada na sucessora `OPERATIONAL-CANDIDATE-166e397e3ca63f02`, manifesto `166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de`, ZIP SHA-256 `620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785`. [Entrega e matriz actualizadas](transferencia-sgf.md). Nenhuma instalação real autorizada ou executada: SGF real permanece 0.1.1. Planos/IDs do destino real e da candidata anterior abaixo são históricos e não autorizam aplicar esta sucessora; devem ser reconferidos se a implantação real voltar a ser solicitada.
|
||||
|
||||
## Candidata corrigida — decisão actualizada em 02/11/2026
|
||||
|
||||
**Preparação técnica concluída em isolamento; nenhuma instalação ou alteração de acessos autorizada no destino.** Esta secção sucede à proposta histórica abaixo. O SGF permanece em **0.1.1**. A recomendação passa a ser rever esta candidata para uma futura instalação técnica inicialmente desactivada; não há bloqueante de código conhecido no percurso básico ensaiado. A autorização da operação, o backup/restauro dos dados reais e a aprovação das alterações SQL continuam indispensáveis antes de executar.
|
||||
|
||||
| Identificação | Valor conferido |
|
||||
| --- | --- |
|
||||
| Destino | `C:\laragon\www\sgf-tempo`; URL `http://127.0.0.1:8000` |
|
||||
| Base/ligação normal | `sgf_tempo_dev`, PostgreSQL `127.0.0.1:55432`, runtime `sgf_dev_app` |
|
||||
| Candidata corrigida | `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20` — identificação por conteúdo, não activação de 0.1.2 |
|
||||
| ZIP privado | `storage/app/private/candidate-archives/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20.zip` na origem Compras |
|
||||
| SHA-256 ZIP | `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703` |
|
||||
| SHA-256 manifesto | `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b` |
|
||||
| Plano de código, destino | `d29833b1c48b9d666522d20d87040d5ad30370cf114c9a70f272cd041eea59e9` |
|
||||
| Plano de schema, destino | `169e6e863f28b67740f07b6e7cbc664fbe585a7760a83dccebc8d482ad8ab792` |
|
||||
| ID de aplicação previsto | `a6478223e17a5430652f0fcb` — usar sempre o ID efectivamente devolvido |
|
||||
|
||||
### Dependências, configuração e permissões
|
||||
|
||||
A migração passa à **revisão 2, com 16 tabelas novas**: as 14 anteriores mais `cb_ui_commands` e `cb_consumption_sources`. Inclui constraints e histórico imutável. Mantém o alargamento e o CHECK do catálogo de `permission_grants`, preservando dados e direitos anteriores. Não executa a migração 46 de Compras. Tabelas preexistentes incompatíveis ou uma revisão 1 anterior exigem reconciliação explícita, não são sobrescritas. Neste destino não existe nenhuma dessas tabelas.
|
||||
|
||||
O ecrã `/purchasing/internal-requisitions/budget-setup` permite criar centros e preparar/aprovar dotações com os serviços orçamentais existentes. Campos de valores/ano começam vazios; não existem centros, dotações, contas ou concessões sintéticas no pacote. Criar centro exige `purchasing_requisitions.configure` e `purchasing_budget.create`; dotações mantêm autorização e aprovação independente. Movimentos genéricos/consumo fora do percurso de requisição estão fechados nesta composição. O catálogo administrativo inclui os direitos e rótulos vindos do servidor, com frontend compilado no pacote; delegação indevida e consulta sem gestão são recusadas. Instalar não outorga direitos funcionais.
|
||||
|
||||
**Ligação migradora:** usar `sgf_dev_owner` (ou principal DBA autorizado equivalente) exclusivamente no processo CLI migrador, com CONNECT à base, USAGE/CREATE no schema e autoridade de proprietário para alterar `permission_grants`, criar tabelas, constraints, sequências, funções e triggers. Não requer superutilizador, criação de bases/roles ou promoção do runtime. As credenciais devem ser fornecidas por canal privado ao processo, nunca no ZIP, argumentos, logs ou `.env` persistente. Se existir cache de configuração, invalidá-lo na janela aprovada e conferir `current_database()`/`current_user` antes do DDL; variáveis de ambiente não substituem silenciosamente um cache privilegiado. O runtime continua com a sua ligação habitual.
|
||||
|
||||
O [plano SQL exacto proposto para sgf_dev_app](proofs/sgf-corrected-sql-plan.json) **não foi aplicado**. Contém GRANT e REVOKE apenas nos 16 novos objectos/sequências: SELECT em todos; INSERT nos cadastros/comandos/históricos necessários; UPDATE apenas em `cb_budgets`, `ir_requests`, `ir_steps`, `ir_policies`; USAGE apenas nas sequências com INSERT. Sem DELETE/TRUNCATE, UPDATE de históricos, ownership, CREATE ou alteração global de default privileges. Remove ACLs directas mais amplas herdadas dos defaults e de PostgreSQL `PUBLIC` nesses novos objectos; não altera direitos públicos funcionais nem as 87 tabelas existentes. Conferir também pertenças/inheritance e privilégios efectivos: outra role herdada pode exigir proposta adicional. **Qualquer execução efectiva desse plano, incluindo revogações, exige confirmação separada.** Não é executado pelo instalador.
|
||||
|
||||
### Classificação actual das pendências
|
||||
|
||||
| Elemento | Decisão actual |
|
||||
| --- | --- |
|
||||
| Duas tabelas em falta | **Resolvido na candidata**, incluídas na migração revisionada e no restauro verificado. |
|
||||
| Centros/dotações | **Implementado e ensaiado pela interface/API**. Valores reais e aprovação da empresa continuam pendentes para activação; não presumidos. |
|
||||
| Catálogo administrativo | **Implementado, compilado e ensaiado**, ainda não instalado nem com direitos atribuídos no destino. |
|
||||
| SQL migrador/runtime | **Mapa e separação ensaiados**. Confirmar operador/ligação e alterações SQL reais antes da instalação; não há concessão automática. |
|
||||
| Equipamentos/peças | **Fora do âmbito**, rejeitados no servidor. Não bloqueiam consumíveis, serviços e outros; não converter tipos para contornar essa exclusão. |
|
||||
| Navegação modular | Aceitação técnica anterior reutilizada; entrada, selecção de módulo e assets desta composição conferidos na reprodução. Revisão visual, associações antigas e volume continuam separados. |
|
||||
| Dois administradores/configuração real | Pendentes para entrega/activação. Não criar nem promover utilizadores automaticamente. |
|
||||
| Contratações para terceiros | Excluídas do SGF; nenhuma introduzida nesta candidata. |
|
||||
|
||||
### Prova nova e limites
|
||||
|
||||
[Prova da candidata corrigida](proofs/sgf-corrected-delivery.json): **157 controlos aprovados** (incluem conferência individual das ACLs das 87 tabelas anteriores), usando cópia do código actual e exportação **só de estrutura**, sem contas/dados reais importados. A migração produziu 103 tabelas na base descartável; owner e runtime eram roles distintas, restritas e sintéticas. Foram ensaiados falha transaccional de migração, instalação vazia, centros/dotações, três tipos básicos, reserva, identidade independente, histórico/consulta, revogação, contexto forjado, navegação, catálogo/compilação frontend, proibições DDL/DML, actualização e reversão de código. O restauro em segunda base reconciliou **103 impressões digitais de tabelas**, schema/triggers, origem, decisão e reserva, incluindo acesso pelo runtime sem DDL. Bases e roles de teste foram removidas.
|
||||
|
||||
As provas anteriores de concorrência, adaptadores, caches e navegação modular permanecem válidas e não foram repetidas integralmente. Esta reprodução não é backup/restauro dos dados reais do SGF, nem aprovação visual integral ou entrega comercial de todos os módulos. O destino foi lido para schema/metadados e reconferido: permissões/pertenças/auditoria, estrutura e ponteiro preservados.
|
||||
|
||||
### Procedimento exacto proposto — não executar sem confirmação
|
||||
|
||||
1. Extrair o ZIP para pasta privada de staging no próprio destino; conferir os hashes acima, manifesto e os dois planos de leitura. Nenhuma instalação cliente depende da pasta Compras. Repetir planos imediatamente antes da janela: divergência exige reconciliação. Não executar comandos a partir de outro bundle antigo.
|
||||
2. Pelo procedimento DBA autorizado, guardar backup integral custom de **somente `sgf_tempo_dev`**, código/configuração, documentos externos e ACLs, com hash e corte registados, em pasta privada fora da raiz web. Ensaiar esse backup real numa base nova e conferir ownership, ACLs, constraints/triggers, contagens/digests e arranque restrito. `pg_restore --list` não basta. Não parar o cluster 55432 nem usar `sgf.ps1 stop`.
|
||||
3. Na janela aprovada, colocar apenas este projecto em manutenção e parar os seus escritores. Executar `plan` e `migrate` com a ligação migradora temporária. Aplicar **somente o plano SQL expressamente confirmado**, numa transacção própria; conferir privilégios efectivos e preservação das ACLs anteriores. Voltar ao runtime para as verificações normais.
|
||||
4. Aplicar código com o manifesto e plano de código reconferidos; registar o ID retornado. Recompilar caches nativos com o runtime, preservando a configuração operacional **desactivada**. Conferir arranque e inacessibilidade das operações desactivadas. Não alterar `packages/purchasing/current.json`, atribuir direitos, preencher valores ou activar âmbitos nesta operação.
|
||||
5. Comandos parametrizados (executar através do PHP fixado do SGF; `<B>` é o staging extraído, `<H>` o destino acima; restantes valores vêm dos planos/backup/operador, nunca são presumidos):
|
||||
|
||||
```text
|
||||
php <B>/scripts/sgf-internal-install.php plan <H>
|
||||
php <B>/scripts/sgf-internal-install.php privileges-plan <H> sgf_dev_app
|
||||
php <B>/scripts/sgf-internal-install.php migrate <H> <schema-target-sha> <backup-file> <backup-sha> <operator> <manifest-sha>
|
||||
php <B>/scripts/sgf-code-deploy.php plan <H> <B> <manifest-sha>
|
||||
php <B>/scripts/sgf-code-deploy.php apply <H> <B> <manifest-sha> <code-target-sha> <operator> --offline
|
||||
php <B>/scripts/sgf-code-deploy.php cache <H> <deployment-id> <operator> --offline
|
||||
```
|
||||
|
||||
6. Falha de migração: rollback transaccional; se resultado do commit for incerto, inspeccionar ledger/assinatura antes de retry. Falha de aplicação/caches: manter manutenção, `php <B>/scripts/sgf-code-deploy.php rollback <H> <deployment-id> <operator> --offline`, conferir hashes e reconstruir os caches da versão reposta quando aplicável. Na primeira instalação, regressar ao host sem bundle; não inventar versão anterior. A reversão de código preserva schema, dados e ACLs e não os recupera.
|
||||
7. Falha de dados/schema: preservar estado falhado; restaurar o backup para **outra base**, reconciliar operações posteriores ao corte, revogações e documentos, conferir com runtime restrito e decidir separadamente a troca de ligação/caches. Não executar `down`, apagar tabelas ou restaurar por cima da base activa. Mudança de base exige novo fingerprint operacional. Direitos SQL não são revertidos indiscriminadamente com o código.
|
||||
|
||||
**Próxima decisão do proprietário:** confirmar ou recusar uma operação concreta de instalação técnica desactivada, incluindo ligação migradora e plano SQL. Não foi pedida nem inferida essa confirmação nesta preparação. Activação funcional e configuração real ficam para decisão própria.
|
||||
|
||||
---
|
||||
|
||||
## Registo histórico anterior — sucedido pela candidata corrigida acima
|
||||
|
||||
Data: **02/11/2026**. Estado: **proposta para decisão; instalação não autorizada nem executada**.
|
||||
|
||||
## Recomendação concreta
|
||||
|
||||
**Não confirmar ainda a instalação desta candidata como percurso operacional.** Os mecanismos e provas isoladas permanecem válidos, mas a conferência do destino revelou lacunas de implantação que esses ensaios com roles proprietárias e cadastros sintéticos não cobriam: ligação de migração/privilégios SQL da aplicação e preparação operacional de centros/dotações pela interface.
|
||||
|
||||
A operação futura proposta é **instalação técnica de schema e código, inicialmente desactivada**, no destino abaixo, sem outorga automática de direitos, sem dados de Compras e sem mudar o pacote activo 0.1.1. Antes de pedir a confirmação dessa operação, fechar o plano de privilégios e de recuperação do destino. A disponibilização aos utilizadores exige ainda resolver o cadastro/orçamento e a configuração nominativa. Não há autorização para executar `migrate`, `apply`, `cache`, concessões, manutenção ou paragem/reinício de serviços nesta decisão.
|
||||
|
||||
## Destino conferido, não uma instalação genérica
|
||||
|
||||
| Elemento | Valor |
|
||||
| --- | --- |
|
||||
| Projecto SGF local | `C:\laragon\www\sgf-tempo` |
|
||||
| URL configurada | `http://127.0.0.1:8000` — não é publicação num domínio |
|
||||
| Ambiente/PHP | `local`; PHP **8.4.27** pelo wrapper do SGF |
|
||||
| Base efectiva | PostgreSQL **`sgf_tempo_dev`**, `127.0.0.1:55432` |
|
||||
| Role da aplicação | `sgf_dev_app` — sem CREATE no schema public e sem autoridade para ALTER em permission_grants |
|
||||
| Proprietário existente da tabela de permissões | `sgf_dev_owner` — LOGIN e CREATE em public confirmados; credenciais não consultadas nem registadas |
|
||||
| Estado anterior | 87 tabelas public; fundação de identidade presente; nenhum `cb_*`, `ir_*` ou `sgc_installations` da candidata |
|
||||
| Configuração de integração | Bundle/ficheiro operacional ausentes; manutenção inactiva; configuração Laravel não compilada no instante da leitura |
|
||||
| Pacote instalado de Compras | **0.1.1**, ponteiro SHA-256 `81899e531e9eec449717e694d8bdc106a9e220a828f5491ce918c1d2dc612787` |
|
||||
| Revisão Git SGF observada | `fcf0ada22701337acd4c84b404207b8400f490a2`; não substitui a conferência dos ficheiros locais em curso |
|
||||
|
||||
Inventário por **transacção PostgreSQL READ ONLY**, sem migrar: [prova da decisão](proofs/sgf-installation-decision-inventory.json). As contagens/impressões digitais preservadas incluem 89 concessões, duas pertenças e 162 eventos de auditoria. Há uma empresa, uma unidade e **uma concessão de administração da empresa**; não foram enumerados nomes, emails ou credenciais. A base `sgf_tempo_test`, a instância modular isolada em 8039/cluster 55439 e as demonstrações anteriores não são este destino e não podem ser reiniciadas/resetadas por arrasto.
|
||||
|
||||
O registo antigo de processo web não foi tomado como confirmação de processo actualmente activo. Na janela futura, conferir o PID/executável/instante de arranque e vinculação ao projecto antes de actuar. **Não usar `sgf.ps1 stop` indiscriminadamente**: esse comando também pára o cluster PostgreSQL 55432, partilhado com outras bases.
|
||||
|
||||
## Candidata exacta e planos de leitura
|
||||
|
||||
| Referência | Valor |
|
||||
| --- | --- |
|
||||
| Identidade da candidata | **`OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba`** — versão identificada por conteúdo, não 0.1.2 |
|
||||
| Directório | `C:\laragon\www\compras\storage\app\private\candidates\OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba` |
|
||||
| ZIP | `C:\laragon\www\compras\storage\app\private\candidate-archives\OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba.zip` |
|
||||
| SHA-256 ZIP, reconferido | `d14a3261d5aaab80f96250af781c700ab53376021f4638e3aee43c15ff37ceed` |
|
||||
| SHA-256 manifesto | `b1ec47f7a9ad87e0b232bac41e402d4a00ee5f03f00a79b93effc692ce8b39a2` |
|
||||
| Impressão de destino do plano de código | `bab719ee4e08555a8e1de694dd3a93df82680c7aba653613f0714dd76e4065d6` |
|
||||
| Impressão de destino do plano de schema | `169e6e863f28b67740f07b6e7cbc664fbe585a7760a83dccebc8d482ad8ab792` |
|
||||
| ID previsto de primeira aplicação | `b1ec47f7a9ad87e0b232bac4` — reconfirmar no retorno real da CLI |
|
||||
|
||||
Os dois comandos `plan` passaram no destino e só leram/conferiram estado. Isso **não executa nem valida a capacidade de escrever DDL com a role runtime**, e `schema_prepared=false`. Os hashes são uma fotografia: repetir os planos de leitura imediatamente antes de uma operação aprovada; divergência impede usar esta decisão antiga como autorização para sobrescrever o destino.
|
||||
|
||||
## Alterações previstas, sem efeitos já aplicados
|
||||
|
||||
**Código:** criar o bundle local `packages/internal-requisitions`; aplicar sobreposições conferidas apenas a `Permissions.php`, `IdentityWorkflow.php`, `BudgetAdapter.php`, `AppServiceProvider.php` e `ModuleNavigation.php`. Guardar backups/journal em `storage/app/private/module-deployments`; tratar os caches padrão. Preservar restantes ficheiros/trabalho local. Não alterar `packages/purchasing/current.json`, activar 0.1.2, publicar ou copiar a base de Compras.
|
||||
|
||||
**Schema, revisão 1:** acrescentar **14 tabelas**: sete `cb_*` (`centres`, `origins`, `budgets`, `budget_entries`, `budget_consumptions`, `budget_reinforcements`, `audit`), seis `ir_*` (`requests`, `steps`, `events`, `commands`, `policies`, `policy_versions`) e `sgc_installations`. Sem dados sintéticos iniciais. Alargar `permission_grants.permission` de 40 para 100 e estender o CHECK unitário com os novos valores, preservando a expressão e os registos anteriores. Acrescentar FKs, unicidade e triggers de origem/versão/aprovação independente/histórico imutável. Os dados e decisões existentes não são classificados nem reescritos. **A migração 46 de Compras não é aplicada no SGF.**
|
||||
|
||||
**Permissões funcionais:** o catálogo candidato acrescenta seis direitos de requisição (`view`, `prepare`, `configure`, `approve.department_head`, `approve.procurement`, `approve.finance`) e os sete direitos do orçamento comum já desenvolvido. A criação de unidades continua a conceder apenas `unit.view`/`unit.edit`. Instalar o catálogo **não concede** nenhum novo direito a administradores, perfis de desenvolvimento ou outros utilizadores. A atribuição inicial nominativa e auditada exige âmbito habilitado explicitamente; as seguintes usam a administração nativa. Requerente e decisores continuam a exigir identidades distintas. A concessão de `purchasing_budget.record` é necessária para reserva/libertação, sem substituir a autorização de requisição.
|
||||
|
||||
**Privilégios PostgreSQL:** são outra camada. `sgf_dev_app` não deve receber CREATE, ownership ou a palavra-passe do proprietário. A execução de DDL exige a ligação de migração autorizada (`sgf_dev_owner` ou principal autorizado equivalente), só nessa execução e sem reescrever `.env` com credenciais privilegiadas. Depois, a aplicação precisa dos privilégios mínimos nas novas tabelas/sequências. **O instalador desta candidata não entrega esses GRANTs à role runtime**; as provas anteriores usaram role proprietária nas bases descartáveis. Falta fechar/ensaiar esse mapa com as duas roles separadas. O plano deve permitir leitura do registo de instalação, operações de requisição/política, append-only do histórico, reserva/auditoria e locks orçamentais necessários, sem UPDATE/DELETE sobre históricos ou grants genéricos sobre todo o schema. Nenhum GRANT é executado nesta análise.
|
||||
|
||||
## Bloqueantes e elementos fora do âmbito
|
||||
|
||||
| Questão | Classificação e impacto |
|
||||
| --- | --- |
|
||||
| Ligação de migração e privilégios SQL runtime | **Bloqueante para executar a instalação tal como está com a ligação normal.** A ligação actual não pode executar o DDL. Definir execução migradora e mapa mínimo de acesso a tabelas/sequências, preservar o runtime restrito e ensaiar a separação antes de aplicação. |
|
||||
| Centros e dotações de orçamento em instalação vazia | **Bloqueante para disponibilizar o percurso básico neste destino.** Não existem `cb_*`; o formulário candidato só selecciona centros existentes e a configuração exige pelo menos um. Não há no percurso entregue cadastro/associação inicial de centros pela interface. Os ensaios prepararam essas fontes e a dotação aprovada por fixtures; não importar fixtures nem criar SQL manual como configuração comercial. |
|
||||
| Interface orçamental SGF existente | **Bloqueante para a preparação operacional pela interface sem correcção.** `BudgetWorkspace` consulta `cb_ui_commands` e `cb_consumption_sources`, ausentes no destino e no schema de 14 tabelas. A reserva pelo motor comum foi ensaiada; isso não declara entregue o bootstrap administrativo/orçamental completo. Não ampliar consumo/facturas por arrasto: integrar o recorte necessário ou manter esse percurso fechado até completar a sua composição. |
|
||||
| Catálogo administrativo de permissões | **Implementado e testado na candidata; ainda não instalado.** No destino, `Permissions::UNIT` continua só com dois direitos. Aplicar a sobreposição resolve a presença/gestão dos novos nomes; não resolve sozinho DCL PostgreSQL, centros/dotações ou atribuições nominativas. Não classificar como falta de implementação do catálogo nem como acessos já configurados. |
|
||||
| Duas administrações nominativas | **Pendente para entrega/activação**, não condição para copiar código desactivado. O destino tem uma concessão `access.manage`; a regra da entrega exige duas administrações permanentes da empresa. Não criar conta/perfil ou outorgar direitos automaticamente para satisfazer a contagem. |
|
||||
| Âmbito/configuração e dotação reais | **Pendente para activação:** seleccionar empresa/unidade, centros/fontes, intervenientes, política/limites, responsáveis e dotação aprovada pelas regras usuais. Não retirar a exigência de orçamento para contornar a falta de preparação. |
|
||||
| Equipamentos/peças | **Fora do âmbito deste percurso básico.** Não impedem consumíveis, serviços e outros de completar requisição/submissão/aprovação/consulta quando as dependências acima estiverem preparadas. Equipamentos/peças não têm o seu percurso técnico SGF entregue; não os registar como `other` para contornar isso. |
|
||||
| Navegação modular | **Tecnicamente aceite em 02/11/2026** na entrega própria. Não é bloqueante técnico da candidata. Revisão visual/recargas, associações antigas e volume/paginação mantêm o âmbito separado dessa aceitação. Não repetir as suites para resolver a instalação. |
|
||||
|
||||
## Backup, restauro e recuperação — procedimento proposto, não executado
|
||||
|
||||
1. **Antes de qualquer escrita aprovada:** reconferir raiz, URL/base/roles, hashes e estado dos ficheiros/contas/auditoria. Guardar o código/configuração anterior e documentos/ficheiros externos. Reservar directório privado por execução em `C:\laragon\data\sgf-tempo\backups\purchasing\<data-id>`, fora da raiz pública e sem sobrescrever backups anteriores. O backup de configuração pode conter segredos; não o distribuir com o ZIP.
|
||||
2. **Backup integral apenas de `sgf_tempo_dev`:** PostgreSQL 17 `pg_dump --format=custom` com host `127.0.0.1`, porta `55432`, ligação autorizada e ficheiro privado. Registar hash, instante de corte, código/manifesto/schema, inventário das 87 tabelas e ACLs/roles pertinentes. Credenciais por mecanismo privado como PGPASSFILE; nunca nos argumentos/logs. Não parar o cluster inteiro nem exportar/substituir outras bases.
|
||||
3. **Restauro para base nova vazia:** criar `sgf_tempo_dev_restore_<id>` pelo procedimento DBA autorizado, restaurar o dump e conferir dados, documentos externos, tabelas, constraints/triggers, ACLs, contagens/digests de acessos/pertenças/auditoria e arranque com roles distintas. Os ensaios anteriores provaram restauro isolado, mas **não substituem o ensaio desta base e dos seus privilégios**. `pg_restore --list` só lê o índice. Se usar `--no-owner --no-privileges`, repor/validar explicitamente as ownerships/ACLs, incluindo CONNECT/USAGE, antes de ligar o runtime. Não converter o runtime em proprietário para fazer o ensaio passar.
|
||||
4. **Janela de aplicação, só depois dos bloqueantes e da confirmação:** manutenção, parar apenas serviços de escrita desse projecto; preparar schema pela ligação migradora; aplicar o mapa SQL aprovado; conferir preservação. Aplicar código e compilar caches pelas CLIs aprovadas, com destino/manifesto reconferidos. Não outorgar direitos funcionais, habilitar âmbitos ou abrir o módulo por arrasto. Reiniciar/conferir serviços próprios mantendo o acesso fechado até decisão de activação.
|
||||
5. **Falha de schema antes do commit:** rollback transaccional e conferência com o inventário anterior. Se a ligação se perder durante o commit, inspeccionar revisão/schema antes de repetir. Não executar migrações `down` ou apagar histórico para forçar retry.
|
||||
6. **Falha de código/aplicação parcial:** manter serviços parados/manutenção; `sgf-code-deploy.php rollback` para o ID devolvido/registado; conferir backups/hashes. A rotina repõe ficheiros/bundle anterior, recusa alterações locais e não reverte dados/schema/concessões. Depois de cache nativo recompilado, invalida-o; executar `cache` para a versão reposta, quando houver bundle anterior, e reiniciar/conferir antes de reabrir. Na primeira instalação, voltar ao host sem bundle não cria uma versão anterior de requisições. O ponteiro 0.1.1 continua preservado.
|
||||
7. **Corrupção ou incompatibilidade de dados/schema:** preservar também o estado que falhou; restaurar para outra base, nunca por cima da base activa. Reconciliar operações posteriores ao backup, configurações e revogações para não perder decisões ou reabrir direitos. Só trocar a ligação da aplicação após conferência e decisão registada; uma base restaurada com outro nome requer nova impressão de destino operacional e caches recompilados. Conservar a base anterior para diagnóstico. Rollback de código não é restauro de dados.
|
||||
|
||||
Os comandos parametrizados e contratos das CLIs estão em [transferencia-sgf.md](transferencia-sgf.md). **Não pedir confirmação para executar um procedimento ainda incompleto:** primeiro resolver os bloqueantes identificados, actualizar/ensaiar a candidata e apresentar os novos hashes se houver alteração. O ZIP actual permanece imutável e disponível como evidência, não aprovado para implantação operacional.
|
||||
|
||||
|
||||
Actualiza??o de 02/11/2026: a proposta desactivada anterior foi substitu?da pela candidata utiliz?vel 401ce31ae5f2dd20, ainda N?O instalada. Fonte vinculativa actual: C:/laragon/www/sgf-tempo/docs/PURCHASING_INSTALLATION_PROPOSAL.md. Mant?m-se 0.1.1, aus?ncia de concess?es e decis?o posterior de instala??o/configura??o.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Requisições internas na demonstração 8039 — execução autorizada
|
||||
|
||||
**Extensão posterior:** [consultas/cotações/adjudicação](consultas-adjudicacao-sgf.md), candidata 040fb0cb67a77d61, preserva este percurso e a origem de 100 MZN. Preparação/leitura do novo percurso estão disponíveis em 8039; adjudicação completa foi testada apenas nas cópias, por faltar composição de identidades autorizada na demonstração. O relato abaixo preserva a entrega anterior.
|
||||
|
||||
## Actualização de leitura — limitação resolvida em 02/11/2026
|
||||
|
||||
A autorização posterior do proprietário permitiu corrigir exclusivamente a leitura no adaptador, sem direitos novos. A sucessora `OPERATIONAL-CANDIDATE-166e397e3ca63f02` está aplicada em 8039; Consulta lê a requisição existente ID 1/100 MZN na listagem, detalhe e histórico, mantendo todas as recusas de escrita/aprovação. Foi ensaiada a reversão para a anterior e a reaplicação; 109 tabelas reconciliadas, uma reserva, versão 3, contas/direitos/configuração preservados e auditoria apenas acrescentada. [Matriz, hashes e provas actuais](transferencia-sgf.md). O relato da limitação abaixo é histórico, sucedido por esta correcção. Revisão visual e implantação real continuam separadas.
|
||||
|
||||
02/11/2026. A confirmação do proprietário foi executada **apenas** na demonstração existente. Não foi criada instalação 8041, base ou conta nova, nem executado GRANT/REVOKE SQL. A instalação real permanece adiada, com `sgf_tempo_dev`, configuração Tempo e pacote 0.1.1 preservados.
|
||||
|
||||
## Local e configuração aplicada
|
||||
|
||||
- URL: `http://127.0.0.1:8039`; seleccionar a empresa/unidade sintéticas e o módulo Compras.
|
||||
- Pasta: `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\development-app`.
|
||||
- Base `sgf_modules_dev_2576427d1939`, cluster `sgf_modular_regression_2576427d1939`, porta 55439; contexto `1/1/1`.
|
||||
- Empresa Horizonte — Demonstração / Unidade Central — Demonstração; três contas sintéticas anteriores, mesmas credenciais e mesmos registos de utilizador.
|
||||
- Centro ID 1 `SINTETICO-REPRO`, referência de política `OPER`; dotação ID 1: **10.000,00 MZN sintéticos**, exercício 2026, operacional, inclui impostos; preparada por ID 1 e aprovada por ID 2.
|
||||
- Política activa com orçamento obrigatório, limites de 10.000,00 e 20.000,00, impostos incluídos no cálculo; intervenientes Preparador e Revisor em OPER, conforme proposta aprovada. O montante disponível do exemplo só cobre o primeiro nível.
|
||||
- Candidata `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, manifesto `a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b`; schema revisão 2 e código aplicado sem variante de compatibilidade. O ponteiro purchasing 0.1.1 continua preservado, inclusive na cópia.
|
||||
|
||||
Foram acrescentados exactamente os **14 direitos aprovados**, 8 ao utilizador 1, 4 ao 2 e 2 ao 3, apenas no contexto 1/1/1. [Prova com lista exacta e comparação dos direitos/contas anteriores](proofs/sgf-demo-8039-delivery.json). A inicialização extraordinária foi fechada após a outorga; administração posterior segue o catálogo nativo. Nenhum direito anterior foi retirado ou reescrito.
|
||||
|
||||
O servidor foi parado/reiniciado exclusivamente em 8039. A sessão local estava configurada como `array`, que perdia o estado entre pedidos depois desse arranque; passou a `file` no `.env` desta cópia, preservando autenticação e CSRF, sem alterar contas ou palavras-passe. Não houve compilação de caches nem mudança no `.env` real. A ligação proprietária isolada é limitação conhecida e não modelo de instalação cliente.
|
||||
|
||||
## Verificação e limitação encontrada
|
||||
|
||||
[Verificação HTTP focada](proofs/sgf-demo-8039-http.json) e [reconciliação final SQL somente de leitura](proofs/sgf-demo-8039-delivery.json):
|
||||
|
||||
- Login das três contas existentes; configuração/grants pelos mecanismos auditados existentes.
|
||||
- Centro e dotação preparados pela API, com aprovação de dotação por outra identidade; autoaprovação recusada.
|
||||
- Requisição ID 1 de **100,00 sintéticos**, submetida por ID 1, aprovada por ID 2; consulta de origem/versões e três eventos pelo Preparador e Revisor.
|
||||
- Repetição da submissão com a mesma chave devolveu a mesma requisição; reconciliação confirmou **uma requisição e uma reserva**, sem duplicação.
|
||||
- Perfil Consulta recusado em criação de centro, preparação, configuração de política, submissão, decisão, aprovação de dotação e administração de acessos. Nenhuma mutação indevida criada.
|
||||
|
||||
**Limitação funcional explicitamente detectada:** `purchasing_requisitions.view` sozinho não autoriza uma terceira identidade a ler este registo; a regra actual restringe a consulta a requerente/configurador/decisores elegíveis. A Consulta abre a listagem mas recebe 404 no registo da outra pessoa. Não se concedeu aprovação/configuração para contornar isso nem se alterou a regra de visibilidade. A proposta de um perfil que consulte requisições de terceiros não fica demonstrada como concluída: requer decisão específica sobre visibilidade e eventual correcção do servidor. Esta limitação não impede o percurso Preparador → Revisor → consulta pelos intervenientes já autorizados.
|
||||
|
||||
As recusas ao Consulta podem ocorrer antes da autorização de operação, pela regra de visibilidade (404), e não apenas por 403. As provas anteriores de concorrência/restauro/caches e navegação não foram repetidas integralmente. Revisão visual permanece separada; nenhum navegador utilizado.
|
||||
|
||||
## Recuperação preparada antes da primeira escrita
|
||||
|
||||
Pasta privada existente: `C:\laragon\data\sgf-tempo\run\modular-regression-2576427d1939\private\requisitions-recovery`.
|
||||
|
||||
- `before.dump`: backup custom integral **só da base sintética**, SHA-256 `2eed6d9f775d8d5dc8c7b1606e230b8782c09a69098d31e659d9ad28a4d608fa`; índice conferido por pg_restore. Não se declara novo ensaio integral de restauro deste dump; as provas anteriores de recuperação são reutilizadas.
|
||||
- `files/`: cópias anteriores de todos os overlays, `.env`, configuração modular e ponteiro; `baseline.json`: identidade, grants anteriores e hash das contas; `applied.json`: ID `a6478223e17a5430652f0fcb` e journal de aplicação.
|
||||
- Journal/backups nativos: `development-app/storage/app/private/module-deployments`. Preservar também os três novos ficheiros `config/purchasing-{operational,internal,access}.php` ao diagnosticar uma falha; não existiam antes.
|
||||
|
||||
Em falha, fechar apenas 8039. Reversão de código usa a CLI do bundle e o ID registado, sob manutenção/servidor parado; repor os ficheiros de configuração anteriores, incluindo `.env`, e retirar da composição os novos ficheiros apenas após os arquivar. Não voltar a criar contas ou apagar concessões para simular reversão. Código não reverte dados: recuperar o dump para base alternativa com procedimento DBA autorizado, reconciliar quaisquer operações posteriores ao backup e só então decidir a ligação da demonstração. Não sobrescrever a base activa, não usar comandos de paragem do cluster partilhado e não tocar na porta 55432.
|
||||
|
||||
Scripts específicos deste incremento: `scripts/sgf-demo-8039.php` e `scripts/sgf-demo-8039-http.mjs`. A preparação recusa sobrescrever a recuperação anterior. O runner HTTP é um registo operacional com chaves persistentes privadas; não o reaplicar cegamente após o fecho da inicialização. Não contém credenciais nem acede a contas reais.
|
||||
@@ -1,5 +1,57 @@
|
||||
# Desenvolvimento do SGC
|
||||
|
||||
## Consulta de fornecedores/cotações/adjudicação — entrega para revisão, 02/11/2026
|
||||
|
||||
Implementado núcleo partilhado Sourcing e adaptador/ecrãs SGF, reutilizando preços/descontos e pontuação existentes, defaults preservados de comparação, autenticação/contexto/permissões e reserva da origem. Candidata **040fb0cb67a77d61**, ZIP SHA-256 `dfb122bc4532ce730d500ddec0112fe4d93cb1197fa5aef21b576f4049e7312a`; [relatório/matriz/provas](consultas-adjudicacao-sgf.md). Fonte aprovada ID 1/100 MZN/versão 3, reserva e consumo preservados; sem nova requisição ou efeito de pagamento. Schema aditivo com quatro tabelas e ledger sourcing R1; ledger base R2 preservado, catálogo explicitamente ampliado sem outorga automática.
|
||||
|
||||
Após a decisão do proprietário «Preparar sem criar outra conta» e autorização do mapa, concedidos **somente três direitos** em 1/1/1: prepare ao ID 1, evaluate/propose ao ID 2. Consulta sem acréscimos. Em 8039 ficam três fornecedores/cotações e uma avaliação elegível por cotação, **sem proposta/confirmação** por faltar o segundo avaliador e confirmador independente. Fluxo completo passou apenas nas cópias, com identidade/departamento de fixture; nenhum utilizador persistente criado. Testados replay, concorrência real/CAS/confirmações concorrentes, versão obsoleta, revogação, acesso indevido, falha transaccional, migração/replay/CLI, imutabilidade e recuperação de código separada de restauro de dados/schema. Refinamentos finais de código preservaram também os dados SS antes/depois. Provas anteriores aceites reutilizadas.
|
||||
|
||||
Pendências explícitas: composição de identidades, parametrização comercial/cadastro real, orçamento divergente, níveis acima do primeiro limite e restrição nativa de delegação. Não ampliar direitos ou mudar regras por arrasto. Próxima decisão é a revisão destas limitações, não iniciar encomendas/recepção/pagamentos. Tempo/sgf_tempo_dev/0.1.1 preservados; nenhuma publicação, contacto externo, conta 8041 ou GRANT/REVOKE SQL. Revisão visual separada.
|
||||
|
||||
## Primeiro percurso de requisições fechado em 8039 — 02/11/2026
|
||||
|
||||
Autorizada e aplicada a correcção de leitura exclusivamente no adaptador SGF: view no contexto permitido já não exige autoria/aprovação/configuração. Nova candidata `166e397e3ca63f02` altera apenas Port.php face à 401ce31ae5f2dd20; hashes e [provas de HTTP/actualização/reversão/reconciliação](transferencia-sgf.md) registados. Consulta lê listagem/detalhe/três eventos da requisição existente de 100 MZN, sem recriação/submissão; recusas de escrita, decisão, administração e contexto indevido mantidas. Reversão reproduziu a limitação antiga e reaplicação restituiu leitura; reserva única, versão 3, direitos/contas/configuração intactos. 109 tabelas reconciliadas na demonstração actual; auditoria anterior preservada com novos eventos de recusas. Sem schema/DCL ou implantação real. Próximo percurso escolhido: requisição aprovada → processo/consulta de fornecedores → cotações/comparação → adjudicação independente e histórico, ainda por incorporar. Não confundir com integração integral. Revisão visual separada; Tempo/sgf_tempo_dev em 0.1.1 preservados.
|
||||
|
||||
## Execução autorizada na demonstração 8039 — 02/11/2026
|
||||
|
||||
Confirmados pasta/base/cluster/contexto e preparado backup privado antes da escrita. Habilitada candidata somente no desenvolvimento existente 8039/55439, com exactamente 14 direitos aprovados no contexto 1/1/1, centro SINTETICO-REPRO/OPER e dotação 10.000 MZN/2026 aprovada por ID 2 após preparação por ID 1. Requisição 1/100,00 submetida, retry sem reserva duplicada, aprovada por outra identidade e consultada pelos intervenientes. Contas/direitos anteriores preservados; bootstrap extraordinário fechado. Sessões desta cópia corrigidas de array para file. [Entrega/provas/recuperação e limitação de consulta](demonstracao-requisicoes-8039.md): view isolado não permite ao Consulta ler requisição alheia, recebe 404; não alterada a regra nem acrescentados direitos para contornar. Próxima decisão é definir esse âmbito de leitura. Nenhum GRANT/REVOKE, 8041, conta nova, publicação ou alteração da Tempo/sgf_tempo_dev; implantação real adiada, 0.1.1. Revisão visual separada e provas anteriores reutilizadas.
|
||||
|
||||
## Proposta única de desenvolvimento isolado — próximo âmbito corrigido
|
||||
|
||||
Por indicação do proprietário, suspensa a proposta de instalação técnica desactivada no SGF real. [Proposta concreta](proposta-desenvolvimento-sgf.md) reutiliza desenvolvimento persistente 8039/55439, base `sgf_modules_dev_2576427d1939`, empresa/unidade e três identidades sintéticas existentes. Leitura READ ONLY confirmou a identidade e role proprietária e ausência de cb/ir; nenhum acesso alterado. Valores de centro/dotação/política são exclusivamente proposta sintética. Sem novo DCL, sem segundo administrador exigido para demonstração; regra de dois na entrega empresarial identificada. Confirmação pendente antes de habilitar/atribuir acessos; `sgf_tempo_dev` e Tempo intactos, pacote 0.1.1.
|
||||
|
||||
## Candidata SGF corrigida — estado actual, 02/11/2026
|
||||
|
||||
Sucede ao inventário/bloqueantes históricos abaixo: `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, schema revisão 2 com as duas dependências incluídas, centros/dotações por interface e catálogo administrativo compilado. **157 controlos aprovados**, restauro de **103 tabelas**, roles distintas e restritas, apenas dados sintéticos em bases removidas. [Decisão/procedimento actualizado](decisao-instalacao-sgf.md), [matriz](transferencia-sgf.md), [prova](proofs/sgf-corrected-delivery.json) e [SQL proposto, não executado](proofs/sgf-corrected-sql-plan.json). SGF permanece **0.1.1**, sem instalação, activação ou alteração de acessos/base. Próximo passo exige confirmação separada da operação concreta e SQL, backup/restauro real e configuração da empresa; detalhes do incremento no registo final deste documento.
|
||||
|
||||
## Decisão de instalação: inventário real e bloqueantes — 02/11/2026
|
||||
|
||||
Pedido expresso: apresentar decisão concreta, **sem instalar até confirmação**. Reconferidos ZIP/hashes e planos read-only; inspeccionada a base efectiva `sgf_tempo_dev`/127.0.0.1:55432 por transacção READ ONLY. Resultado em [proofs/sgf-installation-decision-inventory.json](proofs/sgf-installation-decision-inventory.json); decisão em [decisao-instalacao-sgf.md](decisao-instalacao-sgf.md). 87 tabelas existentes, nenhuma cb/ir/instalação, role runtime `sgf_dev_app` sem DDL; proprietário `sgf_dev_owner` identificado, sem credenciais consultadas. Baseline de concessões/pertenças/auditoria e ponteiro preservado. Sem backups, migrações, grants, aplicação de código ou controlo de serviços.
|
||||
|
||||
Recomendação fundamentada: não aprovar o ZIP actual como percurso operacional no destino. Além de ligação migradora e privilégios mínimos das novas tabelas/sequências (não incluídos no instalador para runtime separado), falta bootstrap operacional por interface dos centros/dotações; `BudgetWorkspace` depende de cb_ui_commands/cb_consumption_sources ausentes do contrato candidato. As provas sintéticas com role proprietária e cadastros preparados conservam o seu âmbito, não provam esse arranque vazio/least-privilege. Catálogo funcional está implementado na candidata, ainda não instalado; equipamentos/peças são fora do âmbito básico. Uma só concessão administrativa no destino, sem segundo administrador para entrega/activação. Não contornar com fixtures, SQL manual de negócio, permissões DDL no runtime ou orçamento desactivado por conveniência.
|
||||
|
||||
Matriz reconciliada com **aceitação técnica expressa da navegação modular em 02/11**; pendências visual/associações/volume separadas, sem repetir suites. Próxima acção técnica: resolver/ensaiar as lacunas de implantação e só então apresentar operação exacta para confirmação; não presumir instalação/activação pelo pedido de decisão. SGF continua 0.1.1 e candidata 5fae7cf7f99c42ba imutável.
|
||||
|
||||
## Reconciliação do estado da navegação modular SGF — 02/11/2026
|
||||
|
||||
Na continuação após a candidata de updates, conferidos os documentos próprios do SGF `MODULAR_NAVIGATION_DELIVERY`, `MODULAR_NAVIGATION_COMPLETION`, `MODULAR_NAVIGATION_VISUAL_REVIEW` e `proofs/modular-navigation-recheck-20261102.json`. Existe resultado técnico documentado: seis módulos, 185 testes/3.220 asserções, suite focada de 8/90 e três perfis isolados, com retoma de integridade já registada no SGF. As referências anteriores de Compras a «sem resultado apresentado» são agora históricas e sucedidas por este registo e pela matriz actualizada. Não foram repetidas suites, comparação de 184 ficheiros ou provisionamento nesta leitura documental.
|
||||
|
||||
Revisão visual/recargas, reconciliação de associações antigas e volume/paginação mantêm pendências próprias; não confundir navegação implementada com processos comerciais completos ou aceitação. A interface chama à configuração geral «Opções gerais». Sem alteração de código/dados/permissões, navegador ou instalação de candidata; SGF 0.1.1 preservado. A decisão de instalar a candidata continua separada. [Matriz reconciliada](transferencia-sgf.md).
|
||||
|
||||
## Transição versionada e caches Laravel nativos — 02/11/2026
|
||||
|
||||
`CodeDeployment` evoluiu para actualização de um bundle instalado sob o mesmo contrato: registo privado da versão seleccionada, plano ligado ao estado actual, arquivo do bundle anterior, reversão local exacta, recusa de reversão fora de ordem/replay antigo, novas tentativas sem sobrescrever journals e verificação de origem/conteúdo extra/schema/baseline. Diferenças em schema/catálogo/composição exigem plano próprio. Bundle antigo sem proveniência não é adoptado implicitamente. Ponteiro purchasing 0.1.1 continua separado e preservado.
|
||||
|
||||
Acção `cache` usa Artisan nativo (`config:cache`, `route:cache`, `event:cache`) pelo PHP fixado, arquiva/invalida caches antes de compilar e regista resultados parciais/hashes. Reversão de caches recompilados invalida-os para voltar a compilar na versão reposta; não reintroduz configuração compilada antiga. Retry de rollback não mexe nos caches reconstruídos da versão anterior. Caches posteriormente alterados são preservados por recusa de recuperação cega. Caches personalizados mantêm contrato separado.
|
||||
|
||||
Prova nova [sgf-update-cache-delivery.json](proofs/sgf-update-cache-delivery.json): **32 controlos aprovados**, transição desde `10dce6cd57fa1f31`, falha de troca, nova tentativa, caches nativos completos, compilação interrompida/cache inválido recuperados, CLI e reinícios de servidor PHP de teste. Verificados 503 de manutenção, 401 na rota em cache, 404 após retirar módulo e healthcheck 200. Sem suites anteriores repetidas, intervenção em serviços/bases/direitos públicos do destino ou publicação. Candidata `OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba` e ZIP verificável registados na prova e na [matriz/procedimento](transferencia-sgf.md). Dependencies reutilizadas por hardlinks apenas de cópia anterior descartável, nunca do vendor real. SGF de destino permanece 0.1.1. Próxima acção é revisão da candidata/procedimento e decisão separada de instalação; não activar por consequência do desenvolvimento. Reorganização modular posterior continua com entrega própria pendente.
|
||||
|
||||
## Aplicação e reversão do código em manutenção — 02/11/2026
|
||||
|
||||
Implementados `CodeDeployment` e `scripts/sgf-code-deploy.php`, com plan read-only, conferência de destino/manifesto/baseline, lock exclusivo, backups/journal duráveis, preparação/troca dos ficheiros e bundle, invalidação/restauro de caches padrão e recuperação de aplicação parcial. Exige manutenção de ficheiro e declaração --offline do operador; não termina/reinicia serviços, não abre manutenção, não activa configuração, não concede direitos e não abre base de dados. Recusa cache personalizado, ficheiros locais alterados e caches recriados; mantém estado para reconciliação em vez de sobrescrever. Primeiro baseline apenas; transição entre versões de bundle existente ainda pendente. Não confundir rename individual/journal com transacção multi-ficheiro: os serviços devem permanecer parados.
|
||||
|
||||
`scripts/sgf-code-deploy-proof.php`, executado pelo wrapper/PHP 8.4.27: **42 controlos novos aprovados**, CLI real e quatro cópias descartáveis só de ficheiros, incluindo interrupção durante sobreposições/depois da troca do bundle, recuperação, rejeição de concorrência/alterações locais/cache divergente e ponteiro preservado. [Prova](proofs/sgf-code-deploy-delivery.json), candidata `OPERATIONAL-CANDIDATE-10dce6cd57fa1f31`. Sem DB, browser, reinício de serviços ou repetição das suites aceites. Nenhum código/schema/concessão/ponteiro alterado no SGF de destino; permanece 0.1.1. Procedimento exacto e limites actualizados em [transferencia-sgf.md](transferencia-sgf.md). Próximo trabalho técnico: transição versionada do novo bundle e conferência dos caches nativos/gestor de serviços numa instalação isolada, sem aplicar no destino nem presumir activação.
|
||||
|
||||
## Composição de arranque e prova HTTP isolada SGF — 02/11/2026
|
||||
|
||||
Implementados `BootOverlay`, `Composition`, `RuntimeContext` e entry point local do bundle. A sobreposição do provider nativo carrega classes e rotas do módulo; descoberta de Compras reconhece `purchasing_requisitions.view`. Middleware específico é acrescentado depois da sessão/autenticação/contexto nativos e confere opção, destino/schema/âmbito, módulos adquiridos e selecção/geração do contexto. Autoridade inicial mantém âmbito administrativo, sem exigir direito funcional ainda não atribuído. A CLI aceita/verifica o entry point `bootstrap.php` do bundle. Candidatas anteriores não foram reescritas.
|
||||
@@ -1810,3 +1862,298 @@ Limites explícitos: autorização NÃO reserva fisicamente stock exclusivo; out
|
||||
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.
|
||||
|
||||
## 02/11/2026 — Reserva física e reposição automática parametrizada
|
||||
|
||||
Este incremento substitui as pendências de reserva exclusiva e geração de compra descritas no registo anterior de autorização/entrega; não substitui decisões, requisições, políticas ou provas históricas.
|
||||
|
||||
A migração49 acrescenta warehouse_reservations, diário imutável com vínculo à requisição, linha, autorização, operador e comando. Autorizar reserva material utilizável livre, sem lançar saída. Na autorização parcial só se reserva a parte disponível. A entrega consome exactamente a sua reserva e lança o movimento no StockWorkflow, na mesma transacção do evento e auditoria. Cancelar o saldo exige revisão departamental independente do requisitante e liberta só a reserva ainda não consumida; entregas e recepções anteriores são conservadas. Actualizar reserva permite comprometer material que entretanto entrou, dentro da autorização e do saldo original. Pedidos autorizados antes desta instalação exigem esta actualização explícita, sem reserva retroactiva silenciosa.
|
||||
|
||||
O único StockWorkflow protege compromissos também nas saídas avulsas, transferências, correcções aprovadas, devoluções/abates e estornos que reduzam material utilizável. Quarentena, validade e retenções de qualidade continuam independentes: podem tornar material reservado indisponível, mas a entrega é impedida e o défice aparece na disponibilidade. Uma devolução ou estorno físico não reabre automaticamente a requisição nem recria reserva consumida. Reservas de SupplyCoordination continuam a ser planeamento e não são somadas como compromissos físicos.
|
||||
|
||||
Novo menu Armazém e activos > Reposição automática, /stock/reposicao. A regra reutiliza a política aprovada do artigo/armazém: mínimo, alvo, máximo e múltiplo de embalagem. Requisitante, centro de custo, prazo pretendido e vigência são parametrizados uma vez; reutiliza preço em MZN do acordo e imposto da empresa quando disponíveis, permitindo estimativas fundamentadas. Peças publicadas no catálogo reutilizam a peça cadastrada e exigem equipamento compatível no departamento do requisitante; uma origem com destino técnico diferente fica pendente para reconciliação. Equipamento novo e serviços mantêm os seus percursos próprios. Não se assume destino, pessoa, preço, conversão de unidade, taxa cambial ou competência ausentes.
|
||||
|
||||
A preparação usa supply_prepare e a activação/suspensão usa supply_review. Todos os preparadores da regra ficam impedidos de a activar. Alterar a regra exige nova activação e aplica-se às gerações futuras. O catálogo de tarefas dos perfis explicita estas responsabilidades. Sem grants ou contas adicionais. A regra activa permite somente preparar um novo rascunho pelo motor InternalRequisitions/RequisitionWorkflow em nome do requisitante parametrizado: revalida competência vigente no contexto interno e centro/departamento, conserva o executor autenticado na auditoria e não muda a autenticação para fingir acção pessoal do requisitante. A configuração não permite submeter, aprovar, reservar orçamento, adjudicar, encomendar ou lançar saída automaticamente.
|
||||
|
||||
Após confirmação das alterações de stock, lotes, pedidos ou requisições de compra associadas, a avaliação corre sob o mesmo bloqueio de empresa. Considera stock utilizável menos compromisso físico, saldo ainda não reservado dos pedidos e reposição efectiva já em curso. Calcula a quantidade com aritmética inteira e regras aprovadas; embalagens que excedam o máximo ficam pendentes. Reutiliza compras automáticas anteriores, sugestões convertidas e entregas confirmadas ligadas ao local, sem contar duas vezes a mesma ordem. Recebimentos aceites efectivos, incluindo estornos, reduzem a cobertura pendente. Rascunhos alterados de artigo/repartidos exigem reconciliação. Uma compra rejeitada não é recriada pela mesma regra: exige revisão e activação de nova versão. Compras manuais sem vínculo autorizado a este artigo/armazém não têm o destino inferido a partir do nome.
|
||||
|
||||
As necessidades e regras usam versões imutáveis de supply_records, sem outra estrutura paralela. A requisição gerada conserva ligação à necessidade e mostra as requisições ao armazém de origem, local de utilização e equipamento; versões posteriores não reescrevem essa origem. Sem configuração válida, regista necessidade pendente e motivo. GET apenas consulta. Reavaliar é uma intervenção explícita de preparação/revisão. Uso interno continua sem factura; venda e integração de facturação pertencem à composição SGF.
|
||||
|
||||
Instalação49 aplicada exclusivamente à avaliação8005, com backup/restauro conferidos e109 tabelas existentes preservadas; nova tabela vazia, sem contas, permissões ou dados sintéticos. Prova: docs/proofs/warehouse-reservations-install.json. A avaliação existente foi retomada porque a porta estava desligada, sem provisionar outra base ou redefinir contas.
|
||||
|
||||
Verificações: bateria geral920 testes,918 passaram,2 anteriormente ignorados,9620 asserções, sem falhas; storage/logs/warehouse-reservations-full.txt. PostgreSQL descartável24 testes/232 asserções e três disputas reais de bloqueio passaram: repetição da mesma entrega, comandos diferentes da mesma entrega, activação concorrente da regra com um só rascunho. Prova em warehouse-drafts-pg.json. Compilação, tipos, lint/formatação, análise PHP e integridade dos18 ficheiros dos núcleos passaram; acesso HTTP à página de reposição confirmado como administrador com consulta, sem preparação/revisão ou comandos de negócio. Prova consolidada em warehouse-replenishment.json. Sem revisão visual por capturas e sem declarar todos os CA01–09 cumpridos.
|
||||
|
||||
Limites e continuidade: preenchimento de reservas após reposição é explícito pelo revisor; não foi criada política automática de prioridade entre pedidos concorrentes. Avaliação automática é posterior à confirmação das alterações, com bloqueio/idempotência e reavaliação pela interface; ainda não existe fila durável com recuperação automática de interrupção entre confirmação física e avaliação, nem varrimento agendado para passagem do tempo/validade sem qualquer evento. Necessidades podem ser reavaliadas pela tarefa autorizada. Prazos de expiração de reservas, revisão de pedidos já submetidos, avisos próprios do percurso de armazém e composição comercial SGF continuam a exigir incrementos específicos; nenhuma alçada ou canal foi inventado.
|
||||
# 02/11/2026 — candidata SGF corrigida, exclusivamente isolada
|
||||
|
||||
Preparada `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`: schema revisão 2 inclui as duas dependências `cb_ui_commands`/`cb_consumption_sources` (16 tabelas novas); preparação autorizada de centros/dotações pela interface, catálogo administrativo integrado no frontend compilado e plano SQL mínimo separado da ligação migradora. Nenhum dado sintético distribuído. [Decisão e procedimento](decisao-instalacao-sgf.md), [matriz](transferencia-sgf.md), [prova](proofs/sgf-corrected-delivery.json), [SQL proposto, não executado](proofs/sgf-corrected-sql-plan.json).
|
||||
|
||||
157 controlos passaram na reprodução do schema actual de 87 tabelas, com owner/runtime restritos distintos: migração/falha, percurso básico/independência/reserva, catálogo/navegação, revogação/contexto, permissões SQL e actualização/reversão. Restauro em segunda base reconcilia 103 tabelas, schema/triggers e origem/decisão/reserva; apenas dados sintéticos, bases/roles removidas. Provas anteriores válidas reutilizadas. ZIP SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`.
|
||||
|
||||
SGF permanece **0.1.1**; nenhuma instalação, concessão/revogação, mudança de base/configuração ou publicação. Pendências: confirmação separada da operação e SQL, backup/restauro real na janela aprovada, configuração real e dois administradores para activação. Equipamentos/peças fora do âmbito; navegação modular com aceitação técnica anterior reconciliada. Preservado trabalho local alheio ao incremento.
|
||||
|
||||
|
||||
|
||||
### 02/11/2026 ? candidata SGF utiliz?vel, ainda n?o instalada
|
||||
|
||||
Conclu?das depend?ncias or?amentais de interface, administra??o pelo cat?logo nativo e prepara??o de centros/dota??es. Candidata 401ce31ae5f2dd20, schema revis?o 2; 157 controlos integrados + 17 React/render/assets + 54 deployment. Restauro efectivo de 103 tabelas e actualiza??o/recupera??o de c?digo sobre dados sint?ticos verificados. Ver docs/transferencia-sgf.md e a proposta revista no SGF. N?o executar a proposta antiga 5FAE. SGF continua 0.1.1, sem tocar em sgf_tempo_dev ou conceder direitos; instala??o/configura??o e mapa SQL s? para decis?o posterior. Preservar trabalho paralelo; equipamento/pe?as fora, revis?o visual/associa??es antigas/volume-pagina??o pendentes.
|
||||
|
||||
## Avaliação do estado do SGC — 02/11/2026
|
||||
|
||||
Avaliação de código, registos de implementação, provas existentes e acesso HTTP; sem alteração de regras ou dados operacionais. Diagnóstico: base funcional ampla, adequada a piloto controlado após configuração, ainda em consolidação para entrega comercial. Compras, Pessoas/relatórios e armazém têm percursos implementados; parametrização integral, automatização resiliente, avisos de armazém e composição comercial não devem ser declarados concluídos.
|
||||
|
||||
Verificação executada nesta avaliação:33 testes dirigidos passaram,314 asserções (AutomaticPurchases, WarehouseFulfilment, WarehouseAvailability e WorkContext);18 ficheiros comuns conferidos por SHA-256. HTTP confirmou reposição/avisos e relatórios de Pessoas com CSV na avaliação8005. A bateria geral920/918 passaram/2ignorados/9620 asserções é prova anterior reutilizada, não nova execução integral nesta avaliação. Não houve auditoria visual completa, teste de carga ou confirmação de paridade com produção.
|
||||
|
||||
Prioridades identificadas por leitura do código: AutomaticPurchases::signal usa afterCommit sem fila durável; interrupção entre confirmação física e avaliação pode exigir reavaliação explícita. Não existe varrimento agendado de validade/mínimo sem evento. AutomaticPurchaseController limita regras/necessidades/histórico a100 registos sem paginação, podendo omitir pendências mais antigas; avaliação percorre pedidos/históricos sob bloqueio de empresa, pelo que capacidade com volume não está demonstrada. Sequência de papéis de aprovação permanece parcialmente definida no código, apesar de limites e permissões configuráveis. Completar avisos/prioridades/expiração de reservas e validar tarefas/ecrãs/impressão com utilizadores antes da entrega. Integrações SGF em demonstração/candidata não equivalem a instalação no SGF real; a documentação própria conserva os respectivos limites e autorizações.
|
||||
|
||||
## Piloto controlado SGC — reposição resiliente e paginação — 02/11/2026
|
||||
|
||||
Prioridade confirmada pelo proprietário: consolidar Compras autónomo para piloto, começando pela recuperação de reposição após interrupções, verificação periódica de mínimos/validade e paginação; depois acompanhamento das reservas e validação com utilizadores. O incremento não altera a instalação real SGF0.1.1, não instala candidatas SGF e não cria a identidade sintética adicional cuja proposta continua sem autorização. Os33testes/314verificações e918aprovados/2ignorados da avaliação anterior continuam provas históricas, sem serem apresentados como execução nova ou revisão visual completa.
|
||||
|
||||
Migração50: replenishment_work, um trabalho durável por empresa/artigo/armazém, com geração do sinal, geração processada, responsável da origem, próxima execução, tentativas e erro técnico sanitizado. O sinal é inserido/actualizado na mesma transacção das alterações físicas/documentais; rollback da alteração também reverte o sinal. Sinais repetidos são acumulados no mesmo trabalho e a avaliação lê os dados actuais. A criação do rascunho pelo motor existente, origem imutável e confirmação de processamento são atómicas, sob bloqueio de empresa e trabalho. Interrupção antes da confirmação reverte tudo; interrupção após confirmação física mas antes da avaliação deixa trabalho recuperável. Não há reserva de identidade humana, concessão de competências, aprovação por tempo decorrido ou novo motor financeiro.
|
||||
|
||||
ReplenishmentRun é um âmbito restrito a CLI, sem sessão/utilizador autenticado, composição SGC e empresa do trabalho persistido. Não modifica a autenticação nem cria conta técnica. A auditoria identifica scheduled_replenishment, trabalho/empresa e pessoa responsável pelo sinal/regra como atribuição de origem, não como autora de uma intervenção interactiva. O histórico no detalhe da requisição distingue execução periódica. CurrentActor mantém a validação normal de todos os comandos humanos; só o novo rascunho de reposição sob regra aprovada pode usar o âmbito restrito. Requisitante, centro, política, catálogo, destino técnico e contexto continuam revalidados. Contexto empresarial desligado ou produto fora da composição SGC impede execução. Autoridade de origem pode mudar enquanto um recuperador espera: o responsável é novamente conferido no trabalho sob o bloqueio da empresa.
|
||||
|
||||
sgc:replenishment-tick descobre pares de políticas/regras/necessidades e origens de armazém já existentes, recuperando também a janela anterior à instalação, e processa trabalhos vencidos por lotes. O agendamento Laravel está declarado a cada minuto; cada trabalho tem próxima verificação persistida, por defeito5minutos, configurável em SGC_REPLENISHMENT_SCAN_MINUTES. É uma cadência técnica, não prazo de aprovação. Falhas técnicas mantêm o sinal e voltam a tentar com espera limitada; situações funcionais sem configuração válida permanecem necessidades pendentes visíveis e voltam a ser conferidas. Não é preciso um novo movimento físico para detectar validade de lote/regra ou stock mínimo. A elegibilidade de qualidade continua independente e nunca se liberta lote para satisfazer uma reserva.
|
||||
|
||||
/stock/reposicao pagina separadamente regras, necessidades, histórico e trabalhos técnicos pendentes/interrompidos. Necessidades pendentes aparecem primeiro, com filtro de estado; os registos antigos deixaram de desaparecer pelo limite fixo100. Os trabalhos sem necessidade apresentada ainda são visíveis por artigo/armazém, motivo sanitizado e próxima tentativa. Reavaliar agora exige supply_prepare ou supply_review; administrador só de consulta não executa a acção. Links preservam parâmetros e o servidor filtra empresa, permissões e contexto. GET não cria compras. A interface mostra intervalo, contadores e data/resultado da última passagem conhecida do serviço, sem afirmar serviço activo a partir da mera instalação.
|
||||
|
||||
Instalação50 apenas na avaliação8005: backup/restauro verificados,110 tabelas anteriores preservadas, nova tabela inicialmente vazia, sem contas, grants ou dados sintéticos adicionados. Depois, o serviço descobre e acompanha apenas origens já existentes. Prova replenishment-recovery-install.json. scripts/replenishment-evaluation.php valida a configuração/base isolada existente e mantém verificação periódica com exclusão local, executando processos CLI frescos e estado técnico sem segredos. Arrancado oculto nesta sessão e observado em passagens sucessivas; integrado no arranque de desenvolvimento existente. Não é um serviço Windows instalado para arranque após reinício. Retomar composer run dev ou o auxiliar guardado depois de reiniciar o ambiente; em instalação comercial, preparar o agendador da instalação na respectiva janela autorizada.
|
||||
|
||||
Novas provas, separadas das anteriores: regressão alargada198testes/1579asserções passou; verificação final da reposição18testes/155asserções passou; PostgreSQL descartável33testes/310asserções e4disputas reais passaram, incluindo saída física confirmada seguida de término controlado do processo no início do callback e dois recuperadores concorrentes, com um único rascunho e sem humano autenticado. Também provados rollback de rascunho/checkpoint, rollback de sinal físico, validade sem movimentos, paginação de105pendências antigas, isolamento entre empresas, impossibilidade de usar âmbito de background numa sessão e bloqueio na composição SGF. Tipos, lint/formatação, compilação, análise PHP dos componentes alterados,18hashes e HTTP com paginação/estado recente do serviço passaram. Logs replenishment-pilot-regression.txt e replenishment-pilot-final-tests.txt; provas replenishment-pilot-pg.json, replenishment-pilot-http.json e consolidada replenishment-pilot.json. Não foi repetida a bateria geral anterior nem efectuada revisão visual completa ou aceitação por pessoas reais.
|
||||
|
||||
### Roteiro preparado para aceitação do piloto — ainda não executado com utilizadores
|
||||
|
||||
Usar contas e competências existentes, nominativas e autorizadas pela empresa; não criar identidades para ultrapassar independência. Identificar previamente quem requisita, quem autoriza e quem entrega. O requisitante confirma recepção. Esta distribuição do SGC autónomo não concede novas identidades/competências no SGF.
|
||||
|
||||
| Caso | Acção do participante | Resultado a conferir |
|
||||
| --- | --- | --- |
|
||||
| Pedido interno | Requisitante selecciona artigo, centro e local/equipamento aplicável | Origem reutilizada; sem factura e sem saída/reserva no rascunho |
|
||||
| Autorização | Outro responsável decide; aceita parcial apenas se o requisitante a tiver aceite | Reserva só do material utilizável livre; decisão e autoria preservadas |
|
||||
| Entrega parcial | Armazém, distinto de requisitante e autorizador, confirma quantidade real | Só a quantidade entregue sai; reserva restante e pendência ficam visíveis |
|
||||
| Recepção | Requisitante confirma uma entrega concreta | Não há segunda saída física |
|
||||
| Cancelamento de saldo | Responsável competente fundamenta o cancelamento | Liberta só o restante; entregas/recepções anteriores preservadas |
|
||||
| Falta/mínimo | Necessidade numa regra já activada por responsável independente | Um rascunho para o requisitante configurado; nenhuma aprovação automática |
|
||||
| Recuperação | Conferir trabalhos interrompidos e repetição de reavaliação | Um rascunho associado; erro/pendência visível e tentativa seguinte registada |
|
||||
| Validade | Conferir lote/regra que deixa de ser válido | Sem saída de lote indisponível; nova avaliação sem movimento; pendência fundamentada |
|
||||
| Pendências antigas | Filtrar e percorrer páginas | Conseguir localizar o pedido antigo sem conhecer o seu ID |
|
||||
|
||||
Continuidade: completar acompanhamento próprio das reservas (avisos/tarefas, prioridades e expiração expressamente parametrizadas), executar este roteiro com utilizadores existentes e registar observações por percurso. Não declarar aceitação humana, desempenho em grande volume ou cobertura comercial SGF com base nestes testes técnicos.
|
||||
|
||||
## Acompanhamento das reservas e validação sintética — 02/11/2026
|
||||
|
||||
Clarificação posterior do proprietário: nesta fase não criar pessoas reais; continuar a desenvolver e validar com contas sintéticas, tendo em vista a comercialização após o desenvolvimento. Orientação incorporada em AGENTS.md. A consulta de utilizadores da empresa na avaliação8005 confirmou contas sintéticas já existentes; não foram criadas contas, alterados perfis ou concedidas competências nesse ambiente. A validação funcional adicional usa actores/perfis sintéticos explícitos em bases descartáveis. Não confundir estes ensaios com aceitação por utilizadores reais, configuração de uma empresa cliente ou autorização da identidade adicional proposta no SGF. SGF real permanece fora deste incremento, em0.1.1.
|
||||
|
||||
Novo WarehouseWorkdesk, exclusivamente de leitura, sobre WarehouseRequest, WarehouseRequestEvent e warehouse_reservations existentes. /stock/requisicoes reúne tarefas por competência efectiva: autorizar, acompanhar reservas abertas, entregas pendentes e recepções por confirmar, além da consulta do âmbito permitido. Não cria uma segunda lista de concessões ou outro workflow. Tarefas desaparecem/reaparecem com o estado e a autoridade actuais; requisição própria não é autorizada pelo seu autor, e quem autorizou não se torna entregador do mesmo pedido. Revogação/vigência de perfil e departamento/contexto continuam aplicáveis. Administração só de consulta não recebe tarefas operacionais por consultar o painel.
|
||||
|
||||
Filtros por tarefa, estado, armazém, referência RA-id, requisitante ou local são aplicados no servidor antes da paginação. Contagens abrangem todo o conjunto autorizado, sem depender da página ou dos últimos100registos; ordem de registo, mais antigos primeiro, não é uma prioridade automática de afectação de stock. A projecção SQL de estados deriva dos eventos e quantidades preservadas, sem escrever saldos/estados paralelos. Foi confrontada com WarehouseFulfilment::progress nos estados rascunho, submetida, autorizada, parcial, entregue, encerrada, rejeitada e cancelada, em SQLite e PostgreSQL. As decisões/movimentos continuam a usar os comandos e o único StockWorkflow existentes.
|
||||
|
||||
Cada pedido apresenta saldo reservado/sem reserva e avisos de acompanhamento. Lotes não reconciliados, défice global de material utilizável comprometido, alteração da origem/destino e saldo ainda sem reserva ficam explícitos, sem divulgar novas quantidades globais a quem só consulta o seu documento. A indicação de prontidão é momentânea, não uma garantia de saída: o servidor revalida versão, origem, reserva, qualidade e disponibilidade no registo efectivo. O formulário de entrega só é apresentado com material reservado elegível no momento da consulta; não se liberta qualidade nem se autoriza entrega para satisfazer o aviso.
|
||||
|
||||
O detalhe mostra pessoas actualmente elegíveis para requisição/recepção, autorização/reserva e entrega, reutilizando perfis e âmbito reais do servidor. Ausência de pessoa elegível é visível e exige parametrização explícita, sem nome ou tarefa presumidos. Esta informação actual não reescreve autores históricos e fica fora da impressão do documento de saída. A confirmação de recepção pendente continua como tarefa mesmo depois de cancelar o saldo não entregue; confirmar não lança outra saída. Pedidos fechados/cancelados não são reabertos por mera consulta ou estorno físico.
|
||||
|
||||
O formulário de nova requisição ficou recolhido até ser solicitado e abre em caso de erro, para deixar o acompanhamento visível sem acrescentar campos obrigatórios ao percurso. O catálogo de tarefas nos perfis explicita preparação/confirmação e entrega independente, usando as permissões existentes. Lista e detalhe usam leitura coerente em transacção PostgreSQL read-only/repeatable-read; consultas não geram compras, avisos persistentes ou movimentos. Este incremento acrescenta avisos visíveis no acompanhamento, não envio externo nem notificações com estado de leitura. Não foram inventadas prioridades organizacionais, prazos de expiração ou libertações automáticas de reserva.
|
||||
|
||||
Validação NOVA com contas sintéticas:111testes/1008asserções na regressão dirigida;18testes/326asserções na verificação final de pedidos; PostgreSQL descartável38testes/517asserções e3disputas reais reutilizando os cenários de entrega/compra, sem duplicação. Cobertura específica inclui percurso completo parcial/recepção, cancelamento com recepção ainda pendente, ausência/revogação de entregador elegível, separação de funções, filtros antes da paginação, pesquisa de pedido antigo, âmbito empresarial/departamental/armazém e impedimento de saída de lote retido. Tipos, lint/formatação, compilação, análise PHP,18hashes e HTTP de consulta passaram. Provas separadas: warehouse-workdesk.json, warehouse-workdesk-pg.json, warehouse-workdesk-http.json; logs warehouse-workdesk-regression.txt e warehouse-workdesk-final.txt. Sem migração, dados sintéticos adicionados à avaliação persistente, concessões ou instalação SGF; sem nova execução da bateria geral anterior e sem declarar revisão visual completa.
|
||||
|
||||
Continuidade: continuar validação sintética e consolidar cenários de acompanhamento antes da comercialização. Se forem acrescentadas prioridades, prazos de expiração ou notificações persistentes, preparar configuração explícita e revisão independente dos efeitos; não usar o tempo decorrido para aprovar, entregar ou libertar material por inferência. A configuração funcional das contas da avaliação deve continuar explícita e separada dos perfis ensaiados em bases descartáveis.
|
||||
|
||||
## Preparação do piloto e ensaio sintético integrado — 02/11/2026
|
||||
|
||||
Adicionado /stock/piloto, menu Armazém e activos > Preparação do piloto, reservado a administração. WarehousePilotReadiness é diagnóstico somente de leitura, em transacção coerente PostgreSQL read-only/repeatable-read. Apresenta componentes instalados, tarefas e independência, existência de centro no departamento, catálogo publicado com origem actual, regras activas e fontes válidas, execução periódica observada, trabalhos a recuperar e configuração própria da empresa. Mostra contas cadastradas e pessoas com tarefas elegíveis, com ligações à parametrização existente. Não cria contas, atribui perfis, activa regras, lança movimentos ou aprova compras.
|
||||
|
||||
O diagnóstico distingue condições básicas para ensaio de armazém, reposição periódica e configuração de cliente. Há verificação de três intervenientes distintos, requisitante/autorizador do mesmo departamento e centro de custo compatível; distribuir os três rótulos por apenas duas pessoas não basta. Administração permanente segue a regra existente de dois administradores nominativos, fora de manutenção e sem prazo. Compatibilidade com papéis de desenvolvimento e contexto de avaliação não são certificados como configuração de cliente concluída. Falta de stock não é tratada como erro de configuração, pois é precisamente um cenário a ensaiar.
|
||||
|
||||
AutomaticPurchases::inspectRule reutiliza a validação de requisitante/centro e as mesmas fontes/políticas/catálogo/acordo/destino técnico da geração, mais os limites de preço/imposto do formulário canónico. A parte comum de validação do requisitante foi separada da aquisição de locks, preservando-os nos comandos e permitindo inspecção sem escrita. Não executa o comando de geração nem substitui autenticação para obter um resultado. Regra expirada, origem alterada, perfil revogado e centro incompatível deixam de contar como válidos. Regras/fontes mudadas continuam a exigir revisão explícita, sem actualizar os documentos históricos.
|
||||
|
||||
Estado do serviço periódico centralizado em ReplenishmentQueue::status e reutilizado na reposição e no diagnóstico. Telemetria opcional inexistente/danificada não bloqueia a consulta; registo antigo ou de futuro não prova serviço activo. Uma passagem recente com resultado favorável não oculta sinais técnicos por processar nem trabalhos em recuperação: esses impedem o indicador de reposição pronta. Necessidades funcionais sem configuração continuam no acompanhamento de reposição. Não foi acrescentado prazo de aprovação ou expiração de reserva; a recência é apenas observação técnica do serviço.
|
||||
|
||||
Ensaio integrado novo em WarehousePilotJourneyTest: três actores sintéticos, artigo publicado, política e parametrização com conferência independente; pedido interno125unidades com120utilizáveis, reserva parcial120 e um rascunho canónico de reposição108 conforme embalagem/alvo do ensaio. Entrega100, confirmação, entrada108 numa ordem já emitida usada como origem física independente, reforço autorizado de reserva5, entrega final25 e recepção encerram o pedido. Saldo físico final103, reserva0, sem factura de saída interna e sem segundo rascunho. A requisição automática continua rascunho, com zero etapas decididas e sem reserva orçamental automática. Este ensaio não apresenta a chegada da ordem preexistente como execução/aprovação do novo rascunho de compra: a preservação do circuito financeiro é parte da verificação.
|
||||
|
||||
Testes do diagnóstico cobrem leitura sem alteração de dados/perfis/auditoria, administração exclusiva, isolamento empresarial, combinação de papéis insuficiente, cadastro desactualizado, regra expirada/requisitante sem competência, telemetria antiga/corrompida e passagem recente que não esconde recuperação pendente. As alterações de ambiente/ficheiro necessárias ao teste de telemetria ficam em armazenamento temporário próprio, restaurado no final; não substituem o estado do serviço da avaliação persistente.
|
||||
|
||||
Provas NOVAS: regressão53testes/705asserções; final diagnóstico+percurso8testes/142asserções; PostgreSQL descartável46testes/659asserções e3disputas reais preservando entrega e rascunho únicos. Tipos, lint/formatação, compilação, análise PHP dos componentes alterados,18hashes e HTTP passaram. Provas warehouse-pilot-readiness.json, -pg.json e -http.json; logs warehouse-pilot-readiness-regression.txt e -final.txt. Suites sobrepostas não devem ser somadas. Não houve bateria geral anterior repetida, migração, alteração de concessões/contas persistentes, criação de pessoas reais, instalação SGF ou revisão visual completa.
|
||||
|
||||
Observação da avaliação8005 nesta conferência:12contas sintéticas cadastradas; instalação e passagem recente do serviço conferidas, recuperação técnica sem sinais de erro na consulta; tarefas/independência, catálogo, parametrização de reposição e configuração própria de cliente ainda apresentam pendências. O diagnóstico conserva esses resultados em vez de dar a avaliação por pronta ou preencher configurações de forma presumida. SGF real continua fora deste incremento, em0.1.1, sem autorização inferida para a identidade adicional.
|
||||
|
||||
## Diagnóstico do piloto exportável — 02/11/2026
|
||||
|
||||
/stock/piloto passa a oferecer exportação CSV das conferências e opção de impressão pelo navegador. A exportação em /stock/piloto.csv exige a mesma administração autenticada da consulta, lê o mesmo WarehousePilotReadiness em transacção coerente/read-only e inclui data, âmbito SGC autónomo, conferência, resultado, observação e ligação à configuração. Não exporta o cadastro de contas nem executa os passos do roteiro. Cada exportação reflecte a sua própria data de leitura, podendo diferir de uma página anteriormente aberta; não é aprovação, activação, certificado de entrega ou arquivo imutável do servidor.
|
||||
|
||||
CSV UTF-8, separado por ponto e vírgula, com resposta privada/no-store e tratamento de texto que possa ser interpretado como fórmula por leitores de folhas de cálculo. As verificações incluem recusa a leitor operacional sem administração, ausência de alteração de utilizadores/perfis/auditoria/stock/requisições e neutralização de campos semelhantes a fórmulas. A opção de impressão foi acrescentada à interface, sem declarar revisão visual de impressão.
|
||||
|
||||
Provas NOVAS:10testes/161asserções passaram no diagnóstico/percurso e exportação, tipos/lint/build/análise PHP e18hashes passaram; CSV conferido no HTTP da avaliação8005. Logs pilot-export-tests.txt e pilot-export-build.txt; provas pilot-export.json e pilot-export-http.json. Sem migração, contas, pessoas reais, concessões, configurações financeiras ou instalação SGF. As pendências de preparação já identificadas continuam explícitas; o relatório permite revê-las sem preenchimento presumido. Evidências anteriores e ausência de nova bateria geral/revisão visual completa preservadas.
|
||||
|
||||
## Interface reutilizável de entregas do armazém — 02/11/2026
|
||||
|
||||
Implementado WarehouseExchange e GET /stock/integracao/entregas, na aplicação SGC autónoma. É uma interface de leitura dos eventos imutáveis existentes, sem tabelas novas, cópias de stock, concessões ou comandos empresariais. Contrato de dados versionado em integrations/contracts/warehouse-fulfilment-v1.json. O serviço pode ser reutilizado na composição autenticada; a rota usa a sessão, verificação e configuração de empresa/contexto já existentes. Não é uma API pública nem autenticação técnica entre servidores.
|
||||
|
||||
Publica apenas deliver e receive: entregas físicas efectivas e confirmações do requisitante. Reserva, autorização e cancelamento de saldo não são publicados como saídas. Cada entrega transporta RA, empresa, versão do evento, data UTC, interveniente, armazém, centro, requisitante e destino originais. Nas linhas reutiliza artigo/unidade, quantidade inteira em milésimos, utilização, equipamento e stock_operation_id do único motor StockWorkflow. A confirmação referencia a chave da entrega e não repete linhas/movimento. Alterações posteriores ao cadastro não reescrevem estes dados. A requisição interna continua documento de saída sem factura.
|
||||
|
||||
Leitura incremental: limit entre1e100 (por defeito50), cursor opaco devolvido em next_cursor e has_more. Sem cursor, começa nos eventos antigos visíveis; mesmo no fim devolve cursor para futuras consultas. O cursor é cifrado/autenticado, ligado à empresa, conta, contexto, departamento e competências efectivas; mudança desse âmbito exige reiniciar leitura. Cada página revalida acesso e filtra antes de limitar; isolamento empresarial/departamental e leitura coerente PostgreSQL preservados. Não guardar cursor como autorização nem o transportar entre instalações/contas. Os comandos existentes serializam eventos por empresa, antes de se avançar por ID; não há outro escritor de eventos neste módulo.
|
||||
|
||||
Contrato de consumo: guardar os factos recebidos e o próximo cursor na mesma transacção do consumidor, avançando só depois de os aceitar. Uma repetição deve reutilizar a idempotency_key, com unicidade por ligação à instalação de origem + tenant_id + chave. A chave não é global entre instalações independentes. payload_sha256 identifica o conteúdo produzido (JSON dos campos precedentes, na ordem emitida, sem espaços/escapes de Unicode ou barras); igualdade serve para conferir replays. Mesma chave com conteúdo diferente exige reconciliação, não sobrescrita silenciosa. O resumo não autentica um canal externo. Campos adicionais compatíveis podem ser ignorados; alteração incompatível exige outra versão de contrato.
|
||||
|
||||
Âmbito preciso: estes são factos históricos de entrega/recepção, não sincronização de saldos nem instruções para lançar outra saída. Estornos físicos mantêm o percurso StockWorkflow e não apagam entregas antigas; não interpretar este feed isolado como espelho do stock actual. Não inclui autorizações completas, reservas actuais, catálogo integral, factura de venda, contabilidade ou contratos de Pessoas/RH. Conector remoto, credenciais técnicas, confirmação durável do consumidor e instalação no SGF são incrementos próprios, ainda não implementados por esta interface. Preservar a candidata e o SGF real0.1.1 sem inferir instalação/autorização da identidade adicional.
|
||||
|
||||
Validação NOVA:41testes/523asserções na regressão dirigida (Exchange, Fulfilment, Requests e WorkContext); subconjunto novo10testes/83asserções também em PostgreSQL descartável, sem disputas novas ou ensaio de carga. Cobertura de repetição estável, paginação/novas consultas, alteração posterior de cadastro/equipamento, reserva sem saída, cancelamento com recepção posterior, acesso revogado, cursor adulterado/de outra conta/âmbito, limites, isolamento e consulta sem alterar diários/perfis/auditoria. Formatação e análise PHP dirigidas passaram;18hashes comuns preservados. HTTP8005 confirmou consulta, cursor e limite, com zero factos na primeira página da avaliação actual; os factos sintéticos foram exercitados apenas nas bases de testes. Provas warehouse-exchange.json, warehouse-exchange-pg.json e warehouse-exchange-http.json; logs warehouse-exchange-regression.txt e warehouse-exchange-pg.txt. Contrato JSON analisado sem erro de sintaxe; não foi usado validador externo de JSON Schema. Sem mudanças de interface visual, build novo, execução geral ou revisão visual completa. Não foram criadas contas, alterados perfis persistentes ou instalados módulos no SGF.
|
||||
|
||||
## Receptor durável e leitura por lotes para integração — 02/11/2026
|
||||
|
||||
Acrescentado packages/integration, independente de Laravel/SGF e sem dependências financeiras adicionais: WarehouseFeed valida o contrato v1 de entregas; WarehouseInbox mantém recepção deduplicada e checkpoint numa só transacção PDO; WarehousePull coordena consulta por lotes e retoma pelo cursor guardado. Carregamento explícito por bootstrap.php; exemplo de ligação em packages/README.md. Este incremento prepara a parte destinatária da integração; não instala o módulo no SGC persistente/SGF e não implementa transporte remoto, credenciais técnicas ou projecções de negócio no host.
|
||||
|
||||
O host configura explicitamente instalação de origem, empresa de origem, empresa local e geração do leitor. Não equiparar IDs de empresas entre sistemas, nem aceitar estes vínculos de um pedido não autorizado. A chave deduplicada inclui origem+empresa de origem+empresa local+evento; leitores distintos nesse mesmo âmbito partilham factos e têm checkpoints separados. Mudança de conta/perfis/contexto na origem pode exigir uma nova geração de leitura, preservando a anterior e reutilizando factos já recebidos; isto é uma chave técnica de acompanhamento, não criação de outra identidade humana/sintética. Permissões, instalação e configuração do host continuam a exigir o respectivo percurso autorizado.
|
||||
|
||||
WarehouseInbox::schema fornece duas definições de tabela para migração explícita do destinatário: sgc_exchange_streams e sgc_exchange_facts. Construção, consulta e recepção não criam schema. Compatibilidade ensaiada com PDO SQLite e PostgreSQL em modo de excepções. O receptor recusa transacção exterior activa para não confirmar falsamente um cursor antes do commit do dono dessa transacção. Não há down, remoção de factos, restauro, grants, seeds ou migrações executadas em instalações persistentes neste incremento. O adaptador não substitui o plano de instalação, endurecimento de privilégios e auditoria de acesso do host.
|
||||
|
||||
A página completa é validada antes da escrita: versão, empresa, identidade do evento, ordem crescente, acção, intervenientes, documento interno, destino, quantidades inteiras positivas, equipamento condicional e hash do conteúdo original. Metadados adicionais compatíveis são preservados. Entrega/recepção mantêm os snapshots e vínculos de StockWorkflow; não lançam outra saída, compra, aprovação ou factura. Recepção exige entrega original já recebida, com a mesma requisição/snapshot; duas confirmações diferentes da mesma entrega são impedidas. Os factos são históricos e não representam saldo actual ou estorno implícito.
|
||||
|
||||
Na recepção, bloquear o checkpoint e conferir versão/cursor solicitado; guardar factos e cursor atomicamente. Retry exacto da última página após confirmação perdida é reconhecido sem nova inserção. Páginas antigas, disputa de versão, mesma identidade com hash diferente e confirmação sem origem não fazem avançar a leitura. Falha numa linha posterior reverte também os factos anteriores dessa página. Duas streams que recebem o mesmo evento reutilizam o facto pela chave comum. As operações de aceitação usam apenas SQL parametrizado e âmbitos explícitos. O hash identifica conteúdo do produtor e não autentica um remetente externo.
|
||||
|
||||
WarehousePull recebe do host uma função de consulta já autenticada, sem escolher URLs ou credenciais. Cada consulta usa checkpoint actual; limites técnicos de páginas/linhas são validados antes de chamar o transporte. Interrupção ou erro numa página conserva as anteriores já aceites; uma execução seguinte retoma a página pendente. has_more=true quando o lote atinge o limite conserva a continuação explícita. Sem agendador, chamadas externas ou associação a contas reais activados. A projecção funcional/confirmacão de consumo pelo sistema de destino continua separada do diário de recepção agora implementado.
|
||||
|
||||
Correcção complementar na origem WarehouseExchange: cursor contém fingerprint ordenado das competências em vez de toda a lista, mantendo o vínculo de autoridade e um tamanho limitado mesmo com o catálogo completo de permissões. Cursors anteriores ao refinamento exigem reinício de leitura; os factos/chaves v1 mantêm-se. Não confundir invalidar um cursor técnico com alterar uma requisição ou conceder competências.
|
||||
|
||||
Validação NOVA:56testes/628asserções na regressão dirigida (receptor, feed, requisições, entrega e contexto);25testes/188asserções do subconjunto em PostgreSQL descartável. Dois executores com contenção real disputaram a mesma página e páginas diferentes: um facto e um único avanço de checkpoint, com retry reconhecido ou conflito explícito. Processo terminado após commit e antes de devolver confirmação foi retomado por outro processo sem duplicar facto/avanço. Testes incluem falha a meio da página, origem/empresa local distintas, confirmação sem entrega ou repetida, hash adulterado/conflitante, versão/cursor antigos, transporte indisponível, limites de lotes, metadados e preservação dos movimentos originais. Ensaio integrado consome JSON real da rota de entregas para o receptor, em base de testes. Não somar suites sobrepostas.
|
||||
|
||||
Formatação e análise PHP dirigidas passaram;18hashes financeiros comuns preservados. Consulta HTTP8005/cursor/limites passou, ainda com zero factos na primeira página local. Provas warehouse-inbox.json e warehouse-inbox-pg.json, HTTP em warehouse-exchange-http.json; logs warehouse-inbox-regression.txt e warehouse-inbox-pg.txt. Nenhuma bateria geral, build frontend ou revisão visual completa novos. Toda a instalação de tabelas de recepção e criação de intervenientes destes ensaios foi descartável. Sem migração, contas/perfis/grants persistentes, instalação ou identidade adicional no SGF. SGF real continua0.1.1; preservar candidatas/trabalho paralelo. Próximo recorte: transporte/configuração autenticados no host e reconciliação/projecção autorizada dos vínculos, sem duplicar o motor de stock ou presumir entrega comercial completa.
|
||||
|
||||
## Transporte HTTP autenticado do armazém — 02/11/2026
|
||||
|
||||
Implementado WarehouseHttpSource em packages/integration e diagnóstico WarehouseTransportFailure. O módulo destinatário já pode executar consulta HTTP real por WarehousePull com uma sessão existente e autorizada na origem. Consulta fixa GET /stock/integracao/entregas, sem endpoints/credenciais na página recebida. O host fornece origem HTTPS, empresa e fornecedor de Cookie por canal protegido. Não foi criado outro utilizador, token permanente ou motor de autenticação; conta, perfis, isolamento, contexto e verificação da origem continuam a cargo do percurso já aprovado. O cliente não faz login/POST/seleção silenciosa de contexto nem altera aprovações.
|
||||
|
||||
Origem validada sem userinfo/query/fragmento ou travessia; query de cursor/limite codificada. Não seguir redireccionamentos impede reencaminhar a sessão para outro destino ou aceitar página de login como dados. HTTPS verifica certificado e hostname. HTTP exige opção explícita e endereço literal127.0.0.1/[::1], com proxy desligado só nesse percurso local; não existe opção de desactivar verificação TLS. Limites técnicos no construtor:15s totais/5s ligação/8MiB por defeito, com corte da resposta descomprimida; não são regras organizacionais. Credenciais consultadas por pedido, protegidas na depuração/parâmetros sensíveis, sem persistência ou apresentação em erros.
|
||||
|
||||
Aceitar apenas HTTP200/application-json/contrato v1 da empresa esperada, com validação do produtor. Erros estruturados e sanitizados distinguem sessão, competência, redireccionamento, cursor, indisponibilidade, timeout, tamanho e conteúdo. Não incluem corpo remoto, cookie ou cadeia original de excepções. retryable identifica falhas técnicas mas não executa tentativas automáticas. Invalidar/expirar a sessão, recusar âmbito ou devolver cursor antigo conserva checkpoint/factos; retoma/renovação continua explícita. Falha na rede não aprova, entrega ou movimenta stock.
|
||||
|
||||
Novos testes usam um servidor HTTP descartável em porta aleatória127.0.0.1, sem Laravel/contas reais, ligado a receptor SQLite ou PostgreSQL descartável. Cobrem leitura autenticada/paginada real, parâmetros codificados, consulta repetida do fornecedor de sessão, limites antes de credenciais, origens inseguras/ambíguas,401/419/403/302/409/422/429/503/404, HTML/JSON inválido, outra empresa, hash alterado, credencial indisponível/injecção de cabeçalhos, resposta excessiva, timeout e ligação recusada sem avançar o checkpoint.
|
||||
|
||||
Provas NOVAS75testes/746asserções na regressão dirigida;44testes/306asserções no subconjunto PostgreSQL descartável com2contenções reais e interrupção após commit antes da confirmação preservadas. Suites sobrepostas: não somar. Formatação/análise PHP dirigidas e18hashes comuns passaram. Cliente nativo PHP/cURL ensaiado também contra8005 com sessão sintética existente: consulta e roundtrip do cursor passaram; leitura sem sessão válida foi recusada. O receptor dessa prova foi exclusivamente SQLite em memória; a primeira página persistente tinha0factos, sem criar entregas para alimentar o ensaio. Sessão passada por stdin privado ao filho oculto, sem argumento, ficheiro de credencial ou conteúdo secreto nas provas. Código de prova usa só a origem local fixa.
|
||||
|
||||
Provas warehouse-http-source.json, warehouse-http-source-pg.json e warehouse-http-source-http.json; logs warehouse-http-source-regression.txt/warehouse-http-source-pg.txt. Sem build frontend, execução geral, revisão visual completa ou ensaio de certificados TLS de um host comercial. Nenhum schema de recepção, serviço permanente ou credencial técnica instalados em aplicações persistentes, e nenhum grant/conta/perfil alterados. SGF real mantém0.1.1 e a identidade adicional sem autorização. Preservar candidatas/trabalho paralelo.
|
||||
|
||||
A lacuna histórica de transporte foi parcialmente resolvida com o cliente HTTP de sessão existente, não com API técnica permanente: faltam configuração administrativa da ligação no host, armazenamento/renovação protegidos, autenticação técnica restrita se necessária, agendamento e reconciliação/projecção autorizada dos vínculos no destino. Não apresentar ensaio local/cliente preparado como integração activa comercial, nem usar os factos recebidos para duplicar o motor de stock/facturação/contabilidade. A configuração comercial pela interface continua pendente neste recorte.
|
||||
|
||||
## Integração funcional entre módulos do SGC — 02/11/2026
|
||||
|
||||
O proprietário esclareceu que a integração desta etapa é entre os módulos do próprio SGC. Orientação registada em AGENTS.md; não é instalação/ligação ao SGF nem pedido para criar acessos externos. Este incremento usa directamente os motores e documentos internos, sem exportar/importar dados para ligar módulos do mesmo produto, criar chaves técnicas, novas tabelas, pessoas ou concessões.
|
||||
|
||||
Implementado InternalSupplyTrace: a requisição ao armazém liga à necessidade de reposição, à requisição de compra canónica, à etapa humana pendente, às encomendas e aos recebimentos já associados. A compra oferece ligação de regresso à requisição ao armazém quando o utilizador tem competência para a consultar. Os caminhos existentes são reutilizados; nenhum formulário duplicado, novo motor ou documento empresarial paralelo foi introduzido. O destino, equipamento e origem preservados nos registos existentes continuam a ser a referência, sem reescrever decisões/versões anteriores.
|
||||
|
||||
No detalhe do armazém, painel Reposição e compras associadas mostra artigo/unidade, saldo do pedido ainda sem reserva, referência da necessidade capturada e da situação actualmente registada para esse artigo/armazém. Mostra estados de compra, tarefa de aprovação pendente, encomendas e quantidades aceites efectivas neste destino, assinalando estornos pelo motor InventoryReversals existente. Uma entrada física não é apresentada como reserva/entrega já decidida; o autorizador elegível tem atalho para abrir o mesmo formulário de actualização de reserva, com as mesmas verificações e razão obrigatória. A impressão da requisição de saída não inclui o acompanhamento operacional adicional. O uso interno continua sem factura.
|
||||
|
||||
A reposição é partilhada por artigo/armazém: a ligação não atribui uma compra exclusiva nem promete que uma entrada esteja livre, utilizável ou automaticamente afectada ao pedido. A qualidade, validade, disponibilidade, reserva, autorização e entrega mantêm WarehouseWorkdesk/WarehouseFulfilment/WarehouseReservations/StockWorkflow como autoridade. Uma compra rascunho ainda exige conferência/submissão e as aprovações vigentes; não foi criada uma passagem automática por etapas.
|
||||
|
||||
A associação histórica usa a última versão da necessidade que efectivamente mencionou a requisição ao armazém; a situação actual da mesma necessidade é consultada separadamente. Uma compra futura do mesmo artigo, criada depois de desaparecer esta origem, não é retrospectivamente atribuída ao pedido antigo. SQL de selecção/grouping é aplicado na base, antes de materializar os registos, sem um limite global100 que esconda origens antigas. Implementações SQLite/PostgreSQL ensaiadas. Nada é associado por igualdade de nome ou por destino presumido.
|
||||
|
||||
Permissões por módulo mantidas: compra só surge quando Requisition::visibleTo permite consultar o respectivo processo; caso contrário aparece indicação genérica de acesso restrito, sem IDs/números privados, valores ou campos financeiros. Ligações a encomendas respeitam os papéis de consulta desse percurso. WarehouseWorkdesk::canView centraliza o mesmo acesso ao detalhe de armazém, incluindo empresa, proprietário, departamento, competências e acumulação necessária à entrega. A ligação inversa não concede consulta de armazém a quem apenas tem Compras. O detalhe continua numa transacção de leitura coerente PostgreSQL, sem comandos/avisos novos por GET.
|
||||
|
||||
Validação NOVA:52testes/668asserções na regressão dirigida (novo rastreio, piloto integrado, atendimento, compra automática e contexto);7testes/105asserções em PostgreSQL descartável no subconjunto novo+piloto. Não somar suites sobrepostas. Os6testes novos exercitam necessidade real a partir de pedido125 com stock120, criação de rascunho canónico, submissão e etapa humana ainda pendente, ligação de ida/volta, leitura sem alterar diários/aprovações/reservas/orçamento, ausência de permissão de compra, outra empresa e preservação da origem perante versões futuras sem esse pedido. O piloto integrado anterior foi executado nesta suite PostgreSQL: entrega parcial, entrada física em ordem já emitida, reforço de reserva e conclusão, sem aprovar o novo rascunho.
|
||||
|
||||
Teste documental separado de projecção usa a ordem/recepção já emitidas do fixture (120unidades) e referência sintética da necessidade para verificar as quantidades mostradas; a classificação de estorno é fornecida pelo serviço simulado de InventoryReversals. Não representa adjudicação/emissão real do novo rascunho, associação manual disponível na interface ou novo ensaio completo do motor de estornos. O agregado PDO PostgreSQL foi normalizado apenas na asserção de quantidade da base, preservando a tipagem inteira do relatório.
|
||||
|
||||
Tipos, lint/formatação, build, análise PHP dirigida e18hashes comuns conferidos. HTTP8005 confirmou a consulta do armazém, sem criar pedidos para produzir evidência:0detalhes existentes disponíveis nessa primeira página. Os detalhes/ligações foram exercitados com dados sintéticos apenas nas bases descartáveis. Sem revisão visual completa ou nova bateria geral. Provas internal-supply-integration.json, internal-supply-integration-pg.json e internal-supply-integration-http.json; logs internal-supply-integration-regression.txt, internal-supply-integration-pg.txt e internal-supply-integration-build.txt. Nenhuma migração, perfil, conta ou dado empresarial persistente alterados; SGF0.1.1 preservado. Este percurso de acompanhamento está ligado, mas não declara todas as funcionalidades/propostas ou a configuração comercial integralmente concluídas.
|
||||
|
||||
## Tarefas integradas por perfil, equipa e área do SGC — 02/11/2026
|
||||
|
||||
Implementado WorkflowTaskInbox e As minhas tarefas nos menus de Requisições (/requisicoes/tarefas) e Pessoas e contratações (/pessoas/tarefas). São projecções do trabalho actual, sem outra máquina de workflow, tabela de tarefas, cópia de documentos, credenciais ou outorga de competências. Compras/armazém e Pessoas mantêm rotas, filtros e contextos separados. As ligações levam aos formulários originais, que continuam a revalidar versão, pessoa, competência, independência, conflito e demais condições da intervenção.
|
||||
|
||||
Cobertura deste incremento: rascunhos próprios que o utilizador pode conferir/submeter, etapas de requisição de compra em que Requisition::canDecide autoriza a decisão, decisões de armazém, reservas com falta ou conferência necessária, entregas e confirmações individuais de recepção. A lista de Pessoas reutiliza PeopleWorkdesk e PeopleAccess, incluindo a equipa, tipo de processo, intervenção e prazo efectivamente registados. Outras tarefas de consulta ao mercado, adjudicação, facturas/pagamentos e fases ainda por desenvolver continuam nos seus percursos próprios; esta lista não declara coberto todo o catálogo funcional da aplicação.
|
||||
|
||||
O armazém reutiliza WarehouseWorkdesk::forView/progress/health: não atribui a decisão ao requisitante, não atribui entrega ao autorizador e exige as duas competências de entrega/armazém existentes. Uma reserva completa sem problema deixa de gerar tarefa de conferência; uma falta ou origem/qualidade a conferir mantém a atenção. Entrega impedida continua visível como bloqueada e abre o pedido para consulta, sem recomendar lançamento físico. Cada entrega não confirmada gera a sua própria ligação de recepção, mesmo após cancelar só o saldo não entregue. Identificadores HTML das confirmações passaram a incluir o evento, evitando âncoras repetidas em entregas parciais. A confirmação real elimina apenas a tarefa correspondente, sem outra saída ou factura.
|
||||
|
||||
Administração de consulta e manutenção temporária não recebem competências operacionais por abrir a página. Perfil expirado, alteração de competência, âmbito departamental, empresa e contexto continuam a ser conferidos. Em Pessoas, atribuição global de função não substitui a equipa/autorização do processo; retirar a vigência da equipa retira a tarefa visível. Minutas continuam minutas e não foram tratadas como contratos formalizados/execução RH. Abrir a tarefa não marca um aviso como lido, não toma a decisão e não cria prazos, prioridades ou destinatários presumidos. Os avisos/resultados existentes permanecem no seu percurso de leitura.
|
||||
|
||||
As consultas percorrem o âmbito autorizado em lotes de100, aplicam as verificações dos motores existentes e só depois filtram/paginam25tarefas. Não há corte global100 que faça desaparecer pendências antigas. Ordenação de apresentação pela data de origem, com desempate natural dos identificadores, sem definir prioridade de atendimento/alocação; ausência de data não recebe uma data fictícia. Contagens por módulo cobrem o âmbito inteiro e são separadas do resultado filtrado. Pesquisa aceita referência ou tarefa. A resposta é privada/no-store, com leitura coerente PostgreSQL; auditoria de consulta de manutenção já existente não é substituída. Não existem notificações externas, subscrições ou automatismo de decisão neste incremento.
|
||||
|
||||
Validação NOVA:60testes/925asserções na regressão dirigida;15testes/362asserções no subconjunto PostgreSQL descartável (8novos, rastreio interno e piloto integrado). Suites sobrepostas, não somar. Os8testes novos percorrem rascunho/submissão/etapa humana, autorização/entregas parciais/cancelamento de saldo/confirmações individuais, reserva em falta e entrega impedida por origem alterada,30rascunhos com paginação/pesquisa e retenção do mais antigo, leitura sem alterar diários/decisões/stock, retirada de competência, equipas de Pessoas e abertura independente com a declaração de conflito exigida, manutenção, empresa e contexto. Actores adicionais e dados de ensaio apenas em bases descartáveis.
|
||||
|
||||
Tipos, lint/formatação, build, análise PHP dirigida e18hashes comuns passaram. HTTP8005 confirmou as duas rotas por selecção do contexto autorizado, com a conta administrativa sintética existente e0tarefas em cada área; isso demonstra acesso/estrutura, não atribuição de tarefas operacionais ao administrador ou percurso visual com pendências. Os percursos com tarefas foram ensaiados nas bases de testes. Sem revisão visual completa, nova bateria geral, novas disputas concorrentes, migração, contas/perfis/grants ou dados empresariais persistentes. SGF real0.1.1 preservado e fora do âmbito pedido. Provas workflow-task-inbox.json, workflow-task-inbox-pg.json e workflow-task-inbox-http.json; logs workflow-task-inbox-tests.txt, workflow-task-inbox-regression.txt, workflow-task-inbox-pg.txt e workflow-task-inbox-build.txt.
|
||||
|
||||
## 02/11/2026 — integração interna de facturas, pagamentos e tarefas financeiras
|
||||
|
||||
InvoiceWorkflow reutiliza unidade, preço e IVA das linhas preservadas na encomenda quando o pedido traz apenas a referência da linha e a quantidade. Valores explicitamente enviados continuam sujeitos à coincidência exacta; quantidades continuam limitadas pela recepção aceite, devoluções, estornos e facturas já validadas. A interface apresenta esses dados sem os pedir novamente nem os transmitir como nova fonte. Número/data e totais do documento do fornecedor permanecem necessários à conferência independente. A validação mantém a reconstituição das linhas históricas, arredondamento acumulado, conflitos e consumo único no núcleo orçamental.
|
||||
|
||||
As minhas tarefas inclui o módulo Finanças: facturas pendentes e registos de pagamento externo pendentes, apenas para conferente financeiro independente e actualmente elegível. InvoiceWorkflow::reviewerEligible e ExternalPaymentWorkflow::reviewerEligible são projecções dos mesmos requisitos funcionais; os comandos continuam a revalidar os factos dentro da transacção. A lista não decide, não lê avisos nem revela referências externas, evidências ou dados bancários. O catálogo de tarefas dos perfis explica esta intervenção dentro da competência financeira existente; não cria uma segunda concessão nem torna as subacções seleccionáveis de forma independente.
|
||||
|
||||
Pagamentos importados continuam dependentes da activação própria e de pa_access/validate. Desactivação, conflito de origem ou ausência dessa competência retiram a tarefa sem converter o saldo para um cálculo paralelo. Uma nova revisão bancária não esconde a possibilidade de rejeitar fundamentadamente um registo pendente; a confirmação exige novamente a versão bancária aprovada, saldo, elegibilidade e evidência. Manutenção temporária pode consultar nos limites existentes, sem receber formulários de execução ou referências reservadas do pagamento e sem intervir nas decisões.
|
||||
|
||||
As páginas /facturas e /pagamentos aceitam focos autorizados por documento; /facturas também por encomenda e /pagamentos por factura. As listas são paginadas com os filtros preservados. A factura validada abre os pagamentos da sua origem; o percurso da compra abre as facturas da encomenda e cada factura/registo externo concreto. Focos de outra empresa são recusados. O circuito reaproveita documentos existentes, sem transferências bancárias, nova dívida, nova saída interna, aprovação automática ou fusão com Pessoas/SGF.
|
||||
|
||||
Verificações novas: 81 testes/800 asserções na regressão de FinanceIntegrationTest, PaymentIntegrationTest, InvoiceTest, ExternalPaymentTest, PurchaseProcessTest e WorkflowTaskInboxTest; 23 testes/357 asserções em PayablePaymentDecisionTest e InternalRequisitionCandidateTest. O teste antigo de activação do ensaio foi ajustado para reconhecer o PostgreSQL descartável explicitamente permitido; a ausência de concessões específicas continua a recusar todos os endpoints financeiros próprios. A versão corrigida desse teste passou também isoladamente em SQLite (1/6). Tipos, lint/formatação, build, análise PHP dirigida e 18 hashes comuns conferidos. HTTP8005 com Administração e Finanças sintéticos existentes confirmou facturas, pagamentos e tarefas financeiras: 1 factura visível, 0 pagamentos e 0 tarefas financeiras em cada perfil. A consulta de pagamentos conserva a auditoria de acesso existente. Os cenários com decisões e pendências ficaram nas bases descartáveis; não houve migração, criação de contas/perfis/grants ou decisão empresarial persistente. Não há revisão visual completa, nova bateria geral ou certificação comercial integral. A prova PostgreSQL é registada abaixo após conclusão; suites sobrepostas não devem ser somadas. SGF real 0.1.1 e trabalho paralelo preservados.
|
||||
|
||||
Prova PostgreSQL concluída:83 testes/623 asserções aprovados, incluindo decisões importadas e a correcção da expectativa de activação; base descartável removida. A primeira tentativa excedeu120s; o modo financeiro passou a permitir300s. Nova execução identificou e corrigiu o pressuposto antigo de ambiente do teste; execução final integral aprovada em112,485s. Provas finance-integration.json, finance-integration-pg.json e finance-integration-http.json; logs finance-integration-tests.txt, finance-integration-authority-tests.txt e finance-integration-pg.txt. Não contar as tentativas interrompida/reprovada como aprovações nem somar suites sobrepostas.
|
||||
|
||||
## 02/11/2026 — adjudicação e conferência da emissão na lista de tarefas
|
||||
|
||||
WorkflowTaskInbox acrescenta decisões de adjudicação e conferência para emissão da ordem na categoria Compras. A elegibilidade da decisão vem de PurchaseWorkflow::canDecide, incluindo a etapa efectivamente pendente, departamento, independência, competências actuais e conflitos. A verificação do cancelamento pendente foi centralizada no mesmo motor; o congelamento deixa de ser apenas uma guarda do comando/controlador. A emissão usa PurchaseWorkflow::canIssue tanto na lista como no formulário e considera adjudicação aprovada, função de Compras, manutenção, âmbito de leitura, cancelamento, ordem já existente e conflitos de participação.
|
||||
|
||||
A tarefa de emissão pede conferência: não garante que a matriz/proposta ainda permita emitir. O comando continua a revalidar a base, propostas válidas, regras preservadas, aprovações finais, entrega, investimento, admissão/recomendação e orçamento dentro da transacção. Proposta expirada após aprovação mantém a necessidade de conferência, mas impede criar a ordem. Emitir efectivamente retira a tarefa; a consulta não emite nem altera orçamento/histórico. A data de entrega não foi convertida em prazo de decisão, nem foi inventada prioridade ou atribuição exclusiva. Registar a selecção, decidir e emitir recusam manutenção também no serviço; o controlador usa explicitamente o utilizador interno autenticado.
|
||||
|
||||
As descrições de tarefas dos perfis explicam as intervenções de adjudicação/emissão nas competências existentes. Sem nova concessão, etapa de aprovação, tabela, migração ou notificação persistente da adjudicação. O encaminhamento é uma projecção actual e conserva o percurso original. O perfil de consulta administrativo não recebe tarefas de execução. Uma atribuição expirada sem outra competência válida continua a impedir acesso à aplicação (403), por decisão do middleware existente; o teste foi corrigido para esperar esse bloqueio, sem o enfraquecer.
|
||||
|
||||
Quatro novos testes percorrem a selecção real com três propostas e avaliações, três etapas finais (departamento/Compras/Finanças), emissão efectiva e desaparecimento da tarefa, cancelamento pendente e rejeição que retoma a etapa, consulta sem mudar adjudicação/orçamento/auditoria, manutenção/expiração e proposta expirada que impede emissão. PostgreSQL descartável:4 testes/217 asserções aprovados, base removida. HTTP8005 com os três perfis sintéticos já existentes confirmou a lista no contexto autorizado; cada um viu0tarefas de Compras e0tarefas de adjudicação. Isso não substitui os percursos com pendências das bases descartáveis, nem representa revisão visual. Tipos/lint/build/análise PHP e18hashes comuns conferidos; regressão integral dirigida registada após conclusão abaixo. Sem contas/perfis/grants, decisões empresariais ou instalação SGF persistentes. Continuam por ligar à lista as intervenções de consulta ao mercado, avaliação documental/formal e execução contratual; não declarar estas fases cobertas apenas pela ligação da adjudicação.
|
||||
|
||||
Regressão final da adjudicação:98 testes/2070 asserções aprovados (PurchaseAwardOrderTest, WorkflowTaskInboxTest e InternalRequisitionCandidateTest). A primeira tentativa falhou somente na expectativa de acesso após expiração; o teste corrigido passou isoladamente1/32 e a suite integral foi repetida e aprovada. PostgreSQL4/217. Provas award-task-integration[-pg/-http].json e logs award-task-integration-tests.txt, award-task-integration-pg.txt, award-task-revocation-test.txt, award-task-integration-build.txt. Não somar suites financeiras/adjudicação sobrepostas nem declarar bateria geral reexecutada.
|
||||
|
||||
## 02/11/2026 — consulta ao mercado e avaliações ligadas às tarefas
|
||||
|
||||
QuotationParticipation centraliza a autoridade de preparação e avaliação anteriormente concentrada no controlador. Formulário, tarefas e comandos consultam as mesmas condições de empresa, departamento/visibilidade, estado aprovado, encerramento pela adjudicação, manutenção, independência do requisitante, conflitos, validade/elegibilidade do fornecedor e avaliação pessoal já registada. O comando recarrega a competência do interveniente depois do bloqueio da empresa, sem confiar num utilizador de sessão desactualizado. Cálculo de preços, descontos, IVA, mínimos, ponderações e avaliação mantêm os motores existentes. A comparação continua a decidir quais as avaliações qualificadas; perder competência não apaga nem substitui a avaliação histórica.
|
||||
|
||||
As tarefas de Compras passam a distinguir propostas em falta face ao mínimo preservado, avaliações pessoais ainda possíveis enquanto o mínimo de conferentes qualificados está incompleto, impedimentos da comparação e resultado pronto para conferência dos requisitos de adjudicação. Mínimos não são fixados novamente nem atribuídos por pessoa. Avaliação opcional continua possível no formulário após satisfação do mínimo, sem ser apresentada como pendência obrigatória. O resultado pronto encaminha para o formulário original, sem prometer que todos os requisitos formais de adjudicação estejam preenchidos ou efectuar selecção. Uma adjudicação existente fecha a consulta mesmo se rejeitada; não reabre etapas nem apaga histórico. Não são criados avisos persistentes, decisões automáticas, alçadas ou prazos novos.
|
||||
|
||||
Ligações quotation_id abrem a proposta concreta e prepare=1 abre a preparação existente, reutilizando as linhas da requisição; origem inválida/outra requisição recusada. Formulários usam elegibilidade por proposta no servidor, e a preparação desaparece se a competência se perder. História Inertia protegida e resposta privada sem cache. A lista é ordenada/paginada no âmbito completo e conserva consultas antigas.
|
||||
|
||||
Provas novas:169 testes/2924 asserções na regressão dirigida de SupplierQuotationTest, PurchaseAwardOrderTest, WorkflowTaskInboxTest, ConflictDeclarationTest, FinanceIntegrationTest e PaymentIntegrationTest. PostgreSQL descartável11/353 (inclui passagem real da comparação à adjudicação e encerramento), base removida. A primeira tentativa dirigida revelou um pressuposto errado da fixture: a mudança de função tinha sido ignorada pelo preenchimento permitido do modelo. O teste passou a fazer a mudança explícita e a suite integral foi aprovada. Inclui mínimos preservados mesmo após alteração da configuração geral, tarefas exactas, expiração, conflitos declarados, perda de competência, manutenção, departamento/empresa,30consultas/paginação/pesquisa, leitura sem efeito e avaliações históricas preservadas. Tipos/lint/build/análise PHP/18hashes passaram. HTTP8005 com os três perfis existentes viu0tarefas de consulta em cada perfil; pendências ensaiadas nas bases descartáveis. Provas quotation-task-integration[-pg/-http].json e logs quotation-task-integration-tests.txt, quotation-task-integration-pg.txt e quotation-task-integration-build.txt. Sem migração/contas/grants/decisões persistentes, revisão visual completa, bateria geral ou alteração SGF0.1.1. Suites sobrepostas não se somam.
|
||||
|
||||
## 02/11/2026 — diagnóstico da activação dos perfis e ligação às tarefas
|
||||
|
||||
Administração → Parametrização mostra por pessoa as competências actuais e dos perfis guardados, acrescentadas/retiradas, preparação implícita de requisição no modo antigo, contextos efectivamente contratados/configurados/funcionais, manutenção, tarefas do catálogo e administração permanente. É uma consulta, não concede acessos nem confirma decisões. OrganizationActivationPreview reutiliza configuredPermissions/configuredMaintenanceOnly de OrganizationAccess, configuredAvailable de WorkContext e permanentAdministrators de OrganizationConfiguration; estas extracções também são usadas no percurso efectivo, evitando outra interpretação de concessões. As regras existentes de activação continuam: confirmação explícita, versão actual, dois administradores permanentes e regras não provisórias. Não é imposta uma condição nova de atribuir todos os utilizadores, nem se activam módulos pendentes por constarem dos contextos.
|
||||
|
||||
A consulta administrativa usa transacção de leitura consistente, privada e com história protegida. Selecção de contexto, competências específicas do financeiro, segregação por processo e limitações de manutenção continuam a aplicar-se depois da configuração; o catálogo explica tarefas potenciais, não decisões já atribuídas em processos. Provas:6/101 nos testes novos; regressão dirigida89/1394 incluindo configuração, contextos, cotações, tarefas e integração financeira; PostgreSQL19/215 com os testes novos e WorkContextTest, base removida. Tipos/lint/build/análise PHP/18hashes passaram. Provas organization-activation-preview[-pg/-http].json e logs organization-preview-tests.txt, organization-preview-regression.txt, organization-preview-pg.txt, organization-preview-build.txt. Não somar suites sobrepostas, nem declarar bateria geral/revisão visual novas.
|
||||
|
||||
O diagnóstico HTTP8005 encontrou12contas sintéticas, configuração inactiva versão1,0perfis/atribuições,0administradores permanentes configurados e regras provisórias. A projecção dos perfis vazios retiraria acesso aos12; a activação directa já é impedida pela regra existente de dois administradores. Nenhum perfil/atribuição/regra foi alterado nesta avaliação.
|
||||
|
||||
## Proposta concreta pendente de autorização — perfis do piloto sintético local
|
||||
|
||||
Destino exclusivo: SGC em http://127.0.0.1:8005,12contas sintéticas já cadastradas, nenhuma conta nova. Preparada a partir do diagnóstico de02/11/2026 às17:20:38UTC, versão1; origem e hash em docs/proofs/pilot-access-proposal.json. O ficheiro é proposta pendente, não instalador nem configuração por defeito de clientes. scripts/prepare-pilot-access-proposal.php só lê o diagnóstico e escreve a proposta; não inicia a aplicação nem liga à base.
|
||||
|
||||
Preservar as funções e os contextos actuais das12contas, tornando explícita nos perfis a competência requester que actualmente é implícita no modo antigo. Criar seis perfis base de compatibilidade e cinco perfis de tarefas do piloto (11no total), com atribuições permanentes somente às contas sintéticas existentes. Manter a administração actual das contas6 («Obrigações — consulta») e8 («Proprietário — avaliação local»); não conceder administração a outras contas. Preservar separadamente pa_access, identidades, documentos, aprovações, regras capturadas nos processos e vínculos actuais. As opções de terceiros já existentes conservam o seu estado funcional limitado; a proposta não implementa nem abre os percursos transferidos ao SGF.
|
||||
|
||||
| Conta existente | Tarefa adicional proposta | Competência funcional |
|
||||
| --- | --- | --- |
|
||||
| 9 — Solicitante — avaliação local | Preparar/submeter requisição interna e confirmar recepção própria | warehouse_request_prepare |
|
||||
| 10 — Chefe de departamento — avaliação local | Autorizar/recusar requisição interna do departamento e acompanhar reservas | warehouse_request_review |
|
||||
| 13 — Recepção / Armazém — avaliação local | Confirmar entrega física, independente do requisitante e autorizador | warehouse_request_deliver, com warehouse já existente |
|
||||
| 11 — Procurement — avaliação local | Preparar políticas e parametrização da reposição | supply_prepare |
|
||||
| 12 — Finanças — avaliação local | Conferir a parametrização preparada por outra pessoa | supply_review |
|
||||
|
||||
Confirmar para este piloto sintético as alçadas actualmente apresentadas (25.000,00MZN e150.000,00MZN, IVA incluído), sem mudar os seus valores, passando-as de provisórias a expressamente configuradas. Preservar o IVA predefinido16% e os mínimos/ponderações de comparação existentes. Activar os perfis depois da sua conferência pelos motores existentes e registar todos os comandos/versionamento na auditoria. Não publicar artigos, criar movimentos, definir quantidades mínimas/reposição, executar compras/aprovações ou alterar o SGF por esta autorização. Responsável/centro de custo de futuras regras automáticas continuam na parametrização específica de cada artigo/armazém.
|
||||
|
||||
A proposta está a ser validada apenas por reprodução descartável: criação dos perfis/atribuições, confirmação das mesmas alçadas e contextos, diagnóstico e activação explícita no ensaio, comparação antes/depois de funções, preparação, departamento e contextos dos12utilizadores. A aplicação no ambiente8005 depende de autorização expressa para esta substituição da autoridade de acesso e confirmação das alçadas, conforme AGENTS.md, Administração e entrega. O desenvolvimento de código e os ensaios já autorizados prosseguiram sem esse efeito persistente.
|
||||
|
||||
Ensaio final da proposta aprovado:1 teste/156 asserções em SQLite em memória e1/156 em PostgreSQL descartável, base removida. Executou os comandos reais de criação de11perfis,12atribuições, regras/contextos e activação apenas na simulação. Confirmou12utilizadores, nenhuma perda de competências/contextos/preparação/âmbito departamental, dois administradores permanentes, acréscimos exactamente iguais aos cinco propostos e nenhum processo/ordem/factura/grant pa_access criado. Provas pilot-access-proposal-dry-run.json e pilot-access-proposal-pg.json; logs pilot-access-proposal-tests.txt e pilot-access-proposal-pg.txt. A configuração8005 permanece inactiva e a proposta pendente de autorização; o ensaio não é autorização nem aplicação. Resumos das integrações em quotation-task-integration.json e organization-activation-preview.json.
|
||||
|
||||
## 02/11/2026 — devolução nativa de excedente, isolada
|
||||
|
||||
Implementado NATIVE-WAREHOUSE-UNUSED-RETURN-SIM-v1: entrega interna autorizada/recebida → declaração individual de não utilização pelo destinatário original → recepção física por armazém independente. Prévia, confirmação, retirada e recusa, versões/contextos/permissões revalidados, decisões append-only, auditoria e escritor único inventory-return 0.1.0. Reutiliza AutomaticPurchases para sinalizar uma vez dentro da transacção; não aprova, encomenda ou altera políticas. Interface e histórico paginado com leitura conjunta de preparadores. Direitos warehouse_return_read/prepare/confirm apenas configuráveis e atribuídos a QA isolada; feature desactivada por defeito, sem grants/activação no piloto ou reais.
|
||||
|
||||
Provas consolidadas 36 testes distintos/544 asserções (19 nativos/440, reversões afectadas16/81, observador SGF1/23), sem somar repetições: recepção10, entrega5, devolução3, saldo8 e três movimentos. Bloqueio real da empresa observado: repetição após libertação não duplica; disputa por capacidade409; HTTP423 sem efeitos e recuperação explícita; falha transaccional/artefacto503 recuperam; retirada/confirmação mutuamente exclusivas. Tipos, PHPStan dirigido, bundle e renderização do histórico passaram. Build privado qualificado passou sem React Compiler opcional/fontes remotas; não valida build optimizado predefinido ou revisão visual.
|
||||
|
||||
Ver docs/warehouse-unused-return.md e C:/laragon/www/sgf-tempo/docs/NATIVE_WAREHOUSE_RETURN_DELIVERY.md, com relatório/hash de provas. O SGC funciona sem ligação SGF; observação SGF foi só QA e não composição operacional instalada. A previsão existente mantém saídas brutas: devolução muda saldo/hash, não deduz da média. Consumo líquido temporal é evolução separada. Preservados dados/demos/acessos/cache/seis associações/checkpoints/trabalho alheio. Sem publicação, checkpoint, activação de candidatas ou uso real.
|
||||
|
||||
## 02/11/2026 — AUTORIZADO E APLICADO: perfis do piloto sintético local
|
||||
|
||||
O proprietário respondeu «autorizo» à pergunta de activação da distribuição concreta acima, exclusivamente na avaliação SGC8005. Autorização registada em docs/proofs/pilot-access-authorization.json, vinculada ao hashff45883f1a0e1e3ab228f8bc4e6c03a66e9a5953b04af44b2e26cd6b74b60a44. A proposta original não foi reescrita: o seu estado pendente é histórico; a autorização/aplicação actuais têm provas próprias. Esta entrada substitui a pendência de autorização anterior para este âmbito exacto.
|
||||
|
||||
scripts/apply-pilot-access-proposal.php conferiu a identidade do destino local, o hash da proposta ensaiada, a autorização específica, versão1,12utilizadores e respectivas identidades/funções/departamentos/contextos, ausência de perfis/atribuições e as mesmas alçadas. Preflight passou sem alterar dados. A aplicação usou OrganizationConfiguration e os resolvedores efectivos, com o administrador existente8;11perfis,12comandos de atribuição (17vínculos), confirmação das mesmas regras/contextos e activação foram executados numa transacção consistente, sob bloqueio da empresa. Qualquer divergência anterior ao commit cancelaria a transacção. Não houve migração, alteração de código do núcleo comum, criação de contas ou decisão operacional.
|
||||
|
||||
Às17:52:30UTC de02/11/2026 a configuração foi confirmada na versão27. Mantidos os administradores6/8 e as tarefas adicionais autorizadas9/10/13/11/12. As competências/contextos, preparação de compra e âmbito departamental dos12utilizadores foram comparados antes/depois e preservados, com acréscimos exactamente iguais aos da proposta. As alçadas25.000/150.000MZN comIVA passaram a explicitamente configuradas para estes ensaios, sem alterar os valores ou IVA16%. Requisições, aprovações, orçamentos, documentos, stock e concessões financeiras pa_access foram preservados.
|
||||
|
||||
Cópia privada guardada em C:/laragon/data/sgf-tempo/backups/compras-pilot-access-20261102-175214-d26e4c/before.dump, SHA-256afa964b52ea6c552466360a2ebc2523126f37ccf0a3aecd21f822a0767d35d75. pg_dump usou o snapshot exportado da mesma transacção; restauro de controlo descartável conferiu112tabelas antes de aplicar e foi removido. Após os comandos,107tabelas fora da configuração/auditoria/Tenant coincidiram integralmente com a origem; os restantes campos de Tenant também foram conferidos, permitindo apenas rules_provisional, work_context_policy e updated_at. Prova pilot-access-activation.json e log pilot-access-activation.txt. Sintaxe, formatação do aplicador e18hashes comuns conferidos. As provas anteriores de testes descartáveis não são declaradas reexecutadas por esta aplicação.
|
||||
|
||||
A autorização não publica artigos nem define mínimos, quantidades, políticas ou regras automáticas de artigo/armazém. Essas parametrizações permanecem no seu percurso próprio, com conferência independente; não foram criados movimentos, requisições, encomendas, facturas ou pagamentos por esta aplicação. SGF real0.1.1 e a proposta não autorizada de identidade adicional nesse ambiente permanecem fora do âmbito. A verificação HTTP posterior dos cinco perfis e do diagnóstico é registada abaixo após conclusão; nenhuma revisão visual completa foi declarada.
|
||||
|
||||
Verificação posterior concluída por HTTP em8005: configuração activa27,11perfis,17vínculos,12contas, dois administradores permanentes e nenhuma perda de competências/contextos/preparação/departamento. Os cinco perfis sintéticos abriram os seus percursos: requisição, revisão do departamento, entrega, preparação da reposição e conferência da reposição. Procurement não recebeu conferência; Finanças não recebeu preparação; chefe/armazém não receberam preparação de requisição interna. Todos os cinco foram impedidos de entrar na administração de perfis. Cada perfil viu0tarefas na situação actual, e a consulta de tarefas de Pessoas continuou disponível à administração no seu contexto próprio. Sem comandos de negócio nesta verificação. Prova pilot-access-activation-http.json e log pilot-access-activation-http.txt; sem revisão visual completa.
|
||||
|
||||
Diagnóstico efectivo do piloto: intervenientes independentes prontos,1armazém,0artigos publicados com origem actual,0políticas internas aprovadas e0regras automáticas activas. Serviço periódico e recuperação técnica recentes/sem pendências. Warehouse/replenishment_scenario_ready continuamfalse pela ausência de catálogo/políticas; client_configuration_complete também permanecefalse porque o diagnóstico exige contexto interno explicitamente contratado, além dos perfis/administração, enquanto a avaliação conserva o âmbito sintético existente. Não alterar direitos comerciais por inferência nem declarar entrega de cliente. Próxima preparação funcional: catálogo e políticas/regras específicas por artigo/armazém, com responsáveis/centros e quantidades escolhidos na parametrização e conferidos independentemente. Esta autorização de perfis não fixa esses valores.
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Continuidade do desenvolvimento do SGC
|
||||
|
||||
**Último estado — sourcing preparado, demonstração parcial:** candidata **040fb0cb67a77d61**, manifesto `040fb0cb67a77d618011bc7ab365c53d3d4b1a787c4c0c0a2d54f315b3658e58`, [entrega/provas/recuperação](consultas-adjudicacao-sgf.md). Em 8039/55439/1:1:1 há fornecedores, três cotações e um avaliador por cotação; o fluxo completo foi confirmado **só nas cópias isoladas**. Proprietário recusou quarta conta e autorizou exactamente prepare ao ID 1, evaluate/propose ao ID 2; não conceder confirm, aprovação ao requerente ou escrita ao Consulta para completar o cenário. Requisição 1/100 MZN/versão 3 e reserva única intactas. Não declarar integração integral ou adjudicação concluída na demonstração.
|
||||
|
||||
Schema sourcing R1 é extensão auditada do ledger base R2. Código e schema/dados recuperam-se separadamente; simples rollback ao 166 não permite reabrir com o catálogo alterado. Backup privado antes da extensão em `private/sourcing-recovery/before.dump`, hash `cb9d55db5fee08cb36e3af4c962556b465be9e14071b5c3505c834d2ef4d7bcd`; journals nativos e prova de restauro das cópias preservados. Não usar down/truncate na demonstração nem executar plano SQL real. Valores diferentes da reserva e níveis superiores de aprovação estão bloqueados/sinalizados, não contornados. Implantação real/Tempo mantêm 0.1.1. Encomendas/recepção/pagamentos/contactos externos e revisão visual continuam separados; não repetir browser bloqueado ou suites aceites sem mudança relevante.
|
||||
|
||||
**Último estado — leitura Consulta corrigida e primeiro percurso fechado em 8039:** sucessora `OPERATIONAL-CANDIDATE-166e397e3ca63f02`, ZIP SHA-256 `620660d76329c800f3c6b18a521c03ccdadce09502d2a7744e1e91b77560f785`, manifesto `166e397e3ca63f02d3dc57651c8eecf50e1fc314410c78e05386b518d3a661de`. [Matriz/provas](transferencia-sgf.md). Não conceder direitos adicionais para leitura; Port::authorize permite somente view em registo já isolado. Requisição 1/100 MZN/versão 3 e reserva única preservadas; actualização→reversão→reaplicação conferidas, sem schema novo. A limitação 404 descrita historicamente abaixo foi resolvida. Próximo percurso escolhido: da requisição aprovada até cotações/comparação/adjudicação independente; mapear antes de implementar. Não declarar integração integral. Implantação real permanece adiada em 0.1.1, revisão visual separada. Recuperação desta correcção em `private/read-correction-recovery`; ID final de aplicação `3afbba5e3ed60b853d62b869`. Nenhum novo direito, conta ou DCL SQL.
|
||||
|
||||
**Último estado — demonstração autorizada e habilitada:** 8039/55439/base sgf_modules_dev_2576427d1939/contexto 1/1/1. Aplicados exactamente os 14 direitos aprovados; centro/dotação 10.000 MZN/2026 sintéticos; percurso e reserva única conferidos, independente ID 2 aprova preparação ID 1. [Entrega, provas e recuperação](demonstracao-requisicoes-8039.md). Não reexecutar bootstrap (fechado) nem provisionadores destrutivos. Nenhuma conta ou DCL SQL criada; preservada instalação real em 0.1.1. **Pendência concreta:** perfil Consulta tem view mas regra actual oculta requisições de terceiros (404); decidir âmbito de leitura antes de alterar regra/direitos, sem conceder aprovação/configuração como contorno. Sessão desta cópia agora file; owner/runtime mantido como limitação de demonstração. Revisão visual e implantação real continuam separadas. Registos de proposta pendente abaixo são históricos, sucedidos pela confirmação e execução exclusivamente sintéticas.
|
||||
|
||||
**Última orientação: não avançar com a instalação desactivada no destino real.** Próximo passo é a confirmação da [proposta única de ambiente sintético](proposta-desenvolvimento-sgf.md), reutilizando a instância existente 8039/55439, não criar outra. Nenhuma alteração funcional/SQL autorizada por esta proposta; não executar GRANT/REVOKE do plano real. Preservar `sgf_tempo_dev`, configuração Tempo e 0.1.1. Os registos de implantação real abaixo ficam adiados; valores sintéticos e contas existentes estão discriminados na proposta, sem obrigação de segundo administrador para esta demonstração.
|
||||
|
||||
**Estado actual — candidata corrigida, 02/11/2026:** os bloqueantes históricos de duas tabelas, preparação de centros/dotações e separação SQL foram resolvidos/ensaiados na candidata `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`. [Decisão actualizada](decisao-instalacao-sgf.md) e [prova de 157 controlos/restauro de 103 tabelas](proofs/sgf-corrected-delivery.json) prevalecem sobre o estado anterior abaixo. **Não instalar, activar nem executar GRANT/REVOKE sem confirmação separada**; SGF mantém 0.1.1. Proposta SQL concreta em `docs/proofs/sgf-corrected-sql-plan.json`, nunca aplicada no destino. Backup/restauro de dados reais e configuração nominativa continuam por realizar na janela autorizada. Não repetir suites aceites nem navegador bloqueado. Ver também o registo detalhado no final deste documento.
|
||||
|
||||
**Decisão de instalação sem execução — 02/11/2026:** proprietário escolheu decidir, não instalar. [Decisão](decisao-instalacao-sgf.md) e [inventário read-only](proofs/sgf-installation-decision-inventory.json): destino C:/laragon/www/sgf-tempo, PostgreSQL sgf_tempo_dev/127.0.0.1:55432, PHP 8.4.27, 87 tabelas, runtime sgf_dev_app sem DDL, cb/ir ausentes. Candidata 5fae7cf7f99c42ba/ZIP/hashes e ambos os plans conferidos sem aplicar. Não recomendar percurso operacional antes de fechar ligação migradora/DCL mínima e bootstrap de centros/dotações: interface selecciona fontes existentes e BudgetWorkspace depende de duas tabelas não entregues nesse schema. Catálogo funcional pronto em candidata não equivale a direitos runtime/admin já configurados; equipamento/peças não bloqueiam o recorte básico. Há só uma concessão administrativa da empresa, pendente regra de dois na entrega/activação. Navegação modular tecnicamente aceite em 02/11, separada de pendências visuais/associações/volume e da instalação de Compras. Sem repetir suites, criar backup, migrar, outorgar, parar/reiniciar serviço ou activar. Manter SGF 0.1.1. A decisão não autoriza executar a operação antes de confirmação nem ocultar as lacunas com fixtures/SQL de negócio.
|
||||
|
||||
**Reconciliação da entrega modular — 02/11/2026:** documentos próprios do SGF agora conferidos registam entrega técnica de navegação modular e conclusão de regressão/perfis (185/3.220, focados 8/90, três perfis sintéticos), com recheck de integridade datado de 02/11. As referências históricas seguintes a «sem resultado apresentado» ficam sucedidas por esta evidência e pela matriz: não reimplementar nem repetir suites por causa delas. Revisão visual, recargas, associações antigas e volume/paginação continuam separados. Esta retoma fez leitura/reconciliação documental, sem nova execução dos resultados reportados. Candidata de requisições não instalada, SGF permanece 0.1.1. Instalação continua a exigir decisão separada; esta reconciliação não a presume.
|
||||
|
||||
**Transição de bundles/caches nativos — 02/11/2026:** candidata `OPERATIONAL-CANDIDATE-5fae7cf7f99c42ba`, ZIP e hashes em [proofs/sgf-update-cache-delivery.json](proofs/sgf-update-cache-delivery.json). 32 controlos novos aprovados, sem suites anteriores repetidas: actualização com proveniência, arquivo/reposição do bundle anterior, falha/recuperação, ordem de reversão, nova tentativa com histórico, Artisan config/route/event cache nativos, compilação interrompida e cache inválido, CLI e reinícios efectivos de servidor isolado. `current.json` privado da implantação não é o ponteiro purchasing, que continua 0.1.1. Não adoptar bundle desconhecido nem migrar schema por arrasto. Acção cache exige manutenção/--offline; após rollback, voltar a compilar para a versão reposta e reiniciar os serviços próprios. Caches personalizados e gestores de serviços de cada cliente continuam a exigir procedimento compatível. Nenhuma aplicação no destino/publicação/concessão real. Próxima acção: revisão e decisão separada de instalação, não nova autorização por etapa técnica nem activação presumida. Reorganização modular permanece distinta e pendente.
|
||||
|
||||
**Aplicação/reversão do código — 02/11/2026:** `CodeDeployment` e `sgf-code-deploy.php` preparados para primeira instalação com manutenção/serviços parados, journal/backups, lock, verificação de hashes/baseline e restauro de código/caches padrão. 42 controlos novos aprovados via PHP fixado, sem DB/serviços/browser/suites antigas; [prova](proofs/sgf-code-deploy-delivery.json). Candidata `OPERATIONAL-CANDIDATE-10dce6cd57fa1f31`. Não activa configuração nem retira manutenção/reinicia serviços. Recusa caches personalizados e recuperação sobre alterações locais/caches posteriores. Não implementa ainda transição sobre bundle existente: não inferir updates integrais do novo percurso. Preservados SGF de destino 0.1.1 e artefactos anteriores. Próximo incremento: transição versionada e caches nativos/gestão de serviços em isolamento; nenhuma instalação ou activação no destino sem decisão separada. Reorganização modular permanece distinta e pendente.
|
||||
|
||||
**Arranque e HTTP SGF — 02/11/2026:** candidata `OPERATIONAL-CANDIDATE-4b25f81932efd69d` integra bundle/rotas pelo provider nativo e associa `purchasing_requisitions.view` à descoberta de Compras. Novo middleware mantém contexto/opção/âmbito/schema, módulo adquirido/seleccionado e geração de contexto; autoridade inicial administrativa permanece sem outorga automática. 27 controlos novos HTTP aprovados em instalação com código e dependências copiados, APP_ENV production, base/role próprias removidas; sem repetir suites aceites ou usar browser. [Prova](proofs/sgf-composition-delivery.json). Nenhuma aplicação no destino, ponteiro 0.1.1 e candidatas antigas preservados. Pendência seguinte: aplicação atómica/reversão das sobreposições/código e caches/reinício, antes de propor activação. Não declarar instalação comercial concluída nem confundir esta integração com reorganização modular posterior, que mantém entrega própria pendente.
|
||||
|
||||
**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.
|
||||
@@ -533,3 +553,112 @@ Próxima acção: diagnosticar elegibilidade de encaminhamento para cada etapa a
|
||||
**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.
|
||||
|
||||
**02/11/2026 — Reserva e reposição parametrizada concluídas neste incremento:** migração49 instalada só na avaliação8005, diário warehouse_reservations imutável;109 tabelas preservadas por backup/restauro conferidos. Autorização reserva stock utilizável livre; entrega consome a reserva e o StockWorkflow atomicamente; revisor pode actualizar reserva ou cancelar apenas o saldo. Saídas, transferências, correcções, devoluções/abates e estornos protegem os compromissos. Retenção/validade pode criar défice, que impede a entrega; não remover controles de qualidade para satisfazer a reserva. Autorizações antigas precisam de actualização explícita da reserva.
|
||||
|
||||
/stock/reposicao no menu Armazém e activos: supply_prepare prepara regra por artigo/armazém, supply_review activa/suspende com independência de todos os preparadores. Requisitante e centro são parametrizados conforme autorização expressa do proprietário, sem pessoas/valores fictícios. Política/catálogo/acordo/tributação reutilizados; peças exigem equipamento compatível. Gera só rascunho canónico, com executor real auditado e requisitante designado, sem impersonação, submissão, aprovação, orçamento, encomenda ou factura automática. Origem/local/equipamento visíveis na requisição gerada. Necessidade pendente quando falta configuração, fonte válida, destino ou estimativa. Reposição ligada em curso evita duplicação; rejeição não se recria pela mesma regra. Preservar as aprovações e o motor comum; não voltar a tratar compromisso físico como reserva planeada.
|
||||
|
||||
Provas: geral920/918 passaram/2 antigos ignorados/9620 asserções; PG24/232 mais3disputas reais (incluindo activação concorrente com um rascunho); tipos/lint/build/análise PHP/18 hashes e HTTP de consulta passaram. docs/proofs/warehouse-replenishment.json e warehouse-reservations-install.json. A porta8005 estava desligada e foi retomada com a configuração/base existentes. Sem dados sintéticos/grants e sem prova visual por capturas. Estes resultados substituem a pendência histórica de reserva/compra automática; não significam que toda a proposta esteja concluída.
|
||||
|
||||
Próximos incrementos concretos: avisos/tarefas do próprio armazém; fila durável e recuperação de sinal após interrupção, com varrimento de validade/mínimo sem evento; prioridades e prazos de expiração parametrizados; reserva após nova entrada continua a exigir intervenção explícita do revisor. Não introduzir prioridades, datas ou destinatários presumidos. Vendas e facturação continuam reservadas à composição SGF.
|
||||
# Continuidade — candidata SGF corrigida em 02/11/2026
|
||||
|
||||
**Não instalar nem activar por continuidade implícita.** Proprietário autorizou apenas preparação isolada; SGF destino `C:\laragon\www\sgf-tempo`, base `sgf_tempo_dev` em `127.0.0.1:55432`, mantém pacote 0.1.1. Candidata actual `OPERATIONAL-CANDIDATE-401ce31ae5f2dd20`, ZIP SHA-256 `59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703`; [decisão actualizada](decisao-instalacao-sgf.md) prevalece sobre os bloqueantes históricos.
|
||||
|
||||
Resolvidos na candidata: duas tabelas UI (migração revisão 2, 16 novas), preparação de centros/dotações por API/interface e catálogo administrativo/frontend. 157 controlos aprovados numa reprodução de estrutura actual com roles distintas; recuperação de 103 tabelas conferida. Não repetir suites aceites sem motivo novo. Proposta SQL em `docs/proofs/sgf-corrected-sql-plan.json` exige confirmação antes de qualquer GRANT/REVOKE real; instalador nunca a executa. Usar ligação migradora temporária, runtime sem CREATE/ownership; não colocar segredos no bundle/.env persistente.
|
||||
|
||||
Próximo passo: decisão explícita de instalação técnica desactivada com pacote/hashes e SQL concretos. Na janela aprovada, backup/restauro dos dados reais em base nova, planos reconferidos, migração e código/caches com procedimentos de recuperação distintos; não usar `sgf.ps1 stop` (pára cluster partilhado). Configuração real/dotações e dois administradores continuam requisitos para activação; fixtures nunca vão para cliente. Sem publicação, alteração de direitos públicos funcionais ou contratação para terceiros. Preservar alterações locais em curso e a aceitação própria da navegação modular.
|
||||
|
||||
|
||||
|
||||
### 02/11/2026 ? candidata SGF utiliz?vel, ainda n?o instalada
|
||||
|
||||
Conclu?das depend?ncias or?amentais de interface, administra??o pelo cat?logo nativo e prepara??o de centros/dota??es. Candidata 401ce31ae5f2dd20, schema revis?o 2; 157 controlos integrados + 17 React/render/assets + 54 deployment. Restauro efectivo de 103 tabelas e actualiza??o/recupera??o de c?digo sobre dados sint?ticos verificados. Ver docs/transferencia-sgf.md e a proposta revista no SGF. N?o executar a proposta antiga 5FAE. SGF continua 0.1.1, sem tocar em sgf_tempo_dev ou conceder direitos; instala??o/configura??o e mapa SQL s? para decis?o posterior. Preservar trabalho paralelo; equipamento/pe?as fora, revis?o visual/associa??es antigas/volume-pagina??o pendentes.
|
||||
|
||||
**Avaliação geral de02/11/2026:** base funcional ampla, em consolidação para piloto/configuração e posterior entrega comercial. Revalidados33 testes/314 asserções,18 hashes e HTTP de reposição/avisos/relatórios de Pessoas/CSV. A geral918 aprovados é evidência anterior, não repetida nesta avaliação. Prioridade: recuperação durável da reposição após falhas e varrimento agendado; depois paginação das listas actualmente limitadas a100, avaliação de volume e parametrização das etapas ainda fixas. Completar tarefas/avisos de armazém e aceitação por perfis/impressão. Sem revisão visual completa ou certificação de produção. Não confundir candidata/demonstração SGF com instalação real, nem declarar a proposta integralmente concluída.
|
||||
|
||||
**Consolidação para piloto — reposição,02/11/2026:** prioridades1–3 implementadas. Migração50 replenishment_work instalada só8005, backup/restauro110tabelas preservadas, sem contas/grants. Sinal durável atómico com alteração física; rascunho/origem/checkpoint atómicos; recuperação após interrupção e dois trabalhadores concorrentes não duplicam compra. ReplenishmentRun restringe execução a CLI/empresa/trabalho/SGC, sem login ou conta sintética; auditoria identifica execução periódica e atribuição do responsável de origem. Revalida regra/requisitante/centro/contexto/destino e mantém todas as aprovações.
|
||||
|
||||
sgc:replenishment-tick descobre origens existentes e reavalia trabalhos vencidos; agendador por minuto, intervalo técnico persistido por trabalho por defeito5minutos. Validade/mínimo não dependem de novo movimento. Auxiliar scripts/replenishment-evaluation.php ligado à base de avaliação existente e observado em passagens sucessivas, integrado no arranque dev; não instalou serviço de arranque Windows nem alterou produção. Depois de reinício, retomar desenvolvimento/auxiliar; não provisionar outra base.
|
||||
|
||||
/stock/reposicao: paginação independente de regras/necessidades/histórico/trabalhos interrompidos, filtro, pendências primeiro, indicação da última passagem conhecida e recuperação explícita só para supply_prepare/review. Actualizar não cria aprovações; admin de consulta não recupera. Sem revisão visual completa. Acompanhamento adicional das reservas e validação com utilizadores existentes são a fase seguinte; roteiro preparado em desenvolvimento.md, ainda não executado por pessoas reais.
|
||||
|
||||
Provas NOVAS: regressão198/1579, final reposição18/155, PG33/310+4disputas, tipos/lint/build/análise/18hashes/HTTP passaram; docs/proofs/replenishment-pilot.json e replenishment-recovery-install.json. Não somar suites sobrepostas nem apresentar33/314 e918+2 históricos como execução nova. SGF real continua0.1.1; candidatura/transferência e identidade adicional mantêm as autorizações próprias, sem instalação nem criação neste incremento. Preservar trabalho paralelo no repositório.
|
||||
|
||||
**02/11/2026 — acompanhamento de reservas concluído neste incremento:** /stock/requisicoes tem tarefas por perfil (autorizar/reservas/entregas/recepções), pesquisa/filtros antes da paginação e contagens completas do âmbito. WarehouseWorkdesk é projecção de leitura dos diários existentes; estados conferidos contra progress(), sem outro motor ou concessão paralela. Detalhe mostra saldo sem reserva, qualidade/origem a conferir e pessoas elegíveis actuais; falta de responsável fica visível, sem atribuição automática. Entrega continua independente, com reserva/stock revalidados. Recepção permanece pendente após cancelar só o saldo; consulta/recepção não lançam saída.
|
||||
|
||||
Proprietário esclareceu: desenvolvimento/validação seguem com contas sintéticas, sem pessoas reais nesta fase. AGENTS.md actualizado. A avaliação persistente mantém contas/perfis/competências existentes; ensaios com actores/perfis suplementares só em bases descartáveis. Não voltar a exigir participantes reais para prosseguir nesta etapa. Isto não autoriza a identidade adicional do SGF nem instala candidatas no destino0.1.1.
|
||||
|
||||
Provas NOVAS: regressão111/1008, final18/326, PG38/517+3disputas, tipos/lint/build/análise/18hashes e HTTP passaram. docs/proofs/warehouse-workdesk.json e ficheiros -pg/-http. Sem migração ou concessões; sem bateria geral repetida, aceitação real ou revisão visual completa. Próximos incrementos mantêm validação sintética e qualquer expiração/prioridade/notificação persistente depende de configuração explícita dos efeitos. Preservar o trabalho paralelo e a separação Compras autónomo/SGF.
|
||||
|
||||
**02/11/2026 — diagnóstico e ensaio integrado do piloto:** /stock/piloto é consulta administrativa, sem efeitos operacionais, com sete conferências e contas/tarefas elegíveis. Verifica independência real de três intervenientes, centro/âmbito, catálogo actual, regras activas/fontes, serviço recente e trabalhos pendentes; distingue condições de ensaio de configuração comercial concluída. Reutiliza inspectRule/validação canónica sem locks de escrita. Telemetria partilhada via ReplenishmentQueue::status não aceita registo antigo/danificado como confirmação de serviço.
|
||||
|
||||
Ensaio sintético integrado percorreu pedido125/reserva120/entrega100, entrada física108 de ordem já emitida, reforço autorizado5, entrega25 e recepção: pedido encerrado, stock103, reserva0, uma requisição automática ainda rascunho, sem factura interna, aprovação ou orçamento automáticos. Não confundir origem física preexistente com execução do novo rascunho. Só bases descartáveis, sem pessoas reais ou novas concessões na avaliação.
|
||||
|
||||
Provas NOVAS53/705, final8/142, PG46/659+3disputas, tipos/lint/build/análise/18hashes/HTTP; docs/proofs/warehouse-pilot-readiness.json e respectivos -pg/-http. Na avaliação8005:12contas existentes; instalação/serviço conferidos, mas tarefas, catálogo, regras de reposição e configuração própria ainda a preparar. Não declarar cliente/piloto integralmente pronto nem activar perfis/valores por inferência. SGF real0.1.1 e proposta da identidade adicional continuam separados, sem instalação/autorização nova. Evidências anteriores e ausência de revisão visual completa preservadas.
|
||||
|
||||
**02/11/2026 — diagnóstico exportável:** /stock/piloto.csv exporta as conferências datadas em UTF-8/CSV, só para administração, usando a mesma inspecção read-only. Não exporta contas nem activa configurações. Interface inclui imprimir, ainda sem revisão visual de impressão.10testes/161asserções, tipos/lint/build/análise/18hashes e HTTP passaram; docs/proofs/pilot-export.json. Manter as configurações pendentes explícitas e a validação sintética; não apresentar exportação como aprovação ou aceitação comercial. SGF e contas/perfis persistentes preservados.
|
||||
|
||||
**02/11/2026 — ligação de entregas do armazém para integração:** WarehouseExchange, GET /stock/integracao/entregas e contrato integrations/contracts/warehouse-fulfilment-v1.json. Só leitura autenticada dos factos deliver/receive já registados; reutiliza RA, destino, equipamento, intervenientes e stock_operation_id, sem nova saída ou factura. Cursor incremental cifrado liga empresa/conta/contexto/departamento/competências; mudar âmbito exige reiniciar. Paginação preserva eventos antigos e permite novas consultas depois do fim. Consumidor deve persistir factos+cursor atomicamente e deduplicar por instalação de origem/empresa/idempotency_key; hash divergente exige reconciliação. Não usar feed como saldo actual ou réplica do motor de stock; estornos continuam no percurso próprio.
|
||||
|
||||
Provas NOVAS41/523 na regressão dirigida e10/83 do subconjunto novo em PostgreSQL descartável; formatação/análise PHP/18hashes/HTTP passaram. Provas warehouse-exchange[-pg/-http].json. Avaliação8005 devolveu zero factos na primeira página; percursos com entregas/equipamento foram sintéticos em testes. Sem contas/grants/migrações, build ou revisão visual nova, nem instalação SGF (real0.1.1 preservado). Não somar suites sobrepostas nem confundir com a bateria geral histórica. Próxima ligação deve consumir o contrato e reutilizar os motores/fontes existentes; autenticação técnica remota e confirmação persistente do consumidor ainda não fazem parte deste incremento. Contratos de Pessoas continuam com formalização/RH próprios; não exportar rascunhos como contratos formalizados. Ver desenvolvimento.md para contrato e limites exactos.
|
||||
|
||||
**02/11/2026 — receptor durável de entregas preparado:** packages/integration (WarehouseFeed/WarehouseInbox/WarehousePull), carregável sem Laravel. Duas definições de tabela para migração explícita do destinatário; nenhum schema instalado em aplicações persistentes. Origem+empresa de origem+empresa local+evento deduplicam factos; geração de leitor mantém checkpoint próprio. Recepção e cursor confirmados atomicamente, com versão/cursor de comparação. Retry exacto após commit sem confirmação é reconhecido; conflito/alteração de conteúdo/página antiga não avança. Recepção exige entrega original reconciliada, sem outra saída, factura ou aprovação.
|
||||
|
||||
Leitura por lotes reutiliza consulta autenticada fornecida pelo host; falha preserva páginas anteriores e execução posterior retoma. Não há transporte remoto, credenciais técnicas, agendador ou projecção funcional do destinatário activados. Mudança de âmbito na origem pode usar nova geração técnica de leitor preservando factos/checkpoint anterior, sem criar outra conta. Exemplo e limites em packages/README.md e desenvolvimento.md. Cursor da origem agora resume permissões por hash para caber mesmo com perfil completo; os antigos exigem reinício de leitura, sem alterar eventos/chaves.
|
||||
|
||||
Provas NOVAS56/628 na regressão dirigida;25/188 em PostgreSQL descartável, duas contenções reais (mesma página/páginas diferentes) e processo terminado após commit antes da confirmação, retomado sem duplicar. Formatação/análise PHP/18hashes/HTTP passaram. warehouse-inbox.json, warehouse-inbox-pg.json e warehouse-exchange-http.json; logs warehouse-inbox-regression.txt/warehouse-inbox-pg.txt. Suites sobrepostas, sem bateria geral/build/revisão visual nova. Preservados dados/perfis persistentes e SGF real0.1.1; identidade adicional continua sem autorização. Próximo recorte é transporte/configuração do host e reconciliação autorizada de vínculos, preservando o motor único e distinguindo diário recebido de projecção de stock/contabilidade e de instalação efectiva.
|
||||
|
||||
**02/11/2026 — consulta HTTP autenticada implementada:** WarehouseHttpSource/WarehouseTransportFailure ligam WarehousePull ao endpoint fixo da origem através de sessão já autorizada. Não fazem login, contexto, contas ou grants. Origem HTTPS, Cookie por fornecedor protegido, query codificada, sem redirects e com verificação TLS/limites de tempo/tamanho; HTTP só em loopback explícito para desenvolvimento. Erros sanitizados distinguem expiração/âmbito/cursor/falha técnica e preservam o checkpoint. Não gravar cookies ou colocar credenciais em URLs/argumentos/logs.
|
||||
|
||||
Provas NOVAS75/746 na regressão dirigida;44/306 em PostgreSQL descartável +2contenções reais e saída de processo após commit/antes de confirmação; formatação/análise PHP/18hashes passaram. Prova nativa cURL contra8005 reutilizou sessão sintética existente por stdin privado e receptor SQLite em memória: cursor válido e consulta sem sessão recusada,0factos na primeira página. warehouse-http-source[-pg/-http].json e logs respectivos; sem dados ou direitos empresariais persistentes alterados. Sem build/bateria geral/revisão visual/TLS comercial novo.
|
||||
|
||||
Transporte de sessão preparado, não API técnica permanente nem integração comercial activada. Próximas lacunas: configuração administrativa/armazenamento e renovação protegidos no host, eventual autenticação técnica restrita, agendamento e reconciliação/projecção autorizada no destinatário. Não duplicar motores ou inferir implantação. SGF real0.1.1 e identidade adicional continuam sob as autorizações existentes, sem instalação/conta nova. Documentação de ligação e limites em packages/README.md/desenvolvimento.md.
|
||||
|
||||
**02/11/2026 — integração desta etapa é interna ao SGC, conforme resposta expressa do proprietário:** AGENTS.md actualizado. Priorizar as ligações funcionais e a reutilização entre módulos, sem pressupor instalação/integração com SGF ou novos acessos externos. InternalSupplyTrace integra no detalhe do armazém necessidade→compra→aprovação pendente→encomendas→recebimentos, com ligação inversa da compra ao pedido permitido. Sem novas tabelas/formulários/credenciais ou cópia de documentos; impressão interna preservada, sem factura.
|
||||
|
||||
Última versão que mencionou a origem define as compras historicamente ligadas; a situação actual do artigo/armazém é separada. Não atribuir compras futuras ao pedido já fora dessa origem. Reposição partilhada não equivale a afectação exclusiva nem a stock livre/utilizável. Entradas/estornos são lidos das fontes actuais; actualização de reserva é intervenção explícita no formulário existente, por pessoa elegível. WarehouseWorkdesk::canView centraliza acesso ao detalhe e às ligações; processos de compra invisíveis não revelam referências/valores. Aprovações, qualidade, stock e perfis mantêm os motores existentes.
|
||||
|
||||
Provas NOVAS52/668 na regressão dirigida e7/105 em PostgreSQL descartável, incluindo piloto parcial/entrada de ordem preexistente/reserva/conclusão. Não somar suites sobrepostas. Teste documental de encomenda/recepção e estorno simulado verifica a projecção; não declarar que o novo rascunho percorreu adjudicação/encomenda ou que o motor de estornos foi integralmente reexecutado. Tipos/lint/build/análise PHP/18hashes e HTTP conferidos;0pedidos persistentes disponíveis para inspecção do detalhe em8005, com detalhes exercitados nas bases de testes. Sem revisão visual completa/bateria geral/migração/contas/grants/dados empresariais persistentes novos. Provas internal-supply-integration[-pg/-http].json e logs respectivos. SGF real0.1.1 e trabalho paralelo preservados. Continuar a cobertura interna por percursos concretos, sem apresentar o conjunto de propostas como concluído.
|
||||
|
||||
**02/11/2026 — listas de tarefas ligam os módulos internos:** /requisicoes/tarefas reúne rascunhos/aprovações de compra e decisões/reservas/entregas/confirmações de armazém; /pessoas/tarefas reutiliza PeopleWorkdesk e a equipa atribuída, mantendo a área separada. Novas entradas As minhas tarefas. WorkflowTaskInbox delega a autoridade aos motores actuais e abre os formulários originais; não cria outro workflow, tabela, prazo, pessoa ou concessão. Consulta não decide nem marca avisos como lidos. Facturas/consulta/adjudicação e fases ainda não cobertas conservam os seus percursos; não declarar o catálogo inteiro concluído.
|
||||
|
||||
Entrega bloqueada permanece como atenção; cada recepção tem tarefa/âncora própria, incluindo após cancelamento apenas do saldo. Confirmar uma entrega retira só a sua tarefa, sem novo movimento. Autorizar próprio pedido/entregar o que se autorizou continuam impedidos; administração de consulta/manutenção não recebem tarefas de execução. Empresa, departamento, contexto, competência e vigência de equipa continuam efectivos. Ordenação/paginação sobre o âmbito autorizado completo, sem corte global100 nem prioridade organizacional inventada.
|
||||
|
||||
Provas NOVAS60/925 na regressão dirigida;15/362 em PostgreSQL descartável, incluindo8testes novos. Tipos/lint/build/análise PHP/18hashes/HTTP passaram. A conta sintética administrativa da avaliação8005 viu0tarefas em cada área; pendências e intervenções reais do ensaio apenas nas bases descartáveis. workflow-task-inbox[-pg/-http].json e logs respectivos. Não somar suites sobrepostas, nem contar a bateria geral/contenções anteriores como repetidas. Sem revisão visual completa/migração/contas/grants/dados empresariais persistentes; SGF0.1.1 e trabalho paralelo preservados. Continuar pelas funcionalidades internas em falta, usando fontes/motores existentes e mantendo Compras/Pessoas separados.
|
||||
|
||||
**02/11/2026 — Finanças integrada no percurso interno:** a factura reutiliza as linhas congeladas da encomenda (unidade/preço/IVA); só referências, quantidades e novos dados do documento são introduzidos. Totais independentes, limites de recepção/devoluções/estornos, arredondamento, conflitos e validação por outra pessoa permanecem nos motores existentes. As minhas tarefas acrescenta Finanças para facturas e conferências de pagamentos externos; projecções de elegibilidade comuns às listas/formulários, decisões revalidadas no servidor. Catálogo de tarefas dos perfis actualizado dentro da competência financeira existente, sem concessões novas ou granularidade fictícia.
|
||||
|
||||
Focos por factura/encomenda e pagamento/factura preservam contexto e paginação; compra → encomenda → factura → registo externo usa documentos originais e ligações concretas. Empresa, independência, manutenção e conflitos continuam efectivos. Tarefa não revela referências/evidências/contas e não produz decisão. Revisão de pendentes importados continua sujeita a activação e competência específica pa_access/validate; não há saldo alternativo quando o núcleo está indisponível. Mudança bancária permite rejeitar, mas confirmação continua bloqueada até nova conferência válida. Não há transferência bancária, segunda dívida, factura de saída interna ou ligação nova ao SGF.
|
||||
|
||||
Provas novas: regressão 81/800; autoridade/integração financeira e catálogo 23/357; teste corrigido de activação isolado em SQLite 1/6. Tipos/lint/build/análise PHP/18 hashes e seis consultas HTTP8005 passaram. Facturas visíveis 1, pagamentos/tarefas 0 nas contas administrativas/financeiras existentes; pendências e decisões exercitadas só nas bases descartáveis. Prova PostgreSQL e artefactos de resumo acrescentados após conclusão abaixo. Sem bateria geral/revisão visual completa/migração/contas/grants/decisões persistentes; SGF real0.1.1 preservado. Consulta de pagamentos conserva auditoria de acesso. Continuar pelas fases internas ainda não representadas na lista de tarefas (consulta ao mercado, avaliações/adjudicação e execução contratual), sem substituir os motores ou confundir cobertura parcial com produto concluído. O piloto persistente continua dependente de parametrização explícita; não inventar responsáveis, artigos publicados, mínimos ou centros de custo.
|
||||
|
||||
Prova PostgreSQL final:83/623 aprovados, base descartável removida. Após limite120s e correcção do pressuposto de ambiente num teste antigo, execução integral aprovada com limite300s; não contar tentativas anteriores como aprovações. Artefactos finance-integration[-pg/-http].json e logs correspondentes. Continuar para consulta ao mercado/adjudicação usando as autoridades existentes.
|
||||
|
||||
**02/11/2026 — adjudicação/emissão ligada às tarefas:** PurchaseWorkflow::canDecide é a fonte da etapa final actual, incluindo congelamento por cancelamento pendente e exclusão da manutenção. canIssue partilhado por formulário/lista projecta elegibilidade para conferir a emissão após aprovação, sem prometer base válida ou executar a ordem. O comando revalida matriz/propostas, aprovações, conflitos, regras, datas e orçamento; proposta expirada mantém conferência pendente mas impede emitir. Após emissão real a tarefa desaparece. Catálogo dos perfis explica adjudicação/emissão sem concessão nova. Atribuição expirada sem outro acesso continua a produzir403, não uma lista vazia autorizada.
|
||||
|
||||
Quatro testes novos em PostgreSQL descartável (4/217) passaram: selecção real com três propostas/avaliações, três etapas e emissão, cancelamento/rejeição, leitura sem efeito, manutenção/expiração e validade da proposta. Base removida. Tipos/lint/build/análise PHP/18hashes e HTTP dos três perfis existentes passaram;0tarefas locais de Compras/adjudicação em todos. Provas award-task-integration-pg.json, award-task-integration-http.json e logs respectivos; resumo/regressão final abaixo. Sem revisão visual/bateria geral/migração/contas/grants/decisões persistentes/alteração SGF0.1.1. A projecção não cria avisos persistentes da adjudicação. Próxima cobertura interna: tarefas da consulta ao mercado e avaliações, extraindo/reutilizando a autoridade actual antes de acrescentar linhas à lista; depois execução contratual. Não copiar guardas para um segundo workflow nem inventar responsabilidades, prazos ou regras de cliente.
|
||||
|
||||
Regressão final aprovada:98/2070 (adjudicação/ordens, tarefas e requisições internas), com expectativa de expiração corrigida e repetição integral. Provas award-task-integration[-pg/-http].json; PostgreSQL4/217, teste isolado1/32. Suites sobrepostas, não somar. Não houve revisão visual completa nem execução da bateria geral.
|
||||
|
||||
**02/11/2026 — consulta ao mercado integrada:** QuotationParticipation é autoridade comum a formulário/tarefas/comandos; competências recarregadas sob bloqueio da empresa. Pendências: propostas em falta pelos mínimos preservados, avaliações elegíveis enquanto falta o mínimo qualificado, impedimentos da comparação e conferência do resultado na adjudicação. Não seleccionar/aprovar automaticamente nem reabrir consulta encerrada por adjudicação rejeitada. Proposta exacta por quotation_id, preparação existente por prepare=1; origem validada e história/cache protegidos. Avaliações históricas não são substituídas quando o avaliador perde competência; a comparação reavalia qualificação. Sem prazos/pessoas/minímos inventados.169/2924 na regressão dirigida;11/353 em PostgreSQL descartável. Tipos/lint/build/análise PHP/18hashes/HTTP passaram;0tarefas locais nos três perfis. Provas quotation-task-integration[-pg/-http].json. Sem novas contas/grants/decisões persistentes/bateria geral/revisão visual/alteração SGF0.1.1.
|
||||
|
||||
**Diagnóstico antes de activar perfis:** OrganizationActivationPreview na Parametrização compara competências actuais/perfis, preparação implícita, contextos, manutenção, tarefas e administradores. Reutiliza os mesmos resolvedores de OrganizationAccess/WorkContext e o contador de administradores do comando real; consulta consistente sem concessões/activação.6/101 novos,89/1394 regressão dirigida,19/215 PostgreSQL descartável e HTTP8005 passaram. Tipos/lint/build/análise PHP/18hashes conferidos. Provas organization-activation-preview[-pg/-http].json. Suites sobrepostas: não somar.
|
||||
|
||||
**Próximo passo concreto sujeito a autorização:** diagnóstico local8005 versão1 tem12contas sintéticas,0perfis/atribuições,0administradores permanentes configurados e alçadas provisórias. Activação directa não é permitida pelos controlos existentes. Proposta pendente em docs/proofs/pilot-access-proposal.json e secção final de desenvolvimento.md: preservar funções/contextos/preparação implícita das12contas,11perfis (6base+5tarefas), manter administradores6/8; tarefas adicionais9requisitar,10autorizar,13entregar,11preparar reposição,12conferir reposição. Confirmar somente no piloto as mesmas alçadas25.000/150.000MZN comIVA, sem alterar valores; preservar IVA16%, processos/histórico/pa_access e SGF. Não criar pessoas reais/contas novas, publicar artigos, definir mínimos ou executar negócio. Substituição da autoridade de acesso requer escolha explícita e auditada (AGENTS.md). Proposta preparada sem aplicação, ensaio exclusivamente descartável em PilotAccessProposalTest; resultados finais anexados abaixo. Antes de aplicar após eventual autorização, conferir novamente a origem/versão e preservar os dados; nenhuma autorização do SGF decorre daqui.
|
||||
|
||||
Proposta concreta ensaiada e pronta para decisão:SQLite1/156 ePostgreSQL1/156 aprovados, com os mesmos comandos do produto e sem aplicação local.11perfis/12atribuições, dois administradores existentes e cinco tarefas adicionais, sem perdas ou mudança de âmbito/pa_access/processos. Provas pilot-access-proposal-dry-run.json e pilot-access-proposal-pg.json. A proposta e a activação8005 continuam pendentes de autorização expressa; não confundir a simulação com mudança persistente. Próxima intervenção dependente: confirmar a autorização do proprietário e verificar novamente versão/origem antes de aplicar a distribuição concreta. Outras funcionalidades não ficam declaradas concluídas por este pedido de autorização.
|
||||
|
||||
## 02/11/2026 — continuidade da devolução de excedente
|
||||
|
||||
Percurso nativo implementado e ensaiado em base própria; ver docs/warehouse-unused-return.md. Manter stock_returns.enabled=false fora da QA e competências warehouse_return_* sem concessões por arrasto. Reutilizar entrega e recepção originais, confirmação independente, escritor único e auditoria; não acrescentar devolução ao formulário genérico nem confundir declaração de excedente com consumo técnico SGF. Provas válidas consolidadas36/544, sem necessidade de repetir suites inalteradas. Consulta conjunta da história51/páginas25 usa3SELECTs centrais; não é ensaio de carga.
|
||||
|
||||
Continuam separadas implantação/composição operacional, revisão visual, carga de escrita e eventual regra de previsão líquida. A média histórica actual é por saídas brutas; devolução altera saldo e hash, preservando a convenção. Não reescrever históricos aprovados nem inventar atribuição temporal. Sem lotes/reservas/equipamento/peças/qualidade ou correcção de devolução confirmada neste recorte. Preservar os dados, cache, seis associações, demonstrações e trabalho em curso; não activar piloto/candidatas ou conceder direitos por consequência destes testes.
|
||||
|
||||
**02/11/2026 — autorizado e aplicado o piloto de acessos:** proprietário respondeu «autorizo» à proposta exacta para SGC8005. Não voltar a pedir autorização para esta distribuição nem reaplicar o instalador. Proposta original mantém o hash/estado histórico de preparação; autorização e estado efectivo estão em pilot-access-authorization.json e pilot-access-activation.json. Configuração passou de1para27:11perfis/17vínculos em12contas existentes; administradores6/8, tarefas9requisitar/10autorizar/13entregar/11preparar reposição/12conferir reposição. Preservadas competências/contextos/preparação/âmbito anteriores. Mesmas alçadas25.000/150.000MZN comIVA confirmadas só para o piloto; IVA16% e snapshots históricos intactos.
|
||||
|
||||
Aplicação atómica por OrganizationConfiguration, sob bloqueio da empresa e revalidação da origem/versão/hash autorizado. Backup privado antes da mudança, snapshot consistente e restauro verificado112tabelas;107tabelas protegidas e demais campos de Tenant preservados, incluindo contas/pa_access/processos/aprovações/orçamento/stock. Restore descartável removido. Prova pilot-access-activation.json e logs correspondentes. Não houve migração/contas novas/decisão operacional/publicação de artigos/mínimos/políticas/regras automáticas ou mudança SGF0.1.1. Perfis sintéticos não são defaults de entrega comercial; a empresa continua a definir os seus próprios intervenientes. Verificação HTTP posterior e pendências reais do piloto são anexadas após conclusão abaixo, sem declarar nova bateria geral ou revisão visual.
|
||||
|
||||
**Verificação final da activação8005:** pilot-access-activation-http.json confirmou versão27,12contas,11perfis/17vínculos,2administradores e todos os acessos/contextos anteriores. Cinco perfis abriram os percursos próprios e não obtiveram administração; preparação/conferência da reposição e requisição/revisão/entrega ficaram separados. Tarefas0na situação actual; Pessoas continua separado e acessível à administração no contexto próprio. Só consultas, sem nova operação de negócio/revisão visual/bateria geral.
|
||||
|
||||
O piloto ainda não tem artigos publicados (0), políticas internas aprovadas (0) ou regras automáticas activas (0); existe1armazém e os intervenientes independentes estão prontos. Serviço periódico/recuperação recentes e sem pendências. O indicador de configuração de cliente mantémfalse pelo requisito de contexto contratado explicitamente, distinto do âmbito sintético preservado; não acrescentar direitos comerciais por inferência. Próxima etapa: parametrizar catálogo e regras específicas sem inventar quantidades/mínimos ou responsáveis. Manter esta separação entre acesso já autorizado/aplicado e configuração operacional/comercial ainda por concluir.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T16:31:19.533Z",
|
||||
"checked_at": "2026-11-02T10:11:34.369Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"profile_task_catalogue": "passed",
|
||||
"workflow_notices": "ready",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T16:52:13.253Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"routes": [
|
||||
{
|
||||
"role": "procurement",
|
||||
"total_purchase_tasks": 0,
|
||||
"shown_award_tasks": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "department_head",
|
||||
"total_purchase_tasks": 0,
|
||||
"shown_award_tasks": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"total_purchase_tasks": 0,
|
||||
"shown_award_tasks": 0,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"existing_accounts_used": true,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_review": false,
|
||||
"note": "Only current first-page tasks are followed; disposable tests cover pending approval and issuance."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T16:53:11+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":4,\"passed\":4,\"assertions\":217,\"duration_ms\":11934}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T16:55:21.5246476Z",
|
||||
"scope": "Native SGC award decisions and order issuance task routing",
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 98,
|
||||
"passed": 98,
|
||||
"assertions": 2070,
|
||||
"duration_ms": 76232
|
||||
},
|
||||
"postgres": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 4,
|
||||
"passed": 4,
|
||||
"assertions": 217,
|
||||
"duration_ms": 11934
|
||||
},
|
||||
"targeted_revocation": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 32,
|
||||
"duration_ms": 2740
|
||||
},
|
||||
"typescript": "passed",
|
||||
"lint_build": "passed",
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"runtime_hashes": 18,
|
||||
"http_proof": "award-task-integration-http.json",
|
||||
"persistent_accounts_or_grants_changed": false,
|
||||
"persistent_business_commands": false,
|
||||
"sgf_installation_changed": false,
|
||||
"visual_review": false,
|
||||
"general_suite_rerun": false,
|
||||
"limitations": [
|
||||
"Issuance task means review required, not guaranteed issuance readiness",
|
||||
"Quotation, documentary and contractual tasks remain outside this increment",
|
||||
"Existing QA profiles had no current purchase task",
|
||||
"Overlapping suites must not be summed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T16:38:36.337Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"routes": [
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/facturas",
|
||||
"shown": 1,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/pagamentos",
|
||||
"shown": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/requisicoes/tarefas?module=finance",
|
||||
"total": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"path": "/facturas",
|
||||
"shown": 1,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"path": "/pagamentos",
|
||||
"shown": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"path": "/requisicoes/tarefas?module=finance",
|
||||
"total": 0,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"existing_accounts_used": true,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"sgf_installation_changed": false,
|
||||
"visual_review": false,
|
||||
"note": "Payment consultation retains its existing access audit; no financial decision is executed."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T16:45:16+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":83,\"passed\":83,\"assertions\":623,\"duration_ms\":112485}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T16:46:16.6004450Z",
|
||||
"scope": "native SGC invoice, external payment and task integration",
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 81,
|
||||
"passed": 81,
|
||||
"assertions": 800,
|
||||
"duration_ms": 76021
|
||||
},
|
||||
"authority": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 23,
|
||||
"passed": 23,
|
||||
"assertions": 357,
|
||||
"duration_ms": 40232
|
||||
},
|
||||
"postgres": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 83,
|
||||
"passed": 83,
|
||||
"assertions": 623,
|
||||
"duration_ms": 112485
|
||||
},
|
||||
"typescript": "passed",
|
||||
"frontend_lint_and_build": "passed",
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"runtime_hashes": 18,
|
||||
"http_proof": "finance-integration-http.json",
|
||||
"visual_review": false,
|
||||
"general_suite_rerun": false,
|
||||
"persistent_accounts_or_grants_changed": false,
|
||||
"persistent_business_decisions": false,
|
||||
"sgf_installation_changed": false,
|
||||
"limitations": [
|
||||
"No full visual review or commercial certification",
|
||||
"QA current data contains no pending financial task",
|
||||
"Existing payment access audit preserved",
|
||||
"Suites overlap and must not be summed"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T18:21:22.559Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"routes": [
|
||||
{
|
||||
"role": "procurement",
|
||||
"formal_tasks_shown": 0,
|
||||
"task_links_followed": 0,
|
||||
"components": [
|
||||
{
|
||||
"component": "purchases/qualification",
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"component": "purchases/admission",
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"component": "purchases/recommendation",
|
||||
"result": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"formal_tasks_shown": 0,
|
||||
"task_links_followed": 0,
|
||||
"components": [
|
||||
{
|
||||
"component": "purchases/qualification",
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"component": "purchases/admission",
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"component": "purchases/recommendation",
|
||||
"result": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "department_head",
|
||||
"formal_tasks_shown": 0,
|
||||
"task_links_followed": 0,
|
||||
"components": [
|
||||
{
|
||||
"component": "purchases/qualification",
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"component": "purchases/admission",
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"component": "purchases/recommendation",
|
||||
"result": "passed"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"sgf_installation_changed": false,
|
||||
"visual_review": false,
|
||||
"coverage": "Existing current scope and first-page tasks only; pending decisions tested in disposable databases."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T18:16:10+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":11,\"passed\":11,\"assertions\":383,\"duration_ms\":35159}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T15:23:04.604Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"internal_sgc_only": true,
|
||||
"warehouse_index": true,
|
||||
"existing_request_details_checked": 0,
|
||||
"synthetic_journeys_in_disposable_tests": true,
|
||||
"persistent_business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"sgf_installation_changed": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T15:22:15+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":7,\"passed\":7,\"assertions\":105,\"duration_ms\":36419}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T15:29:13.7856940Z",
|
||||
"scope": "internal SGC modules only, explicitly selected by owner",
|
||||
"module": "warehouse replenishment purchasing receipts trace",
|
||||
"regression": {
|
||||
"tests": 52,
|
||||
"assertions": 668,
|
||||
"result": "passed",
|
||||
"log": "storage/logs/internal-supply-integration-regression.txt"
|
||||
},
|
||||
"disposable_postgresql": {
|
||||
"tests": 7,
|
||||
"assertions": 105,
|
||||
"proof": "internal-supply-integration-pg.json",
|
||||
"result": "passed"
|
||||
},
|
||||
"http": "internal-supply-integration-http.json",
|
||||
"source_sha256": {
|
||||
"app/Services/InternalSupplyTrace.php": "f705434f8db45b671f370e228db3b49f308abe1483aed2878e883f9fa04b147d",
|
||||
"app/Services/WarehouseWorkdesk.php": "a4312b5395a1ab64003a84f54c813f70ae5f08c0acb9f506948098ceb5bac6b1",
|
||||
"app/Http/Controllers/WarehouseRequestController.php": "2b5df44456424f7bac0657b62496d2297b372bdcb794e2889352bbce0c841a72",
|
||||
"app/Http/Controllers/RequisitionController.php": "8f1ce0790ed738682f2d1a178c373e545e7a6f7a8c07d2a256b11a34539c37c1",
|
||||
"resources/js/pages/stock/request.tsx": "d0de70b8ff4c67247f38885fa9e1ca69432073961d2bffc6b6da61c36f47e02f",
|
||||
"resources/js/pages/requisitions/show.tsx": "8db5e514e70532ad8eabc559834eed63127d10050ce8b714fb45ada646c40951"
|
||||
},
|
||||
"types": "passed",
|
||||
"phpstan": "passed",
|
||||
"frontend_lint": "passed",
|
||||
"frontend_build": "passed",
|
||||
"runtime_hashes": 18,
|
||||
"canonical_purchase_draft_and_human_submission_tested": true,
|
||||
"full_new_purchase_to_order_journey_claimed": false,
|
||||
"documentary_receipt_projection_fixture": true,
|
||||
"reversal_classification_mocked_in_projection_test": true,
|
||||
"new_persistent_schema_accounts_grants_or_business_records": false,
|
||||
"remote_access_activated": false,
|
||||
"sgf_installation_changed": false,
|
||||
"full_general_suite_rerun": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T17:20:38.379Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"version": 1,
|
||||
"active": false,
|
||||
"users": 12,
|
||||
"losing_access": 12,
|
||||
"permanent_administrators": 0,
|
||||
"can_activate": false,
|
||||
"provisional_rules": true,
|
||||
"existing_account_used": true,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"sgf_installation_changed": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T17:19:58+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":19,\"passed\":19,\"assertions\":215,\"duration_ms\":27349}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"verification": {
|
||||
"checked_at": "2026-11-02T17:28:38.0997373Z",
|
||||
"typescript": "passed",
|
||||
"lint_build": "passed",
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"runtime_hashes": 18,
|
||||
"visual_review": false,
|
||||
"general_suite_rerun": false,
|
||||
"persistent_accounts_or_grants_changed": false,
|
||||
"persistent_business_commands": false,
|
||||
"sgf_installation_changed": false,
|
||||
"overlapping_suites_must_not_be_summed": true
|
||||
},
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 89,
|
||||
"passed": 89,
|
||||
"assertions": 1394,
|
||||
"duration_ms": 75748
|
||||
},
|
||||
"postgres": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 19,
|
||||
"passed": 19,
|
||||
"assertions": 215,
|
||||
"duration_ms": 27349
|
||||
},
|
||||
"http_proof": "organization-activation-preview-http.json",
|
||||
"scope": "Read-only activation effects using canonical permissions, context and permanent-administrator sources",
|
||||
"activation_applied": false
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"checked_at": "2026-11-01T14:26:12.336Z",
|
||||
"checked_at": "2026-11-02T10:11:38.749Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"pages": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T17:55:28.896Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"active": true,
|
||||
"version": 27,
|
||||
"users": 12,
|
||||
"profiles": 11,
|
||||
"memberships": 17,
|
||||
"permanent_administrators": 2,
|
||||
"original_access_and_contexts_preserved": true,
|
||||
"first_limit": "25000.00",
|
||||
"second_limit": "150000.00",
|
||||
"includes_tax": true,
|
||||
"provisional_rules": false,
|
||||
"participants_separated": true,
|
||||
"roles": [
|
||||
{
|
||||
"role": "requester",
|
||||
"route": "/stock/requisicoes",
|
||||
"result": "passed",
|
||||
"tasks": 0
|
||||
},
|
||||
{
|
||||
"role": "department_head",
|
||||
"route": "/stock/requisicoes?view=review",
|
||||
"result": "passed",
|
||||
"tasks": 0
|
||||
},
|
||||
{
|
||||
"role": "warehouse",
|
||||
"route": "/stock/requisicoes?view=delivery",
|
||||
"result": "passed",
|
||||
"tasks": 0
|
||||
},
|
||||
{
|
||||
"role": "procurement",
|
||||
"route": "/stock/reposicao",
|
||||
"result": "passed",
|
||||
"tasks": 0
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"route": "/stock/reposicao",
|
||||
"result": "passed",
|
||||
"tasks": 0
|
||||
}
|
||||
],
|
||||
"readiness": {
|
||||
"warehouse_scenario_ready": false,
|
||||
"replenishment_scenario_ready": false,
|
||||
"client_configuration_complete": false,
|
||||
"checks": [
|
||||
{
|
||||
"key": "installation",
|
||||
"ok": true,
|
||||
"detail": "Requisições, reservas, catálogo e recuperação durável instalados."
|
||||
},
|
||||
{
|
||||
"key": "participants",
|
||||
"ok": true,
|
||||
"detail": "Há combinação elegível de requisitante, autorizador e entregador distintos, com centro de custo do departamento."
|
||||
},
|
||||
{
|
||||
"key": "catalogue",
|
||||
"ok": false,
|
||||
"detail": "1 armazém(ns), 0 artigo(s) publicado(s) com origem actual e 0 publicação(ões) a rever."
|
||||
},
|
||||
{
|
||||
"key": "replenishment",
|
||||
"ok": false,
|
||||
"detail": "0 política(s) interna(s) aprovada(s), 0 regra(s) activa(s) válida(s) e 0 a rever. Falta de stock não é, por si, erro de configuração."
|
||||
},
|
||||
{
|
||||
"key": "scheduler",
|
||||
"ok": true,
|
||||
"detail": "Há uma passagem recente conhecida do serviço."
|
||||
},
|
||||
{
|
||||
"key": "administration",
|
||||
"ok": false,
|
||||
"detail": "Perfis próprios activos; 2 administrador(es) permanente(s). Conferir também o contexto interno contratado/activado."
|
||||
},
|
||||
{
|
||||
"key": "recovery",
|
||||
"ok": true,
|
||||
"detail": "0 sinal(is) por processar e 0 trabalho(s) com recuperação pendente. Necessidades funcionais sem configuração válida continuam visíveis na reposição."
|
||||
}
|
||||
]
|
||||
},
|
||||
"business_commands_executed": false,
|
||||
"new_accounts": false,
|
||||
"sgf_changes": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"result": "applied",
|
||||
"checked_at": "2026-11-02T17:52:30+00:00",
|
||||
"environment": "http:\/\/127.0.0.1:8005",
|
||||
"proposal_sha256": "ff45883f1a0e1e3ab228f8bc4e6c03a66e9a5953b04af44b2e26cd6b74b60a44",
|
||||
"source_version": 1,
|
||||
"active_version": 27,
|
||||
"users_preserved": 12,
|
||||
"profiles_created": 11,
|
||||
"memberships_created": 17,
|
||||
"administrators_preserved": [
|
||||
6,
|
||||
8
|
||||
],
|
||||
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-pilot-access-20261102-175214-d26e4c\/before.dump",
|
||||
"backup_sha256": "afa964b52ea6c552466360a2ebc2523126f37ccf0a3aecd21f822a0767d35d75",
|
||||
"restore_verified_tables": 112,
|
||||
"protected_tables_preserved": 107,
|
||||
"tenant_other_fields_preserved": true,
|
||||
"business_data_preserved": true,
|
||||
"payables_specific_grants_preserved": true,
|
||||
"new_accounts": false,
|
||||
"new_stock_publications_or_policies": false,
|
||||
"business_decisions_executed": false,
|
||||
"sgf_installation_changed": false,
|
||||
"atomic_transaction": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"recorded_at": "2026-11-02T17:50:38.2941267Z",
|
||||
"owner_reply": "autorizo",
|
||||
"authorization_source": "Resposta expressa do proprietário à pergunta de activação da proposta concreta neste chat",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"scope": "synthetic_local_pilot_only",
|
||||
"proposal_sha256": "ff45883f1a0e1e3ab228f8bc4e6c03a66e9a5953b04af44b2e26cd6b74b60a44",
|
||||
"new_accounts_authorized": false,
|
||||
"sgf_changes_authorized": false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"verification": {
|
||||
"checked_at": "2026-11-02T17:28:38.0997373Z",
|
||||
"typescript": "passed",
|
||||
"lint_build": "passed",
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"runtime_hashes": 18,
|
||||
"visual_review": false,
|
||||
"general_suite_rerun": false,
|
||||
"persistent_accounts_or_grants_changed": false,
|
||||
"persistent_business_commands": false,
|
||||
"sgf_installation_changed": false,
|
||||
"overlapping_suites_must_not_be_summed": true
|
||||
},
|
||||
"proposal_status": "pending_owner_authorization",
|
||||
"proposal_sha256": "FF45883F1A0E1E3AB228F8BC4E6C03A66E9A5953B04AF44B2E26CD6B74B60A44",
|
||||
"sqlite": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 156,
|
||||
"duration_ms": 9139
|
||||
},
|
||||
"postgres": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 156,
|
||||
"duration_ms": 15787
|
||||
},
|
||||
"scope": "Disposable simulation of all proposed profile, assignment, rule, context and explicit activation commands",
|
||||
"users_preserved": 12,
|
||||
"base_profiles": 6,
|
||||
"task_profiles": 5,
|
||||
"existing_administrators_preserved": [
|
||||
6,
|
||||
8
|
||||
],
|
||||
"local_application_changed": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T17:25:11+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":1,\"passed\":1,\"assertions\":156,\"duration_ms\":15787}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"status": "pending_owner_authorization",
|
||||
"scope": "synthetic_local_pilot_only",
|
||||
"environment": "http:\/\/127.0.0.1:8005",
|
||||
"prepared_at": "2026-11-02T17:24:12+00:00",
|
||||
"source_captured_at": "2026-11-02T17:20:38.379Z",
|
||||
"source_sha256": "c3192a7ab7f52b5220ebbb1d739ad4745433f6a0023cd163466246b1b1a90548",
|
||||
"source_version": 1,
|
||||
"current_rules": {
|
||||
"first_limit": "25000.00",
|
||||
"second_limit": "150000.00",
|
||||
"includes_tax": true,
|
||||
"provisional": true,
|
||||
"default_tax_rate": "16.00"
|
||||
},
|
||||
"proposed_rules": {
|
||||
"first_limit": "25000.00",
|
||||
"second_limit": "150000.00",
|
||||
"includes_tax": true,
|
||||
"provisional": false,
|
||||
"default_tax_rate": "16.00"
|
||||
},
|
||||
"enabled_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales",
|
||||
"people.internal",
|
||||
"people.third_party"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"key": "base.department_head",
|
||||
"name": "Piloto sintético — base department_head",
|
||||
"permissions": [
|
||||
"department_head",
|
||||
"requester"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "task.10",
|
||||
"name": "Piloto sintético — Autorizar requisições internas",
|
||||
"permissions": [
|
||||
"warehouse_request_review"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "base.finance",
|
||||
"name": "Piloto sintético — base finance",
|
||||
"permissions": [
|
||||
"finance",
|
||||
"requester"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "task.12",
|
||||
"name": "Piloto sintético — Conferir reposição",
|
||||
"permissions": [
|
||||
"supply_review"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "base.procurement",
|
||||
"name": "Piloto sintético — base procurement",
|
||||
"permissions": [
|
||||
"procurement",
|
||||
"requester"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "base.warehouse",
|
||||
"name": "Piloto sintético — base warehouse",
|
||||
"permissions": [
|
||||
"warehouse",
|
||||
"requester"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "base.admin",
|
||||
"name": "Piloto sintético — base admin",
|
||||
"permissions": [
|
||||
"admin",
|
||||
"requester"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales",
|
||||
"people.internal",
|
||||
"people.third_party"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "task.11",
|
||||
"name": "Piloto sintético — Preparar reposição",
|
||||
"permissions": [
|
||||
"supply_prepare"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "task.13",
|
||||
"name": "Piloto sintético — Entregar requisições internas",
|
||||
"permissions": [
|
||||
"warehouse_request_deliver"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "base.requester",
|
||||
"name": "Piloto sintético — base requester",
|
||||
"permissions": [
|
||||
"requester"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "task.9",
|
||||
"name": "Piloto sintético — Requisitar ao armazém",
|
||||
"permissions": [
|
||||
"warehouse_request_prepare"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Chefe de departamento — avaliação local",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"department_head"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.department_head",
|
||||
"task.10"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"department_head",
|
||||
"requester",
|
||||
"warehouse_request_review"
|
||||
],
|
||||
"added_task_permissions": [
|
||||
"warehouse_request_review"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Finanças — avaliação local",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"finance"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.finance",
|
||||
"task.12"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"finance",
|
||||
"requester",
|
||||
"supply_review"
|
||||
],
|
||||
"added_task_permissions": [
|
||||
"supply_review"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Joan Volkman",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"procurement"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.procurement"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"procurement",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Kira Rowe",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"warehouse"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.warehouse"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"warehouse",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Newell Oberbrunner",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"warehouse"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.warehouse"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"warehouse",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Obrigações — consulta",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"admin"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales",
|
||||
"people.internal",
|
||||
"people.third_party"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.admin"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"admin",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Obrigações — preparacao",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"finance"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.finance"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"finance",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Obrigações — validacao",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"finance"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.finance"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"finance",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "Procurement — avaliação local",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"procurement"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.procurement",
|
||||
"task.11"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"procurement",
|
||||
"requester",
|
||||
"supply_prepare"
|
||||
],
|
||||
"added_task_permissions": [
|
||||
"supply_prepare"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Proprietário — avaliação local",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"admin"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales",
|
||||
"people.internal",
|
||||
"people.third_party"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.admin"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"admin",
|
||||
"requester"
|
||||
],
|
||||
"added_task_permissions": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Recepção \/ Armazém — avaliação local",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"warehouse"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.warehouse",
|
||||
"task.13"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"warehouse",
|
||||
"requester",
|
||||
"warehouse_request_deliver"
|
||||
],
|
||||
"added_task_permissions": [
|
||||
"warehouse_request_deliver"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Solicitante — avaliação local",
|
||||
"department_id": 1,
|
||||
"current_permissions": [
|
||||
"requester"
|
||||
],
|
||||
"current_contexts": [
|
||||
"procurement.internal",
|
||||
"procurement.sales"
|
||||
],
|
||||
"current_can_request": true,
|
||||
"profile_keys": [
|
||||
"base.requester",
|
||||
"task.9"
|
||||
],
|
||||
"planned_permissions": [
|
||||
"requester",
|
||||
"warehouse_request_prepare"
|
||||
],
|
||||
"added_task_permissions": [
|
||||
"warehouse_request_prepare"
|
||||
]
|
||||
}
|
||||
],
|
||||
"permanent_administrator_source_ids": [
|
||||
6,
|
||||
8
|
||||
],
|
||||
"effects": {
|
||||
"activate_saved_profiles": true,
|
||||
"preserve_existing_permissions_and_contexts": true,
|
||||
"preserve_legacy_request_preparation": true,
|
||||
"make_existing_approval_limits_explicit_for_synthetic_pilot": true,
|
||||
"new_accounts": false,
|
||||
"change_existing_process_snapshots": false,
|
||||
"change_payables_specific_grants": false,
|
||||
"publish_stock_or_set_minimum_quantities": false,
|
||||
"change_sgf": false
|
||||
},
|
||||
"departments": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Operações"
|
||||
}
|
||||
],
|
||||
"cost_centers": [
|
||||
{
|
||||
"id": 1,
|
||||
"code": "OPS",
|
||||
"name": "Operações"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T12:09:52.229Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"csv": "passed",
|
||||
"dated_configuration_checks": true,
|
||||
"account_register_exported": false,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_print_review": false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T12:11:33.4980997Z",
|
||||
"scope": "SGC synthetic pilot diagnostics export",
|
||||
"tests": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 10,
|
||||
"passed": 10,
|
||||
"assertions": 161,
|
||||
"duration_ms": 3994
|
||||
},
|
||||
"http": {
|
||||
"checked_at": "2026-11-02T12:09:52.229Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"csv": "passed",
|
||||
"dated_configuration_checks": true,
|
||||
"account_register_exported": false,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_print_review": false
|
||||
},
|
||||
"checks": {
|
||||
"types": "passed",
|
||||
"formatting_and_lint": "passed",
|
||||
"build": "passed",
|
||||
"scoped_php_analysis": "passed",
|
||||
"common_runtime_sha256_files": 18
|
||||
},
|
||||
"administration_only": true,
|
||||
"account_register_exported": false,
|
||||
"spreadsheet_formula_text_handled": true,
|
||||
"configuration_or_business_commands": false,
|
||||
"new_persistent_accounts_or_grants": false,
|
||||
"sgf_real_changed": false,
|
||||
"visual_print_review": false,
|
||||
"historical_general_suite_rerun": false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T17:10:47.106Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"routes": [
|
||||
{
|
||||
"role": "procurement",
|
||||
"purchase_tasks": 0,
|
||||
"shown_quotation_tasks": 0,
|
||||
"followed": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"purchase_tasks": 0,
|
||||
"shown_quotation_tasks": 0,
|
||||
"followed": 0,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"role": "department_head",
|
||||
"purchase_tasks": 0,
|
||||
"shown_quotation_tasks": 0,
|
||||
"followed": 0,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"existing_accounts_used": true,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"sgf_installation_changed": false,
|
||||
"visual_review": false,
|
||||
"coverage": "Current first-page tasks only; complete pending journeys covered by disposable tests."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T17:10:35+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":11,\"passed\":11,\"assertions\":353,\"duration_ms\":36918}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"verification": {
|
||||
"checked_at": "2026-11-02T17:28:38.0997373Z",
|
||||
"typescript": "passed",
|
||||
"lint_build": "passed",
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"runtime_hashes": 18,
|
||||
"visual_review": false,
|
||||
"general_suite_rerun": false,
|
||||
"persistent_accounts_or_grants_changed": false,
|
||||
"persistent_business_commands": false,
|
||||
"sgf_installation_changed": false,
|
||||
"overlapping_suites_must_not_be_summed": true
|
||||
},
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 169,
|
||||
"passed": 169,
|
||||
"assertions": 2924,
|
||||
"duration_ms": 145167
|
||||
},
|
||||
"postgres": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 11,
|
||||
"passed": 11,
|
||||
"assertions": 353,
|
||||
"duration_ms": 36918
|
||||
},
|
||||
"http_proof": "quotation-task-integration-http.json",
|
||||
"scope": "Native quotation preparation, qualified pending evaluation, comparison and award routing",
|
||||
"limitations": [
|
||||
"No automatic selection or approval",
|
||||
"Formal admission, documentary and contractual tasks remain distinct",
|
||||
"Current persistent QA profiles had no pending quotation task"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T11:45:30.639Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"component": "stock/replenishment",
|
||||
"recovery_installed": true,
|
||||
"pagination": [
|
||||
"rules",
|
||||
"needs",
|
||||
"history",
|
||||
"workItems"
|
||||
],
|
||||
"filter": "pending",
|
||||
"scheduler": {
|
||||
"started_at": "2026-11-02T11:44:58+00:00",
|
||||
"finished_at": "2026-11-02T11:45:00+00:00",
|
||||
"pid": 24528,
|
||||
"scope": "SGC evaluation 8005",
|
||||
"result": "passed",
|
||||
"exit_code": 0
|
||||
},
|
||||
"administrator_read_only": true,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_browser_review": false
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T10:40:05+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":33,\"passed\":33,\"assertions\":310,\"duration_ms\":25855}",
|
||||
"races": [
|
||||
{
|
||||
"scenario": "same_operation",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "different_operations",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "automatic_purchase_activation_retry",
|
||||
"real_lock_contention": true,
|
||||
"single_draft": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "abrupt_exit_after_physical_commit_then_two_recovery_workers",
|
||||
"real_lock_contention": true,
|
||||
"single_draft": true,
|
||||
"no_authenticated_human": true,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T10:43:28.0372487Z",
|
||||
"scope": "SGC standalone controlled pilot",
|
||||
"delivered": [
|
||||
"transactional durable replenishment signal",
|
||||
"atomic draft and processing checkpoint",
|
||||
"retry after interruption without duplicate drafts",
|
||||
"scheduled minimum and validity reevaluation without stock movement",
|
||||
"separate pagination and pending filters",
|
||||
"authorized recovery of interrupted work",
|
||||
"explicit system execution attribution without human login"
|
||||
],
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 198,
|
||||
"passed": 198,
|
||||
"assertions": 1579,
|
||||
"duration_ms": 51432
|
||||
},
|
||||
"final_replenishment": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 18,
|
||||
"passed": 18,
|
||||
"assertions": 155,
|
||||
"duration_ms": 4866
|
||||
},
|
||||
"postgresql": {
|
||||
"checked_at": "2026-11-02T12:40:05+02:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":33,\"passed\":33,\"assertions\":310,\"duration_ms\":25855}",
|
||||
"races": [
|
||||
{
|
||||
"scenario": "same_operation",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "different_operations",
|
||||
"real_lock_contention": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "automatic_purchase_activation_retry",
|
||||
"real_lock_contention": true,
|
||||
"single_draft": true,
|
||||
"result": "passed"
|
||||
},
|
||||
{
|
||||
"scenario": "abrupt_exit_after_physical_commit_then_two_recovery_workers",
|
||||
"real_lock_contention": true,
|
||||
"single_draft": true,
|
||||
"no_authenticated_human": true,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
},
|
||||
"installation": "replenishment-recovery-install.json",
|
||||
"http": {
|
||||
"checked_at": "2026-11-02T10:40:17.957Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"component": "stock/replenishment",
|
||||
"recovery_installed": true,
|
||||
"pagination": [
|
||||
"rules",
|
||||
"needs",
|
||||
"history",
|
||||
"workItems"
|
||||
],
|
||||
"filter": "pending",
|
||||
"scheduler": {
|
||||
"started_at": "2026-11-02T12:39:47+02:00",
|
||||
"finished_at": "2026-11-02T12:39:48+02:00",
|
||||
"pid": 24528,
|
||||
"scope": "SGC evaluation 8005",
|
||||
"result": "passed",
|
||||
"exit_code": 0
|
||||
},
|
||||
"administrator_read_only": true,
|
||||
"business_commands": false,
|
||||
"accounts_or_grants_changed": false,
|
||||
"visual_browser_review": false
|
||||
},
|
||||
"runtime": {
|
||||
"observed_passes": 7,
|
||||
"last_pass": {
|
||||
"started_at": "2026-11-02T12:42:50+02:00",
|
||||
"finished_at": "2026-11-02T12:42:51+02:00",
|
||||
"pid": 24528,
|
||||
"scope": "SGC evaluation 8005",
|
||||
"result": "passed",
|
||||
"exit_code": 0
|
||||
},
|
||||
"windows_startup_service_installed": false,
|
||||
"technical_interval_minutes": 5
|
||||
},
|
||||
"checks": {
|
||||
"types": "passed",
|
||||
"common_runtime_sha256_files": 18,
|
||||
"build": "passed",
|
||||
"scoped_php_analysis": "passed",
|
||||
"formatting_and_lint": "passed"
|
||||
},
|
||||
"historical_evidence_preserved_without_rerun": [
|
||||
"assessment 33 tests / 314 assertions",
|
||||
"general 918 passed / 2 skipped"
|
||||
],
|
||||
"human_acceptance": "prepared script; not performed",
|
||||
"complete_visual_review": false,
|
||||
"sgf_real_installation_changed": false,
|
||||
"persistent_accounts_or_grants_created": false,
|
||||
"restrictions": [
|
||||
"SGF installation remains 0.1.1",
|
||||
"additional synthetic identity proposal remains unauthorized"
|
||||
],
|
||||
"next": [
|
||||
"warehouse reservation workdesk notices and expressly configured priorities/expiry",
|
||||
"controlled acceptance with existing authorized users"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked_at": "2026-11-02T10:36:06+00:00",
|
||||
"migration": "2026_11_02_000050_create_replenishment_work",
|
||||
"environment": "local evaluation 8005",
|
||||
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-supply-20261102-103601\/before.dump",
|
||||
"backup_sha256": "8680431889ff5c9cd157813cb592e45bcc92092de6c7932e3d9bfe64a35af390",
|
||||
"restore_verified": true,
|
||||
"existing_tables_preserved": 110,
|
||||
"new_tables": {
|
||||
"replenishment_work": 0
|
||||
},
|
||||
"accounts_or_grants_changed": false,
|
||||
"domain_installed": false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"Host expl\u00edcito obrigat\u00f3rio",
|
||||
"Bundle expl\u00edcito obrigat\u00f3rio",
|
||||
"plan is read-only",
|
||||
"Manifesto divergente",
|
||||
"Cache Laravel personalizado",
|
||||
"manuten\u00e7\u00e3o activa",
|
||||
"servi\u00e7os de escrita parados",
|
||||
"Destino de c\u00f3digo divergente",
|
||||
"applied offline without activation",
|
||||
"invalidates known Laravel caches without touching unrelated files",
|
||||
"applied verified overlay app\/Modules\/Identity\/Services\/Permissions.php",
|
||||
"applied verified overlay app\/Modules\/Identity\/Services\/IdentityWorkflow.php",
|
||||
"applied verified overlay app\/Modules\/Purchasing\/BudgetAdapter.php",
|
||||
"applied verified overlay app\/Modules\/Purchasing\/BudgetWorkspace.php",
|
||||
"applied verified overlay app\/Providers\/AppServiceProvider.php",
|
||||
"applied verified overlay app\/Shared\/Context\/ModuleNavigation.php",
|
||||
"applied verified overlay resources\/js\/app.jsx",
|
||||
"applied verified overlay resources\/js\/PurchasingHome.jsx",
|
||||
"applied verified overlay public\/build\/manifest.json",
|
||||
"apply retry does not duplicate installation",
|
||||
"C\u00f3digo local alterado",
|
||||
"refused rollback preserves bundle and local code",
|
||||
"Cache recriado",
|
||||
"rollback preserves maintenance and never touches data schema",
|
||||
"rollback restores exact previous cache",
|
||||
"restored original overlay app\/Modules\/Identity\/Services\/Permissions.php",
|
||||
"restored original overlay app\/Modules\/Identity\/Services\/IdentityWorkflow.php",
|
||||
"restored original overlay app\/Modules\/Purchasing\/BudgetAdapter.php",
|
||||
"restored original overlay app\/Modules\/Purchasing\/BudgetWorkspace.php",
|
||||
"restored original overlay app\/Providers\/AppServiceProvider.php",
|
||||
"restored original overlay app\/Shared\/Context\/ModuleNavigation.php",
|
||||
"restored original overlay resources\/js\/app.jsx",
|
||||
"restored original overlay resources\/js\/PurchasingHome.jsx",
|
||||
"restored original overlay public\/build\/manifest.json",
|
||||
"withdrawn bundle preserved for diagnosis",
|
||||
"clone remains on original package pointer",
|
||||
"Injected deployment interruption",
|
||||
"interruption retains maintenance",
|
||||
"interrupted deployment recovers app\/Modules\/Identity\/Services\/Permissions.php",
|
||||
"interrupted deployment recovers app\/Modules\/Identity\/Services\/IdentityWorkflow.php",
|
||||
"interrupted deployment recovers app\/Modules\/Purchasing\/BudgetAdapter.php",
|
||||
"interrupted deployment recovers app\/Modules\/Purchasing\/BudgetWorkspace.php",
|
||||
"interrupted deployment recovers app\/Providers\/AppServiceProvider.php",
|
||||
"interrupted deployment recovers app\/Shared\/Context\/ModuleNavigation.php",
|
||||
"interrupted deployment recovers resources\/js\/app.jsx",
|
||||
"interrupted deployment recovers resources\/js\/PurchasingHome.jsx",
|
||||
"interrupted deployment recovers public\/build\/manifest.json",
|
||||
"destination pointer untouched",
|
||||
"Injected post-switch interruption",
|
||||
"post-switch interruption recovers without exposing partial code",
|
||||
"Outra implanta\u00e7\u00e3o em curso",
|
||||
"CLI plan verifies bundle without writes",
|
||||
"CLI apply leaves services and maintenance under operator control",
|
||||
"CLI rollback preserves 0.1.1 pointer"
|
||||
],
|
||||
"candidate": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20",
|
||||
"clones": [
|
||||
"C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-f2382065595e",
|
||||
"C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-1de2f989fa65",
|
||||
"C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-7dd4d6ad2e94",
|
||||
"C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-code-deploy-eeca8efbf821"
|
||||
],
|
||||
"checked_at": "2026-11-02T07:09:25+00:00",
|
||||
"destination_version": "0.1.1",
|
||||
"destination_applied": false,
|
||||
"old_suites_repeated": false,
|
||||
"database_opened": false,
|
||||
"services_restarted": false
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"reproduced current 87-table schema without source rows",
|
||||
"no accounts or grants imported from destination",
|
||||
"runtime cannot create schema objects",
|
||||
"injected dependency migration failure",
|
||||
"failed migration restores original schema and identity data",
|
||||
"successful migration preserves existing identity and grants",
|
||||
"installation creates empty centres and allocations",
|
||||
"runtime module SQL access denied before test-only privilege plan",
|
||||
"minimal runtime excludes historical update and requisition deletion",
|
||||
"runtime reads corrected revision without ownership",
|
||||
"provider really loaded from isolated copy",
|
||||
"new bundle bootstrap accepted by installer integrity check",
|
||||
"anonymous direct initialisation URL denied",
|
||||
"native clone authentication 1",
|
||||
"native clone context 1",
|
||||
"native clone authentication 2",
|
||||
"native clone context 2",
|
||||
"provider registers initial authority page without prior functional rights",
|
||||
"initial authority CSRF enforced",
|
||||
"initial authority through normal middleware",
|
||||
"work page requires explicit module selection",
|
||||
"new requisition view right makes purchasing selectable",
|
||||
"stale module generation refused",
|
||||
"provider registers operational work page",
|
||||
"different enterprise option refused by operational composition",
|
||||
"native administrative delegation purchasing_requisitions.view",
|
||||
"native administrative delegation purchasing_requisitions.approve.department_head",
|
||||
"native administrative delegation purchasing_budget.view",
|
||||
"native administrative delegation purchasing_budget.approve",
|
||||
"empty installation exposes authorised centre and budget preparation",
|
||||
"centre created through authorised UI API with runtime role",
|
||||
"centre retries preserve one source",
|
||||
"existing budget API prepares synthetic allocation",
|
||||
"budget approval requires independent identity",
|
||||
"independent allocation approval uses existing budget kernel",
|
||||
"workspace dependencies available on reproduced schema",
|
||||
"direct budget movements cannot bypass requisition lifecycle",
|
||||
"operational policy configured via HTTP",
|
||||
"integrated operational draft",
|
||||
"integrated submit uses existing budget adapter",
|
||||
"integrated independent identity enforced",
|
||||
"integrated approval by another identity",
|
||||
"integrated authorised consultation and history",
|
||||
"native administration receives complete unit catalogue",
|
||||
"native administration receives canonical server labels",
|
||||
"reviewer cannot inspect administrative catalogue without management right",
|
||||
"forged delegation of an unowned catalogue right refused",
|
||||
"packaged frontend dependency served before authentication",
|
||||
"packaged frontend dependency served before authentication",
|
||||
"native SGF entry renders the packaged frontend manifest",
|
||||
"undeclared frontend asset refused",
|
||||
"minimal plan removes inherited update and delete on new append-only sources",
|
||||
"runtime cannot truncate requisitions",
|
||||
"existing table ACL preserved a_billing_drafts",
|
||||
"existing table ACL preserved a_clients",
|
||||
"existing table ACL preserved a_commands",
|
||||
"existing table ACL preserved a_events",
|
||||
"existing table ACL preserved a_history",
|
||||
"existing table ACL preserved a_outbox",
|
||||
"existing table ACL preserved a_pending",
|
||||
"existing table ACL preserved a_service_drafts",
|
||||
"existing table ACL preserved a_services",
|
||||
"existing table ACL preserved b_internal_confirmations",
|
||||
"existing table ACL preserved ca_commands",
|
||||
"existing table ACL preserved ca_decisions",
|
||||
"existing table ACL preserved ca_proposals",
|
||||
"existing table ACL preserved ca_resolutions",
|
||||
"existing table ACL preserved ca_submissions",
|
||||
"existing table ACL preserved cache",
|
||||
"existing table ACL preserved cache_locks",
|
||||
"existing table ACL preserved companies",
|
||||
"existing table ACL preserved company_memberships",
|
||||
"existing table ACL preserved dc_calculations",
|
||||
"existing table ACL preserved dc_commands",
|
||||
"existing table ACL preserved dc_decisions",
|
||||
"existing table ACL preserved dc_events",
|
||||
"existing table ACL preserved dc_outbox",
|
||||
"existing table ACL preserved dc_rates",
|
||||
"existing table ACL preserved doc_history",
|
||||
"existing table ACL preserved doc_records",
|
||||
"existing table ACL preserved document_preview_model_history",
|
||||
"existing table ACL preserved document_preview_models",
|
||||
"existing table ACL preserved failed_jobs",
|
||||
"existing table ACL preserved ib_budgets",
|
||||
"existing table ACL preserved ib_commands",
|
||||
"existing table ACL preserved ib_comparisons",
|
||||
"existing table ACL preserved ib_decisions",
|
||||
"existing table ACL preserved ib_events",
|
||||
"existing table ACL preserved ib_outbox",
|
||||
"existing table ACL preserved ic_commands",
|
||||
"existing table ACL preserved ic_decisions",
|
||||
"existing table ACL preserved ic_events",
|
||||
"existing table ACL preserved ic_inputs",
|
||||
"existing table ACL preserved ic_lots",
|
||||
"existing table ACL preserved ic_outbox",
|
||||
"existing table ACL preserved ic_products",
|
||||
"existing table ACL preserved ic_results",
|
||||
"existing table ACL preserved identity_audit",
|
||||
"existing table ACL preserved job_batches",
|
||||
"existing table ACL preserved jobs",
|
||||
"existing table ACL preserved migrations",
|
||||
"existing table ACL preserved password_reset_tokens",
|
||||
"existing table ACL preserved permission_grants",
|
||||
"existing table ACL preserved service_calculation_profiles",
|
||||
"existing table ACL preserved service_calculation_results",
|
||||
"existing table ACL preserved sessions",
|
||||
"existing table ACL preserved sf_activation_events",
|
||||
"existing table ACL preserved sf_decisions",
|
||||
"existing table ACL preserved sf_proposals",
|
||||
"existing table ACL preserved si_commands",
|
||||
"existing table ACL preserved si_correction_requests",
|
||||
"existing table ACL preserved si_counters",
|
||||
"existing table ACL preserved si_decisions",
|
||||
"existing table ACL preserved si_events",
|
||||
"existing table ACL preserved si_invoices",
|
||||
"existing table ACL preserved si_outbox",
|
||||
"existing table ACL preserved si_proposals",
|
||||
"existing table ACL preserved sr_commands",
|
||||
"existing table ACL preserved sr_deliveries",
|
||||
"existing table ACL preserved sr_effects",
|
||||
"existing table ACL preserved sr_events",
|
||||
"existing table ACL preserved sr_outbox",
|
||||
"existing table ACL preserved sr_records",
|
||||
"existing table ACL preserved tenants",
|
||||
"existing table ACL preserved units",
|
||||
"existing table ACL preserved users",
|
||||
"existing table ACL preserved wb_claims",
|
||||
"existing table ACL preserved wb_commands",
|
||||
"existing table ACL preserved wb_decisions",
|
||||
"existing table ACL preserved wb_events",
|
||||
"existing table ACL preserved wb_generations",
|
||||
"existing table ACL preserved wb_outbox",
|
||||
"existing table ACL preserved wb_proposals",
|
||||
"existing table ACL preserved wo_commands",
|
||||
"existing table ACL preserved wo_decisions",
|
||||
"existing table ACL preserved wo_entries",
|
||||
"existing table ACL preserved wo_equipment",
|
||||
"existing table ACL preserved wo_events",
|
||||
"existing table ACL preserved wo_orders",
|
||||
"existing table ACL preserved wo_outbox",
|
||||
"actual runtime CREATE denied",
|
||||
"actual runtime ALTER of identity catalogue denied",
|
||||
"centre creation requires own configuration and budget rights",
|
||||
"centre context cannot be forged",
|
||||
"refused centre operations leave original source intact",
|
||||
"unsupported requisition type refused equipment",
|
||||
"unsupported requisition type refused part",
|
||||
"approved lifecycle covers the three supported basic types",
|
||||
"unsupported types create neither a second request nor reservation",
|
||||
"native revocation applies to direct module URL",
|
||||
"uncontracted module refused at server",
|
||||
"disabled composition exposes no executable work page",
|
||||
"code-only successor installed over existing provenance",
|
||||
"code-only successor rollback restores exact candidate",
|
||||
"code update and rollback preserve every reproduced data table",
|
||||
"code recovery preserves submitted origin decision and reservation",
|
||||
"code recovery leaves baseline and maintenance intact"
|
||||
],
|
||||
"clone": "C:\\Users\\macbook\\AppData\\Local\\Temp\/sgf-update-625f1bba6e30",
|
||||
"data_fingerprints": {
|
||||
"a_billing_drafts": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_clients": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_history": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_pending": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_service_drafts": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"a_services": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"b_internal_confirmations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ca_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ca_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ca_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ca_resolutions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ca_submissions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"cache": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"cache_locks": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"cb_audit": "ccf5b17cacb6e9d1cbbebdc55baca6b2b51103f3573c32dd1bddb24a055d2e2b",
|
||||
"cb_budget_consumptions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"cb_budget_entries": "ba73d3b7eb20c25d833df7b3b85f7f1f7575d78365ee1a3c2246e7749f2448ad",
|
||||
"cb_budget_reinforcements": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"cb_budgets": "8df00e7e6b821e2767118c352f58391d4c44bb8df1fcc38a3d40a6a9989543a5",
|
||||
"cb_centres": "45bebc4ca0d417711e2a428b360c343e6dba17a64a4d799639165815e5aaef45",
|
||||
"cb_consumption_sources": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"cb_origins": "f470bed486f1955724e99cde0404445c4b9753320faee2d202289fedffc4d561",
|
||||
"cb_ui_commands": "424c4f2f643087911333a3ecf002bbb5fc5055c5ccf24f5cefd94c9db56b398b",
|
||||
"companies": "e5098861f02b2904e068c604ecbe1379ec6597b39fcdc9575c9d9eaa3b459aec",
|
||||
"company_memberships": "562084217604e0bd153e36375251db9e4e87f92c744c69d38d2a39c7821454e5",
|
||||
"dc_calculations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"dc_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"dc_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"dc_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"dc_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"dc_rates": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"doc_history": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"doc_records": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"document_preview_model_history": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"document_preview_models": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"failed_jobs": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ib_budgets": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ib_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ib_comparisons": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ib_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ib_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ib_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_inputs": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_lots": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_products": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"ic_results": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"identity_audit": "b714264c1c89e456f9217c285169fa91bc379bb744684e39c2028bb48a025ca7",
|
||||
"ir_commands": "fa90f00d334ae11e38a4aea327720569da50f196ad5aa4876fcaa0b6154d78ad",
|
||||
"ir_events": "8379dcb6a4fb3d1cad80cab5a4afe9a49b86a7145e2dc613d6d5371d18e96051",
|
||||
"ir_policies": "7ff4f648cbf36386f78743ed2394eb12e6328fb6e7722c6ba1acb5fab36ca1d1",
|
||||
"ir_policy_versions": "7ff4f648cbf36386f78743ed2394eb12e6328fb6e7722c6ba1acb5fab36ca1d1",
|
||||
"ir_requests": "874bbaa494e41ddc2bee527f3660909123a19c270f7eaee0e19d534d8c355fd6",
|
||||
"ir_steps": "dcde70900864b1e974f66a4778e80e8317907c14a3dc0545afadb468c6f9eef6",
|
||||
"job_batches": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"jobs": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"migrations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"password_reset_tokens": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"permission_grants": "2219ad7945c6cd3d4e28bfc4a06c04eb6141ceaa53097efd01ab5f89811fec13",
|
||||
"service_calculation_profiles": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"service_calculation_results": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sessions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sf_activation_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sf_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sf_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sgc_installations": "75449b61764076870970ddf93e9b987e6b7b24889de47fb8d9046a5345065f1c",
|
||||
"si_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_correction_requests": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_counters": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_invoices": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"si_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sr_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sr_deliveries": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sr_effects": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sr_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sr_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"sr_records": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"tenants": "0487a4884eb77b28bd8c052e9d2bd1a8177bab511463657d326a17c0c77b3619",
|
||||
"units": "c0a43a3bf8907bcbebf727f089e8c75399ed7a94049ad85b666fe6b90851842e",
|
||||
"users": "ea313e4a8b0a16e17dc1b61cea28c6832a76feed78540fe40ab61bbf88c6200c",
|
||||
"wb_claims": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wb_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wb_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wb_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wb_generations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wb_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wb_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_decisions": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_entries": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_equipment": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_orders": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"wo_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
|
||||
},
|
||||
"destination_activated": false,
|
||||
"normal_provider_routes": true,
|
||||
"browser_used": false,
|
||||
"data_recovery": {
|
||||
"verified": true,
|
||||
"tables_reconciled": 103,
|
||||
"approved_origin_reservation_versions_preserved": true,
|
||||
"runtime_read_verified": true,
|
||||
"runtime_ddl_denied": true,
|
||||
"schema_and_triggers_verified": true,
|
||||
"sql_acl_reapplied_only_to_isolated_role": true
|
||||
},
|
||||
"destination_security_and_schema_preserved": true,
|
||||
"artifact": {
|
||||
"directory": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidates\/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20",
|
||||
"archive": {
|
||||
"file": "C:\\laragon\\www\\compras\/storage\/app\/private\/candidate-archives\/OPERATIONAL-CANDIDATE-401ce31ae5f2dd20.zip",
|
||||
"sha256": "59dbdc64c4d9f14dfb4025a6177b829d136a4e2cb633ca036b10b5ae3197e703",
|
||||
"manifest_sha256": "a6478223e17a5430652f0fcbf31a44a931ae2a99cd90b151c98d2df5df43c10b"
|
||||
},
|
||||
"installed": false
|
||||
},
|
||||
"source_schema_tables": 87,
|
||||
"source_rows_exported": 0,
|
||||
"source_schema_sha256": "b530e03c759305247f46cdad5013e64a6caae00bded3d9d5ae5b7b5aa06a4647",
|
||||
"runtime_and_migrator_separate": true,
|
||||
"destination_version": "0.1.1",
|
||||
"destination_changed": false,
|
||||
"prior_proofs_reused": true,
|
||||
"checked_at": "2026-11-02T07:10:03+00:00",
|
||||
"test_database_and_roles_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