feat: implement comprehensive modules for daily reports, project management, material logistics, and approval workflows
This commit is contained in:
@@ -53,6 +53,7 @@ class MaterialRequisitionController extends Controller
|
||||
$selectedProject = null;
|
||||
$prefilledItems = [];
|
||||
$hasRemainingEstimates = false;
|
||||
$workflowService = new ProjectWorkflowService();
|
||||
|
||||
if ($request->filled('project_ulid')) {
|
||||
$selectedProject = $this->availableProjectsQuery()
|
||||
@@ -60,40 +61,12 @@ class MaterialRequisitionController extends Controller
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
$prefilledItems = $workflowService->getRemainingMaterialEstimates($selectedProject);
|
||||
$hasRemainingEstimates = !empty($prefilledItems);
|
||||
}
|
||||
|
||||
$materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost', 'sku', 'category', 'status')
|
||||
@@ -128,35 +101,8 @@ class MaterialRequisitionController extends Controller
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
->map(function ($proj) use ($workflowService) {
|
||||
$estimates = $workflowService->getRemainingMaterialEstimates($proj);
|
||||
|
||||
return [
|
||||
'id' => $proj->id,
|
||||
@@ -170,7 +116,7 @@ class MaterialRequisitionController extends Controller
|
||||
'target_end_date' => $proj->target_end_date,
|
||||
'contract_value' => $proj->contract_value,
|
||||
'contractor' => $proj->contractor,
|
||||
'has_remaining_estimates' => $hasRemaining,
|
||||
'has_remaining_estimates' => !empty($estimates),
|
||||
'remaining_estimates' => $estimates,
|
||||
];
|
||||
});
|
||||
@@ -190,6 +136,8 @@ class MaterialRequisitionController extends Controller
|
||||
$validated = $request->validate([
|
||||
'project_ulid' => 'required|string|exists:projects,ulid',
|
||||
'requisition_type' => 'nullable|string|in:estimated,unestimated',
|
||||
'needed_by_date' => 'required|date',
|
||||
'purpose' => 'required|string|min:3|max:1000',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
@@ -210,6 +158,11 @@ class MaterialRequisitionController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state.');
|
||||
}
|
||||
|
||||
$validationError = $workflowService->validateEstimatedQuantities($project, $validated['items']);
|
||||
if ($validationError) {
|
||||
return redirect()->back()->withInput()->with('error', $validationError);
|
||||
}
|
||||
|
||||
$docService = new DocumentNumberService();
|
||||
$requisitionType = $validated['requisition_type'] ?? 'estimated';
|
||||
|
||||
@@ -220,6 +173,8 @@ class MaterialRequisitionController extends Controller
|
||||
'requisition_type' => $requisitionType,
|
||||
'status' => 'draft',
|
||||
'requested_by' => auth()->id(),
|
||||
'needed_by_date' => $validated['needed_by_date'],
|
||||
'purpose' => $validated['purpose'],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
@@ -255,18 +210,19 @@ class MaterialRequisitionController extends Controller
|
||||
return $mr;
|
||||
});
|
||||
|
||||
return redirect()->route('requisitions.show', $mr)->with('success', 'Material Requisition created.');
|
||||
return redirect()->route('requisitions.show', $mr)->with('success', 'Material Requisition created as draft.');
|
||||
}
|
||||
|
||||
public function edit(MaterialRequisition $requisition)
|
||||
{
|
||||
if ($requisition->status !== 'draft') {
|
||||
return redirect()->back()->with('error', 'Only draft requisitions can be edited.');
|
||||
return redirect()->route('requisitions.show', $requisition)->with('error', 'Only draft requisitions can be edited.');
|
||||
}
|
||||
|
||||
$requisition->load(['project', 'items.material']);
|
||||
$requisition->load(['items.material', 'project']);
|
||||
$workflowService = new ProjectWorkflowService();
|
||||
|
||||
$materials = Material::select('id', 'ulid', 'name', 'sku', 'category', 'unit', 'unit_cost', 'status')
|
||||
$materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost', 'sku', 'category', 'status')
|
||||
->where('status', 'active')
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
@@ -298,35 +254,8 @@ class MaterialRequisitionController extends Controller
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
->map(function ($proj) use ($workflowService) {
|
||||
$estimates = $workflowService->getRemainingMaterialEstimates($proj);
|
||||
|
||||
return [
|
||||
'id' => $proj->id,
|
||||
@@ -340,28 +269,52 @@ class MaterialRequisitionController extends Controller
|
||||
'target_end_date' => $proj->target_end_date,
|
||||
'contract_value' => $proj->contract_value,
|
||||
'contractor' => $proj->contractor,
|
||||
'has_remaining_estimates' => $hasRemaining,
|
||||
'has_remaining_estimates' => !empty($estimates),
|
||||
'remaining_estimates' => $estimates,
|
||||
];
|
||||
});
|
||||
|
||||
$estimates = $workflowService->getRemainingMaterialEstimates($requisition->project);
|
||||
|
||||
return Inertia::render('MaterialLogistics::Requisitions/Form', [
|
||||
'requisition' => $requisition,
|
||||
'requisition' => [
|
||||
'id' => $requisition->id,
|
||||
'ulid' => $requisition->ulid,
|
||||
'document_number' => $requisition->document_number,
|
||||
'status' => $requisition->status,
|
||||
'requisition_type' => $requisition->requisition_type,
|
||||
'needed_by_date' => $requisition->needed_by_date?->format('Y-m-d'),
|
||||
'purpose' => $requisition->purpose,
|
||||
'notes' => $requisition->notes,
|
||||
'project_ulid' => $requisition->project->ulid,
|
||||
'project' => $requisition->project,
|
||||
'items' => $requisition->items->map(fn ($item) => [
|
||||
'material_ulid' => $item->material->ulid,
|
||||
'quantity' => (string) $item->quantity,
|
||||
'unit_cost' => (string) $item->unit_cost,
|
||||
'is_unestimated' => (bool) $item->is_unestimated,
|
||||
]),
|
||||
],
|
||||
'materials' => $materials,
|
||||
'materialGroups' => $materialGroups,
|
||||
'projects' => $projects,
|
||||
'selectedProject' => $requisition->project,
|
||||
'prefilledItems' => $estimates,
|
||||
'hasRemainingEstimates' => !empty($estimates),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, MaterialRequisition $requisition)
|
||||
{
|
||||
if ($requisition->status !== 'draft') {
|
||||
return redirect()->back()->with('error', 'Only draft requisitions can be updated.');
|
||||
return redirect()->route('requisitions.show', $requisition)->with('error', 'Only draft requisitions can be edited.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'project_ulid' => 'required|string|exists:projects,ulid',
|
||||
'requisition_type' => 'nullable|string|in:estimated,unestimated',
|
||||
'needed_by_date' => 'required|date',
|
||||
'purpose' => 'required|string|min:3|max:1000',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
@@ -382,12 +335,19 @@ class MaterialRequisitionController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot update material requisition: Project is not in scheduled/procuring state.');
|
||||
}
|
||||
|
||||
$validationError = $workflowService->validateEstimatedQuantities($project, $validated['items'], $requisition->id);
|
||||
if ($validationError) {
|
||||
return redirect()->back()->withInput()->with('error', $validationError);
|
||||
}
|
||||
|
||||
$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,
|
||||
'needed_by_date' => $validated['needed_by_date'],
|
||||
'purpose' => $validated['purpose'],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
@@ -429,6 +389,7 @@ class MaterialRequisitionController extends Controller
|
||||
public function show(MaterialRequisition $requisition)
|
||||
{
|
||||
$requisition->load([
|
||||
'project:id,ulid,name,code',
|
||||
'requester:id,ulid,name',
|
||||
'approver:id,ulid,name',
|
||||
'items.material' => function ($query) {
|
||||
@@ -438,6 +399,26 @@ class MaterialRequisitionController extends Controller
|
||||
'approvalChains.steps.approver:id,ulid,name',
|
||||
]);
|
||||
|
||||
if ($requisition->status === 'submitted' && $requisition->approvalChains->isEmpty()) {
|
||||
$approverIds = \App\Models\User::withoutGlobalScopes()
|
||||
->where('user_type', '!=', 'contractor')
|
||||
->where(function ($query) {
|
||||
$query->whereHas('roles', function ($roleQuery) {
|
||||
$roleQuery->whereIn('name', ['Project Manager', 'project_manager', 'Super Admin', 'admin', 'Main Contractor Admin']);
|
||||
})->orWhere('user_type', 'admin');
|
||||
})
|
||||
->where('status', 'active')
|
||||
->pluck('id')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (!empty($approverIds)) {
|
||||
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
|
||||
$approvalService->createChain($requisition, $approverIds, 'material_requisition', $requisition->requested_by);
|
||||
$requisition->load('approvalChains.steps.approver:id,ulid,name');
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('MaterialLogistics::Requisitions/Show', [
|
||||
'requisition' => $requisition,
|
||||
]);
|
||||
@@ -541,6 +522,62 @@ class MaterialRequisitionController extends Controller
|
||||
return $pdf->download("MR-{$requisition->document_number}.pdf");
|
||||
}
|
||||
|
||||
public function approve(Request $request, MaterialRequisition $requisition)
|
||||
{
|
||||
$user = $request->user();
|
||||
$isAdminOrPm = $user->user_type === 'admin'
|
||||
|| $user->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin'])
|
||||
|| $user->can('approve_mr');
|
||||
|
||||
if (!$isAdminOrPm) {
|
||||
return back()->with('error', 'Unauthorized to approve material requisitions.');
|
||||
}
|
||||
|
||||
$request->validate(['notes' => 'nullable|string|max:1000']);
|
||||
|
||||
$chain = $requisition->approvalChains()->whereIn('status', ['pending', 'in_review'])->first();
|
||||
if ($chain) {
|
||||
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
|
||||
$approvalService->approve($chain, $user, $request->notes);
|
||||
} else {
|
||||
$requisition->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => $user->id,
|
||||
'approved_at' => now(),
|
||||
'notes' => $request->notes ? ($requisition->notes ? $requisition->notes . "\n" . $request->notes : $request->notes) : $requisition->notes,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Material Requisition approved successfully.');
|
||||
}
|
||||
|
||||
public function reject(Request $request, MaterialRequisition $requisition)
|
||||
{
|
||||
$user = $request->user();
|
||||
$isAdminOrPm = $user->user_type === 'admin'
|
||||
|| $user->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin'])
|
||||
|| $user->can('approve_mr');
|
||||
|
||||
if (!$isAdminOrPm) {
|
||||
return back()->with('error', 'Unauthorized to reject material requisitions.');
|
||||
}
|
||||
|
||||
$request->validate(['notes' => 'required|string|max:1000']);
|
||||
|
||||
$chain = $requisition->approvalChains()->whereIn('status', ['pending', 'in_review'])->first();
|
||||
if ($chain) {
|
||||
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
|
||||
$approvalService->reject($chain, $user, $request->notes);
|
||||
} else {
|
||||
$requisition->update([
|
||||
'status' => 'rejected',
|
||||
'notes' => $requisition->notes ? $requisition->notes . "\n[Rejection Reason]: " . $request->notes : "[Rejection Reason]: " . $request->notes,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Material Requisition rejected.');
|
||||
}
|
||||
|
||||
private function availableProjectsQuery()
|
||||
{
|
||||
// Project's TenantScope is the source of truth for project visibility.
|
||||
|
||||
Reference in New Issue
Block a user