550 lines
24 KiB
PHP
550 lines
24 KiB
PHP
<?php
|
|
|
|
namespace Modules\MaterialLogistics\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use Barryvdh\DomPDF\Facade\Pdf;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Inertia;
|
|
use Modules\ApprovalWorkflow\Services\ApprovalService;
|
|
use Modules\MasterData\Models\Material;
|
|
use Modules\MaterialLogistics\Models\MaterialRequisition;
|
|
use Modules\MasterData\Models\MaterialGroup;
|
|
use Modules\MaterialLogistics\Services\DocumentNumberService;
|
|
use Modules\ProjectManagement\Models\Project;
|
|
use Modules\ProjectManagement\Models\ProjectMaterialsEstimate;
|
|
use Modules\ProjectManagement\Services\ProjectWorkflowService;
|
|
|
|
class MaterialRequisitionController extends Controller
|
|
{
|
|
public function index(Request $request)
|
|
{
|
|
$query = MaterialRequisition::with([
|
|
// The requisition is already tenant/project-scoped. Load the
|
|
// display names without User's tenant scope so a valid requester
|
|
// from the assigned project is not serialized as null.
|
|
'requester' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'),
|
|
'approver' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'),
|
|
'project:id,ulid,name,code',
|
|
'items:id,material_requisition_id,quantity,unit_cost'
|
|
]);
|
|
|
|
// Requisitions inherit visibility from their projects.
|
|
$query->whereIn('project_id', $this->availableProjectsQuery()->select('projects.id'));
|
|
|
|
if ($request->filled('project_ulid')) {
|
|
$project = Project::where('ulid', $request->project_ulid)->first();
|
|
if ($project) {
|
|
$query->where('project_id', $project->id);
|
|
}
|
|
}
|
|
|
|
$requisitions = $query->latest()->paginate(20);
|
|
|
|
return Inertia::render('MaterialLogistics::Requisitions/Index', [
|
|
'requisitions' => $requisitions,
|
|
'projects' => Project::active()->where('current_wizard_step', '>=', 8)->select('id', 'ulid', 'name', 'code', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')->get(),
|
|
]);
|
|
}
|
|
|
|
public function create(Request $request)
|
|
{
|
|
$selectedProject = null;
|
|
$prefilledItems = [];
|
|
$hasRemainingEstimates = false;
|
|
|
|
if ($request->filled('project_ulid')) {
|
|
$selectedProject = $this->availableProjectsQuery()
|
|
->where('ulid', $request->project_ulid)
|
|
->with('materialsEstimates.material:id,ulid,name,unit,unit_cost')
|
|
->firstOrFail();
|
|
|
|
$workflowService = new ProjectWorkflowService();
|
|
if (!$workflowService->canCreateRequisition($selectedProject)) {
|
|
return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state or task resources are missing.');
|
|
}
|
|
|
|
// Calculate already requested quantities in non-rejected/non-cancelled MRs
|
|
$alreadyReqMap = \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) use ($selectedProject) {
|
|
$q->where('project_id', $selectedProject->id)
|
|
->whereNotIn('status', ['rejected', 'cancelled']);
|
|
})
|
|
->where('is_unestimated', false)
|
|
->select('material_id', DB::raw('SUM(quantity) as total_qty'))
|
|
->groupBy('material_id')
|
|
->pluck('total_qty', 'material_id')
|
|
->toArray();
|
|
|
|
// Auto-populate from project materials estimation where remaining > 0
|
|
foreach ($selectedProject->materialsEstimates as $estimate) {
|
|
$alreadyReq = (float) ($alreadyReqMap[$estimate->material_id] ?? 0);
|
|
$remaining = max(0, (float) $estimate->estimated_qty - $alreadyReq);
|
|
|
|
if ($remaining > 0) {
|
|
$hasRemainingEstimates = true;
|
|
$prefilledItems[] = [
|
|
'material_ulid' => $estimate->material->ulid,
|
|
'material_name' => $estimate->material->name,
|
|
'unit' => $estimate->material->unit,
|
|
'quantity' => $remaining,
|
|
'unit_cost' => (float) $estimate->unit_cost,
|
|
'estimated_qty' => (float) $estimate->estimated_qty,
|
|
'remaining_qty' => $remaining,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost', 'sku', 'category', 'status')
|
|
->where('status', 'active')
|
|
->orderBy('category')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$materialGroups = Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost')
|
|
->whereIn('type', ['kit', 'assembly'])
|
|
->where('status', 'active')
|
|
->orderBy('name')
|
|
->get()
|
|
->map(function ($kit) {
|
|
return [
|
|
'id' => $kit->id,
|
|
'ulid' => $kit->ulid,
|
|
'name' => $kit->name,
|
|
'description' => $kit->description ?? '',
|
|
'status' => $kit->status,
|
|
'materials' => $kit->components->map(function ($comp) {
|
|
return array_merge($comp->component->toArray(), [
|
|
'pivot' => ['quantity' => $comp->quantity]
|
|
]);
|
|
})->toArray()
|
|
];
|
|
});
|
|
|
|
$projects = $this->availableProjectsQuery()
|
|
->active()
|
|
->where('current_wizard_step', '>=', 8)
|
|
->with(['contractor:id,company_name', 'materialsEstimates.material:id,ulid,name,unit,unit_cost'])
|
|
->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')
|
|
->get()
|
|
->map(function ($proj) {
|
|
$alreadyReqMap = \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) use ($proj) {
|
|
$q->where('project_id', $proj->id)
|
|
->whereNotIn('status', ['rejected', 'cancelled']);
|
|
})
|
|
->where('is_unestimated', false)
|
|
->select('material_id', DB::raw('SUM(quantity) as total_qty'))
|
|
->groupBy('material_id')
|
|
->pluck('total_qty', 'material_id')
|
|
->toArray();
|
|
|
|
$estimates = [];
|
|
$hasRemaining = false;
|
|
foreach ($proj->materialsEstimates as $est) {
|
|
$alreadyReq = (float) ($alreadyReqMap[$est->material_id] ?? 0);
|
|
$rem = max(0, (float) $est->estimated_qty - $alreadyReq);
|
|
if ($rem > 0) {
|
|
$hasRemaining = true;
|
|
$estimates[] = [
|
|
'material_ulid' => $est->material->ulid,
|
|
'material_name' => $est->material->name,
|
|
'unit' => $est->material->unit,
|
|
'quantity' => $rem,
|
|
'unit_cost' => (float) $est->unit_cost,
|
|
'estimated_qty' => (float) $est->estimated_qty,
|
|
'remaining_qty' => $rem,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'id' => $proj->id,
|
|
'ulid' => $proj->ulid,
|
|
'name' => $proj->name,
|
|
'code' => $proj->code,
|
|
'client_name' => $proj->client_name,
|
|
'location' => $proj->location,
|
|
'status' => $proj->status,
|
|
'start_date' => $proj->start_date,
|
|
'target_end_date' => $proj->target_end_date,
|
|
'contract_value' => $proj->contract_value,
|
|
'contractor' => $proj->contractor,
|
|
'has_remaining_estimates' => $hasRemaining,
|
|
'remaining_estimates' => $estimates,
|
|
];
|
|
});
|
|
|
|
return Inertia::render('MaterialLogistics::Requisitions/Form', [
|
|
'materials' => $materials,
|
|
'materialGroups' => $materialGroups,
|
|
'projects' => $projects,
|
|
'selectedProject' => $selectedProject,
|
|
'prefilledItems' => $prefilledItems,
|
|
'hasRemainingEstimates' => $hasRemainingEstimates,
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'project_ulid' => 'required|string|exists:projects,ulid',
|
|
'requisition_type' => 'nullable|string|in:estimated,unestimated',
|
|
'notes' => 'nullable|string|max:1000',
|
|
'items' => 'required|array|min:1',
|
|
'items.*.material_ulid' => 'required|string',
|
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
|
'items.*.unit_cost' => 'required|numeric|min:0',
|
|
'items.*.is_unestimated' => 'nullable|boolean',
|
|
]);
|
|
|
|
$project = $this->availableProjectsQuery()
|
|
->where('ulid', $validated['project_ulid'])
|
|
->firstOrFail();
|
|
if ($project->current_wizard_step < 7) {
|
|
return redirect()->back()->with('error', 'Cannot create material requisition: Project estimation is not submitted or approved.');
|
|
}
|
|
|
|
$workflowService = new ProjectWorkflowService();
|
|
if (!$workflowService->canCreateRequisition($project)) {
|
|
return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state.');
|
|
}
|
|
|
|
$docService = new DocumentNumberService();
|
|
$requisitionType = $validated['requisition_type'] ?? 'estimated';
|
|
|
|
$mr = DB::transaction(function () use ($validated, $project, $docService, $requisitionType) {
|
|
$mr = MaterialRequisition::create([
|
|
'project_id' => $project->id,
|
|
'document_number' => $docService->nextMrNumber(),
|
|
'requisition_type' => $requisitionType,
|
|
'status' => 'draft',
|
|
'requested_by' => auth()->id(),
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
|
|
foreach ($validated['items'] as $item) {
|
|
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
|
$isUnestimated = isset($item['is_unestimated'])
|
|
? (bool) $item['is_unestimated']
|
|
: ($requisitionType === 'unestimated');
|
|
|
|
$mr->items()->create([
|
|
'material_id' => $material->id,
|
|
'quantity' => $item['quantity'],
|
|
'unit_cost' => $item['unit_cost'],
|
|
'is_unestimated' => $isUnestimated,
|
|
]);
|
|
|
|
// Ensure the project material estimates registry reflects this requested material
|
|
ProjectMaterialsEstimate::firstOrCreate(
|
|
[
|
|
'project_id' => $project->id,
|
|
'material_id' => $material->id,
|
|
],
|
|
[
|
|
'estimated_qty' => 0,
|
|
'unit_cost' => $item['unit_cost'],
|
|
]
|
|
);
|
|
}
|
|
|
|
// Automatically recalculate project capitalization so unestimated materials are added to the project cap
|
|
$project->recalculateCapitalization();
|
|
|
|
return $mr;
|
|
});
|
|
|
|
return redirect()->route('requisitions.show', $mr)->with('success', 'Material Requisition created.');
|
|
}
|
|
|
|
public function edit(MaterialRequisition $requisition)
|
|
{
|
|
if ($requisition->status !== 'draft') {
|
|
return redirect()->back()->with('error', 'Only draft requisitions can be edited.');
|
|
}
|
|
|
|
$requisition->load(['project', 'items.material']);
|
|
|
|
$materials = Material::select('id', 'ulid', 'name', 'sku', 'category', 'unit', 'unit_cost', 'status')
|
|
->where('status', 'active')
|
|
->orderBy('category')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
$materialGroups = Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost')
|
|
->whereIn('type', ['kit', 'assembly'])
|
|
->where('status', 'active')
|
|
->orderBy('name')
|
|
->get()
|
|
->map(function ($kit) {
|
|
return [
|
|
'id' => $kit->id,
|
|
'ulid' => $kit->ulid,
|
|
'name' => $kit->name,
|
|
'description' => $kit->description ?? '',
|
|
'status' => $kit->status,
|
|
'materials' => $kit->components->map(function ($comp) {
|
|
return array_merge($comp->component->toArray(), [
|
|
'pivot' => ['quantity' => $comp->quantity]
|
|
]);
|
|
})->toArray()
|
|
];
|
|
});
|
|
|
|
$projects = $this->availableProjectsQuery()
|
|
->active()
|
|
->where('current_wizard_step', '>=', 8)
|
|
->with(['contractor:id,company_name', 'materialsEstimates.material:id,ulid,name,unit,unit_cost'])
|
|
->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')
|
|
->get()
|
|
->map(function ($proj) {
|
|
$alreadyReqMap = \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) use ($proj) {
|
|
$q->where('project_id', $proj->id)
|
|
->whereNotIn('status', ['rejected', 'cancelled']);
|
|
})
|
|
->where('is_unestimated', false)
|
|
->select('material_id', DB::raw('SUM(quantity) as total_qty'))
|
|
->groupBy('material_id')
|
|
->pluck('total_qty', 'material_id')
|
|
->toArray();
|
|
|
|
$estimates = [];
|
|
$hasRemaining = false;
|
|
foreach ($proj->materialsEstimates as $est) {
|
|
$alreadyReq = (float) ($alreadyReqMap[$est->material_id] ?? 0);
|
|
$rem = max(0, (float) $est->estimated_qty - $alreadyReq);
|
|
if ($rem > 0) {
|
|
$hasRemaining = true;
|
|
$estimates[] = [
|
|
'material_ulid' => $est->material->ulid,
|
|
'material_name' => $est->material->name,
|
|
'unit' => $est->material->unit,
|
|
'quantity' => $rem,
|
|
'unit_cost' => (float) $est->unit_cost,
|
|
'estimated_qty' => (float) $est->estimated_qty,
|
|
'remaining_qty' => $rem,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'id' => $proj->id,
|
|
'ulid' => $proj->ulid,
|
|
'name' => $proj->name,
|
|
'code' => $proj->code,
|
|
'client_name' => $proj->client_name,
|
|
'location' => $proj->location,
|
|
'status' => $proj->status,
|
|
'start_date' => $proj->start_date,
|
|
'target_end_date' => $proj->target_end_date,
|
|
'contract_value' => $proj->contract_value,
|
|
'contractor' => $proj->contractor,
|
|
'has_remaining_estimates' => $hasRemaining,
|
|
'remaining_estimates' => $estimates,
|
|
];
|
|
});
|
|
|
|
return Inertia::render('MaterialLogistics::Requisitions/Form', [
|
|
'requisition' => $requisition,
|
|
'materials' => $materials,
|
|
'materialGroups' => $materialGroups,
|
|
'projects' => $projects,
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, MaterialRequisition $requisition)
|
|
{
|
|
if ($requisition->status !== 'draft') {
|
|
return redirect()->back()->with('error', 'Only draft requisitions can be updated.');
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'project_ulid' => 'required|string|exists:projects,ulid',
|
|
'requisition_type' => 'nullable|string|in:estimated,unestimated',
|
|
'notes' => 'nullable|string|max:1000',
|
|
'items' => 'required|array|min:1',
|
|
'items.*.material_ulid' => 'required|string',
|
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
|
'items.*.unit_cost' => 'required|numeric|min:0',
|
|
'items.*.is_unestimated' => 'nullable|boolean',
|
|
]);
|
|
|
|
$project = $this->availableProjectsQuery()
|
|
->where('ulid', $validated['project_ulid'])
|
|
->firstOrFail();
|
|
if ($project->current_wizard_step < 8) {
|
|
return redirect()->back()->with('error', 'Cannot update material requisition: Project estimation is not approved.');
|
|
}
|
|
|
|
$workflowService = new ProjectWorkflowService();
|
|
if (!$workflowService->canCreateRequisition($project)) {
|
|
return redirect()->back()->with('error', 'Cannot update material requisition: Project is not in scheduled/procuring state.');
|
|
}
|
|
|
|
$requisitionType = $validated['requisition_type'] ?? ($requisition->requisition_type ?? 'estimated');
|
|
|
|
DB::transaction(function () use ($validated, $project, $requisition, $requisitionType) {
|
|
$requisition->update([
|
|
'project_id' => $project->id,
|
|
'requisition_type' => $requisitionType,
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
|
|
$requisition->items()->delete(); // remove old items
|
|
|
|
foreach ($validated['items'] as $item) {
|
|
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
|
$isUnestimated = isset($item['is_unestimated'])
|
|
? (bool) $item['is_unestimated']
|
|
: ($requisitionType === 'unestimated');
|
|
|
|
$requisition->items()->create([
|
|
'material_id' => $material->id,
|
|
'quantity' => $item['quantity'],
|
|
'unit_cost' => $item['unit_cost'],
|
|
'is_unestimated' => $isUnestimated,
|
|
]);
|
|
|
|
// Ensure the project material estimates registry reflects this requested/supplemental material
|
|
ProjectMaterialsEstimate::firstOrCreate(
|
|
[
|
|
'project_id' => $project->id,
|
|
'material_id' => $material->id,
|
|
],
|
|
[
|
|
'estimated_qty' => 0,
|
|
'unit_cost' => $item['unit_cost'],
|
|
]
|
|
);
|
|
}
|
|
|
|
// Recalculate project capitalization
|
|
$project->recalculateCapitalization();
|
|
});
|
|
|
|
return redirect()->route('requisitions.show', $requisition)->with('success', 'Material Requisition updated.');
|
|
}
|
|
|
|
public function show(MaterialRequisition $requisition)
|
|
{
|
|
$requisition->load([
|
|
'requester:id,ulid,name',
|
|
'approver:id,ulid,name',
|
|
'items.material' => function ($query) {
|
|
$query->select('id', 'ulid', 'name', 'unit', 'unit_cost', 'type')
|
|
->with('components.component:id,name,unit');
|
|
},
|
|
'approvalChains.steps.approver:id,ulid,name',
|
|
]);
|
|
|
|
return Inertia::render('MaterialLogistics::Requisitions/Show', [
|
|
'requisition' => $requisition,
|
|
]);
|
|
}
|
|
|
|
public function submitForApproval(MaterialRequisition $requisition)
|
|
{
|
|
if ($requisition->status !== 'draft') {
|
|
return back()->with('error', 'Only draft requisitions can be submitted.');
|
|
}
|
|
|
|
// Material Requests may be approved by an explicit permission or by
|
|
// the system's Project Manager/executive roles. Query without the
|
|
// tenant scope so a site user can submit a request for an assigned
|
|
// project even when the approver belongs to another tenant context.
|
|
$approverIds = \App\Models\User::withoutGlobalScopes()
|
|
->where('user_type', '!=', 'contractor')
|
|
->whereDoesntHave('roles', function ($roleQuery) {
|
|
$roleQuery->whereIn('name', [
|
|
'Contractor Admin',
|
|
'Contractor',
|
|
'Subcontractor Staff',
|
|
'Site Technical',
|
|
'Construction Supervisor',
|
|
]);
|
|
})
|
|
->where(function ($query) {
|
|
$query->whereHas('roles', function ($roleQuery) {
|
|
$roleQuery->whereIn('name', [
|
|
'Project Manager',
|
|
'project_manager',
|
|
'Super Admin',
|
|
'admin',
|
|
]);
|
|
})
|
|
->orWhere('user_type', 'admin');
|
|
})
|
|
->where('status', 'active')
|
|
->pluck('id')
|
|
->values()
|
|
->toArray();
|
|
|
|
if (empty($approverIds)) {
|
|
return back()->with('error', 'No approvers found in the system for Material Requests.');
|
|
}
|
|
|
|
$approvalService = app(ApprovalService::class);
|
|
$approvalService->createChain(
|
|
$requisition,
|
|
$approverIds,
|
|
'material_requisition',
|
|
auth()->id(),
|
|
);
|
|
|
|
$requisition->update(['status' => 'submitted']);
|
|
|
|
return back()->with('success', 'Material Requisition submitted for approval.');
|
|
}
|
|
|
|
public function downloadPdf(MaterialRequisition $requisition)
|
|
{
|
|
$requisition->load([
|
|
'requester' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'name'),
|
|
'approver' => fn ($query) => $query->withoutGlobalScopes()->select('id', 'name'),
|
|
'items.material:id,name,unit',
|
|
'approvalChains.steps.approver:id,name',
|
|
]);
|
|
|
|
// Global approver roles
|
|
$approverDetails = [];
|
|
if ($requisition->approvalChains->isNotEmpty()) {
|
|
$chain = $requisition->approvalChains->sortByDesc('created_at')->first();
|
|
foreach ($chain->steps as $step) {
|
|
$stepStatus = $step->status instanceof \BackedEnum ? $step->status->value : $step->status;
|
|
$approver = $step->approver ?: ($step->approver_id
|
|
? \App\Models\User::withoutGlobalScopes()->find($step->approver_id)
|
|
: null);
|
|
if ($stepStatus === 'approved' && $approver) {
|
|
$approverDetails[] = [
|
|
'name' => $approver->name,
|
|
'role' => 'Approver',
|
|
'date' => $step->acted_at?->format('M d, Y'),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($approverDetails) && $requisition->status === 'approved' && $requisition->approver) {
|
|
$approverDetails[] = [
|
|
'name' => $requisition->approver->name,
|
|
'role' => 'Approver',
|
|
'date' => $requisition->approved_at ? $requisition->approved_at->format('M d, Y') : null,
|
|
];
|
|
}
|
|
|
|
$pdf = Pdf::loadView('materiallogistics::pdf.material-requisition', [
|
|
'requisition' => $requisition,
|
|
'approverDetails' => $approverDetails,
|
|
]);
|
|
|
|
return $pdf->download("MR-{$requisition->document_number}.pdf");
|
|
}
|
|
|
|
private function availableProjectsQuery()
|
|
{
|
|
// Project's TenantScope is the source of truth for project visibility.
|
|
return Project::query();
|
|
}
|
|
}
|