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:
GSB Construction Engineering
2026-08-25 17:08:03 +08:00
parent 778f27e0a5
commit d4e2b468ff
114 changed files with 3923 additions and 10031 deletions

View File

@@ -13,6 +13,7 @@ use Modules\ProjectManagement\Enums\ProjectStatus;
use Modules\ProjectManagement\Enums\WeatherCondition;
use Modules\ProjectManagement\Events\ProjectStatusChanged;
use Modules\ProjectManagement\Models\Project;
use Modules\ProjectManagement\Models\ProjectClassification;
use Modules\ProjectManagement\Models\ProjectMilestone;
use Modules\ProjectManagement\Models\Task;
use Modules\Labors\Models\Labor;
@@ -79,21 +80,23 @@ class ProjectController extends Controller
{
abort_unless($this->canCreateProject(request()->user()), 403);
$employees = User::whereIn('user_type', ['admin', 'employee'])
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', 'profile_picture')
->get();
$projects = Project::active()->with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
$classifications = ProjectClassification::orderBy('id')->get(['id', 'code', 'name', 'description']);
return Inertia::render('ProjectManagement::Projects/Create', [
'employees' => $employees,
'projects' => $projects,
'classifications' => $classifications,
]);
}
@@ -127,6 +130,8 @@ class ProjectController extends Controller
'start_date' => 'nullable|date',
'target_end_date' => 'nullable|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
'classifications' => 'nullable|array',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
@@ -136,6 +141,7 @@ class ProjectController extends Controller
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['contract_value'] = $validated['contract_value'] ?? 0;
$validated['classifications'] = $validated['classifications'] ?? [];
$validated['current_wizard_step'] = 2;
$project = Project::create($validated);
@@ -145,6 +151,10 @@ class ProjectController extends Controller
$pmId = User::resolveUlidToId($request->pm_id);
if ($pmId) {
$project->personnel()->attach($pmId, ['role' => 'pm']);
$pmUser = User::find($pmId);
if ($pmUser && $pmUser->contractor_id) {
$project->contractors()->syncWithoutDetaching([$pmUser->contractor_id]);
}
}
}
@@ -200,9 +210,15 @@ class ProjectController extends Controller
{
$project->load(['personnel:id,name,email']);
$employees = User::whereIn('user_type', ['admin', 'employee'])
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', 'profile_picture')
->get();
return Inertia::render('ProjectManagement::Projects/Team', [
@@ -260,15 +276,15 @@ class ProjectController extends Controller
'materialsEstimates.material',
]);
$employees = User::whereIn('user_type', ['admin', 'employee'])
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', 'profile_picture')
->get();
$projects = Project::active()->with('parentProject:id,ulid,name,code')->where('id', '!=', $project->id)->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
@@ -304,6 +320,7 @@ class ProjectController extends Controller
'project' => $project,
'employees' => $employees,
'projects' => $projects,
'classifications' => ProjectClassification::orderBy('id')->get(['id', 'code', 'name', 'description']),
'materials' => $materials,
'materialGroups' => $materialGroups,
'labors' => $labors,
@@ -327,6 +344,8 @@ class ProjectController extends Controller
'start_date' => 'nullable|date',
'target_end_date' => 'nullable|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
'classifications' => 'nullable|array',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
@@ -337,6 +356,7 @@ class ProjectController extends Controller
$validated['parent_project_id'] = null;
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['classifications'] = $validated['classifications'] ?? [];
$project->update($validated);
@@ -379,8 +399,21 @@ class ProjectController extends Controller
return back()->with('error', 'User is already assigned to this project.');
}
// If role is PM, ensure single PM by demoting existing PMs to member
if ($request->role === 'pm') {
\DB::table('project_user')
->where('project_id', $project->id)
->where('role', 'pm')
->update(['role' => 'member']);
}
$project->personnel()->attach($userId, ['role' => $request->role]);
$user = User::find($userId);
if ($user && $user->contractor_id) {
$project->contractors()->syncWithoutDetaching([$user->contractor_id]);
}
return back()->with('success', 'Team member added.');
}
@@ -440,11 +473,11 @@ class ProjectController extends Controller
'personnel:id,ulid,name,email',
]);
$employees = User::whereIn('user_type', ['admin', 'employee'])
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with(['employeeProfile', 'roles'])
@@ -488,6 +521,7 @@ class ProjectController extends Controller
'step' => $step,
'employees' => $employees,
'projects' => $parentProjects,
'classifications' => ProjectClassification::orderBy('id')->get(['id', 'code', 'name', 'description']),
'materials' => $materials,
'materialGroups' => $materialGroups,
'labors' => $labors,
@@ -507,12 +541,15 @@ class ProjectController extends Controller
'name' => 'required|string|max:255',
'client_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'pm_id' => 'nullable|string',
'location' => 'nullable|string|max:255',
'contract_value' => 'nullable|numeric|min:0',
'contract_duration' => 'nullable|integer|min:0',
'start_date' => 'nullable|date',
'target_end_date' => 'nullable|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
'classifications' => 'nullable|array',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
@@ -523,6 +560,7 @@ class ProjectController extends Controller
$validated['parent_project_id'] = null;
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['classifications'] = $validated['classifications'] ?? [];
if ($project->current_wizard_step < 2) {
$validated['current_wizard_step'] = 2;
@@ -530,6 +568,25 @@ class ProjectController extends Controller
$project->update($validated);
if ($request->filled('pm_id')) {
$newPmId = User::resolveUlidToId($request->pm_id);
if ($newPmId) {
// Strictly single PM rule: demote any other existing PM
\DB::table('project_user')
->where('project_id', $project->id)
->where('role', 'pm')
->where('user_id', '!=', $newPmId)
->update(['role' => 'member']);
$project->personnel()->syncWithoutDetaching([$newPmId => ['role' => 'pm']]);
$pmUser = User::find($newPmId);
if ($pmUser && $pmUser->contractor_id) {
$project->contractors()->syncWithoutDetaching([$pmUser->contractor_id]);
}
}
}
return redirect()->route('projects.wizard', [$project, 'step' => 2])
->with('success', 'Project details saved.');
}
@@ -747,7 +804,7 @@ class ProjectController extends Controller
}
}
// Keep existing PM if present
// Keep existing single PM if present
$pm = $project->personnel()->wherePivot('role', 'pm')->first();
if ($pm) {
$userIds[$pm->id] = ['role' => 'pm'];
@@ -756,6 +813,17 @@ class ProjectController extends Controller
// Sync the project personnel
if (isset($validated['team_ulids']) || isset($validated['user_ulids'])) {
$project->personnel()->sync($userIds);
// Auto-link all contractors of assigned personnel to project_contractor
$contractorIds = User::whereIn('id', array_keys($userIds))
->whereNotNull('contractor_id')
->pluck('contractor_id')
->unique()
->toArray();
if (!empty($contractorIds)) {
$project->contractors()->syncWithoutDetaching($contractorIds);
}
}
if ($project->current_wizard_step < 5) {