This commit is contained in:
2026-11-03 11:51:44 +02:00
parent badaada043
commit e753f7a0a4
309 changed files with 14537 additions and 529 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ class AutomaticPurchases
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)
if (! $this->ready() || $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;
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Composition\Bundle;
/** Trust anchor in the host. No executable artifact is loaded before its pinned hashes. */
final class FinanceBundle
{
public static function configure(): void
{
$root = config('common-finance-bundle.root');
if ($root === null) {
return;
}
$pin = config('common-finance-bundle.manifest_sha256');
if (! app()->environment(['testing', 'payable-testing']) || ! is_string($root) || ! is_string($pin) || ! preg_match('/^[a-f0-9]{64}$/D', $pin)) {
throw new \LogicException('FINANCE_BUNDLE_QA_ONLY');
}
$db = DB::connection();
$r = $db->selectOne("SELECT current_database() d,current_user u,inet_server_port() p,current_setting('cluster_name') c");
if ($db->getConfig('host') !== '127.0.0.1' || (int) $r->p !== 55439 || ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $r->d, $m) || $r->u !== 'payable_history_native_'.$m[1] || ! preg_match('/^sgf_modular_regression_[a-f0-9]{12}$/D', $r->c)) {
throw new \LogicException('FINANCE_BUNDLE_DATABASE_REFUSED');
}
if ($db->selectOne('SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user')->elevated) {
throw new \LogicException('FINANCE_BUNDLE_ELEVATED_ROLE');
}
$root = realpath($root) ?: throw new \LogicException('FINANCE_BUNDLE_MISSING');
$manifest = $root.'/manifest.json';
$bytes = is_file($manifest) && ! is_link($manifest) ? file_get_contents($manifest) : false;
if (! is_string($bytes) || ! hash_equals($pin, hash('sha256', $bytes))) {
throw new \LogicException('FINANCE_BUNDLE_MANIFEST_MISMATCH');
}
$m = json_decode($bytes, true, 64, JSON_THROW_ON_ERROR);
$loader = null;
foreach ($m['files'] ?? [] as $f) {
if (($f['file'] ?? null) === 'Bundle.php') {
$loader = $f['sha256'] ?? null;
}
}
$loaderHash = is_file($root.'/Bundle.php') && ! is_link($root.'/Bundle.php') ? hash_file('sha256', $root.'/Bundle.php') : false;
if (! is_string($loader) || ! is_string($loaderHash) || ! hash_equals($loader, $loaderHash)) {
throw new \LogicException('FINANCE_BUNDLE_LOADER_MISMATCH');
}
require_once $root.'/Bundle.php';
$paths = Bundle::verify($root, $pin);
config(['common-payables.path' => $paths['payables'], 'general-ledger.path' => $paths['ledger']]);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\GeneralLedger\LedgerSchema;
use Tempo\Finance\GeneralLedger\PayableJournal;
use Tempo\Finance\GeneralLedger\ReportsWorkspace;
use Tempo\Finance\Payables\Payables;
/** Standalone adapter; same local package, native finance authority, no SGF remote service. */
final class GeneralLedger
{
/** @return array{actor:User,scope:array<string,int>,person:string} */
private function context(User $user): array
{
abort_unless(app()->environment('payable-testing') && config('general-ledger.synthetic_enabled') === true, 403);
$package = config('general-ledger.path');
abort_unless(is_string($package) && is_file($package.'/bootstrap.php'), 503, 'Pacote contabilístico não composto.');
require_once $package.'/bootstrap.php';
LedgerSchema::assertSynthetic(DB::connection());
$context = app(SupplierPayables::class)->readContext($user);
abort_unless(DB::table('pa_access')->where($context['scope'])->where(['user_id' => $user->id, 'action' => 'general_ledger.view', 'person' => $context['person']])->exists(), 403);
return $context;
}
public function can(User $user, string $action): bool
{
$c = $this->context($user);
return DB::table('pa_access')->where($c['scope'])->where(['user_id' => $user->id, 'action' => 'general_ledger.'.$action, 'person' => $c['person']])->exists();
}
public function engine(User $user, ?\Closure $fault = null): PayableJournal
{
$c = $this->context($user);
$scope = $c['scope'];
$person = $c['person'];
$authorize = function (string $action, bool $lock = true) use ($user, $scope, $person) {
if ($lock) {
app(ConflictGuard::class)->lockCompany($user);
app(CurrentActor::class)->lock($user, [PurchasingRole::Finance, PurchasingRole::Admin]);
}
$fresh = $this->context($user);
abort_unless($fresh['scope'] === $scope && $fresh['person'] === $person, 409, 'Pessoa ou contexto alterados.');
abort_unless($this->can($user, $action), 403);
};
return new PayableJournal(DB::connection(), $scope, $user->id, $person, fn ($a) => $authorize($a), fn () => $authorize('view', false), fn ($id) => $this->source($user, $scope, $id), $fault);
}
/** @param array<string,int> $scope
* @return array<string,mixed>
*/
private function source(User $user, array $scope, int $id): array
{
$o = DB::table('pa_obligations')->where($scope)->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação indisponível nesta organização.');
$origin = DB::table('pa_sources')->where($scope)->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.');
$finance = app(SupplierPayables::class);
$doc = $finance->document($user, (int) $origin->local_source_id);
$position = $finance->engine($user)->positionForSource((int) $origin->local_source_id);
if (! $position || (int) $position['id'] !== $id || $position['source_conflict']) {
throw new \DomainException('Origem financeira mudou; nova conferência necessária.');
}
$p = DB::table('pa_proposals')->where($scope)->where('id', $o->proposal_id)->first() ?? throw new \DomainException('Conferência original ausente.');
return ['effect_type' => 'supplier_payable.recognised', 'origin_key' => $origin->source_key, 'obligation_id' => $id, 'local_source_id' => (int) $origin->local_source_id, 'document_revision' => $o->source_revision, 'recognition_revision' => hash('sha256', Payables::canonical(['obligation' => (array) $o, 'recognition_snapshot' => $p->snapshot])), 'principal_minor' => (int) $o->principal_minor, 'currency' => $o->currency, 'due_on' => $o->due_on, 'tax_minor' => $doc['tax_minor'], 'compatible' => true, 'history_imported' => $position['history_imported']];
}
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function report(User $user, string $kind, array $options): array
{
if (! in_array($kind, ['trial-balance', 'account-ledger'], true)) {
throw new \InvalidArgumentException('Relatório desconhecido.');
}
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $kind, $options, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$engine = $this->engine($user);
abort_unless(class_exists(ReportsWorkspace::class), 503, 'Relatórios não incluídos no pacote composto.');
foreach (LedgerSchema::TABLES as $table) {
abort_unless(DB::getSchemaBuilder()->hasTable($table), 503, 'Fonte contabilística indisponível neste ensaio.');
}
return $kind === 'trial-balance' ? $engine->trialBalance($options) : $engine->accountLedger($options);
});
}
/** @return array<string,mixed> */
public function workspace(User $user, int $page = 1): array
{
abort_unless(config('general-ledger.cycle_enabled') === true, 403);
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $page, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$engine = $this->engine($user);
$data = $engine->cycleListing($page);
$data['book'] = $engine->listing(['page' => $page]);
$actions = ['prepare', 'validate', 'post', 'correct_prepare', 'correct_validate', 'correct_post', 'period_prepare', 'period_validate', 'period_close'];
$data['permissions'] = array_combine($actions, array_map(fn ($a) => $this->can($user, $a), $actions));
return $data;
});
}
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function listing(User $user, array $options = []): array
{
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $options, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->engine($user)->listing($options);
});
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Services;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
/** Read projection only; callers retain their native module authority and commands. */
class InventoryCorrectionPage
{
private function actor(): User
{
$actor = auth()->user();
abort_unless($actor instanceof User && $actor->tenant_id !== null, 403);
return User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
}
/** @param Collection<array-key,mixed> $selected
* @return Collection<int,GoodsReceiptItem>
*/
public function receipts(Collection $selected, string $search = ''): Collection
{
$actor = $this->actor();
abort_unless(app(InventoryDispositions::class)->allows($actor, 'prepare') || app(InventoryReversals::class)->allows($actor, 'prepare'), 403);
$query = GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)
->whereNotIn('id', array_keys(app(InventoryReversals::class)->corrections('receipt')));
$recent = (clone $query)->when(trim($search) !== '', fn ($q) => $q->whereIn('goods_receipt_id', GoodsReceipt::query()->whereLike('number', '%'.trim($search).'%')->select('id')))->latest('id')->limit(200)->pluck('id');
return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get();
}
/** @param Collection<array-key,mixed> $selected
* @return Collection<int,StockOperation>
*/
public function operations(Collection $selected, string $search = ''): Collection
{
abort_unless(app(InventoryReversals::class)->allows($this->actor(), 'prepare'), 403);
$query = StockOperation::query()->whereIn('kind', ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'])
->whereNotIn('id', array_keys(app(InventoryReversals::class)->corrections('operation')));
$recent = (clone $query)->when(trim($search) !== '', fn ($q) => $q->whereLike('reference', '%'.trim($search).'%'))->latest('id')->limit(200)->pluck('id');
return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get();
}
/** @return array{ready:bool,focus:SupplyRecord|null,records:Collection<int,SupplyRecord>,filters:array{search:string,state:string},lookup:array<string,mixed>,pagination:array<string,mixed>|null} */
public function read(Request $request, User $actor, string $kind): array
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null && in_array($kind, ['stock_event', 'stock_reversal'], true), 403);
$actor = $this->actor();
$workflow = $kind === 'stock_event' ? app(InventoryDispositions::class) : app(InventoryReversals::class);
abort_unless(collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($capability) => $workflow->allows($actor, $capability)), 403);
$filters = $request->validate([
'record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)],
'records_page' => 'nullable|integer|min:1|max:1000000',
'search' => 'nullable|string|max:100',
'state' => 'nullable|in:draft,returned,approved,executed',
'source_search' => 'nullable|string|max:100', 'source_form_key' => 'nullable|string|max:80',
'selected_receipt_id' => ['nullable', 'integer', 'min:1', Rule::exists('goods_receipt_items', 'id')->where('tenant_id', $actor->tenant_id)],
'selected_operation_id' => ['nullable', 'integer', 'min:1', Rule::exists('stock_operations', 'id')->where('tenant_id', $actor->tenant_id)],
]);
$ready = Schema::hasTable('supply_records');
$focus = isset($filters['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', $kind)->findOrFail((int) $filters['record_id']) : null;
$query = $ready ? app(SupplyPlanning::class)->latest($kind)->where('tenant_id', $actor->tenant_id)->latest('id') : null;
$search = trim($filters['search'] ?? '');
$state = $filters['state'] ?? '';
$page = $focus === null ? $query?->when($search !== '', fn ($q) => $q->whereLike('data->reference', '%'.$search.'%'))
->when($state !== '', fn ($q) => $q->where('state', $state))->paginate(25, ['*'], 'records_page')->withQueryString() : null;
return ['ready' => $ready, 'focus' => $focus,
'lookup' => ['search' => trim($filters['source_search'] ?? ''), 'form' => $filters['source_form_key'] ?? null,
'receipt' => $filters['selected_receipt_id'] ?? null, 'operation' => $filters['selected_operation_id'] ?? null],
'records' => $focus !== null ? $query?->where('record_key', $focus->record_key)->get() ?? collect() : $page?->getCollection() ?? collect(),
'filters' => ['search' => $search, 'state' => $state],
'pagination' => $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()]];
}
}
+13 -2
View File
@@ -20,6 +20,17 @@ use Illuminate\Validation\ValidationException;
/** Physical supplier returns and write-offs; financial corrections remain distinct. */
class InventoryDispositions
{
public function executionSourceCurrent(SupplyRecord $record): bool
{
return $record->kind === 'stock_event' && app(InventoryReviewParticipation::class)->executionSourceCurrent($record, fn (array $data) => $this->basis($data));
}
/** Participation only: source, stock and physical evidence are revalidated by save. */
public function canAct(User $actor, SupplyRecord $record, string $action): bool
{
return app(InventoryReviewParticipation::class)->canAct($actor, $record, 'stock_event', $action, fn (string $capability) => $this->allows($actor, $capability));
}
public function allows(User $actor, string $capability): bool
{
return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($capability) {
@@ -163,7 +174,7 @@ class InventoryDispositions
$data = $old->data;
$snapshot = $old->snapshot;
if ($v['action'] === 'execute') {
$this->check($old->state === 'approved' && $actor->id !== $snapshot['reviewed_by'], 'action', 'A saída exige aprovação prévia e operador diferente de quem aprovou.');
$this->check($old->state === 'approved' && app(InventoryReviewParticipation::class)->independentExecutor($actor, $old), 'action', 'A saída exige aprovação prévia e operador diferente de quem aprovou.');
$event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate();
$this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'A saída não pode anteceder a aprovação.');
$current = $this->basis($data);
@@ -174,7 +185,7 @@ class InventoryDispositions
$snapshot['execution_observed'] = $current['observed'];
$state = 'executed';
} else {
$this->check(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A decisão exige pessoa independente de todos os preparadores.');
$this->check(app(InventoryReviewParticipation::class)->independentReviewer($actor, $old), 'action', 'A decisão exige pessoa independente de todos os preparadores.');
$this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com a decisão.');
$state = $v['action'] === 'approve' ? 'approved' : 'returned';
if ($state === 'approved') {
+43 -1
View File
@@ -21,6 +21,46 @@ use Illuminate\Validation\ValidationException;
/** Lot quantities partition the physical ledger; they never create physical stock. */
class InventoryLots
{
public function canIdentify(User $actor, ?SupplyRecord $metadata): bool
{
return $metadata !== null && $metadata->kind === 'lot' && $metadata->tenant_id === $actor->tenant_id
&& $this->allows($actor, 'inventory_lot_prepare')
&& app(SupplyPlanning::class)->latest('lot')->where('record_key', $metadata->record_key)->value('id') === $metadata->id;
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $entry
*/
public function canDecideLot(User $actor, ?SupplyRecord $metadata, array $context, array $entry, string $action): bool
{
if ($metadata === null || $metadata->tenant_id !== $actor->tenant_id || $metadata->kind !== 'lot'
|| ($entry['metadata_id'] ?? null) !== $metadata->id || ! ($context['reconciled'] ?? false)
|| ($entry['quantity_milli'] ?? 0) <= 0 || ! in_array($action, ['hold', 'release'], true)
|| ! $this->allows($actor, 'inventory_lot_'.$action)
|| app(SupplyPlanning::class)->latest('lot')->where('record_key', $metadata->record_key)->value('id') !== $metadata->id) {
return false;
}
return $action === 'hold' || (($entry['eligible'] ?? false) && ! in_array($actor->id, $entry['contributors'], true));
}
private function independentSetupReviewer(User $actor, SupplyRecord $record): bool
{
return ! in_array($actor->id, $record->snapshot['contributors'], true);
}
/** Participation only: activation still checks balance, active setup and metadata stamps. */
public function canActivate(User $actor, SupplyRecord $record): bool
{
if ($record->kind !== 'lot_setup' || $record->state !== 'draft' || $record->tenant_id !== $actor->tenant_id
|| ! $this->allows($actor, 'inventory_lot_review') || ! $this->independentSetupReviewer($actor, $record)) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('lot_setup')->where('record_key', $record->record_key)->first();
return $latest?->id === $record->id && $this->setup($record->stock_item_id, $record->warehouse_id)?->id === ($record->snapshot['previous_setup_id'] ?? null);
}
public function allows(User $actor, string $permission): bool
{
return $actor->hasFunctions($permission === 'inventory_lot_read' ? [$permission, 'admin'] : [$permission])
@@ -167,7 +207,7 @@ class InventoryLots
}
if ($v['action'] === 'activate') {
$this->check($old?->kind === 'lot_setup' && $old->state === 'draft', 'action', 'Prepare primeiro a conferência do saldo.');
$this->check(! in_array($actor->id, $old->snapshot['contributors'], true), 'action', 'A activação exige conferência independente da preparação.');
$this->check($this->independentSetupReviewer($actor, $old), 'action', 'A activação exige conferência independente da preparação.');
$active = $this->setup($old->stock_item_id, $old->warehouse_id);
$this->check(($active?->id) === ($old->snapshot['previous_setup_id'] ?? null), 'action', 'A configuração activa mudou desde a preparação. Actualize a revisão.');
$this->check(app(StockWorkflow::class)->balance($old->stock_item_id, $old->warehouse_id) === $old->snapshot['balance'], 'action', 'O saldo mudou. Actualize a preparação e confira novamente.');
@@ -186,6 +226,7 @@ class InventoryLots
}
if ($v['action'] === 'identify') {
$this->check($old?->kind === 'lot', 'action', 'Seleccione a identificação existente.');
$this->check($this->canIdentify($actor, $old), 'action', 'A identificação exige competência e versão actual.');
$metadata = $this->metadata($v['data']);
// A correction invalidates the previous quality release by metadata version.
@@ -199,6 +240,7 @@ class InventoryLots
$this->check($entry !== null && $context['reconciled'] && $entry['quantity_milli'] > 0, 'action', 'Lote sem saldo conferido neste armazém.');
$state = app(SupplyPlanning::class)->latest('lot_state')->whereJsonContains('data->lot_id', $identity->id)->where('warehouse_id', $d['warehouse_id'])->first();
$this->check((int) $d['state_version'] === ($state->version ?? 0) && $old?->id === $entry['metadata_id'], 'version', 'A identificação ou a decisão mudou. Actualize a página.');
$this->check($this->canDecideLot($actor, $old, $context, $entry, $v['action']), 'action', 'A decisão exige saldo conferido, competência, identificação válida e independência na libertação.');
if ($v['action'] === 'release') {
$this->check(! in_array($actor->id, $entry['contributors'], true), 'action', 'A libertação exige pessoa independente da identificação e recepção.');
$this->check($entry['eligible'], 'action', 'Confirme identificação e validade suficiente antes de libertar.');
+14 -4
View File
@@ -23,6 +23,16 @@ use Illuminate\Validation\ValidationException;
/** Full corrective reversals; original documents and financial facts remain immutable. */
class InventoryReversals
{
public function executionSourceCurrent(SupplyRecord $record): bool
{
return $record->kind === 'stock_reversal' && app(InventoryReviewParticipation::class)->executionSourceCurrent($record, fn (array $data) => $this->basis($data));
}
public function canAct(User $actor, SupplyRecord $record, string $action): bool
{
return app(InventoryReviewParticipation::class)->canAct($actor, $record, 'stock_reversal', $action, fn (string $capability) => $this->allows($actor, $capability));
}
public function allows(User $actor, string $action): bool
{
return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($action) {
@@ -91,8 +101,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.');
$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();
@@ -198,7 +208,7 @@ class InventoryReversals
$data = $old->data;
$snapshot = $old->snapshot;
if ($v['action'] === 'execute') {
$this->check($old->state === 'approved' && $actor->id !== $snapshot['reviewed_by'], 'action', 'Exige aprovação actual e execução por pessoa diferente de quem aprovou.');
$this->check($old->state === 'approved' && app(InventoryReviewParticipation::class)->independentExecutor($actor, $old), 'action', 'Exige aprovação actual e execução por pessoa diferente de quem aprovou.');
$event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate();
$this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'O estorno não pode anteceder a aprovação.');
$basis = $this->basis($data);
@@ -210,7 +220,7 @@ class InventoryReversals
$snapshot['execution_observed'] = $basis['observed'];
$state = 'executed';
} else {
$this->check(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A decisão exige pessoa independente de todos os preparadores.');
$this->check(app(InventoryReviewParticipation::class)->independentReviewer($actor, $old), 'action', 'A decisão exige pessoa independente de todos os preparadores.');
$this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
$state = $v['action'] === 'approve' ? 'approved' : 'returned';
if ($state === 'approved') {
@@ -0,0 +1,53 @@
<?php
namespace App\Services;
use App\Models\SupplyRecord;
use App\Models\User;
use Closure;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
/** Shared participation in physical dispositions/reversals; source and stock checks remain in their commands. */
class InventoryReviewParticipation
{
/** @param Closure(array<string,mixed>):array<string,mixed> $basis */
public function executionSourceCurrent(SupplyRecord $record, Closure $basis): bool
{
if ($record->state !== 'approved' || ! isset($record->snapshot['source'])) {
return false;
}
try {
return $basis($record->data)['source'] === $record->snapshot['source'];
} catch (ValidationException|ModelNotFoundException) {
return false;
}
}
public function independentReviewer(User $actor, SupplyRecord $record): bool
{
return ! in_array($actor->id, $record->snapshot['contributors'], true);
}
public function independentExecutor(User $actor, SupplyRecord $record): bool
{
return isset($record->snapshot['reviewed_by']) && $actor->id !== $record->snapshot['reviewed_by'];
}
/** @param Closure(string):bool $allows */
public function canAct(User $actor, SupplyRecord $record, string $kind, string $action, Closure $allows): bool
{
if (! in_array($kind, ['stock_event', 'stock_reversal'], true) || $record->kind !== $kind || $record->tenant_id !== $actor->tenant_id
|| ! in_array($action, ['approve', 'return', 'execute'], true) || ! $allows($action === 'execute' ? 'execute' : 'review')) {
return false;
}
$latest = app(SupplyPlanning::class)->latest($kind)->where('record_key', $record->record_key)->first();
if ($latest?->id !== $record->id) {
return false;
}
return $action === 'execute'
? $record->state === 'approved' && $this->independentExecutor($actor, $record)
: $this->independentReviewer($actor, $record) && in_array($record->state, $action === 'return' ? ['draft', 'returned', 'approved'] : ['draft', 'returned'], true);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
use App\Models\ReceiptDocument;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Throwable;
class ReceiptDocuments
{
public function ready(): bool
{
return Schema::hasTable('receipt_documents');
}
public function canAdd(User $actor, GoodsReceipt $receipt): bool
{
$order = PurchaseOrder::query()->find($receipt->purchase_order_id);
return $order !== null && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
&& Gate::forUser($actor)->allows('view', $receipt) && Gate::forUser($actor)->allows('create', [GoodsReceipt::class, $order]);
}
public function canRead(User $actor, GoodsReceipt $receipt): bool
{
return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && Gate::forUser($actor)->allows('view', $receipt);
}
/** @param array<string,mixed> $input */
public function add(User $actor, GoodsReceipt $receipt, array $input, UploadedFile $file): ReceiptDocument
{
abort_unless($this->ready(), 503, 'Anexos indisponíveis nesta instalação.');
$data = Validator::make([...$input, 'file' => $file], [
'operation_key' => 'required|uuid', 'document_type' => 'required|in:delivery_note,quality,other',
'file' => 'required|file|mimes:pdf,jpg,jpeg,png|extensions:pdf,jpg,jpeg,png|max:2048',
])->validate();
$data['operation_key'] = Str::lower($data['operation_key']);
$sha = hash_file('sha256', $file->getPathname());
abort_unless(is_string($sha) && $file->getSize() > 0, 422);
$name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180);
$hash = hash('sha256', json_encode([$receipt->id, $data['document_type'], $sha, $name], JSON_THROW_ON_ERROR));
$newPath = null;
try {
return DB::transaction(function () use ($actor, $receipt, $data, $file, $sha, $hash, $name, &$newPath) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$receipt = GoodsReceipt::query()->lockForUpdate()->findOrFail($receipt->id);
abort_unless($this->canAdd($actor, $receipt), 403);
$old = AuditLog::query()->whereIn('action', ['receipt.document_added', 'receipt.document_reused'])->where('after->operation_key', $data['operation_key'])->first();
if ($old !== null) {
if ($old->user_id !== $actor->id || ($old->after['input_hash'] ?? null) !== $hash) {
throw ValidationException::withMessages(['operation_key' => 'Chave utilizada com outro documento.']);
}
$documentId = $old->after['document_id'] ?? null;
abort_unless(is_int($documentId), 409, 'Referência documental inválida.');
return ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->findOrFail($documentId);
}
$document = ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->where('document_type', $data['document_type'])->where('sha256', $sha)->first();
$reused = $document !== null;
if ($document === null) {
$newPath = $file->store($actor->tenant_id.'/'.$receipt->id, 'receipt_documents');
abort_unless(is_string($newPath), 500);
$document = ReceiptDocument::query()->create(['tenant_id' => $actor->tenant_id, 'goods_receipt_id' => $receipt->id,
'uploaded_by' => $actor->id, 'document_type' => $data['document_type'], 'original_name' => $name,
'path' => $newPath, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'sha256' => $sha, 'created_at' => now()]);
}
$order = PurchaseOrder::query()->findOrFail($receipt->purchase_order_id);
app(RequisitionWorkflow::class)->audit($actor, $reused ? 'receipt.document_reused' : 'receipt.document_added', Requisition::query()->findOrFail($order->requisition_id), null,
['receipt_id' => $receipt->id, 'document_id' => $document->id, 'operation_key' => $data['operation_key'], 'input_hash' => $hash]);
return $document;
});
} catch (Throwable $error) {
if (is_string($newPath)) {
// Never delete a file whose metadata may already have committed.
try {
if (! DB::table('receipt_documents')->where('path', $newPath)->exists()) {
Storage::disk('receipt_documents')->delete($newPath);
}
} catch (Throwable $cleanupError) {
report($cleanupError);
}
}
throw $error;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\DB;
/** Technical observation only; contains no company data or business authority. */
class ReplenishmentHeartbeat
{
public function fingerprint(): string
{
$connection = DB::connection();
return hash('sha256', json_encode([base_path(), config('work_context.composition'), $connection->getDriverName(),
$connection->getConfig('host'), $connection->getConfig('port'), $connection->getDatabaseName()], JSON_THROW_ON_ERROR));
}
public function record(string $started, int $exitCode): void
{
app(Filesystem::class)->replace(storage_path('framework/replenishment-command-status.json'), json_encode([
'started_at' => $started,
'finished_at' => now()->toIso8601String(),
'scope' => 'SGC replenishment command',
'result' => $exitCode === 0 ? 'passed' : 'retry_pending',
'exit_code' => $exitCode,
'installation' => $this->fingerprint(),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Services;
use RuntimeException;
/** Local process exclusion; OS releases the lock if a worker is terminated. */
class ReplenishmentProcessLock
{
/** @var resource|null */
private $handle = null;
public function acquire(): bool
{
if ($this->handle !== null) {
throw new RuntimeException('Verificação periódica já adquirida nesta instância.');
}
$handle = fopen(storage_path('framework/replenishment-command.lock'), 'c');
if ($handle === false) {
throw new RuntimeException('Não foi possível abrir o controlo da verificação periódica.');
}
if (! flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return false;
}
$this->handle = $handle;
return true;
}
public function release(): void
{
if ($this->handle !== null) {
flock($this->handle, LOCK_UN);
fclose($this->handle);
$this->handle = null;
}
}
public function __destruct()
{
$this->release();
}
}
+71 -4
View File
@@ -21,13 +21,81 @@ class ReplenishmentQueue
public function status(User $actor): array
{
abort_unless(auth()->id() === $actor->id, 403);
$observation = $this->observation();
return ['recoveryReady' => $this->ready(), 'scanMinutes' => config('replenishment.scan_minutes'),
'schedulerStatus' => $observation['schedulerStatus'], 'schedulerRecent' => $observation['schedulerRecent'],
'work' => $this->ready() ? $this->workStatus($actor->tenant_id) : null];
}
/** Workdesk sources, with a bound observation required for technical monitoring.
* @return array<string,mixed>
*/
public function health(): array
{
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
return DB::transaction(function () {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$ready = $this->ready();
$observation = $this->observation();
$work = $ready ? $this->workStatus(null, true) : null;
$reasons = [];
if (! $ready) {
$reasons[] = 'not_installed';
}
if (! $observation['schedulerRecent']) {
$reasons[] = 'not_recent';
}
if (($observation['schedulerStatus']['result'] ?? null) !== 'passed') {
$reasons[] = 'last_run_not_successful';
}
if (! $observation['schedulerBound']) {
$reasons[] = 'unbound_observation';
}
if ((int) ($work->pending ?? 0) > 0) {
$reasons[] = 'pending_signals';
}
if ((int) ($work->failed ?? 0) > 0) {
$reasons[] = 'failed_work';
}
return ['checked_at' => now()->toIso8601String(), 'ok' => $reasons === [], 'reasons' => $reasons,
'recovery_ready' => $ready, 'scheduler_recent' => $observation['schedulerRecent'],
'last_result' => $observation['schedulerStatus']['result'] ?? null,
'last_finished_at' => $observation['schedulerStatus']['finished_at'] ?? null,
'work' => $work === null ? null : ['total' => (int) $work->total, 'pending' => (int) ($work->pending ?? 0), 'failed' => (int) ($work->failed ?? 0)]];
});
}
private function workStatus(?int $tenant, bool $global = false): \stdClass
{
return DB::table('replenishment_work')->when(! $global, fn ($query) => $query->where('tenant_id', $tenant))
->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() ?? throw new \LogicException('Estado técnico indisponível.');
}
/** @return array{schedulerStatus:array<string,mixed>|null,schedulerRecent:bool,schedulerBound:bool} */
private function observation(): array
{
$scheduler = null;
$recent = false;
$path = storage_path('framework/replenishment-status.json');
if (app()->environment('payable-testing') && is_file($path)) {
$bound = false;
$path = storage_path('framework/replenishment-command-status.json');
if (! is_file($path) && app()->environment('payable-testing')) {
$path = storage_path('framework/replenishment-status.json');
}
if (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)) {
if (array_key_exists('installation', $data)) {
if (! is_string($data['installation']) || ! hash_equals(app(ReplenishmentHeartbeat::class)->fingerprint(), $data['installation'])) {
return ['schedulerStatus' => null, 'schedulerRecent' => false, 'schedulerBound' => false];
}
$bound = true;
}
try {
$finished = CarbonImmutable::parse($data['finished_at']);
$recent = $finished->greaterThanOrEqualTo(now()->subMinutes(3)) && $finished->lessThanOrEqualTo(now()->addMinute());
@@ -37,8 +105,7 @@ class ReplenishmentQueue
}
}
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];
return ['schedulerStatus' => $scheduler, 'schedulerRecent' => $recent, 'schedulerBound' => $bound];
}
public function ready(): bool
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Requisition;
use Illuminate\Database\Eloquent\Builder;
/** Only metadata already displayed in the authorised requisition detail. */
class RequisitionAuditTrail
{
/** @return Builder<AuditLog> */
public function query(Requisition $request): Builder
{
return AuditLog::query()->where('tenant_id', $request->tenant_id)->where('requisition_id', $request->id)
->select(['id', 'action', 'user_id', 'created_at', 'after']);
}
/** @return array<string,mixed> */
public function row(AuditLog $record): array
{
$mode = $record->after['execution']['mode'] ?? null;
return ['id' => $record->id, 'action' => $record->action, 'user_id' => $record->user_id, 'created_at' => $record->created_at,
'execution' => is_string($mode) ? $mode : null];
}
}
+29 -3
View File
@@ -16,6 +16,27 @@ class SupplierEvaluationWorkflow
{
public const ROLES = [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead];
public function canPrepare(User $actor): bool
{
return $actor->tenant_id !== null && $actor->hasFunctions(self::ROLES)
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
&& (! $actor->departmentScoped() || $actor->department_id !== null);
}
public function canAct(User $actor, SupplierEvaluation $evaluation, string $action): bool
{
if (! app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation)) {
return false;
}
return match ($action) {
'edit' => in_array($evaluation->status, ['draft', 'returned'], true),
'review' => $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true),
'revise' => $evaluation->status === 'approved',
default => false,
};
}
public function reader(User $actor): User
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
@@ -67,7 +88,10 @@ class SupplierEvaluationWorkflow
{
app(ConflictGuard::class)->lockCompany($actor);
return app(CurrentActor::class)->lock($actor, self::ROLES);
$actor = app(CurrentActor::class)->lock($actor, self::ROLES);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
return $actor;
}
private function participating(User $actor, int $supplierId): void
@@ -92,7 +116,7 @@ class SupplierEvaluationWorkflow
{
return DB::transaction(function () use ($actor, $supplierId, $operation, $payload) {
$actor = $this->actor($actor);
abort_if($actor->departmentScoped() && $actor->department_id === null, 403, 'O perfil de chefia precisa do departamento correspondente.');
abort_unless($this->canPrepare($actor), 403, 'A preparação exige um perfil operacional e o departamento correspondente.');
$supplier = Supplier::query()->findOrFail($supplierId);
$this->participating($actor, $supplier->id);
$payload = $this->normalise($actor, $supplier, $payload);
@@ -122,6 +146,7 @@ class SupplierEvaluationWorkflow
$evaluation = $this->locked($actor, $id, $version);
$this->participating($actor, $evaluation->supplier_id);
abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409, 'A avaliação não está aberta para edição.');
abort_unless($this->canAct($actor, $evaluation, 'edit'), 403);
$payload = $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $payload);
if ($payload === $evaluation->payload) {
return $evaluation;
@@ -186,6 +211,7 @@ class SupplierEvaluationWorkflow
$this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $evaluation->payload);
if ($action === 'submit') {
abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409);
abort_unless($this->canAct($actor, $evaluation, 'edit'), 403);
$this->complete($evaluation->payload, false);
$evaluation->fill(['status' => 'review', 'submitted_by' => $actor->id, 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null]);
} else {
@@ -193,7 +219,7 @@ class SupplierEvaluationWorkflow
if ($reason === null || mb_strlen(trim($reason)) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão de revisão.']);
}
abort_if(in_array($actor->id, $evaluation->contributors, true), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.');
abort_unless($this->canAct($actor, $evaluation, 'review'), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.');
if ($action === 'approve') {
$this->complete($evaluation->payload, true);
}
+24 -3
View File
@@ -13,6 +13,12 @@ class SupplierImprovementWorkflow
{
public function canParticipate(User $actor, SupplierEvaluation $evaluation): bool
{
if ($actor->tenant_id === null || $actor->tenant_id !== $evaluation->tenant_id
|| ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)
|| app(OrganizationAccess::class)->maintenanceOnly($actor)
|| ($actor->departmentScoped() && ($actor->department_id === null || $actor->department_id !== $evaluation->department_id))) {
return false;
}
$guard = app(ConflictGuard::class);
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, null)) {
return false;
@@ -26,6 +32,21 @@ class SupplierImprovementWorkflow
return true;
}
public function canAct(User $actor, SupplierEvaluation $evaluation, SupplierImprovement $measure, string $action): bool
{
if ($evaluation->status !== 'approved' || $measure->tenant_id !== $actor->tenant_id
|| $measure->supplier_evaluation_id !== $evaluation->id || ! $this->canParticipate($actor, $evaluation)) {
return false;
}
return match ($action) {
'plan' => in_array($measure->status, ['open', 'returned'], true),
'execute' => $measure->responsible_id === $actor->id && in_array($measure->status, ['open', 'returned'], true),
'review' => $measure->status === 'review' && ! in_array($actor->id, $measure->contributors, true),
default => false,
};
}
/** @return array<string,mixed> */
public function snapshot(SupplierImprovement $measure): array
{
@@ -75,7 +96,7 @@ class SupplierImprovementWorkflow
abort_if($measure->status === 'closed', 409, 'A medida já está encerrada e preservada.');
$before = $this->snapshot($measure);
if ($command === 'plan') {
abort_unless(in_array($measure->status, ['open', 'returned'], true), 409);
abort_unless($this->canAct($actor, $evaluation, $measure, 'plan'), 409);
$responsible = $this->assignee($actor, $evaluation, (int) $data['responsible_id']);
$measure->fill(['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => $responsible->id, 'due_on' => $data['due_on'],
'contributors' => array_values(array_unique([...$measure->contributors, $actor->id, $responsible->id]))]);
@@ -86,7 +107,7 @@ class SupplierImprovementWorkflow
throw ValidationException::withMessages(['reason' => 'Justifique a alteração do plano, prazo ou responsável.']);
}
} elseif (in_array($command, ['progress', 'submit'], true)) {
abort_unless($actor->id === $measure->responsible_id && in_array($measure->status, ['open', 'returned'], true), 403);
abort_unless($this->canAct($actor, $evaluation, $measure, 'execute'), 403);
$measure->fill(['result' => $data['result'] ?? '', 'evidence' => $data['evidence'] ?? '']);
if ($command === 'submit') {
if (trim($measure->result ?? '') === '' || trim($measure->evidence ?? '') === '') {
@@ -99,7 +120,7 @@ class SupplierImprovementWorkflow
}
} else {
abort_unless(in_array($command, ['close', 'return'], true) && $measure->status === 'review', 409);
abort_if(in_array($actor->id, $measure->contributors, true), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.');
abort_unless($this->canAct($actor, $evaluation, $measure, 'review'), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.');
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão sobre a execução.']);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Services;
use App\Models\Supplier;
use App\Models\User;
/** Shared eligibility; review commands still validate the current version and activation facts. */
class SupplierParticipation
{
public function canPrepare(User $actor): bool
{
return $actor->tenant_id !== null && $actor->hasFunctions(['procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
public function canReviewRole(User $actor): bool
{
return $actor->tenant_id !== null && $actor->hasFunctions(['finance']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
public function canReview(User $actor, Supplier $supplier): bool
{
return $actor->tenant_id === $supplier->tenant_id && $this->canReviewRole($actor)
&& $actor->id !== $supplier->created_by && $actor->id !== $supplier->last_changed_by;
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Services;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\Payables;
/** Native sources, bounded by the document page. Financial positions belong to the common core. */
final class SupplierPayablePage
{
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function listing(User $user, ?int $invoiceId, array $options): array
{
abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Leitura paginada requer a composição PostgreSQL conferida.');
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($user, $invoiceId, $options, $own) {
if ($own) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$adapter = app(SupplierPayables::class);
$context = $adapter->readContext($user);
$actor = $context['actor'];
$scope = $context['scope'];
$page = (int) ($options['page'] ?? 1);
$size = (int) ($options['per_page'] ?? 25);
$text = trim((string) ($options['q'] ?? ''));
$recognition = $options['recognition'] ?? 'all';
abort_unless($page >= 1 && $page <= 100000 && in_array($size, [25, 100], true) && mb_strlen($text) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
$query = Invoice::query()->where('tenant_id', $actor->tenant_id);
if ($invoiceId !== null) {
(clone $query)->whereKey($invoiceId)->firstOrFail();
$query->whereKey($invoiceId);
}
if ($text !== '') {
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $text).'%';
$query->where(fn ($q) => $q->where('number', 'ilike', $needle)->orWhereRaw("snapshot->'supplier'->>'name' ILIKE ?", [$needle]));
}
if ($recognition !== 'all') {
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
$query->$method(fn ($q) => $q->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
->whereColumn('s.local_source_id', 'invoices.id')->whereColumn('s.tenant_id', 'invoices.tenant_id')->where('s.company_id', 0)->where('s.unit_id', 0)
->whereColumn('o.tenant_id', 'invoices.tenant_id')->where('o.company_id', 0)->where('o.unit_id', 0));
}
$pager = $query->orderByDesc('id')->paginate($size, ['*'], 'page', $page);
[$documents, $histories] = $this->sources($actor, $pager->items());
$read = function () use ($adapter, $user, $context) {
$now = $adapter->readContext($user);
abort_unless($now['scope'] === $context['scope'] && $now['person'] === $context['person'], 409, 'Identidade/contexto alterado; reabra a consulta.');
};
$engine = new Payables(DB::connection(), $actor->id, $context['person'], $scope,
static function () {
throw new \LogicException('Snapshots de consulta nunca executam comandos.');
},
fn (int $id) => $documents[$id] ?? abort(403),
SupplierPayables::historyEnabled() ? function (int $id) use ($histories) {
$history = $histories[$id] ?? new \DomainException('Histórico ausente nesta página.');
if ($history instanceof \DomainException) {
throw $history;
}
return $history;
} : null, $read);
$data = $engine->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $size);
$data['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
$data['listing_query'] = ['page' => $page, 'per_page' => $size, 'q' => $text, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
if ($invoiceId !== null) {
$data['listing_query']['invoice'] = $invoiceId;
}
$data['permissions'] = ['prepare' => $adapter->can($actor, 'prepare'), 'validate' => $adapter->can($actor, 'validate')];
$data['payments_available'] = app()->environment('payable-testing') && DB::getSchemaBuilder()->hasTable('ps_payments') && $adapter->can($actor, 'payments_view');
$data['history_import_available'] = SupplierPayables::historyEnabled();
$data['history_decisions_available'] = SupplierPayables::historyDecisionsEnabled();
return $data;
});
}
/** @param array<int,Invoice> $invoices
* @return array{array<int,array<string,mixed>>,array<int,list<array<string,mixed>>|\DomainException>}
*/
private function sources(User $actor, array $invoices): array
{
$rows = collect($invoices);
$ids = $rows->pluck('id')->all();
$orders = DB::table('purchase_orders')->where('tenant_id', $actor->tenant_id)->whereIn('id', $rows->pluck('purchase_order_id'))->get(['id'])->keyBy('id');
$suppliers = DB::table('suppliers')->where('tenant_id', $actor->tenant_id)->whereIn('id', $rows->pluck('supplier_id'))->get(['id'])->keyBy('id');
$lines = InvoiceItem::query()->where('tenant_id', $actor->tenant_id)->whereIn('invoice_id', $ids)->orderBy('id')->get()->groupBy('invoice_id');
$validations = DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)->where('action', 'invoice.validated')->whereIn('after->invoice_id', array_map('strval', $ids))->orderByDesc('id')->get(['id', 'after']);
$audits = [];
foreach ($validations as $audit) {
$after = json_decode($audit->after, true, 64, JSON_THROW_ON_ERROR);
$audits[(int) $after['invoice_id']] ??= $audit;
}
$historical = SupplierPayables::historyEnabled();
$payments = collect();
$counts = [];
$paymentQuery = DB::table('external_payments')->where('tenant_id', $actor->tenant_id)->whereIn('invoice_id', $ids);
if ($historical) {
$payments = $paymentQuery->orderBy('id')->get(['id', 'tenant_id', 'invoice_id', 'amount_minor', 'currency', 'status', 'created_by', 'paid_on', 'reviewed_by', 'reviewed_at']);
$counts = $payments->groupBy('invoice_id')->map(fn ($p) => $p->count())->all();
} else {
$counts = $paymentQuery->selectRaw('invoice_id, count(*) AS n')->groupBy('invoice_id')->pluck('n', 'invoice_id')->all();
}
$paymentAudits = [];
if ($historical) {
$auditRows = DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)->whereIn('action', ['payment.registered', 'payment.confirmed', 'payment.rejected'])->whereIn('after->payment_id', $payments->pluck('id')->map(fn ($id) => (string) $id)->all())->orderByDesc('id')->get(['id', 'user_id', 'action', 'after']);
foreach ($auditRows as $audit) {
$a = json_decode($audit->after, true, 64, JSON_THROW_ON_ERROR);
$key = $this->auditKey($audit->action, $audit->user_id, $a['payment_id'] ?? '', $a['invoice_id'] ?? '', $a['amount_minor'] ?? '', $a['status'] ?? '');
$paymentAudits[$key] ??= $audit;
}
}
$histories = [];
foreach ($payments as $payment) {
$registration = $paymentAudits[$this->auditKey('payment.registered', $payment->created_by, $payment->id, $payment->invoice_id, $payment->amount_minor, 'pending')] ?? null;
$decision = $payment->status === 'pending' ? null : ($paymentAudits[$this->auditKey('payment.'.$payment->status, $payment->reviewed_by, $payment->id, $payment->invoice_id, $payment->amount_minor, $payment->status)] ?? null);
try {
$fact = SupplierPayables::paymentFacts($payment, $registration, $decision);
$current = $histories[$payment->invoice_id] ?? [];
if (! ($current instanceof \DomainException)) {
$current[] = $fact;
$histories[$payment->invoice_id] = $current;
}
} catch (\DomainException $e) {
$histories[$payment->invoice_id] = $e;
}
}
$documents = [];
foreach ($invoices as $invoice) {
$documents[$invoice->id] = SupplierPayables::sourceSnapshot($invoice, $orders->get($invoice->purchase_order_id), $suppliers->get($invoice->supplier_id), $audits[$invoice->id] ?? null,
array_values(($lines->get($invoice->id) ?? collect())->map(fn ($l) => SupplierPayables::lineSnapshot($l))->all()), (int) ($counts[$invoice->id] ?? 0));
}
return [$documents, $histories];
}
private function auditKey(mixed ...$values): string
{
foreach ($values as $value) {
if (! is_scalar($value) && $value !== null) {
return 'INVALID-AUDIT';
}
}
return json_encode(array_map('strval', $values), JSON_THROW_ON_ERROR);
}
}
+66 -10
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -75,16 +76,26 @@ final class SupplierPayables
if (! $registration || ($payment->status !== 'pending' && ! $decision)) {
throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.');
}
$result[] = ['id' => (int) $payment->id, 'company' => (int) $payment->tenant_id, 'document' => (int) $payment->invoice_id,
'amount_minor' => (int) $payment->amount_minor, 'currency' => $payment->currency, 'status' => $payment->status,
'created_by' => (int) $payment->created_by, 'paid_on' => $payment->paid_on, 'registration_audit_id' => (int) $registration->id,
'reviewed_by' => $payment->reviewed_by === null ? null : (int) $payment->reviewed_by, 'reviewed_at' => $payment->reviewed_at,
'decision_audit_id' => $decision ? (int) $decision->id : null];
$result[] = self::paymentFacts($payment, $registration, $decision);
}
return $result;
}
/** @return array<string,mixed> */
public static function paymentFacts(\stdClass $payment, ?\stdClass $registration, ?\stdClass $decision): array
{
if (! $registration || ($payment->status !== 'pending' && ! $decision)) {
throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.');
}
return ['id' => (int) $payment->id, 'company' => (int) $payment->tenant_id, 'document' => (int) $payment->invoice_id,
'amount_minor' => (int) $payment->amount_minor, 'currency' => $payment->currency, 'status' => $payment->status,
'created_by' => (int) $payment->created_by, 'paid_on' => $payment->paid_on, 'registration_audit_id' => (int) $registration->id,
'reviewed_by' => $payment->reviewed_by === null ? null : (int) $payment->reviewed_by, 'reviewed_at' => $payment->reviewed_at,
'decision_audit_id' => $decision ? (int) $decision->id : null];
}
/** @return array<string,mixed> */
public function document(User $u, int $id): array
{
@@ -94,12 +105,49 @@ final class SupplierPayables
$supplier = DB::table('suppliers')->where(['id' => $invoice->supplier_id, 'tenant_id' => $u->tenant_id])->first();
$audit = DB::table('audit_logs')->where('tenant_id', $u->tenant_id)->where('action', 'invoice.validated')->where('after->invoice_id', $id)->orderByDesc('id')->first();
return ['local_id' => $id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $u->tenant_id, 'document' => $id, 'number' => $invoice->number,
return self::sourceSnapshot($invoice, $order, $supplier, $audit, array_values($invoice->items()->orderBy('id')->get()->map(fn ($l) => self::lineSnapshot($l))->all()),
DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count());
}
/** @return array<string,mixed> */
public static function lineSnapshot(InvoiceItem $line): array
{
return ['id' => $line->id, 'order_line' => $line->purchase_order_item_id, 'quantity_milli' => (int) $line->quantity_milli,
'subtotal_minor' => (int) $line->subtotal_minor, 'tax_minor' => (int) $line->tax_minor, 'snapshot' => $line->snapshot];
}
/** @param list<array<string,mixed>> $lines
* @return array<string,mixed>
*/
public static function sourceSnapshot(Invoice $invoice, ?\stdClass $order, ?\stdClass $supplier, ?\stdClass $audit, array $lines, int $paymentCount): array
{
return ['local_id' => $invoice->id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $invoice->tenant_id, 'document' => $invoice->id, 'number' => $invoice->number,
'supplier_id' => $supplier?->id, 'supplier_name' => $invoice->snapshot['supplier']['name'] ?? null, 'order_id' => $order?->id, 'currency' => $invoice->snapshot['currency'] ?? null,
'issued_on' => $invoice->issued_on, 'subtotal_minor' => (int) $invoice->subtotal_minor, 'tax_minor' => (int) $invoice->tax_minor, 'total_minor' => (int) $invoice->total_minor,
'status' => $invoice->status, 'validated_by' => $invoice->validated_by, 'validated_at' => $invoice->validated_at, 'validation_audit_id' => $audit?->id,
'lines' => $invoice->items()->orderBy('id')->get()->map(fn ($l) => ['id' => $l->id, 'order_line' => $l->purchase_order_item_id, 'quantity_milli' => (int) $l->quantity_milli, 'subtotal_minor' => (int) $l->subtotal_minor, 'tax_minor' => (int) $l->tax_minor, 'snapshot' => $l->snapshot])->all(),
'payment_count' => DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count()];
'lines' => $lines, 'payment_count' => $paymentCount];
}
/** Fresh read authorization; unlike commands, it never takes writer locks.
* @return array{actor:User,scope:array<string,int>,person:string}
*/
public function readContext(User $u): array
{
abort_unless(self::enabled() && auth()->id() === $u->id && $u->tenant_id !== null, 403);
$fresh = User::query()->where('tenant_id', $u->tenant_id)->findOrFail($u->id);
$access = app(OrganizationAccess::class);
$contexts = app(WorkContext::class);
abort_if($access->maintenanceOnly($fresh), 403);
if ($contexts->enforced($fresh)) {
abort_unless(app()->bound('request') && request()->hasSession() && $contexts->active(request()) === 'procurement.internal', 403, 'Seleccione Compras internas para consultar obrigações.');
abort_unless(array_intersect($access->permissions($fresh, 'procurement.internal'), ['finance', 'admin']) !== [], 403);
}
abort_unless($fresh->hasFunctions([PurchasingRole::Finance, PurchasingRole::Admin]) && $this->can($fresh, 'view'), 403);
$scope = $this->scope($fresh);
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $fresh->id, 'action' => 'view'])->value('person');
abort_unless(is_string($person) && $person !== '', 403);
return ['actor' => $fresh, 'scope' => $scope, 'person' => $person];
}
public function engine(User $u): Payables
@@ -118,9 +166,17 @@ final class SupplierPayables
}, fn (int $id) => $this->document($u, $id), self::historyEnabled() ? fn (int $id) => $this->paymentHistory($u, $id) : null);
}
/** @return array<string,mixed> */
public function listing(User $u, ?int $invoiceId = null): array
/** @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function listing(User $u, ?int $invoiceId = null, array $options = []): array
{
if (config('common-payables.paged_read_enabled') === true) {
abort_unless(in_array('listingPage', get_class_methods(Payables::class), true), 503, 'Pacote financeiro sem leitura paginada; composição explícita pendente.');
return app(SupplierPayablePage::class)->listing($u, $invoiceId, $options);
}
abort_if($options !== [], 503, 'Consulta paginada ainda não activada nesta composição.');
$engine = $this->engine($u);
$d = $engine->listing(Invoice::query()->where('tenant_id', $u->tenant_id)->pluck('id')->all());
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
+18 -1
View File
@@ -10,6 +10,21 @@ use Illuminate\Validation\ValidationException;
class SupplierResponseWorkflow
{
public function canAct(User $actor, SupplierEvaluation $evaluation, SupplierResponse $record, string $action): bool
{
if ($evaluation->status !== 'approved' || $record->tenant_id !== $actor->tenant_id
|| $record->supplier_evaluation_id !== $evaluation->id
|| ! app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation)) {
return false;
}
return match ($action) {
'edit' => in_array($record->status, ['draft', 'returned'], true),
'review' => $record->status === 'review' && ! in_array($actor->id, $record->contributors, true),
default => false,
};
}
/** @return array<string,mixed> */
public function snapshot(SupplierResponse $record): array
{
@@ -55,6 +70,7 @@ class SupplierResponseWorkflow
$before = $this->snapshot($record);
if ($command === 'save') {
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
abort_unless($this->canAct($actor, $evaluation, $record, 'edit'), 403);
$payload = $this->normalise($data['payload']);
if ($payload === $record->payload) {
return $record;
@@ -62,6 +78,7 @@ class SupplierResponseWorkflow
$record->fill(['payload' => $payload, 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
} elseif ($command === 'submit') {
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
abort_unless($this->canAct($actor, $evaluation, $record, 'edit'), 403);
$errors = [];
foreach (['received_on', 'source_reference', 'statement'] as $key) {
if (trim($record->payload[$key] ?? '') === '') {
@@ -77,7 +94,7 @@ class SupplierResponseWorkflow
$record->fill(['status' => 'review', 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
} else {
abort_unless(in_array($command, ['return', 'decide'], true) && $record->status === 'review', 409);
abort_if(in_array($actor->id, $record->contributors, true), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.');
abort_unless($this->canAct($actor, $evaluation, $record, 'review'), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.');
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a análise ou a devolução.']);
}
+39 -1
View File
@@ -22,6 +22,41 @@ use Illuminate\Validation\ValidationException;
class SupplyCoordination
{
public function canManage(User $actor, SupplyRecord $record, string $action): bool
{
if ($actor->tenant_id !== $record->tenant_id || ! in_array($record->kind, ['reservation', 'delivery'], true)
|| ! app(SupplyPlanning::class)->allows($actor, $record->kind === 'reservation' ? 'supply_reserve' : 'supply_delivery')) {
return false;
}
$latest = app(SupplyPlanning::class)->latest($record->kind)->where('record_key', $record->record_key)->first();
if ($latest?->id !== $record->id) {
return false;
}
$open = $record->state === 'active' || ($record->kind === 'reservation' && $record->state === 'fulfilled' && $this->remainingReservation($record) > 0);
return match ($action) {
'edit', 'cancel' => $open,
'fulfil' => $record->kind === 'reservation' && in_array($record->state, ['active', 'fulfilled'], true) && $this->remainingReservation($record) > 0,
default => false,
};
}
private function independentTechnicalReviewer(User $actor, SupplyRecord $record): bool
{
return $actor->tenant_id === $record->tenant_id && app(SupplyPlanning::class)->allows($actor, 'supply_technical_review')
&& ! in_array($actor->id, $record->snapshot['contributors'], true);
}
public function canReviewAlternative(User $actor, SupplyRecord $record, string $action = 'approve'): bool
{
if ($record->kind !== 'alternative' || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentTechnicalReviewer($actor, $record)) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('alternative')->where('record_key', $record->record_key)->first();
return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
}
/** @param array<string,mixed> $input */
public function save(User $actor, string $kind, array $input): SupplyRecord
{
@@ -47,6 +82,9 @@ class SupplyCoordination
}
$old = isset($v['record_key']) ? app(SupplyPlanning::class)->latest($kind)->where('record_key', $v['record_key'])->firstOrFail() : null;
$this->require((int) $v['version'] === (int) ($old->version ?? 0), 'version', 'O registo mudou. Actualize antes de continuar.');
if ($old !== null && in_array($kind, ['reservation', 'delivery'], true)) {
$this->require($this->canManage($actor, $old, $v['action'] === 'save' ? 'edit' : $v['action']), 'action', 'O registo não admite esta intervenção no estado actual.');
}
$data = $old->data ?? [];
$snapshot = $old->snapshot ?? [];
$state = $old->state ?? 'draft';
@@ -72,7 +110,7 @@ class SupplyCoordination
$state = $kind === 'alternative' ? 'draft' : 'active';
} elseif ($kind === 'alternative') {
$this->require($old !== null && in_array($v['action'], ['approve', 'return', 'suspend'], true), 'action', 'Acção técnica inválida.');
$this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A validação técnica exige independência dos preparadores.');
$this->require($this->independentTechnicalReviewer($actor, $old), 'action', 'A validação técnica exige independência dos preparadores.');
$this->require($v['action'] === 'suspend' ? $state === 'approved' : in_array($state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
if ($v['action'] === 'approve') {
$this->require($data['compatibility'] !== 'pending', 'action', 'Conclua a análise técnica antes de aprovar.');
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Services;
use App\Models\SupplyRecord;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/** Shared read scope for the workdesk and its export; no parallel authority. */
class SupplyCoordinationRead
{
public function kind(User $actor, string $view): string
{
[$kind, $permissions] = match ($view) {
'reservations' => ['reservation', ['supply_read', 'supply_reserve']],
'deliveries' => ['delivery', ['supply_read', 'supply_delivery']],
'alternatives' => ['alternative', ['supply_read', 'supply_technical_prepare', 'supply_technical_review']],
default => abort(404),
};
abort_unless(collect($permissions)->contains(fn ($permission) => app(SupplyPlanning::class)->allows($actor, $permission)), 403);
return $kind;
}
/** @return array<string,mixed> */
public function filters(Request $request, string $kind): array
{
$states = $kind === 'alternative' ? ['draft', 'returned', 'approved', 'suspended'] : ($kind === 'reservation' ? ['active', 'cancelled', 'fulfilled'] : ['active', 'cancelled']);
$filters = $request->validate([
'record_search' => 'nullable|string|max:100',
'record_state' => ['nullable', Rule::in($states)],
]);
return ['record_search' => trim($filters['record_search'] ?? ''), 'record_state' => $filters['record_state'] ?? ''];
}
/** @param array<string,mixed> $filters
* @return Builder<SupplyRecord>
*/
public function query(string $kind, array $filters): Builder
{
$search = $filters['record_search'];
return app(SupplyPlanning::class)->latest($kind)
->when($search !== '', fn ($q) => $q->where(fn ($match) => $match
->whereLike('snapshot->item->code', '%'.$search.'%')
->orWhereLike('snapshot->item->name', '%'.$search.'%')
->orWhereLike('snapshot->warehouse->name', '%'.$search.'%')
->orWhereLike('snapshot->order->number', '%'.$search.'%')
->orWhereLike('data->reference', '%'.$search.'%')))
->when($filters['record_state'] !== '', fn ($q) => $q->where('state', $filters['record_state']));
}
}
+18 -1
View File
@@ -18,6 +18,23 @@ use Illuminate\Validation\ValidationException;
class SupplyHistory
{
public function independentReviewer(User $actor, SupplyRecord $record): bool
{
return $actor->tenant_id === $record->tenant_id && app(SupplyPlanning::class)->allows($actor, 'supply_history_review')
&& ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true);
}
/** Eligibility to confer; approval still checks ledger, scope and source changes. */
public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool
{
if ($record->kind !== 'history' || ! in_array($action, ['approve', 'return', 'suspend'], true) || ! $this->independentReviewer($actor, $record)) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('history')->where('record_key', $record->record_key)->first();
return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input): SupplyRecord
{
@@ -58,7 +75,7 @@ class SupplyHistory
$this->require($old !== null, 'action', 'Prepare primeiro a análise.');
$data = $old->data;
$snapshot = $old->snapshot;
$this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A conferência exige independência de todos os preparadores.');
$this->require($this->independentReviewer($actor, $old), 'action', 'A conferência exige independência de todos os preparadores.');
$this->require($v['action'] === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível com esta decisão.');
if ($v['action'] === 'approve') {
$confirm = Validator::make($v['data'], ['confirm_scope' => 'required|accepted'])->validate();
+42 -5
View File
@@ -21,6 +21,46 @@ use Illuminate\Validation\ValidationException;
class SupplySuggestions
{
private function independentReviewer(User $actor, SupplyRecord $record): bool
{
return ! in_array($actor->id, $record->snapshot['contributors'], true);
}
/** Participation only: approval/conversion still revalidate the frozen sources. */
public function canAct(User $actor, SupplyRecord $record, string $action): bool
{
$permission = match ($action) {
'approve', 'return', 'close' => 'supply_suggest_review',
'convert' => 'supply_suggest_convert',
'cancel' => 'supply_suggest_prepare',
default => null,
};
if ($record->kind !== 'suggestion' || $actor->tenant_id !== $record->tenant_id || $permission === null
|| ! app(SupplyPlanning::class)->allows($actor, $permission)) {
return false;
}
$latest = app(SupplyPlanning::class)->latest('suggestion')->where('record_key', $record->record_key)->first();
if ($latest?->id !== $record->id || (in_array($action, ['approve', 'return', 'close'], true) && ! $this->independentReviewer($actor, $record))) {
return false;
}
return match ($action) {
'approve', 'cancel' => in_array($record->state, ['draft', 'returned'], true),
'return' => in_array($record->state, ['draft', 'approved'], true),
'convert' => $record->state === 'approved' && ($record->snapshot['reviewed_by'] ?? null) !== $actor->id && app(OrganizationAccess::class)->canRequest($actor),
'close' => $record->state === 'converted' && $this->completionReady($record),
};
}
private function completionReady(SupplyRecord $record): bool
{
$request = Requisition::query()->whereKey($record->data['requisition_id'])->firstOrFail();
$orders = PurchaseOrder::query()->where('requisition_id', $request->id)->get();
$complete = $orders->isNotEmpty() && $orders->every(fn ($order) => app(ReceiptWorkflow::class)->progress($order)['state'] === 'complete');
return $request->status === 'rejected' || $complete;
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input): SupplyRecord
{
@@ -90,7 +130,7 @@ class SupplySuggestions
$snapshot = $old->snapshot;
$state = $old->state;
if (in_array($v['action'], ['approve', 'return', 'close'], true)) {
$this->require(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A conferência exige independência de todos os preparadores.');
$this->require($this->independentReviewer($actor, $old), 'action', 'A conferência exige independência de todos os preparadores.');
}
if ($v['action'] === 'approve') {
$this->require(in_array($state, ['draft', 'returned'], true), 'action', 'A sugestão não está disponível para decisão.');
@@ -128,10 +168,7 @@ class SupplySuggestions
$state = 'converted';
} elseif ($v['action'] === 'close') {
$this->require($state === 'converted', 'action', 'Apenas uma sugestão convertida pode encerrar acompanhamento.');
$request = Requisition::query()->whereKey($data['requisition_id'])->firstOrFail();
$orders = PurchaseOrder::query()->where('requisition_id', $request->id)->get();
$complete = $orders->isNotEmpty() && $orders->every(fn ($order) => app(ReceiptWorkflow::class)->progress($order)['state'] === 'complete');
$this->require($request->status === 'rejected' || $complete, 'action', 'A necessidade continua em acompanhamento. Aguarde rejeição da requisição ou recepção completa da ordem.');
$this->require($this->completionReady($old), 'action', 'A necessidade continua em acompanhamento. Aguarde rejeição da requisição ou recepção completa da ordem.');
$state = 'closed';
}
if ($v['action'] === 'approve') {
+3 -2
View File
@@ -59,9 +59,10 @@ class WarehousePilotReadiness
}
}
$tables = [];
foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue'] as $table) {
foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue', 'requisition_commands'] as $table) {
$tables[$table] = Schema::hasTable($table);
}
$tables['requisition_process_version'] = Schema::hasColumn('requisitions', 'process_version');
$warehouses = Warehouse::query()->count();
$items = StockItem::query()->get(['id', 'code', 'name', 'unit'])->keyBy('id');
$published = 0;
@@ -98,7 +99,7 @@ class WarehousePilotReadiness
$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' => 'installation', 'label' => 'Instalação dos percursos', 'ok' => ! in_array(false, $tables, true), 'detail' => in_array(false, $tables, true) ? 'Existem componentes por instalar. A submissão com recuperação segura exige recibos de comandos e controlo de versão da requisição.' : 'Requisições, versões, recibos de comandos, 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'],
+266 -6
View File
@@ -12,6 +12,10 @@ use App\Models\QualificationSet;
use App\Models\Quotation;
use App\Models\RecommendationAttempt;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\SupplierResponse;
use App\Models\SupplyRecord;
use App\Models\User;
use Carbon\CarbonImmutable;
@@ -28,12 +32,151 @@ class WorkflowTaskInbox
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)];
$rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->suppliers($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->suggestions($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->inventoryCorrections($actor), ...$this->lotSetups($actor), ...$this->finance($actor)];
if ($area === 'procurement') {
$rows = [...$rows, ...$this->supplierImprovements($actor), ...$this->supplierQuality($actor), ...$this->supplyFollowUp($actor), ...$this->lotQuality($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 lotQuality(User $actor): array
{
$lots = app(InventoryLots::class);
if (! Schema::hasTable('inventory_lot_allocations')
|| (! $lots->allows($actor, 'inventory_lot_prepare') && ! $lots->allows($actor, 'inventory_lot_release') && ! $lots->allows($actor, 'inventory_lot_hold'))) {
return [];
}
$rows = [];
foreach ($lots->activeSetups()->lazyById(100) as $setup) {
$context = $lots->context($setup->stock_item_id, $setup->warehouse_id);
foreach ($context['lots'] ?? [] as $entry) {
if ($entry['quantity_milli'] <= 0) {
continue;
}
$metadata = SupplyRecord::query()->whereKey($entry['metadata_id'])->firstOrFail();
$action = $entry['state'] === 'unidentified' && $lots->canIdentify($actor, $metadata) ? 'identify'
: ($entry['state'] === 'held' && $lots->canDecideLot($actor, $metadata, $context, $entry, 'release') ? 'release'
: ($entry['quality_released'] && ! $entry['eligible'] && $lots->canDecideLot($actor, $metadata, $context, $entry, 'hold') ? 'hold' : null));
if ($action === null) {
continue;
}
$rows[] = ['id' => 'lot-quality:'.$setup->warehouse_id.':'.$entry['id'].':'.$action.':'.$entry['version'].':'.$entry['state_version'], 'module' => 'warehouse',
'reference' => $setup->snapshot['item']['code'].' / LOTE-'.$entry['id'].' / '.$setup->snapshot['warehouse']['code'],
'label' => match ($action) {
'identify' => 'Completar identificação de lote retido', 'release' => 'Conferir qualidade para libertação independente do lote', default => 'Conferir retenção de lote anteriormente libertado com validade insuficiente'
},
'task' => 'lot_'.$action, 'status' => $entry['state'], 'blocked' => false, 'created_at' => $this->date($metadata->created_at), 'due_on' => null,
'href' => '/stock/lotes?lot_id='.$entry['id'].'&warehouse_id='.$setup->warehouse_id.'#lot-'.$setup->warehouse_id.'-'.$entry['id']];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function supplyFollowUp(User $actor): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
$planning = app(SupplyPlanning::class);
$coordination = app(SupplyCoordination::class);
$rows = [];
foreach (['reservation' => 'supply_reserve', 'delivery' => 'supply_delivery'] as $kind => $permission) {
if (! $planning->allows($actor, $permission)) {
continue;
}
foreach ($planning->latest($kind)->whereIn('state', $kind === 'reservation' ? ['active', 'fulfilled'] : ['active'])->lazyById(100) as $record) {
if (! $coordination->canManage($actor, $record, 'edit')) {
continue;
}
$remaining = $kind === 'reservation' ? $coordination->remainingReservation($record) : $coordination->remainingDelivery($record);
if ($remaining <= 0) {
continue;
}
$rows[] = ['id' => 'coordination:'.$record->id.':followup', 'module' => 'supply',
'reference' => trim(($record->snapshot['item']['code'] ?? '').' '.($record->data['reference'] ?? $record->snapshot['order']['number'] ?? 'FOR-'.$record->id)),
'label' => $kind === 'reservation' ? 'Acompanhar necessidade planeada e associar saída efectiva' : 'Acompanhar entrega confirmada ainda por receber',
'task' => $kind.'_followup', 'status' => $record->state, 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => $record->data[$kind === 'reservation' ? 'needed_on' : 'expected_on'],
'href' => '/compras/fornecimento?view='.($kind === 'reservation' ? 'reservations' : 'deliveries').'&record_id='.$record->id.'#supply-record-'.$record->id];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function supplierQuality(User $actor): array
{
if (! Schema::hasTable('supplier_evaluations') || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)) {
return [];
}
$workflow = app(SupplierEvaluationWorkflow::class);
$responses = app(SupplierResponseWorkflow::class);
$rows = [];
foreach (SupplierEvaluation::query()->visibleTo($actor)->whereIn('status', ['draft', 'returned', 'review'])->lazyById(100) as $evaluation) {
$action = $evaluation->status === 'review' ? 'review' : 'edit';
if (! $workflow->canAct($actor, $evaluation, $action)
|| ($action === 'edit' && ! in_array($actor->id, $evaluation->contributors, true))) {
continue;
}
$rows[] = ['id' => 'supplier-evaluation:'.$evaluation->id.':'.$action.':'.$evaluation->version, 'module' => 'purchases',
'reference' => 'AVA-'.$evaluation->id.' / R'.$evaluation->revision,
'label' => $action === 'review' ? 'Conferir avaliação de qualidade do fornecedor' : 'Completar e submeter avaliação de qualidade do fornecedor',
'task' => 'supplier_evaluation_'.$action, 'status' => $evaluation->status, 'blocked' => false,
'created_at' => $this->date($evaluation->created_at), 'due_on' => null, 'href' => '/fornecedores/avaliacoes/'.$evaluation->id];
}
if (! Schema::hasTable('supplier_responses')) {
return $rows;
}
foreach (SupplierResponse::query()->visibleTo($actor)->whereIn('status', ['draft', 'returned', 'review'])
->whereHas('evaluation', fn ($q) => $q->where('status', 'approved'))->with('evaluation')->lazyById(100) as $record) {
$evaluation = $record->evaluation;
$action = $record->status === 'review' ? 'review' : 'edit';
if (! $responses->canAct($actor, $evaluation, $record, $action)
|| ($action === 'edit' && ! in_array($actor->id, $record->contributors, true))) {
continue;
}
$rows[] = ['id' => 'supplier-response:'.$record->id.':'.$action.':'.$record->version, 'module' => 'purchases',
'reference' => 'AVA-'.$evaluation->id.' / CTR-'.$record->id,
'label' => $action === 'review' ? 'Analisar esclarecimento ou contraditório do fornecedor' : 'Completar e submeter esclarecimento do fornecedor',
'task' => 'supplier_response_'.$action, 'status' => $record->status, 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => '/fornecedores/avaliacoes/'.$evaluation->id.'#contraditorio-'.$record->id];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function supplierImprovements(User $actor): array
{
if (! Schema::hasTable('supplier_improvements') || ! $actor->hasFunctions(SupplierEvaluationWorkflow::ROLES)) {
return [];
}
$workflow = app(SupplierImprovementWorkflow::class);
$rows = [];
foreach (SupplierImprovement::query()->visibleTo($actor)->whereIn('status', ['open', 'returned', 'review'])
->whereHas('evaluation', fn ($q) => $q->where('status', 'approved'))->with('evaluation')->lazyById(100) as $measure) {
$action = $measure->status === 'review' ? 'review' : 'execute';
if (! $workflow->canAct($actor, $measure->evaluation, $measure, $action)) {
continue;
}
$rows[] = ['id' => 'supplier-improvement:'.$measure->id.':'.$action.':'.$measure->version, 'module' => 'purchases',
'reference' => 'AVA-'.$measure->supplier_evaluation_id.' / MED-'.$measure->id,
'label' => $action === 'review' ? 'Conferir resultado e evidência da medida de melhoria' : 'Registar execução da medida de melhoria atribuída',
'task' => 'supplier_improvement_'.$action, 'status' => $measure->status, 'blocked' => false,
'created_at' => $this->date($measure->created_at), 'due_on' => $measure->due_on,
'href' => '/fornecedores/avaliacoes/'.$measure->supplier_evaluation_id.'#medida-'.$measure->id];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function purchases(User $actor): array
{
@@ -60,6 +203,81 @@ class WorkflowTaskInbox
return $rows;
}
/** @return list<array<string,mixed>> */
private function suppliers(User $actor): array
{
$access = app(SupplierParticipation::class);
if (! $access->canReviewRole($actor)) {
return [];
}
$rows = [];
foreach (Supplier::query()->where('status', 'pending')->lazyById(100) as $supplier) {
if (! $access->canReview($actor, $supplier)) {
continue;
}
$rows[] = ['id' => 'supplier:'.$supplier->id.':review:'.$supplier->version, 'module' => 'purchases', 'reference' => 'FOR-'.$supplier->id,
'label' => 'Conferir cadastro do fornecedor', 'task' => 'supplier_review', 'status' => $supplier->status, 'blocked' => false,
'created_at' => $this->date($supplier->created_at), 'due_on' => null, 'href' => '/fornecedores/'.$supplier->id.'#supplier-review'];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function inventoryCorrections(User $actor): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
$rows = [];
foreach (['stock_event' => app(InventoryDispositions::class), 'stock_reversal' => app(InventoryReversals::class)] as $kind => $workflow) {
if (! $workflow->allows($actor, 'review') && ! $workflow->allows($actor, 'execute')) {
continue;
}
$prefix = $kind === 'stock_event' ? 'disposition' : 'reversal';
$path = $kind === 'stock_event' ? '/stock/ocorrencias' : '/stock/estornos';
foreach (app(SupplyPlanning::class)->latest($kind)->whereIn('state', ['draft', 'returned', 'approved'])->lazyById(100) as $record) {
$sourceCurrent = $record->state !== 'approved' || $workflow->executionSourceCurrent($record);
$action = $record->state === 'approved' ? ($workflow->canAct($actor, $record, 'execute') ? 'execute' : 'return') : 'approve';
if ($action === 'return' && $sourceCurrent) {
continue;
}
if (! $workflow->canAct($actor, $record, $action)) {
continue;
}
$rows[] = ['id' => $prefix.':'.$record->id.':'.$action, 'module' => 'warehouse', 'reference' => $record->data['reference'],
'label' => ! $sourceCurrent ? ($action === 'return' ? 'Conferir origem ou saldo alterados para devolução' : 'Conferir impedimento da correcção física autorizada')
: ($kind === 'stock_reversal' ? ($action === 'execute' ? 'Conferir e executar estorno integral autorizado' : 'Conferir correcção integral de stock')
: ($action === 'execute' ? 'Conferir e confirmar saída física autorizada' : 'Conferir devolução ou abate físico')),
'task' => $prefix.'_'.$action, 'status' => $record->state, 'blocked' => ! $sourceCurrent, 'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => $path.'?record_id='.$record->id.'#'.$prefix.'-'.$record->id];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function lotSetups(User $actor): array
{
$workflow = app(InventoryLots::class);
if (! Schema::hasTable('inventory_lot_allocations') || ! $workflow->allows($actor, 'inventory_lot_review')) {
return [];
}
$rows = [];
foreach (app(SupplyPlanning::class)->latest('lot_setup')->where('state', 'draft')->lazyById(100) as $record) {
if (! $workflow->canActivate($actor, $record)) {
continue;
}
$rows[] = ['id' => 'lot-setup:'.$record->id.':activate', 'module' => 'warehouse',
'reference' => $record->snapshot['item']['code'].' '.$record->snapshot['warehouse']['code'], 'label' => 'Conferir configuração e saldo inicial por lotes',
'task' => 'lot_setup_activate', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => '/stock/lotes?record_id='.$record->id.'#lot-setup-'.$record->id];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function warehouse(User $actor): array
{
@@ -260,29 +478,71 @@ class WorkflowTaskInbox
private function supply(User $actor): array
{
$planning = app(SupplyPlanning::class);
if (! Schema::hasTable('supply_records') || ! $planning->allows($actor, 'supply_review')) {
if (! Schema::hasTable('supply_records') || (! $planning->allows($actor, 'supply_review') && ! $planning->allows($actor, 'supply_history_review') && ! $planning->allows($actor, 'supply_technical_review'))) {
return [];
}
$rows = [];
foreach (['agreement', 'policy', 'auto_rule'] as $kind) {
foreach (['agreement', 'policy', 'auto_rule', 'history', 'alternative'] as $kind) {
$permission = match ($kind) {
'history' => 'supply_history_review', 'alternative' => 'supply_technical_review', default => 'supply_review'
};
if (! $planning->allows($actor, $permission)) {
continue;
}
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);
$eligible = match ($kind) {
'auto_rule' => app(AutomaticPurchases::class)->canReview($actor, $record),
'history' => app(SupplyHistory::class)->canReview($actor, $record),
'alternative' => app(SupplyCoordination::class)->canReviewAlternative($actor, $record),
default => $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',
'agreement' => 'Conferir acordo de fornecimento', 'policy' => 'Conferir política de reposição', 'history' => 'Conferir histórico de consumo', 'alternative' => 'Conferir alternativa técnica', 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];
: '/compras/fornecimento?view='.match ($kind) {
'agreement' => 'agreements', 'history' => 'history', 'alternative' => 'alternatives', default => 'calendar'
}.'&record_id='.$record->id.'#supply-record-'.$record->id];
}
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function suggestions(User $actor): array
{
$planning = app(SupplyPlanning::class);
if (! Schema::hasTable('supply_records') || (! $planning->allows($actor, 'supply_suggest_review') && ! $planning->allows($actor, 'supply_suggest_convert'))) {
return [];
}
$service = app(SupplySuggestions::class);
$rows = [];
foreach ($planning->latest('suggestion')->whereIn('state', ['draft', 'returned', 'approved', 'converted'])->lazyById(100) as $record) {
$action = match ($record->state) {
'approved' => 'convert', 'converted' => 'close', default => 'approve'
};
if (! $service->canAct($actor, $record, $action)) {
continue;
}
$rows[] = ['id' => 'suggestion:'.$record->id.':'.$action, 'module' => 'supply',
'reference' => trim($record->snapshot['item']['code'].' '.$record->snapshot['warehouse']['code']),
'label' => match ($action) {
'convert' => 'Preparar compra a partir da sugestão aprovada', 'close' => 'Conferir e encerrar acompanhamento da reposição', default => 'Conferir sugestão de reposição'
},
'task' => 'suggestion_'.$action, 'status' => $record->state, 'blocked' => false,
'created_at' => $this->date($record->created_at), 'due_on' => null,
'href' => '/compras/fornecimento?view=suggestions&record_id='.$record->id.'#supply-suggestion-'.$record->id];
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function quotations(User $actor): array
{
+19 -2
View File
@@ -9,15 +9,28 @@ class WorkflowTasks
public function catalogue(): array
{
$specific = [
'inventory_lot_prepare' => ['function.inventory_lot_prepare' => 'Preparar controlo por lotes e completar identificações, preservando os dados anteriores; alterações da identificação exigem nova conferência de qualidade'],
'inventory_lot_release' => ['function.inventory_lot_release' => 'Conferir e libertar lotes com identificação, validade e saldo elegíveis, com independência de todos os preparadores e intervenientes na recepção'],
'inventory_lot_hold' => ['function.inventory_lot_hold' => 'Registar retenção de qualidade e conferir lotes anteriormente libertados cuja validade ficou insuficiente, sem alterar o saldo físico'],
'inventory_lot_review' => ['function.inventory_lot_review' => 'Conferir e activar controlo por lotes e revisões com independência dos preparadores, revalidando saldo, origem e configuração em vigor'],
'warehouse' => ['function.warehouse' => 'Receber encomendas e executar operações de armazém; confirmar devoluções, abates e estornos previamente autorizados, com independência de quem aprovou'],
'inventory_reversal_review' => ['function.inventory_reversal_review' => 'Conferir estornos integrais com independência de todos os preparadores, preservando documentos originais e conferência financeira própria'],
'inventory_disposition_review' => ['function.inventory_disposition_review' => 'Conferir devoluções ao fornecedor e abates físicos com independência de todos os preparadores; devolver para actualização quando necessário'],
'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'],
'supply_reserve' => ['function.supply_reserve' => 'Acompanhar necessidades planeadas; associar saídas efectivas já registadas, rever ou cancelar o saldo pendente; esta reserva de planeamento não bloqueia stock físico'],
'supply_delivery' => ['function.supply_delivery' => 'Registar confirmações do fornecedor e acompanhar quantidades ainda por receber, reutilizando as recepções efectivas; rever ou cancelar a previsão sem movimentar stock'],
'supply_review' => ['supply.review' => 'Conferir acordos, políticas e parametrizações de reposição com independência na aprovação; suspender conforme as regras de cada percurso'],
'supply_history_review' => ['supply.history.review' => 'Conferir cobertura e padrão do histórico calculado a partir das saídas, com independência de todos os preparadores'],
'supply_technical_review' => ['supply.technical.review' => 'Conferir alternativas técnicas com independência de todos os preparadores, preservando aplicação, sentido, condições e validade'],
'supply_suggest_review' => ['supply.suggestion.review' => 'Conferir sugestões de reposição com independência e encerrar acompanhamento após rejeição da compra ou recepção completa'],
'supply_suggest_convert' => ['supply.suggestion.convert' => 'Criar rascunho de compra reutilizando a sugestão aprovada, com competência de requisitante e independência de quem conferiu'],
'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 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'],
'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Conferir cadastro de fornecedor preparado ou alterado por outra pessoa; 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'],
@@ -29,6 +42,10 @@ class WorkflowTasks
foreach ($specific[$permission] ?? ['function.'.$permission => $label] as $key => $task) {
$catalogue[$permission][] = ['key' => $key, 'label' => $task];
}
if (in_array($permission, ['admin', 'procurement', 'finance', 'department_head'], true)) {
$catalogue[$permission][] = ['key' => 'supplier.quality.'.$permission,
'label' => 'Preparar avaliações de qualidade e esclarecimentos de fornecedores no âmbito autorizado; conferir sem ter participado na preparação; executar medidas quando designado e conferir o encerramento com independência'];
}
}
return $catalogue;