commit21
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user