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
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Services\ReplenishmentQueue;
use Illuminate\Console\Command;
use Throwable;
class ReplenishmentHealth extends Command
{
protected $signature = 'sgc:replenishment-health {--json : Resultado técnico para integração operacional}';
protected $description = 'Consultar o estado técnico da reposição sem processar trabalhos ou aprovar compras';
public function handle(ReplenishmentQueue $queue): int
{
try {
$result = $queue->health();
} catch (Throwable $error) {
report($error);
$result = ['checked_at' => now()->toIso8601String(), 'ok' => false, 'reasons' => ['unavailable']];
}
if ($this->option('json')) {
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
} else {
$this->line($result['ok'] ? 'Reposição: verificação técnica sem pendências observadas.' : 'Reposição: verificação técnica requer atenção.');
$labels = ['not_installed' => 'Reposição durável não instalada nesta composição.', 'not_recent' => 'Sem passagem recente confirmada.',
'last_run_not_successful' => 'Última passagem sem sucesso confirmado.', 'pending_signals' => 'Existem sinais duráveis por processar.',
'failed_work' => 'Existem trabalhos a recuperar.', 'unbound_observation' => 'Passagem sem associação confirmada a esta instalação/base.', 'unavailable' => 'Não foi possível consultar. Verifique o registo técnico.'];
foreach ($result['reasons'] as $reason) {
$this->line($labels[$reason]);
}
}
return $result['ok'] ? self::SUCCESS : self::FAILURE;
}
}
+26 -4
View File
@@ -2,6 +2,8 @@
namespace App\Console\Commands;
use App\Services\ReplenishmentHeartbeat;
use App\Services\ReplenishmentProcessLock;
use App\Services\ReplenishmentQueue;
use Illuminate\Console\Command;
@@ -24,10 +26,30 @@ class ReplenishmentTick extends Command
return self::INVALID;
}
$queue->discover();
$result = $queue->drain((int) $limit);
$this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.');
$started = now()->toIso8601String();
$exitCode = self::FAILURE;
$lock = app(ReplenishmentProcessLock::class);
if (! $lock->acquire()) {
$this->info('Verificação periódica já em execução; aguarde a próxima passagem.');
return $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
return self::FAILURE;
}
try {
$queue->discover();
$result = $queue->drain((int) $limit);
$this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.');
$exitCode = $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
return $exitCode;
} finally {
try {
app(ReplenishmentHeartbeat::class)->record($started, $exitCode);
} catch (\Throwable $error) {
report($error);
$this->warn('Não foi possível actualizar o acompanhamento técnico desta execução.');
} finally {
$lock->release();
}
}
}
}
@@ -14,6 +14,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@@ -26,7 +27,10 @@ class AutomaticPurchaseController extends Controller
$prepare = app(SupplyPlanning::class)->allows($actor, 'supply_prepare');
$ready = $service->ready();
$planning = app(SupplyPlanning::class);
$filters = $request->validate(['need_state' => 'nullable|in:all,pending,created,covered,satisfied', 'rules_page' => 'nullable|integer|min:1|max:1000000', 'needs_page' => 'nullable|integer|min:1|max:1000000', 'history_page' => 'nullable|integer|min:1|max:1000000', 'work_page' => 'nullable|integer|min:1|max:1000000']);
$filters = $request->validate(['rule_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'auto_rule')], 'need_state' => 'nullable|in:all,pending,created,covered,satisfied', 'rules_page' => 'nullable|integer|min:1|max:1000000', 'needs_page' => 'nullable|integer|min:1|max:1000000', 'history_page' => 'nullable|integer|min:1|max:1000000', 'work_page' => 'nullable|integer|min:1|max:1000000']);
$focus = isset($filters['rule_id']) ? SupplyRecord::query()->where('kind', 'auto_rule')->findOrFail((int) $filters['rule_id']) : null;
$rules = $planning->latest('auto_rule')->when($focus !== null, fn ($query) => $query->where('record_key', $focus->record_key));
$latestFocus = $focus === null ? null : (clone $rules)->first();
$needs = $planning->latest('auto_need');
if (($filters['need_state'] ?? 'all') !== 'all') {
$needs->where('state', $filters['need_state']);
@@ -36,7 +40,8 @@ class AutomaticPurchaseController extends Controller
$response = Inertia::render('stock/replenishment', [
'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'),
'filters' => ['need_state' => $filters['need_state'] ?? 'all'],
'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString() : null,
'focusRuleId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $latestFocus?->id !== $focus->id,
'rules' => $ready ? $rules->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString()->through(fn (SupplyRecord $rule) => [...$rule->toArray(), 'canApprove' => $service->canReview($actor, $rule), 'canSuspend' => $service->canReview($actor, $rule, 'suspend')]) : null,
'needs' => $ready ? $needs->orderByRaw("CASE WHEN state = 'pending' THEN 0 ELSE 1 END")->oldest('id')->paginate(20, ['*'], 'needs_page')->withQueryString() : null,
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->paginate(30, ['*'], 'history_page')->withQueryString() : null,
...app(ReplenishmentQueue::class)->status($actor),
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\GeneralLedger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Tempo\Finance\GeneralLedger\CycleWorkspace;
use Tempo\Finance\GeneralLedger\ReportsWorkspace;
final class GeneralLedgerController extends Controller
{
public function report(Request $r, string $kind, GeneralLedger $ledger): Response|JsonResponse
{
$d = $r->validate(['from_on' => 'required|date_format:Y-m-d', 'to_on' => 'required|date_format:Y-m-d|after_or_equal:from_on', 'account_id' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
try {
$data = $ledger->report($this->actor($r), $kind, $d);
} catch (\InvalidArgumentException $e) {
abort(422, $e->getMessage());
} catch (\DomainException $e) {
abort(403, $e->getMessage());
}
if ($r->is('api/*')) {
return response()->json($data)->header('Cache-Control', 'no-store');
}
return response(ReportsWorkspace::render($data, ['host' => 'SGC autónomo', 'return_url' => '/general-ledger/cycle']))->header('Cache-Control', 'no-store');
}
private function actor(Request $r): User
{
$user = $r->user();
abort_unless($user instanceof User, 403);
return $user;
}
public function page(Request $r, GeneralLedger $ledger): Response
{
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000']);
abort_if(array_diff(array_keys($r->query()), ['page']) !== [], 422);
$data = $ledger->workspace($this->actor($r), (int) ($d['page'] ?? 1));
return response(CycleWorkspace::render($data, ['host' => 'SGC autónomo', 'csrf' => csrf_token(), 'financial_url' => '/supplier-payments', 'return_url' => '/obrigacoes-fornecedores', 'page_url' => '/general-ledger/cycle']))->header('Cache-Control', 'no-store');
}
public function command(Request $r, string $action, GeneralLedger $ledger): JsonResponse
{
abort_unless(config('general-ledger.cycle_enabled') === true, 403);
try {
return response()->json($ledger->engine($this->actor($r))->command($action, $r->all(), (string) $r->header('Idempotency-Key')))->header('Cache-Control', 'no-store');
} catch (\InvalidArgumentException $e) {
abort(422, $e->getMessage());
} catch (\DomainException $e) {
abort(409, $e->getMessage());
}
}
public function index(Request $r, GeneralLedger $ledger): JsonResponse
{
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000']);
abort_if(array_diff(array_keys($r->query()), ['page']) !== [], 422);
return response()->json($ledger->workspace($this->actor($r), (int) ($d['page'] ?? 1)))->header('Cache-Control', 'no-store');
}
}
@@ -5,12 +5,15 @@ namespace App\Http\Controllers;
use App\Http\Requests\ConfirmReceiptRequest;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
use App\Models\ReceiptDocument;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\ReceiptDocuments;
use App\Services\ReceiptWorkflow;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
@@ -23,13 +26,21 @@ use Inertia\Response;
class GoodsReceiptController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
/** @return Builder<PurchaseOrder> */
private function visibleOrders(Request $request): Builder
{
Gate::authorize('viewAny', GoodsReceipt::class);
$orders = PurchaseOrder::query();
if ($request->user()->departmentScoped()) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'));
if ($this->actor($request)->departmentScoped()) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($this->actor($request))->select('id'));
}
return $orders;
@@ -65,7 +76,7 @@ class GoodsReceiptController extends Controller
public function store(ConfirmReceiptRequest $request, PurchaseOrder $order, ReceiptWorkflow $workflow): JsonResponse|RedirectResponse
{
$receipt = $workflow->confirm($request->user(), $order, $request->validated());
$receipt = $workflow->confirm($this->actor($request), $order, $request->validated());
if ($request->expectsJson()) {
return response()->json(['id' => $receipt->id, 'number' => $receipt->number, 'created' => $receipt->wasRecentlyCreated, 'status' => $receipt->status], $receipt->wasRecentlyCreated ? 201 : 200);
}
@@ -73,14 +84,37 @@ class GoodsReceiptController extends Controller
return to_route('receipts.show', $receipt);
}
public function show(GoodsReceipt $receipt): Response
public function show(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): Response
{
Gate::authorize('view', $receipt);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$ready = $documents->ready();
$order = PurchaseOrder::query()->findOrFail($receipt->purchase_order_id);
$source = Requisition::query()->visibleTo($actor)->whereKey($order->requisition_id)->first(['id']);
Inertia::encryptHistory();
return Inertia::render('receipts/show', [
'documentsReady' => $ready,
'purchaseProcessHref' => $source === null ? null : '/requisicoes/'.$source->id.'/processo',
'canAddDocuments' => $ready && $documents->canAdd($actor, $receipt),
'documents' => $ready && $documents->canRead($actor, $receipt) ? ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->orderBy('id')->get(['id', 'document_type', 'original_name', 'size', 'sha256', 'uploaded_by', 'created_at']) : [],
'correctedReceiptItems' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all())->map(fn ($r) => $r->data['reference']),
'receipt' => $receipt->load('items'),
'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(),
]);
}
public function print(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): \Illuminate\Http\Response
{
Gate::authorize('view', $receipt);
$actor = $this->actor($request);
return response()->view('receipts.print', [
'receipt' => $receipt->load('items'),
'corrections' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all()),
'documents' => $documents->ready() && $documents->canRead($actor, $receipt)
? ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->orderBy('id')->get(['document_type', 'original_name', 'sha256', 'created_at']) : collect(),
])->header('Cache-Control', 'no-store, private')->header('X-Content-Type-Options', 'nosniff');
}
}
@@ -3,24 +3,24 @@
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryCorrectionPage;
use App\Services\InventoryDispositions;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response;
class InventoryDispositionController extends Controller
{
public function index(Request $request, InventoryDispositions $workflow): Response|RedirectResponse
public function index(Request $request, InventoryDispositions $workflow): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
@@ -33,12 +33,25 @@ class InventoryDispositionController extends Controller
return redirect('/stock/estornos');
}
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? app(SupplyPlanning::class)->latest('stock_event')->latest('id')->limit(100)->get() : collect();
return DB::transaction(function () use ($request, $workflow, $actor, $capabilities, $canViewReversals) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->page($request, $workflow, $actor, $capabilities, $canViewReversals);
});
}
/** @param array<string,bool> $capabilities */
private function page(Request $request, InventoryDispositions $workflow, User $actor, array $capabilities, bool $canViewReversals): Response
{
$page = app(InventoryCorrectionPage::class)->read($request, $actor, 'stock_event');
['ready' => $ready, 'focus' => $focus, 'records' => $records] = $page;
$receipts = [];
$lots = [];
if ($ready && $capabilities['prepare']) {
foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) {
foreach (app(InventoryCorrectionPage::class)->receipts($records->pluck('data.receipt_item_id')->push($page['lookup']['receipt']), $page['lookup']['search']) as $line) {
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$receipts[] = ['id' => $line->id, 'stock_item_id' => $line->stock_item_id, 'warehouse_id' => $receipt->warehouse_id, 'accepted_milli' => $line->accepted_milli,
'returned_milli' => $workflow->returnedForReceipt($line->id), 'label' => $receipt->number.' · '.$receipt->snapshot['supplier']['name'].' · '.$line->snapshot['stock_item']['name']];
@@ -54,13 +67,23 @@ class InventoryDispositionController extends Controller
}
}
return Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, 'records' => $records,
Inertia::encryptHistory();
$response = Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
'recordPagination' => $page['pagination'], 'filters' => $page['filters'],
'sourceSearch' => $page['lookup']['search'], 'sourceFormKey' => $page['lookup']['form'],
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $workflow->canAct($actor, $record, 'approve'),
'canReturn' => $workflow->canAct($actor, $record, 'return'), 'canExecute' => $workflow->canAct($actor, $record, 'execute'),
'executionSourceCurrent' => $record->state === 'approved' ? $workflow->executionSourceCurrent($record) : null]),
'canViewOperations' => $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'inventory_adjust_review']),
'canViewReversals' => $canViewReversals,
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->only($records->pluck('data.stock_operation_id')->filter()->all())->map(fn ($r) => $r->data['reference']),
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'receipts' => $receipts, 'lots' => $lots,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]);
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function save(Request $request, InventoryDispositions $workflow): RedirectResponse
@@ -10,9 +10,12 @@ use App\Services\InventoryLots;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response;
class InventoryLotController extends Controller
{
@@ -25,20 +28,76 @@ class InventoryLotController extends Controller
$capabilities[$capability] = $lots->allows($actor, 'inventory_lot_'.$capability);
}
abort_unless(collect($capabilities)->contains(fn (bool $allowed) => $allowed), 403);
return DB::transaction(function () use ($request, $lots, $actor, $capabilities) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->page($request, $lots, $actor, $capabilities);
});
}
/** @param array<string,bool> $capabilities */
private function page(Request $request, InventoryLots $lots, User $actor, array $capabilities): Response
{
$ready = Schema::hasTable('inventory_lot_allocations');
$records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->limit(100)->get() : collect();
$filter = $request->validate([
'record_id' => ['nullable', Rule::prohibitedIf($request->filled('lot_id')), 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'lot_setup')],
'lot_id' => ['nullable', 'required_with:warehouse_id', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'lot')->where('version', 1)],
'warehouse_id' => ['nullable', 'required_with:lot_id', 'integer', 'min:1', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
'controls_page' => 'nullable|integer|min:1|max:1000000',
'history_page' => 'nullable|integer|min:1|max:1000000',
]);
$focus = isset($filter['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', 'lot_setup')->findOrFail((int) $filter['record_id']) : null;
if (isset($filter['lot_id'])) {
$identity = SupplyRecord::query()->findOrFail((int) $filter['lot_id']);
$focus = $lots->setup($identity->stock_item_id, (int) $filter['warehouse_id']);
abort_if($focus === null, 404);
}
$controls = $ready && $focus === null ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->paginate(25, ['record_key'], 'controls_page')->withQueryString() : null;
$keys = $focus !== null ? collect([$focus->record_key]) : ($controls?->getCollection()->pluck('record_key') ?? collect());
$records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->whereIn('record_key', $keys)->latest('id')->get() : collect();
if ($ready) {
$records = $records->concat($lots->activeSetups()->latest('id')->limit(100)->get())->unique('id')->sortByDesc('id')->values();
$records = $records->concat($lots->activeSetups()->whereIn('record_key', $keys)->latest('id')->get())->unique('id')->sortByDesc('id')->values();
}
$contexts = [];
foreach ($records->where('state', 'active') as $record) {
$contexts[$record->record_key] = $lots->context($record->stock_item_id, $record->warehouse_id);
foreach ($contexts[$record->record_key]['lots'] as &$entry) {
$metadata = SupplyRecord::query()->whereKey($entry['metadata_id'])->firstOrFail();
$entry['canIdentify'] = $lots->canIdentify($actor, $metadata);
$entry['canRelease'] = $lots->canDecideLot($actor, $metadata, $contexts[$record->record_key], $entry, 'release');
$entry['canHold'] = $lots->canDecideLot($actor, $metadata, $contexts[$record->record_key], $entry, 'hold');
}
unset($entry);
}
if (isset($filter['lot_id'])) {
abort_unless(collect($contexts[$focus->record_key]['lots'] ?? [])->contains('id', (int) $filter['lot_id']), 404);
}
$history = $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->paginate(50, ['*'], 'history_page')->withQueryString() : null;
return Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities, 'records' => $records, 'contexts' => $contexts,
Inertia::encryptHistory();
$response = Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities,
'focusRecordId' => $focus->id ?? null, 'focusLotId' => isset($filter['lot_id']) ? (int) $filter['lot_id'] : null,
'focusChanged' => ! isset($filter['lot_id']) && $focus !== null && $records->first()?->id !== $focus->id,
'controlPagination' => $this->pagination($controls), 'historyPagination' => $this->pagination($history),
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canActivate' => $lots->canActivate($actor, $record)]), 'contexts' => $contexts,
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->limit(200)->get() : []]);
'history' => $history?->getCollection() ?? []])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
/** @param LengthAwarePaginator<int,SupplyRecord>|null $page
* @return array<string,mixed>|null
*/
private function pagination(?LengthAwarePaginator $page): ?array
{
return $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()];
}
public function save(Request $request, InventoryLots $lots): RedirectResponse
@@ -3,18 +3,17 @@
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\InventoryCorrectionPage;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response;
class InventoryReversalController extends Controller
{
@@ -27,29 +26,46 @@ class InventoryReversalController extends Controller
$capabilities[$capability] = $workflow->allows($actor, $capability);
}
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? app(SupplyPlanning::class)->latest('stock_reversal')->latest('id')->limit(100)->get() : collect();
return DB::transaction(function () use ($request, $workflow, $actor, $capabilities) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->page($request, $workflow, $actor, $capabilities);
});
}
/** @param array<string,bool> $capabilities */
private function page(Request $request, InventoryReversals $workflow, User $actor, array $capabilities): Response
{
$page = app(InventoryCorrectionPage::class)->read($request, $actor, 'stock_reversal');
['ready' => $ready, 'focus' => $focus, 'records' => $records] = $page;
$operations = [];
$receipts = [];
if ($ready && $capabilities['prepare']) {
$corrected = $workflow->corrections('operation');
$operations = StockOperation::query()->whereIn('kind', ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'])->latest('id')->limit(200)->get()
->reject(fn ($op) => isset($corrected[$op->id]))->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all();
$correctedReceipts = $workflow->corrections('receipt');
foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) {
if (isset($correctedReceipts[$line->id])) {
continue;
}
$operations = app(InventoryCorrectionPage::class)->operations($records->pluck('data.source_operation_id')->push($page['lookup']['operation']), $page['lookup']['search'])
->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all();
foreach (app(InventoryCorrectionPage::class)->receipts($records->pluck('data.receipt_item_id')->push($page['lookup']['receipt']), $page['lookup']['search']) as $line) {
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$receipts[] = ['id' => $line->id, 'label' => $receipt->number.' · '.$line->snapshot['stock_item']['name'].' · '.$line->snapshot['warehouse']['name']];
}
}
$other = app(InventoryDispositions::class);
return Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
Inertia::encryptHistory();
$response = Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
'recordPagination' => $page['pagination'], 'filters' => $page['filters'],
'sourceSearch' => $page['lookup']['search'], 'sourceFormKey' => $page['lookup']['form'],
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
'canViewDispositions' => collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => $other->allows($actor, $a)),
'records' => $records, 'operations' => $operations, 'receipts' => $receipts,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]);
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $workflow->canAct($actor, $record, 'approve'),
'canReturn' => $workflow->canAct($actor, $record, 'return'), 'canExecute' => $workflow->canAct($actor, $record, 'execute'),
'executionSourceCurrent' => $record->state === 'approved' ? $workflow->executionSourceCurrent($record) : null]), 'operations' => $operations, 'receipts' => $receipts,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function save(Request $request, InventoryReversals $workflow): RedirectResponse
@@ -8,6 +8,7 @@ use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\ProcurementOperationalInsights;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
@@ -19,6 +20,14 @@ use Symfony\Component\HttpFoundation\Response;
class ProcurementReportController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
private function snapshot(\Closure $read): mixed
{
if (DB::connection()->getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
@@ -37,8 +46,10 @@ class ProcurementReportController extends Controller
/** @return array<string,mixed> */
private function filters(Request $request): array
{
$actor = User::query()->findOrFail($request->user()->id);
abort_unless($actor->tenant_id === $request->user()->tenant_id && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$current = $request->user('web');
abort_unless($current instanceof User, 403);
$actor = User::query()->where('tenant_id', $current->tenant_id)->findOrFail($current->id);
abort_unless(! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request->setUserResolver(fn () => $actor);
$filters = $request->validate([
@@ -47,6 +58,7 @@ class ProcurementReportController extends Controller
'from' => 'nullable|date_format:Y-m-d',
'to' => 'nullable|date_format:Y-m-d',
'department_id' => 'nullable|integer|min:1',
'attention' => 'nullable|in:unapproved_needed,approved_without_quotes,approved_without_order',
]);
if (! empty($filters['from']) && ! empty($filters['to']) && $filters['from'] > $filters['to']) {
throw ValidationException::withMessages(['to' => 'A data final deve ser igual ou posterior à data inicial.']);
@@ -55,10 +67,22 @@ class ProcurementReportController extends Controller
return $filters;
}
/** @return Builder<Quotation> */
private function registeredQuotes(): Builder
{
return Quotation::query()->whereColumn('quotations.requisition_id', 'requisitions.id');
}
/** @return Builder<PurchaseOrder> */
private function visibleOrders(User $actor): Builder
{
return PurchaseOrder::query()->visibleTo($actor)->whereColumn('purchase_orders.requisition_id', 'requisitions.id');
}
/** @param array<string,mixed> $filters
* @return Builder<Requisition>
*/
private function query(User $actor, array $filters): Builder
private function query(User $actor, array $filters, string $today): Builder
{
$query = Requisition::query()->where('tenant_id', $actor->tenant_id)->visibleTo($actor);
if (isset($filters['search']) && $filters['search'] !== '') {
@@ -69,6 +93,15 @@ class ProcurementReportController extends Controller
$query->where($field, $filters[$field]);
}
}
if (($filters['attention'] ?? '') === 'unapproved_needed') {
$query->whereIn('status', ['draft', 'pending'])->whereDate('needed_by', '<', $today);
}
if (($filters['attention'] ?? '') === 'approved_without_quotes') {
$query->where('status', 'approved')->whereNotExists($this->registeredQuotes()->selectRaw('1'));
}
if (($filters['attention'] ?? '') === 'approved_without_order') {
$query->where('status', 'approved')->whereNotExists($this->visibleOrders($actor)->selectRaw('1'));
}
if (! empty($filters['from'])) {
$query->where('created_at', '>=', $filters['from'].' 00:00:00');
}
@@ -82,11 +115,11 @@ class ProcurementReportController extends Controller
/** @param Builder<Requisition> $query
* @return Builder<Requisition>
*/
private function records(Builder $query): Builder
private function records(Builder $query, User $actor): Builder
{
return $query->select('requisitions.*')->with(['department:id,name', 'requester:id,name'])->addSelect([
'quotations_count' => Quotation::query()->selectRaw('COUNT(*)')->whereColumn('requisition_id', 'requisitions.id'),
'order_number' => PurchaseOrder::query()->visibleTo(request()->user())->select('number')->whereColumn('requisition_id', 'requisitions.id')->orderBy('id')->limit(1),
'quotations_count' => $this->registeredQuotes()->selectRaw('COUNT(*)'),
'order_number' => $this->visibleOrders($actor)->select('number')->orderBy('id')->limit(1),
]);
}
@@ -96,6 +129,7 @@ class ProcurementReportController extends Controller
return [
'id' => $record->id, 'number' => $record->number, 'status' => $record->status,
'created_at' => $record->created_at?->toIso8601String(),
'needed_by' => $record->needed_by?->toDateString(),
'department' => $record->department?->name, 'requester' => $record->requester?->name,
'total_minor' => (string) $record->total_minor,
'quotations_count' => (int) $record->getAttribute('quotations_count'),
@@ -106,14 +140,15 @@ class ProcurementReportController extends Controller
public function index(Request $request): Response
{
$props = $this->snapshot(function () use ($request): array {
$instant = CarbonImmutable::now(config('app.timezone'));
$filters = $this->filters($request);
$query = $this->query($request->user(), $filters);
$actor = $this->actor($request);
$query = $this->query($actor, $filters, $instant->toDateString());
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($value) => (int) $value);
$records = $this->records(clone $query)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record));
$departments = Department::query()->when($request->user()->departmentScoped(), fn ($q) => $q->whereKey($request->user()->department_id))->orderBy('name')->get(['id', 'name']);
$records = $this->records(clone $query, $actor)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record));
$departments = Department::query()->when($actor->departmentScoped(), fn ($q) => $q->whereKey($actor->department_id))->orderBy('name')->get(['id', 'name']);
$instant = CarbonImmutable::now();
$insights = app(ProcurementOperationalInsights::class)->build($this->records(clone $query), $instant);
$insights = app(ProcurementOperationalInsights::class)->build($this->records(clone $query, $actor), $instant);
return ['records' => $records, 'counts' => $counts, 'filters' => $filters, 'departments' => $departments, 'generatedAt' => $instant->toIso8601String(), 'insights' => $insights];
});
@@ -127,23 +162,26 @@ class ProcurementReportController extends Controller
public function export(Request $request): Response
{
$filters = $this->filters($request);
$query = $this->records($this->query($request->user(), $filters));
$this->filters($request);
$text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
return response()->streamDownload(fn () => $this->snapshot(function () use ($query, $text, $filters) {
return response()->streamDownload(fn () => $this->snapshot(function () use ($request, $text) {
$instant = CarbonImmutable::now(config('app.timezone'));
$filters = $this->filters($request);
$actor = $this->actor($request);
$query = $this->records($this->query($actor, $filters, $instant->toDateString()), $actor);
$output = fopen('php://output', 'w');
if ($output === false) {
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
}
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, ['Relatório de compras', 'Criado em', now()->toIso8601String()], ';', '"', '');
fputcsv($output, ['Relatório de compras', 'Criado em', $instant->toIso8601String()], ';', '"', '');
fputcsv($output, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência'], ';', '"', '');
fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência', 'Data de necessidade'], ';', '"', '');
foreach ($query->lazyById(200) as $record) {
$row = $this->row($record);
$base = url('/requisicoes/'.$record->id);
fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio'], ';', '"', '');
fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio', $row['needed_by']], ';', '"', '');
}
fclose($output);
}), 'relatorio-compras-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\ReceiptDocument;
use App\Models\User;
use App\Services\ReceiptDocuments;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReceiptDocumentController extends Controller
{
public function store(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
Gate::authorize('view', $receipt);
abort_unless($documents->canRead($actor, $receipt), 403);
abort_unless($documents->canAdd($actor, $receipt), 403);
$file = $request->file('file');
abort_unless($file instanceof UploadedFile, 422);
$documents->add($actor, $receipt, $request->only(['operation_key', 'document_type']), $file);
return redirect('/recepcoes/'.$receipt->id.'#receipt-documents');
}
public function download(Request $request, GoodsReceipt $receipt, int $document, ReceiptDocuments $documents): StreamedResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $documents->canRead($actor, $receipt), 403);
Gate::authorize('view', $receipt);
abort_unless($documents->ready(), 503);
$file = ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->findOrFail($document);
$disk = Storage::disk('receipt_documents');
abort_unless($disk->exists($file->path), 404);
$stream = $disk->readStream($file->path);
abort_unless(is_resource($stream), 404);
$hash = hash_init('sha256');
hash_update_stream($hash, $stream);
if (! hash_equals($file->sha256, hash_final($hash))) {
fclose($stream);
abort(409, 'Documento indisponível para descarga. Solicite conferência ao responsável.');
}
rewind($stream);
return response()->streamDownload(function () use ($stream) {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
}, $file->original_name, ['Content-Type' => 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, no-store', 'Content-Security-Policy' => "default-src 'none'; sandbox"]);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers;
use App\Models\Requisition;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\RequisitionAuditTrail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
class RequisitionAuditController extends Controller
{
public function csv(Request $request, int $requisition, RequisitionAuditTrail $trail): StreamedResponse
{
$this->visible($request, $requisition);
return response()->streamDownload(function () use ($request, $requisition, $trail) {
DB::transaction(function () use ($request, $requisition, $trail) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$source = $this->visible($request, $requisition);
$out = fopen('php://output', 'w');
if ($out === false) {
throw new \RuntimeException('Não foi possível preparar a exportação.');
}
$text = static function (mixed $value): string {
$value = (string) ($value ?? '');
return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value;
};
try {
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Metadados do histórico da requisição', $text($source->number), 'Conferido em', now()->toIso8601String()], ';', '"', '');
fputcsv($out, ['Registo', 'Data registada', 'Acção', 'Utilizador (id)', 'Modo de execução'], ';', '"', '');
foreach ($trail->query($source)->lazyById(100) as $record) {
$row = $trail->row($record);
fputcsv($out, array_map($text, [$row['id'], $row['created_at'], $row['action'], $row['user_id'], $row['execution']]), ';', '"', '');
}
} finally {
fclose($out);
}
});
}, 'historico-requisicao-'.$requisition.'-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
private function visible(Request $request, int $id): Requisition
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
$access = app(OrganizationAccess::class);
abort_unless(! $access->maintenanceOnly($actor) && $access->permissions($actor) !== [], 403);
return Requisition::query()->visibleTo($actor)->findOrFail($id);
}
}
+21 -3
View File
@@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreRequisitionRequest;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
@@ -19,12 +18,14 @@ use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\InternalSupplyTrace;
use App\Services\OrganizationAccess;
use App\Services\RequisitionAuditTrail;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
use App\Services\SupplySuggestions;
use App\Services\WorkContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
@@ -88,7 +89,22 @@ class RequisitionController extends Controller
public function show(Request $request, Requisition $requisition): Response
{
Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
return DB::transaction(function () use ($request, $requisition) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->detail($request, $requisition);
});
}
private function detail(Request $request, Requisition $requisition): Response
{
$requisition = Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
Inertia::encryptHistory();
$request->validate(['audit_page' => 'nullable|integer|min:1|max:1000000']);
$trail = app(RequisitionAuditTrail::class);
$audit = $trail->query($requisition)->orderBy('id')->paginate(25, ['*'], 'audit_page')->withQueryString();
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
return Inertia::render('requisitions/show', [
@@ -101,7 +117,9 @@ class RequisitionController extends Controller
'canEdit' => app(OrganizationAccess::class)->canRequest($this->actor($request)) && $requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft',
'canDecide' => $requisition->canDecide($this->actor($request)),
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($this->actor($request), $requisition),
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at', 'after'])->map(fn ($a) => ['id' => $a->id, 'action' => $a->action, 'user_id' => $a->user_id, 'created_at' => $a->created_at, 'execution' => $a->after['execution']['mode'] ?? null]),
'audit' => $audit->getCollection()->map(fn ($a) => $trail->row($a)),
'auditPagination' => ['total' => $audit->total(), 'current_page' => $audit->currentPage(), 'last_page' => $audit->lastPage(),
'prev_page_url' => $audit->previousPageUrl(), 'next_page_url' => $audit->nextPageUrl()],
'technical' => [
'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition),
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
@@ -5,10 +5,14 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\PasswordUpdateRequest;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use App\Models\User;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;
class SecurityController extends Controller
{
@@ -17,11 +21,34 @@ class SecurityController extends Controller
*/
public function edit(TwoFactorAuthenticationRequest $request): Response
{
if (Features::enabled(Features::twoFactorAuthentication())) {
return app(RequirePassword::class)->handle($request, fn () => $this->page($request));
}
return $this->page($request);
}
private function page(TwoFactorAuthenticationRequest $request): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$props = [
'passwordRules' => Password::defaults()->toPasswordRulesString(),
];
if (Features::enabled(Features::twoFactorAuthentication())) {
$props += [
'canManageTwoFactor' => true,
'twoFactorEnabled' => $actor->hasEnabledTwoFactorAuthentication(),
'twoFactorPending' => $actor->two_factor_secret !== null && ! $actor->hasEnabledTwoFactorAuthentication(),
'requiresConfirmation' => Fortify::confirmsTwoFactorAuthentication(),
];
}
return Inertia::render('settings/security', $props);
Inertia::encryptHistory();
$response = Inertia::render('settings/security', $props)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
/**
+46 -17
View File
@@ -5,7 +5,11 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Http\Requests\SaveSupplierRequest;
use App\Models\Supplier;
use App\Models\User;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierParticipation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -14,44 +18,67 @@ use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class SupplierController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
private function currentActor(Request $request, PurchasingRole $role): User
{
$actor = $this->actor($request);
app(ConflictGuard::class)->lockCompany($actor);
return app(CurrentActor::class)->lock($actor, [$role]);
}
public function index(Request $request): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$filters = $request->validate(['search' => 'nullable|string|max:100']);
$query = Supplier::query();
if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
}
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => app(SupplierParticipation::class)->canPrepare($this->actor($request)), 'canReview' => app(SupplierParticipation::class)->canReviewRole($this->actor($request))]);
}
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
{
$data = $request->validated();
DB::transaction(function () use ($request, $data, $workflow) {
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'last_changed_by' => $request->user()->id, 'status' => 'pending']);
$workflow->audit($request->user(), 'supplier.created', null, null, $supplier->toArray());
$actor = $this->currentActor($request, PurchasingRole::Procurement);
abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403);
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'last_changed_by' => $actor->id, 'status' => 'pending']);
$workflow->audit($actor, 'supplier.created', null, null, $supplier->toArray());
});
return back();
}
public function show(Request $request, Supplier $supplier): Response
public function show(Request $request, Supplier $supplier): HttpResponse
{
Gate::authorize('view', $supplier);
$canDocuments = $request->user()->can('viewDocuments', $supplier);
$canDocuments = $this->actor($request)->can('viewDocuments', $supplier);
return Inertia::render('suppliers/show', [
Inertia::encryptHistory();
$response = Inertia::render('suppliers/show', [
'supplier' => $supplier,
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
'canEdit' => $request->user()->can('update', $supplier),
'canEdit' => $this->actor($request)->can('update', $supplier),
'canDocuments' => $canDocuments,
'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance]),
]);
'canReview' => app(SupplierParticipation::class)->canReview($this->actor($request), $supplier),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function edit(Supplier $supplier): Response
@@ -66,6 +93,8 @@ class SupplierController extends Controller
Gate::authorize('update', $supplier);
$data = $request->validated();
DB::transaction(function () use ($request, $supplier, $workflow, $data) {
$actor = $this->currentActor($request, PurchasingRole::Procurement);
abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403);
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
if ($supplier->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']);
@@ -74,8 +103,8 @@ class SupplierController extends Controller
unset($data['version']);
$supplier->fill($data);
if ($supplier->isDirty()) {
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1])->save();
$workflow->audit($request->user(), 'supplier.updated', null, $before, $supplier->toArray());
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $actor->id, 'version' => $supplier->version + 1])->save();
$workflow->audit($actor, 'supplier.updated', null, $before, $supplier->toArray());
}
});
@@ -84,12 +113,12 @@ class SupplierController extends Controller
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']);
DB::transaction(function () use ($request, $supplier, $data, $workflow) {
$actor = $this->currentActor($request, PurchasingRole::Finance);
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
abort_if($supplier->created_by === $request->user()->id, 403);
abort_if($supplier->last_changed_by === $request->user()->id, 403);
abort_unless(app(SupplierParticipation::class)->canReview($actor, $supplier), 403);
if ($supplier->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A ficha ou os documentos foram alterados. Reabra a ficha antes de validar.']);
}
@@ -97,8 +126,8 @@ class SupplierController extends Controller
throw ValidationException::withMessages(['status' => 'Fornecedor impedido ou com revisão vencida. Não pode ser activado.']);
}
$before = $supplier->toArray();
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
$workflow->audit($request->user(), 'supplier.reviewed', null, $before, $supplier->toArray());
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $actor->id, 'version' => $supplier->version + 1]);
$workflow->audit($actor, 'supplier.reviewed', null, $before, $supplier->toArray());
});
return back();
@@ -20,6 +20,14 @@ use Symfony\Component\HttpFoundation\Response;
class SupplierEvaluationController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
private function ready(): void
{
abort_unless(Schema::hasTable('supplier_evaluations'), 503, 'A instalação do módulo de avaliações ainda não foi concluída.');
@@ -43,7 +51,7 @@ class SupplierEvaluationController extends Controller
public function index(Request $request, SupplierEvaluationWorkflow $workflow): Response
{
$actor = $workflow->reader($request->user());
$actor = $workflow->reader($this->actor($request));
$filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'status' => 'nullable|in:draft,review,returned,approved']);
$ready = Schema::hasTable('supplier_evaluations');
$records = $ready ? SupplierEvaluation::query()->visibleTo($actor)
@@ -54,7 +62,7 @@ class SupplierEvaluationController extends Controller
'title' => $e->payload['title'], 'period_start' => $e->payload['period_start'], 'period_end' => $e->payload['period_end'],
]) : null;
return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'records' => $records, 'filters' => $filters,
return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'canCreate' => $ready && $workflow->canPrepare($actor), 'records' => $records, 'filters' => $filters,
'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name', 'nuit']),
'orders' => PurchaseOrder::query()->visibleTo($actor)->orderByDesc('id')->get(['id', 'number', 'supplier_id'])]);
}
@@ -71,7 +79,7 @@ class SupplierEvaluationController extends Controller
private function view(Request $request, int $id, SupplierEvaluationWorkflow $workflow, bool $report): Response
{
$actor = $workflow->reader($request->user());
$actor = $workflow->reader($this->actor($request));
$this->ready();
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->findOrFail($id);
$improvementWorkflow = app(SupplierImprovementWorkflow::class);
@@ -89,21 +97,21 @@ class SupplierEvaluationController extends Controller
$next = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->value('id');
return $this->render($request, 'suppliers/evaluation', ['evaluation' => $workflow->snapshot($evaluation), 'reportOnly' => $report,
'canEdit' => ! $report && $writable && in_array($evaluation->status, ['draft', 'returned'], true),
'canReview' => ! $report && $writable && $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true),
'canRevise' => ! $report && $writable && $evaluation->status === 'approved' && $next === null,
'canEdit' => ! $report && $workflow->canAct($actor, $evaluation, 'edit'),
'canReview' => ! $report && $workflow->canAct($actor, $evaluation, 'review'),
'canRevise' => ! $report && $workflow->canAct($actor, $evaluation, 'revise') && $next === null,
'nextRevisionId' => $next, 'history' => $history, 'people' => $people,
'responses' => ['ready' => $responsesReady, 'canCreate' => ! $report && $responsesReady && $writable && $evaluation->status === 'approved',
'history' => $responseHistory,
'records' => $responses->map(fn (SupplierResponse $r) => [...app(SupplierResponseWorkflow::class)->snapshot($r),
'canEdit' => ! $report && $writable && in_array($r->status, ['draft', 'returned'], true),
'canReview' => ! $report && $writable && $r->status === 'review' && ! in_array($actor->id, $r->contributors, true)])],
'canEdit' => ! $report && app(SupplierResponseWorkflow::class)->canAct($actor, $evaluation, $r, 'edit'),
'canReview' => ! $report && app(SupplierResponseWorkflow::class)->canAct($actor, $evaluation, $r, 'review')])],
'improvements' => ['ready' => $measuresReady, 'canCreate' => ! $report && $measuresReady && $writable && $evaluation->status === 'approved',
'today' => today()->toDateString(),
'records' => $measures->map(fn (SupplierImprovement $m) => [...$improvementWorkflow->snapshot($m),
'canPlan' => ! $report && $writable && in_array($m->status, ['open', 'returned'], true),
'canExecute' => ! $report && $writable && $actor->id === $m->responsible_id && in_array($m->status, ['open', 'returned'], true),
'canReview' => ! $report && $writable && $m->status === 'review' && ! in_array($actor->id, $m->contributors, true)]),
'canPlan' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'plan'),
'canExecute' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'execute'),
'canReview' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'review')]),
'history' => $measureHistory,
'assignees' => $report ? [] : User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->filter(fn (User $u) => $u->hasFunctions(SupplierEvaluationWorkflow::ROLES) && (! $u->departmentScoped() || ($u->department_id !== null && $u->department_id === $evaluation->department_id)))->map(fn (User $u) => $u->only(['id', 'name']))->values()],
'orders' => PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $evaluation->supplier_id)->orderByDesc('id')->get(['id', 'number', 'supplier_id']),
@@ -124,7 +132,7 @@ class SupplierEvaluationController extends Controller
{
$this->ready();
$data = $request->validate(['supplier_id' => 'required|integer|min:1', 'operation_key' => 'required|uuid']);
$evaluation = $workflow->create($request->user(), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request));
$evaluation = $workflow->create($this->actor($request), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request));
return to_route('supplier-evaluations.show', $evaluation->id)->with('supplier_evaluation_notice', 'Rascunho guardado. Os dados podem ser recuperados nesta avaliação.');
}
@@ -133,7 +141,7 @@ class SupplierEvaluationController extends Controller
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1']);
$workflow->save($request->user(), $evaluation, (int) $data['version'], $this->payload($request));
$workflow->save($this->actor($request), $evaluation, (int) $data['version'], $this->payload($request));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Alterações guardadas no histórico.');
}
@@ -143,7 +151,7 @@ class SupplierEvaluationController extends Controller
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1', 'reason' => $action === 'submit' ? 'nullable|string|max:4000' : 'required|string|min:10|max:4000',
'payload' => 'prohibited', 'supplier_id' => 'prohibited', 'tenant_id' => 'prohibited', 'reviewed_by' => 'prohibited']);
$workflow->transition($request->user(), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null);
$workflow->transition($this->actor($request), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null);
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Estado e decisão registados.');
}
@@ -152,7 +160,7 @@ class SupplierEvaluationController extends Controller
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1']);
$revision = $workflow->revise($request->user(), $evaluation, (int) $data['version']);
$revision = $workflow->revise($this->actor($request), $evaluation, (int) $data['version']);
return to_route('supplier-evaluations.show', $revision->id)->with('supplier_evaluation_notice', 'Nova revisão preparada. A avaliação aprovada anterior mantém-se preservada.');
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\User;
use App\Services\SupplierPayables;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -11,20 +12,42 @@ use Tempo\Finance\Payables\Workspace;
final class SupplierPayablesController extends Controller
{
private function actor(Request $r): User
{
$user = $r->user();
abort_unless($user instanceof User, 403);
return $user;
}
/** @return array<string,mixed> */
private function filters(Request $r): array
{
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000',
'proposal_page' => 'sometimes|integer|min:1|max:100000', 'event_page' => 'sometimes|integer|min:1|max:100000',
'per_page' => 'sometimes|integer|in:25,100', 'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
abort_if(array_diff(array_keys($r->query()), array_keys($filter)) !== [], 422);
return $filter;
}
public function index(Request $r, SupplierPayables $s): JsonResponse
{
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']);
$filter = $this->filters($r);
$options = array_diff_key($filter, ['invoice' => true]);
return response()->json($s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null))->header('Cache-Control', 'no-store');
return response()->json($s->listing($this->actor($r), isset($filter['invoice']) ? (int) $filter['invoice'] : null, $options))->header('Cache-Control', 'no-store');
}
public function page(Request $r, SupplierPayables $s): Response
{
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']);
$data = $s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null);
$company = Tenant::query()->findOrFail($r->user()->tenant_id)->name;
$filter = $this->filters($r);
$options = array_diff_key($filter, ['invoice' => true]);
$actor = $this->actor($r);
$data = $s->listing($actor, isset($filter['invoice']) ? (int) $filter['invoice'] : null, $options);
$company = Tenant::query()->findOrFail($actor->tenant_id)->name;
return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $r->user()->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store');
return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $actor->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'page_url' => '/obrigacoes-fornecedores', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store');
}
public function command(Request $r, string $action, SupplierPayables $s): JsonResponse
@@ -33,7 +56,7 @@ final class SupplierPayablesController extends Controller
$d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']);
abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422);
try {
return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
return response()->json($s->engine($this->actor($r))->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
} catch (\DomainException $e) {
abort(409, $e->getMessage());
} catch (\InvalidArgumentException $e) {
@@ -2,7 +2,10 @@
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\GeneralLedger;
use App\Services\SupplierPayables;
use Illuminate\Http\Request;
use Tempo\Finance\Payables\SettlementHttp;
final class SupplierPaymentsController extends SettlementHttp
@@ -10,4 +13,16 @@ final class SupplierPaymentsController extends SettlementHttp
protected const MODE = 'compras';
protected const SERVICE = SupplierPayables::class;
/** @return array<string,mixed> */
protected function ledgerSettings(Request $request): array
{
if (config('general-ledger.cycle_enabled') !== true) {
return [];
}
$user = $request->user();
abort_unless($user instanceof User, 403);
return app(GeneralLedger::class)->can($user, 'view') ? ['ledger_cycle_url' => '/general-ledger/cycle'] : [];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\SupplyCoordination;
use App\Services\SupplyCoordinationRead;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplyCoordinationReportController extends Controller
{
public function csv(Request $request, SupplyCoordinationRead $read): StreamedResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$view = $request->validate(['view' => 'required|in:reservations,deliveries,alternatives'])['view'];
$kind = $read->kind($actor, $view);
$filters = $read->filters($request, $kind);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.');
// Validate the same focus as the page, without silently exporting other records.
$focus = $request->validate(['record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)]]);
return response()->streamDownload(function () use ($read, $actor, $view, $kind, $filters, $focus) {
DB::transaction(function () use ($read, $actor, $view, $kind, $filters, $focus) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
// Recheck access when the response is consumed.
$read->kind($actor, $view);
$query = $read->query($kind, isset($focus['record_id']) ? ['record_search' => '', 'record_state' => ''] : $filters);
if (isset($focus['record_id'])) {
$key = SupplyRecord::query()->whereKey($focus['record_id'])->value('record_key');
$query->where('record_key', $key);
}
$out = fopen('php://output', 'w');
if ($out === false) {
throw new \RuntimeException('Não foi possível preparar a exportação.');
}
$text = static function (mixed $value): string {
$value = (string) ($value ?? '');
return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value;
};
try {
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Acompanhamento de fornecimentos', $view, 'Conferido em', now()->toIso8601String()], ';', '"', '');
fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), 'Registo em foco', $focus['record_id'] ?? ''], ';', '"', '');
fputcsv($out, ['Registo', 'Versão', 'Estado registado', 'Artigo', 'Designação preservada', 'Local preservado', 'Referência', 'Encomenda', 'Quantidade', 'Unidade', 'Data prevista/necessidade', 'Quantidade por satisfazer (ver estado)', 'Substituto', 'Aplicação', 'Validade', 'Processo'], ';', '"', '');
foreach ($query->lazyById(100) as $record) {
$pending = match ($kind) {
'reservation' => app(SupplyCoordination::class)->remainingReservation($record),
'delivery' => app(SupplyCoordination::class)->remainingDelivery($record),
default => null,
};
fputcsv($out, array_map($text, [$record->record_key, $record->version, $record->state,
$record->snapshot['item']['code'] ?? '', $record->snapshot['item']['name'] ?? '', $record->snapshot['warehouse']['name'] ?? '',
$record->data['reference'] ?? '', $record->snapshot['order']['number'] ?? '',
isset($record->data['quantity_milli']) ? $record->data['quantity_milli'] / 1000 : '', $record->snapshot['item']['unit'] ?? '',
$record->data['needed_on'] ?? $record->data['expected_on'] ?? '', $pending === null ? '' : $pending / 1000,
$record->snapshot['substitute']['code'] ?? '', $record->data['application'] ?? '', $record->data['valid_until'] ?? '',
url('/compras/fornecimento?view='.$view.'&record_id='.$record->id).'#supply-record-'.$record->id]), ';', '"', '');
}
} finally {
fclose($out);
}
});
}, 'fornecimentos-'.$view.'-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
}
+227 -24
View File
@@ -17,37 +17,58 @@ use App\Services\InventoryReversals;
use App\Services\OrganizationAccess;
use App\Services\ReceiptWorkflow;
use App\Services\SupplyCoordination;
use App\Services\SupplyCoordinationRead;
use App\Services\SupplyHistory;
use App\Services\SupplyPlanning;
use App\Services\SupplySuggestions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class SupplyPlanningController extends Controller
{
public function index(Request $request, SupplyPlanning $planning): Response
public function index(Request $request, SupplyPlanning $planning): HttpResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return DB::transaction(function () use ($request, $planning, $actor) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->page($request, $planning, $actor);
});
}
private function page(Request $request, SupplyPlanning $planning, User $actor): HttpResponse
{
Inertia::encryptHistory();
$view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements';
if ($view === 'history') {
return $this->history($actor, $planning);
return $this->privateResponse($request, $this->history($request, $actor, $planning));
}
if ($view === 'suggestions') {
return $this->suggestions($actor, $planning);
return $this->privateResponse($request, $this->suggestions($request, $actor, $planning));
}
if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) {
return $this->coordination($actor, $view, $planning);
return $this->privateResponse($request, $this->coordination($request, $actor, $view, $planning));
}
$import = $view === 'imports';
$capabilities = $import ? ['import_read', 'import_prepare', 'import_customs'] : ['supply_read', 'supply_prepare', 'supply_review'];
abort_unless(collect($capabilities)->contains(fn ($p) => $planning->allows($actor, $p)), 403);
$ready = Schema::hasTable('supply_records');
$kind = $import ? 'import' : ($view === 'calendar' ? 'policy' : 'agreement');
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
$focus = $this->focusedRecord($request, $actor, $kind);
[$records, $page] = $this->recordPage($request, $planning, $ready, $kind, $focus);
$projections = [];
if ($view === 'calendar' && $ready) {
foreach ($records->where('state', 'approved') as $record) {
@@ -55,39 +76,150 @@ class SupplyPlanningController extends Controller
}
}
$prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare');
$orderFilters = $import ? $request->validate([
'order_search' => 'nullable|string|max:100',
'selected_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)],
]) : [];
$historyFilters = $kind === 'policy' ? $request->validate([
'history_stock_item_id' => ['nullable', 'required_with:history_period_from,history_period_until,selected_history_key', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $actor->tenant_id)],
'history_warehouse_id' => ['nullable', 'required_with:history_stock_item_id', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
'history_purpose' => 'nullable|required_with:history_stock_item_id|in:internal,resale',
'history_period_from' => 'nullable|date_format:Y-m-d',
'history_period_until' => ['nullable', 'date_format:Y-m-d', $request->filled('history_period_from') ? 'after_or_equal:history_period_from' : 'nullable'],
'selected_history_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'history')],
'history_form_key' => 'nullable|string|max:80',
]) : [];
return Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, 'records' => $records, 'projections' => $projections,
$agreementFilters = $kind === 'policy' ? $request->validate([
'agreement_search' => 'nullable|string|max:100',
'agreement_stock_item_id' => ['nullable', 'required_with:agreement_search,selected_agreement_key', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $actor->tenant_id)],
'agreement_warehouse_id' => ['nullable', 'required_with:agreement_stock_item_id', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
'selected_agreement_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'agreement')],
'agreement_form_key' => 'nullable|string|max:80',
]) : [];
return $this->privateResponse($request, Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready,
'agreementSearch' => $agreementFilters['agreement_search'] ?? '', 'agreementFormKey' => $agreementFilters['agreement_form_key'] ?? null,
'recordPagination' => $this->pagination($page),
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $planning->canReview($actor, $record), 'canReturn' => $planning->canReview($actor, $record, 'return'), 'canSuspend' => $planning->canReview($actor, $record, 'suspend')]), 'projections' => $projections,
'asOf' => today()->toDateString(),
'histories' => $ready && $prepare && $kind === 'policy' ? $planning->latest('history')->where('state', 'approved')->latest('id')->limit(100)->get()->map(fn ($r) => [
'historyFormKey' => $historyFilters['history_form_key'] ?? null,
'histories' => $ready && $prepare && $kind === 'policy' ? $this->approvedOptions($planning, 'history', $records->pluck('data.history_key')->push($historyFilters['selected_history_key'] ?? null), $historyFilters)->map(fn ($r) => [
'key' => $r->record_key, 'version' => $r->version, 'stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'purpose' => $r->data['purpose'],
'period_from' => $r->data['period_from'], 'period_until' => $r->data['period_until'], 'quality' => $r->data['quality'], 'pattern' => $r->data['pattern'], 'days' => $r->snapshot['analysis']['days'], 'daily_mean_milli' => $r->snapshot['analysis']['daily_mean_milli'],
]) : [],
'agreements' => $ready && $prepare && $kind === 'policy' ? $planning->latest('agreement')->where('state', 'approved')->get(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot']) : [],
'agreements' => $ready && $prepare && $kind === 'policy' ? $this->approvedOptions($planning, 'agreement', $records->pluck('data.agreement_key')->push($agreementFilters['selected_agreement_key'] ?? null), $agreementFilters)->map(fn ($r) => $r->only(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot'])) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'), 'canCustoms' => $planning->allows($actor, 'import_customs'),
'items' => $prepare && ! $import ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $prepare && ! $import ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'suppliers' => $prepare && $kind === 'agreement' ? Supplier::query()->where('status', 'active')->orderBy('name')->get(['id', 'name']) : [],
'orders' => $prepare && $import ? PurchaseOrder::query()->latest('id')->limit(100)->get(['id', 'number']) : [],
]);
'orderSearch' => $orderFilters['order_search'] ?? '',
'orders' => $prepare && $import ? $this->orderOptions($records->pluck('purchase_order_id')->push($orderFilters['selected_order_id'] ?? null), $orderFilters['order_search'] ?? '') : [],
]));
}
private function history(User $actor, SupplyPlanning $planning): Response
private function privateResponse(Request $request, Response $page): HttpResponse
{
$response = $page->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
private function focusedRecord(Request $request, User $actor, string $kind): ?SupplyRecord
{
$filter = $request->validate(['record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)]]);
return isset($filter['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', $kind)->findOrFail((int) $filter['record_id']) : null;
}
/** @return array{Collection<int,SupplyRecord>,LengthAwarePaginator<int,SupplyRecord>|null} */
private function recordPage(Request $request, SupplyPlanning $planning, bool $ready, string $kind, ?SupplyRecord $focus): array
{
$request->validate(['records_page' => 'nullable|integer|min:1|max:1000000']);
if (! $ready) {
return [collect(), null];
}
$query = $planning->latest($kind)->latest('id');
if ($focus !== null) {
return [$query->where('record_key', $focus->record_key)->get(), null];
}
$page = $query->paginate(25, ['*'], 'records_page')->withQueryString();
return [$page->getCollection(), $page];
}
/** @param LengthAwarePaginator<int,SupplyRecord>|null $page
* @return array<string,mixed>|null
*/
private function pagination(?LengthAwarePaginator $page): ?array
{
return $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()];
}
/** Preserve already selected sources even outside the recent choices.
* @param Collection<array-key,mixed> $selected
* @param array<string,mixed> $filters
* @return Collection<int,SupplyRecord>
*/
private function approvedOptions(SupplyPlanning $planning, string $kind, Collection $selected, array $filters = []): Collection
{
$query = $planning->latest($kind)->where('state', 'approved');
if ($kind === 'agreement' && isset($filters['agreement_stock_item_id'])) {
$query->where('stock_item_id', $filters['agreement_stock_item_id'])->where('warehouse_id', $filters['agreement_warehouse_id']);
}
if ($kind === 'history' && isset($filters['history_stock_item_id'])) {
$query->where('stock_item_id', $filters['history_stock_item_id'])->where('warehouse_id', $filters['history_warehouse_id'])->where('data->purpose', $filters['history_purpose']);
}
$recent = (clone $query)
->when(trim($filters['agreement_search'] ?? '') !== '', fn ($q) => $q->where(fn ($match) => $match
->whereLike('data->reference', '%'.trim($filters['agreement_search']).'%')
->orWhereLike('snapshot->supplier->name', '%'.trim($filters['agreement_search']).'%')))
->when(trim($filters['policy_search'] ?? '') !== '', fn ($q) => $q->where(fn ($match) => $match
->whereLike('snapshot->item->code', '%'.trim($filters['policy_search']).'%')
->orWhereLike('snapshot->item->name', '%'.trim($filters['policy_search']).'%')
->orWhereLike('snapshot->warehouse->name', '%'.trim($filters['policy_search']).'%')))
->when($filters['history_period_from'] ?? null, fn ($q, $date) => $q->where('data->period_from', '>=', $date))
->when($filters['history_period_until'] ?? null, fn ($q, $date) => $q->where('data->period_until', '<=', $date))
->latest('id')->limit(100)->pluck('id');
return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('record_key', $selected->filter()->unique()))->latest('id')->get();
}
/** @param Collection<array-key,mixed> $selected
* @return Collection<int,PurchaseOrder>
*/
private function orderOptions(Collection $selected, string $search = ''): Collection
{
$recent = PurchaseOrder::query()->when(trim($search) !== '', fn ($q) => $q->whereRaw('LOWER(number) LIKE ?', ['%'.mb_strtolower(trim($search)).'%']))->latest('id')->limit(100)->pluck('id');
return PurchaseOrder::query()->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get(['id', 'number']);
}
private function history(Request $request, User $actor, SupplyPlanning $planning): Response
{
$prepare = $planning->allows($actor, 'supply_history_prepare');
$review = $planning->allows($actor, 'supply_history_review');
abort_unless($prepare || $review || $planning->allows($actor, 'supply_read'), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest('history')->latest('id')->limit(100)->get() : collect();
$focus = $this->focusedRecord($request, $actor, 'history');
[$records, $page] = $this->recordPage($request, $planning, $ready, 'history', $focus);
$service = app(SupplyHistory::class);
return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'canPrepare' => $prepare, 'canReview' => $review,
return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id,
'recordPagination' => $this->pagination($page),
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $service->canReview($actor, $record), 'canReturn' => $service->canReview($actor, $record, 'return'), 'canSuspend' => $service->canReview($actor, $record, 'suspend')]), 'canPrepare' => $prepare, 'canReview' => $review,
'defaultPeriod' => ['from' => today()->subDays(30)->toDateString(), 'until' => today()->subDay()->toDateString()],
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', 'history')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
private function suggestions(User $actor, SupplyPlanning $planning): Response
private function suggestions(Request $request, User $actor, SupplyPlanning $planning): Response
{
$prepare = $planning->allows($actor, 'supply_suggest_prepare');
$review = $planning->allows($actor, 'supply_suggest_review');
@@ -95,10 +227,16 @@ class SupplyPlanningController extends Controller
$convert = $convertPermission && app(OrganizationAccess::class)->canRequest($actor);
abort_unless($planning->allows($actor, 'supply_read') || $prepare || $review || $convertPermission, 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest('suggestion')->latest('id')->limit(100)->get() : collect();
$focus = $this->focusedRecord($request, $actor, 'suggestion');
[$records, $page] = $this->recordPage($request, $planning, $ready, 'suggestion', $focus);
$filters = $request->validate([
'policy_search' => 'nullable|string|max:100',
'selected_policy_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'policy')],
'policy_form_key' => 'nullable|string|max:80',
]);
$policies = [];
if ($ready && $prepare) {
foreach ($planning->latest('policy')->where('state', 'approved')->latest('id')->limit(100)->get() as $policy) {
foreach ($this->approvedOptions($planning, 'policy', $records->pluck('data.policy_key')->push($filters['selected_policy_key'] ?? null), $filters) as $policy) {
$option = ['key' => $policy->record_key, 'version' => $policy->version, 'label' => $policy->snapshot['item']['code'].' · '.$policy->snapshot['item']['name'].' · '.$policy->snapshot['warehouse']['name'], 'issue' => null];
try {
$basis = app(SupplySuggestions::class)->basis($policy->record_key, today()->toDateString());
@@ -112,7 +250,16 @@ class SupplyPlanningController extends Controller
}
$requests = Requisition::query()->visibleTo($actor)->whereIn('id', $records->pluck('data.requisition_id')->filter())->get(['id', 'number'])->keyBy('id');
return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert,
$service = app(SupplySuggestions::class);
return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id,
'policySearch' => $filters['policy_search'] ?? '', 'policyFormKey' => $filters['policy_form_key'] ?? null,
'recordPagination' => $this->pagination($page),
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(),
'canApprove' => $service->canAct($actor, $record, 'approve'), 'canReturn' => $service->canAct($actor, $record, 'return'),
'canConvert' => $service->canAct($actor, $record, 'convert'), 'canCancel' => $service->canAct($actor, $record, 'cancel'), 'canClose' => $service->canAct($actor, $record, 'close')]),
'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert,
'costCenters' => $convert ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
'requestLinks' => $requests,
'excess' => $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplySuggestions::class)->hasExcess($r)]),
@@ -120,7 +267,7 @@ class SupplyPlanningController extends Controller
]);
}
private function coordination(User $actor, string $view, SupplyPlanning $planning): Response
private function coordination(Request $request, User $actor, string $view, SupplyPlanning $planning): Response
{
[$kind, $prepare, $review] = match ($view) {
'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'],
@@ -128,25 +275,79 @@ class SupplyPlanningController extends Controller
'deliveries' => ['delivery', 'supply_delivery', 'supply_delivery'],
default => abort(404),
};
abort_unless($planning->allows($actor, 'supply_read') || $planning->allows($actor, $prepare) || $planning->allows($actor, $review), 403);
$read = app(SupplyCoordinationRead::class);
$read->kind($actor, $view);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
$focus = $this->focusedRecord($request, $actor, $kind);
$request->validate(['records_page' => 'nullable|integer|min:1|max:1000000']);
$filters = $read->filters($request, $kind);
$query = $ready ? ($focus === null ? $read->query($kind, $filters) : $planning->latest($kind))->latest('id') : null;
$page = $focus === null ? $query?->paginate(25, ['*'], 'records_page')->withQueryString() : null;
$records = $focus !== null ? $query?->where('record_key', $focus->record_key)->get() ?? collect() : $page?->getCollection() ?? collect();
$canPrepare = $planning->allows($actor, $prepare);
$lineFilters = in_array($kind, ['reservation', 'delivery'], true) ? $request->validate([
'line_search' => 'nullable|string|max:100', 'line_form_key' => 'nullable|string|max:80',
'selected_order_line_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_order_items', 'id')->where('tenant_id', $actor->tenant_id)],
'selected_request_line_id' => ['nullable', 'integer', 'min:1', Rule::exists('requisition_items', 'id')->where('tenant_id', $actor->tenant_id)],
]) : [];
$lineSearch = trim($lineFilters['line_search'] ?? '');
$reversedIds = $ready && $kind === 'reservation' ? array_keys(app(InventoryReversals::class)->corrections('operation')) : [];
$orders = $canPrepare && $kind === 'delivery' ? PurchaseOrder::query()->latest('id')->limit(100)->get()->keyBy('id') : collect();
$orders = collect();
if ($canPrepare && $kind === 'delivery') {
$recentOrders = PurchaseOrder::query()->where('status', 'issued')->when($lineSearch !== '', fn ($q) => $q->whereLike('number', '%'.$lineSearch.'%'))->latest('id')->limit(100)->pluck('id');
$selectedOrders = $records->pluck('purchase_order_id')->filter();
if (isset($lineFilters['selected_order_line_id'])) {
$selectedOrders->push(PurchaseOrderItem::query()->whereKey($lineFilters['selected_order_line_id'])->value('purchase_order_id'));
}
$orders = PurchaseOrder::query()->where('status', 'issued')->where(fn ($q) => $q->whereIn('id', $recentOrders)->orWhereIn('id', $selectedOrders))->latest('id')->get()->keyBy('id');
}
$orderLines = PurchaseOrderItem::query()->whereIn('purchase_order_id', $orders->keys())->get()->map(fn ($line) => [
'id' => $line->id, 'label' => $orders[$line->purchase_order_id]->number.' · '.$line->description.' · '.$line->quantity.' '.$line->unit,
'stock_item_id' => $orders[$line->purchase_order_id]->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null,
'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
]);
$requests = $canPrepare && $kind === 'reservation' ? Requisition::query()->where('status', 'approved')->latest('id')->limit(100)->get()->keyBy('id') : collect();
$requests = collect();
if ($canPrepare && $kind === 'reservation') {
$recentRequests = Requisition::query()->where('status', 'approved')->when($lineSearch !== '', fn ($q) => $q->whereLike('number', '%'.$lineSearch.'%'))->latest('id')->limit(100)->pluck('id');
$selectedRequests = RequisitionItem::query()->whereIn('id', $records->pluck('data.requisition_item_id')->push($lineFilters['selected_request_line_id'] ?? null)->filter())->pluck('requisition_id');
$requests = Requisition::query()->where('status', 'approved')->where(fn ($q) => $q->whereIn('id', $recentRequests)->orWhereIn('id', $selectedRequests))->latest('id')->get()->keyBy('id');
}
$requestLines = RequisitionItem::query()->whereIn('requisition_id', $requests->keys())->get()->filter(fn ($line) => isset($line->catalogue_snapshot['stock_item_id']))->map(fn ($line) => [
'id' => $line->id, 'label' => $requests[$line->requisition_id]->number.' · '.$line->description,
'stock_item_id' => $line->catalogue_snapshot['stock_item_id'], 'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
'reference' => $requests[$line->requisition_id]->number.' / '.$line->id, 'needed_on' => $requests[$line->requisition_id]->needed_by->toDateString(),
])->values();
return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready, 'records' => $records,
$coordination = app(SupplyCoordination::class);
$issueFilters = $kind === 'reservation' ? $request->validate([
'issue_search' => 'nullable|string|max:100',
'issue_record_id' => ['nullable', 'required_with:issue_search,selected_issue_id', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'reservation')],
'selected_issue_id' => ['nullable', 'integer', 'min:1', Rule::exists('stock_operations', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'issue')],
]) : [];
$operations = collect();
if ($ready && $canPrepare && $kind === 'reservation') {
$issueRecord = isset($issueFilters['issue_record_id']) ? SupplyRecord::query()->findOrFail((int) $issueFilters['issue_record_id']) : null;
if ($issueRecord !== null) {
$issueRecord = $planning->latest('reservation')->where('record_key', $issueRecord->record_key)->firstOrFail();
abort_unless($coordination->canManage($actor, $issueRecord, 'fulfil'), 403);
}
$query = StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds)
->when($issueRecord !== null, fn ($q) => $q->where('stock_item_id', $issueRecord->stock_item_id)->where('warehouse_id', $issueRecord->warehouse_id));
$search = trim($issueFilters['issue_search'] ?? '');
$recent = (clone $query)->when($search !== '', fn ($q) => $q->whereLike('reference', '%'.$search.'%'))->latest('id')->limit(100)->pluck('id');
$operations = $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhere('id', $issueFilters['selected_issue_id'] ?? 0))
->latest('id')->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']);
}
return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready,
'recordFilters' => ['record_search' => trim($filters['record_search'] ?? ''), 'record_state' => $filters['record_state'] ?? ''],
'recordPagination' => $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()],
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $coordination->canReviewAlternative($actor, $record),
'canReturn' => $coordination->canReviewAlternative($actor, $record, 'return'), 'canSuspend' => $coordination->canReviewAlternative($actor, $record, 'suspend'),
'canEdit' => $coordination->canManage($actor, $record, 'edit'), 'canFulfil' => $coordination->canManage($actor, $record, 'fulfil'),
'canCancel' => $coordination->canManage($actor, $record, 'cancel')]),
'eligible' => $kind === 'alternative' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->eligibleAlternative($r)]) : [],
'pending' => $kind === 'delivery' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingDelivery($r)]) : ($kind === 'reservation' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingReservation($r)]) : []),
'reversedOperations' => $reversedIds,
@@ -154,7 +355,9 @@ class SupplyPlanningController extends Controller
'items' => $canPrepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $canPrepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'orderLines' => $orderLines, 'requestLines' => $requestLines,
'operations' => $canPrepare && $kind === 'reservation' ? StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds)->latest('id')->limit(100)->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']) : [],
'lineSearch' => $lineSearch, 'lineFormKey' => $lineFilters['line_form_key'] ?? null,
'operations' => $operations, 'issueSearch' => $issueFilters['issue_search'] ?? '',
'issueRecordId' => isset($issueFilters['issue_record_id']) ? (int) $issueFilters['issue_record_id'] : null,
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
@@ -6,6 +6,7 @@ use App\Models\User;
use App\Services\WorkflowNotices;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@@ -13,18 +14,41 @@ use Symfony\Component\HttpFoundation\Response;
class WorkflowNoticeController extends Controller
{
public function index(Request $request, WorkflowNotices $notices): Response
{
return DB::transaction(function () use ($request, $notices) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $this->page($request, $notices);
});
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
return $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000', 'search' => 'nullable|string|max:100']);
}
private function page(Request $request, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$filter = $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000']);
$filter = $this->filters($request);
$visible = collect($notices->visible($actor));
$unread = $visible->whereNull('read_at')->count();
$items = ($filter['view'] ?? 'all') === 'unread' ? $visible->whereNull('read_at')->values() : $visible;
if (in_array($filter['view'] ?? '', ['tasks', 'outcomes'], true)) {
$items = $visible->where('kind', $filter['view'] === 'tasks' ? 'task' : 'outcome')->values();
}
$page = (int) ($filter['page'] ?? 1);
$search = trim($filter['search'] ?? '');
if ($search !== '') {
$items = $items->filter(fn ($notice) => mb_stripos($notice['reference'], $search) !== false)->values();
}
$page = min((int) ($filter['page'] ?? 1), max(1, (int) ceil($items->count() / 25)));
Inertia::encryptHistory();
$response = Inertia::render('workflow/notices', ['ready' => Schema::hasTable('workflow_notices'), 'unread' => $unread, 'view' => $filter['view'] ?? 'all',
'search' => $search,
'notices' => (new LengthAwarePaginator($items->slice(($page - 1) * 25, 25)->values(), $items->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -35,9 +59,10 @@ class WorkflowNoticeController extends Controller
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$filters = $this->filters($request);
$notices->read($actor, $notice);
return redirect('/requisicoes/avisos');
return redirect('/requisicoes/avisos'.($filters === [] ? '' : '?'.http_build_query($filters)));
}
public function refresh(Request $request, int $requisition, WorkflowNotices $notices): Response
@@ -29,23 +29,38 @@ class WorkflowTaskInboxController extends Controller
abort_unless($actor instanceof User, 403);
$modules = $area === 'people' ? ['people' => 'Pessoas e contratações'] : ['purchases' => 'Compras', 'warehouse' => 'Armazém', 'supply' => 'Fornecimentos e reposição', 'finance' => 'Finanças'];
$filters = $request->validate(['module' => ['nullable', Rule::in(['all', ...array_keys($modules)])],
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000']);
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000',
'due' => 'nullable|in:all,overdue,today,upcoming,undated']);
return DB::transaction(function () use ($request, $actor, $area, $inbox, $modules, $filters) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$all = collect($inbox->build($actor, $area));
$today = today()->toDateString();
$all = collect($inbox->build($actor, $area))->map(function ($row) use ($today) {
$date = $row['due_on'] ?? null;
$row['due_status'] = $date === null ? 'undated' : ($date < $today ? 'overdue' : ($date === $today ? 'today' : 'upcoming'));
return $row;
});
$counts = [];
foreach ($modules as $key => $label) {
$counts[$key] = $all->where('module', $key)->count();
}
$rows = $all->filter(fn ($row) => (($filters['module'] ?? 'all') === 'all' || $row['module'] === $filters['module'])
&& (($filters['search'] ?? '') === '' || mb_stripos($row['reference'].' '.$row['label'], trim($filters['search'])) !== false))->values();
$deadlineCounts = [];
foreach (['overdue', 'today', 'upcoming', 'undated'] as $status) {
$deadlineCounts[$status] = $rows->where('due_status', $status)->count();
}
if (($filters['due'] ?? 'all') !== 'all') {
$rows = $rows->where('due_status', $filters['due'])->values();
}
$page = (int) ($filters['page'] ?? 1);
Inertia::encryptHistory();
$response = Inertia::render('workflow/tasks', ['area' => $area, 'modules' => $modules, 'counts' => $counts,
'checkedAt' => now()->toIso8601String(), 'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? ''],
'checkedAt' => now()->toIso8601String(), 'asOf' => $today, 'deadlineCounts' => $deadlineCounts,
'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? '', 'due' => $filters['due'] ?? 'all'],
'tasks' => (new LengthAwarePaginator($rows->slice(($page - 1) * 25, 25)->values(), $rows->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class PrivateTwoFactorResponse
{
public function handle(Request $request, Closure $next): Response
{
if (! str_starts_with($request->route()?->getName() ?? '', 'two-factor.')) {
return $next($request);
}
try {
$response = $next($request);
} catch (ValidationException $error) {
// Exclude authentication material from old input without affecting other forms' code fields.
$request->request->remove('code');
$request->request->remove('recovery_code');
$request->json()->remove('code');
$request->json()->remove('recovery_code');
throw $error;
}
if ($request->hasSession()) {
$request->session()->forget(['_old_input.code', '_old_input.recovery_code']);
}
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
}
+5 -2
View File
@@ -2,8 +2,9 @@
namespace App\Http\Requests;
use App\Enums\PurchasingRole;
use App\Models\Supplier;
use App\Models\User;
use App\Services\SupplierParticipation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -11,7 +12,9 @@ class SaveSupplierRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasFunctions([PurchasingRole::Procurement]) ?? false;
$actor = $this->user('web');
return $actor instanceof User && app(SupplierParticipation::class)->canPrepare($actor);
}
/** @return array<string, mixed> */
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $goods_receipt_id
* @property string $path
* @property string $sha256
* @property string $original_name
*/
class ReceiptDocument extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected $hidden = ['path', 'tenant_id'];
protected function casts(): array
{
return ['created_at' => 'datetime'];
}
}
+14
View File
@@ -3,7 +3,9 @@
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
@@ -29,6 +31,18 @@ class SupplierResponse extends Model
return ['payload' => 'array', 'contributors' => 'array', 'decision' => 'array', 'reviewed_at' => 'datetime'];
}
/** @param Builder<SupplierResponse> $query */
public function scopeVisibleTo(Builder $query, User $actor): void
{
$query->where('tenant_id', $actor->tenant_id)->whereIn('supplier_evaluation_id', SupplierEvaluation::query()->visibleTo($actor)->select('id'));
}
/** @return BelongsTo<SupplierEvaluation, $this> */
public function evaluation(): BelongsTo
{
return $this->belongsTo(SupplierEvaluation::class, 'supplier_evaluation_id');
}
protected static function booted(): void
{
static::updating(function (self $record) {
+3 -1
View File
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Fortify\TwoFactorAuthenticatable;
/**
* @property int $id
@@ -44,7 +45,7 @@ class User extends Authenticatable
}
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* Get the attributes that should be cast.
@@ -56,6 +57,7 @@ class User extends Authenticatable
return [
'purchasing_role' => PurchasingRole::class,
'email_verified_at' => 'datetime',
'two_factor_confirmed_at' => 'datetime',
'password' => 'hashed',
];
}
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Policies;
use App\Enums\PurchasingRole;
use App\Models\Supplier;
use App\Models\User;
use App\Services\SupplierParticipation;
class SupplierPolicy
{
@@ -20,7 +21,7 @@ class SupplierPolicy
public function update(User $user, Supplier $supplier): bool
{
return $user->hasFunctions([PurchasingRole::Procurement]) && $user->tenant_id === $supplier->tenant_id;
return app(SupplierParticipation::class)->canPrepare($user) && $user->tenant_id === $supplier->tenant_id;
}
public function viewDocuments(User $user, Supplier $supplier): bool
+1
View File
@@ -19,6 +19,7 @@ class AppServiceProvider extends ServiceProvider
require_once base_path('packages/purchasing/bootstrap.php');
$package = config('common-budget.path');
require_once $package.'/bootstrap.php';
\App\Services\FinanceBundle::configure();
$payables = config('common-payables.path');
require_once $payables.'/bootstrap.php';
}
+18
View File
@@ -68,6 +68,22 @@ class FortifyServiceProvider extends ServiceProvider
'passwordRules' => Password::defaults()->toPasswordRulesString(),
]));
Fortify::twoFactorChallengeView(function () {
Inertia::encryptHistory();
$response = Inertia::render('auth/two-factor-challenge')->toResponse(request());
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
Fortify::confirmPasswordView(function () {
Inertia::encryptHistory();
$response = Inertia::render('auth/confirm-password')->toResponse(request());
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
/**
@@ -82,5 +98,7 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by((string) $request->session()->get('login.id').'|'.$request->ip()));
}
}
+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;