171 lines
12 KiB
PHP
171 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\CostCenter;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\PurchaseOrderItem;
|
|
use App\Models\Requisition;
|
|
use App\Models\RequisitionItem;
|
|
use App\Models\StockItem;
|
|
use App\Models\StockOperation;
|
|
use App\Models\Supplier;
|
|
use App\Models\SupplyRecord;
|
|
use App\Models\User;
|
|
use App\Models\Warehouse;
|
|
use App\Services\InventoryReversals;
|
|
use App\Services\OrganizationAccess;
|
|
use App\Services\ReceiptWorkflow;
|
|
use App\Services\SupplyCoordination;
|
|
use App\Services\SupplyPlanning;
|
|
use App\Services\SupplySuggestions;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class SupplyPlanningController extends Controller
|
|
{
|
|
public function index(Request $request, SupplyPlanning $planning): Response
|
|
{
|
|
$actor = $request->user('web');
|
|
abort_unless($actor instanceof User, 403);
|
|
$view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements';
|
|
if ($view === 'history') {
|
|
return $this->history($actor, $planning);
|
|
}
|
|
if ($view === 'suggestions') {
|
|
return $this->suggestions($actor, $planning);
|
|
}
|
|
if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) {
|
|
return $this->coordination($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();
|
|
$projections = [];
|
|
if ($view === 'calendar' && $ready) {
|
|
foreach ($records->where('state', 'approved') as $record) {
|
|
$projections[$record->record_key] = $planning->calendar($record, today()->toDateString());
|
|
}
|
|
}
|
|
$prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare');
|
|
|
|
return Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, 'records' => $records, 'projections' => $projections,
|
|
'asOf' => today()->toDateString(),
|
|
'histories' => $ready && $prepare && $kind === 'policy' ? $planning->latest('history')->where('state', 'approved')->latest('id')->limit(100)->get()->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']) : [],
|
|
'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']) : [],
|
|
]);
|
|
}
|
|
|
|
private function history(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();
|
|
|
|
return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, '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
|
|
{
|
|
$prepare = $planning->allows($actor, 'supply_suggest_prepare');
|
|
$review = $planning->allows($actor, 'supply_suggest_review');
|
|
$convertPermission = $planning->allows($actor, 'supply_suggest_convert');
|
|
$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();
|
|
$policies = [];
|
|
if ($ready && $prepare) {
|
|
foreach ($planning->latest('policy')->where('state', 'approved')->latest('id')->limit(100)->get() 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());
|
|
$option += ['unit_price' => $basis['unit_price'], 'tax_rate' => $basis['tax_rate'], 'quantity_milli' => $basis['projection']['quantity_milli'] ?? 0,
|
|
'delivery_on' => $basis['projection']['risk_date'] ?? null, 'unit' => $basis['item']['unit']];
|
|
} catch (ValidationException $e) {
|
|
$option['issue'] = $e->getMessage();
|
|
}
|
|
$policies[] = $option;
|
|
}
|
|
}
|
|
$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,
|
|
'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)]),
|
|
'history' => $ready ? SupplyRecord::query()->where('kind', 'suggestion')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
|
|
]);
|
|
}
|
|
|
|
private function coordination(User $actor, string $view, SupplyPlanning $planning): Response
|
|
{
|
|
[$kind, $prepare, $review] = match ($view) {
|
|
'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'],
|
|
'reservations' => ['reservation', 'supply_reserve', 'supply_reserve'],
|
|
'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);
|
|
$ready = Schema::hasTable('supply_records');
|
|
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
|
|
$canPrepare = $planning->allows($actor, $prepare);
|
|
$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();
|
|
$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();
|
|
$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,
|
|
'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,
|
|
'canPrepare' => $canPrepare, 'canReview' => $planning->allows($actor, $review),
|
|
'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']) : [],
|
|
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
|
|
]);
|
|
}
|
|
|
|
public function save(Request $request, string $kind, SupplyPlanning $planning): RedirectResponse
|
|
{
|
|
$actor = $request->user('web');
|
|
abort_unless($actor instanceof User, 403);
|
|
$planning->save($actor, $kind, $request->all());
|
|
|
|
return back();
|
|
}
|
|
}
|