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
@@ -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');