feat: implement comprehensive modules for daily reports, project management, material logistics, and approval workflows

This commit is contained in:
Ajjj
2026-08-26 15:48:41 +08:00
parent d4e2b468ff
commit d6efc46624
52 changed files with 5275 additions and 932 deletions

View File

@@ -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.