feat: complete GSB construction ERP implementation with dual-mode MR, capitalization cap engine, single PM rule, and system sign-off documentation
This commit is contained in:
@@ -13,6 +13,7 @@ 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
|
||||
@@ -51,6 +52,7 @@ class MaterialRequisitionController extends Controller
|
||||
{
|
||||
$selectedProject = null;
|
||||
$prefilledItems = [];
|
||||
$hasRemainingEstimates = false;
|
||||
|
||||
if ($request->filled('project_ulid')) {
|
||||
$selectedProject = $this->availableProjectsQuery()
|
||||
@@ -63,15 +65,34 @@ class MaterialRequisitionController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state or task resources are missing.');
|
||||
}
|
||||
|
||||
// Auto-populate from project materials estimation
|
||||
// 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) {
|
||||
$prefilledItems[] = [
|
||||
'material_ulid' => $estimate->material->ulid,
|
||||
'material_name' => $estimate->material->name,
|
||||
'unit' => $estimate->material->unit,
|
||||
'quantity' => (float) $estimate->estimated_qty,
|
||||
'unit_cost' => (float) $estimate->unit_cost,
|
||||
];
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,17 +122,66 @@ class MaterialRequisitionController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
$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' => $this->availableProjectsQuery()
|
||||
->active()
|
||||
->where('current_wizard_step', '>=', 8)
|
||||
->with('contractor:id,company_name')
|
||||
->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')
|
||||
->get(),
|
||||
'projects' => $projects,
|
||||
'selectedProject' => $selectedProject,
|
||||
'prefilledItems' => $prefilledItems,
|
||||
'hasRemainingEstimates' => $hasRemainingEstimates,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -119,11 +189,13 @@ class MaterialRequisitionController extends Controller
|
||||
{
|
||||
$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()
|
||||
@@ -138,21 +210,14 @@ class MaterialRequisitionController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot create material requisition: Project is not in scheduled/procuring state.');
|
||||
}
|
||||
|
||||
// Validate that all items are in the project's materials estimates
|
||||
$estimatedMaterialIds = $project->materialsEstimates()->pluck('material_id')->toArray();
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
if (!in_array($material->id, $estimatedMaterialIds)) {
|
||||
return redirect()->back()->with('error', "Cannot create material requisition: Material '{$material->name}' is not in the project's approved estimation.");
|
||||
}
|
||||
}
|
||||
|
||||
$docService = new DocumentNumberService();
|
||||
$requisitionType = $validated['requisition_type'] ?? 'estimated';
|
||||
|
||||
$mr = DB::transaction(function () use ($validated, $project, $docService) {
|
||||
$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,
|
||||
@@ -160,13 +225,33 @@ class MaterialRequisitionController extends Controller
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -179,7 +264,7 @@ class MaterialRequisitionController extends Controller
|
||||
return redirect()->back()->with('error', 'Only draft requisitions can be edited.');
|
||||
}
|
||||
|
||||
$requisition->load('items.material:id,ulid,name,unit,unit_cost', 'project:id,ulid,name,code');
|
||||
$requisition->load(['project', 'items.material']);
|
||||
|
||||
$materials = Material::select('id', 'ulid', 'name', 'sku', 'category', 'unit', 'unit_cost', 'status')
|
||||
->where('status', 'active')
|
||||
@@ -207,15 +292,64 @@ class MaterialRequisitionController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
$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' => $this->availableProjectsQuery()
|
||||
->active()
|
||||
->where('current_wizard_step', '>=', 8)
|
||||
->select('id', 'ulid', 'name', 'code', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')
|
||||
->get(),
|
||||
'projects' => $projects,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -227,11 +361,13 @@ class MaterialRequisitionController extends Controller
|
||||
|
||||
$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()
|
||||
@@ -246,18 +382,12 @@ class MaterialRequisitionController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot update material requisition: Project is not in scheduled/procuring state.');
|
||||
}
|
||||
|
||||
// Validate that all items are in the project's materials estimates
|
||||
$estimatedMaterialIds = $project->materialsEstimates()->pluck('material_id')->toArray();
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
if (!in_array($material->id, $estimatedMaterialIds)) {
|
||||
return redirect()->back()->with('error', "Cannot update material requisition: Material '{$material->name}' is not in the project's approved estimation.");
|
||||
}
|
||||
}
|
||||
$requisitionType = $validated['requisition_type'] ?? ($requisition->requisition_type ?? 'estimated');
|
||||
|
||||
DB::transaction(function () use ($validated, $project, $requisition) {
|
||||
DB::transaction(function () use ($validated, $project, $requisition, $requisitionType) {
|
||||
$requisition->update([
|
||||
'project_id' => $project->id,
|
||||
'requisition_type' => $requisitionType,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
@@ -265,12 +395,32 @@ class MaterialRequisitionController extends Controller
|
||||
|
||||
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.');
|
||||
|
||||
Reference in New Issue
Block a user