feat(project): Complete Project Lifecycle capability with full TDD workflow
- Implement ProjectController with CRUD, status transitions, estimate/forecast - Add ProjectService with state machine validation - Extract ProjectStatusService for reusable state machine logic - Add ProjectPolicy for role-based authorization - Create ProjectSeeder with test data - Implement frontend project management UI with modal forms - Add projectService API client - Complete all 9 incomplete unit tests (ProjectModelTest, ProjectForecastTest, ProjectPolicyTest) - Fix E2E test timing issues with loading state waits - Add Scribe API documentation annotations - Improve forecasted effort validation messages with detailed feedback Test Results: - Backend: 49 passed (182 assertions) - Frontend Unit: 32 passed - E2E: 134 passed (Chromium + Firefox) Phase 3 Refactor: - Extract ProjectStatusService for state machine - Optimize project list query with status joins - Improve forecasted effort validation messages Phase 4 Document: - Add Scribe annotations to ProjectController - Generate API documentation
This commit is contained in:
390
backend/app/Http/Controllers/Api/ProjectController.php
Normal file
390
backend/app/Http/Controllers/Api/ProjectController.php
Normal file
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectStatus;
|
||||
use App\Models\ProjectType;
|
||||
use App\Services\ProjectService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* @group Projects
|
||||
*
|
||||
* Endpoints for managing projects.
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* Project Service instance
|
||||
*/
|
||||
protected ProjectService $projectService;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(ProjectService $projectService)
|
||||
{
|
||||
$this->projectService = $projectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all projects
|
||||
*
|
||||
* Get a list of all projects with optional filtering by status and type.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam status_id integer Filter by status ID. Example: 1
|
||||
* @queryParam type_id integer Filter by type ID. Example: 2
|
||||
*
|
||||
* @response 200 [
|
||||
* {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "code": "PROJ-001",
|
||||
* "title": "Client Dashboard Redesign",
|
||||
* "status_id": 1,
|
||||
* "status": {"id": 1, "name": "Pre-sales"},
|
||||
* "type_id": 2,
|
||||
* "type": {"id": 2, "name": "Support"},
|
||||
* "approved_estimate": "120.00",
|
||||
* "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
||||
* "created_at": "2024-01-15T10:00:00.000000Z",
|
||||
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$statusId = $request->query('status_id') ? (int) $request->query('status_id') : null;
|
||||
$typeId = $request->query('type_id') ? (int) $request->query('type_id') : null;
|
||||
|
||||
$projects = $this->projectService->getAll($statusId, $typeId);
|
||||
|
||||
return response()->json($projects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*
|
||||
* Create a new project with code, title, and type.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @bodyParam code string required Project code (must be unique). Example: PROJ-001
|
||||
* @bodyParam title string required Project title. Example: Client Dashboard Redesign
|
||||
* @bodyParam type_id integer required Project type ID. Example: 1
|
||||
*
|
||||
* @response 201 {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "code": "PROJ-001",
|
||||
* "title": "Client Dashboard Redesign",
|
||||
* "status_id": 1,
|
||||
* "status": {"id": 1, "name": "Pre-sales"},
|
||||
* "type_id": 1,
|
||||
* "type": {"id": 1, "name": "Project"}
|
||||
* }
|
||||
* @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$project = $this->projectService->create($request->all());
|
||||
|
||||
return response()->json($project, 201);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project
|
||||
*
|
||||
* Get details of a specific project by ID.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @response 200 {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "code": "PROJ-001",
|
||||
* "title": "Client Dashboard Redesign",
|
||||
* "status": {"id": 1, "name": "Pre-sales"},
|
||||
* "type": {"id": 1, "name": "Project"},
|
||||
* "approved_estimate": "120.00",
|
||||
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||
* }
|
||||
* @response 404 {"message":"Project not found"}
|
||||
*/
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$project = $this->projectService->findById($id);
|
||||
|
||||
if (! $project) {
|
||||
return response()->json([
|
||||
'message' => 'Project not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json($project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project
|
||||
*
|
||||
* Update details of an existing project.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @bodyParam code string Project code (must be unique). Example: PROJ-002
|
||||
* @bodyParam title string Project title. Example: Updated Title
|
||||
* @bodyParam type_id integer Project type ID. Example: 2
|
||||
*
|
||||
* @response 200 {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "code": "PROJ-002",
|
||||
* "title": "Updated Title",
|
||||
* "type_id": 2
|
||||
* }
|
||||
* @response 404 {"message":"Project not found"}
|
||||
* @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$project = Project::find($id);
|
||||
|
||||
if (! $project) {
|
||||
return response()->json([
|
||||
'message' => 'Project not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$project = $this->projectService->update($project, $request->only([
|
||||
'code', 'title', 'type_id',
|
||||
]));
|
||||
|
||||
return response()->json($project);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition project status
|
||||
*
|
||||
* Transition project to a new status following the state machine rules.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @bodyParam status_id integer required Target status ID. Example: 2
|
||||
*
|
||||
* @response 200 {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "status": {"id": 2, "name": "SOW Approval"}
|
||||
* }
|
||||
* @response 404 {"message":"Project not found"}
|
||||
* @response 422 {"message":"Cannot transition from Pre-sales to Done"}
|
||||
*/
|
||||
public function updateStatus(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$project = Project::with('status')->find($id);
|
||||
|
||||
if (! $project) {
|
||||
return response()->json([
|
||||
'message' => 'Project not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'status_id' => 'required|integer|exists:project_statuses,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$project = $this->projectService->transitionStatus(
|
||||
$project,
|
||||
(int) $request->input('status_id')
|
||||
);
|
||||
|
||||
return response()->json($project);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approved estimate
|
||||
*
|
||||
* Set the approved billable hours estimate for a project.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @bodyParam approved_estimate number required Approved estimate hours (must be > 0). Example: 120
|
||||
*
|
||||
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}
|
||||
* @response 404 {"message":"Project not found"}
|
||||
* @response 422 {"message":"Approved estimate must be greater than 0"}
|
||||
*/
|
||||
public function setEstimate(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$project = Project::find($id);
|
||||
|
||||
if (! $project) {
|
||||
return response()->json([
|
||||
'message' => 'Project not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'approved_estimate' => 'required|numeric',
|
||||
]);
|
||||
|
||||
try {
|
||||
$project = $this->projectService->setApprovedEstimate(
|
||||
$project,
|
||||
(float) $request->input('approved_estimate')
|
||||
);
|
||||
|
||||
return response()->json($project);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set forecasted effort
|
||||
*
|
||||
* Set the month-by-month forecasted effort breakdown.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @bodyParam forecasted_effort object required Monthly effort breakdown. Example: {"2024-02": 40, "2024-03": 60}
|
||||
*
|
||||
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}
|
||||
* @response 404 {"message":"Project not found"}
|
||||
* @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"}
|
||||
*/
|
||||
public function setForecast(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$project = Project::find($id);
|
||||
|
||||
if (! $project) {
|
||||
return response()->json([
|
||||
'message' => 'Project not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'forecasted_effort' => 'required|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$project = $this->projectService->setForecastedEffort(
|
||||
$project,
|
||||
$request->input('forecasted_effort')
|
||||
);
|
||||
|
||||
return response()->json($project);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all project types
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @response 200 [
|
||||
* {"id": 1, "name": "Project"},
|
||||
* {"id": 2, "name": "Support"},
|
||||
* {"id": 3, "name": "Engagement"}
|
||||
* ]
|
||||
*/
|
||||
public function types(): JsonResponse
|
||||
{
|
||||
$types = ProjectType::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return response()->json($types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all project statuses
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @response 200 [
|
||||
* {"id": 1, "name": "Pre-sales", "order": 1},
|
||||
* {"id": 2, "name": "SOW Approval", "order": 2},
|
||||
* {"id": 3, "name": "Gathering Estimates", "order": 3}
|
||||
* ]
|
||||
*/
|
||||
public function statuses(): JsonResponse
|
||||
{
|
||||
$statuses = ProjectStatus::orderBy('order')->get(['id', 'name', 'order']);
|
||||
|
||||
return response()->json($statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*
|
||||
* Delete a project. Cannot delete if project has allocations or actuals.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @response 200 {"message":"Project deleted successfully"}
|
||||
* @response 404 {"message":"Project not found"}
|
||||
* @response 422 {"message":"Cannot delete project with allocations"}
|
||||
*/
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
$project = Project::find($id);
|
||||
|
||||
if (! $project) {
|
||||
return response()->json([
|
||||
'message' => 'Project not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$canDelete = $this->projectService->canDelete($project);
|
||||
|
||||
if (! $canDelete['canDelete']) {
|
||||
return response()->json([
|
||||
'message' => "Cannot delete project with {$canDelete['reason']}",
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Project deleted successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
99
backend/app/Policies/ProjectPolicy.php
Normal file
99
backend/app/Policies/ProjectPolicy.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
|
||||
class ProjectPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
// All authenticated users can view projects
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Project $project): bool
|
||||
{
|
||||
// All authenticated users can view individual projects
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
// Only superusers and managers can create projects
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers and managers can update projects
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers and managers can delete projects
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can transition project status.
|
||||
*/
|
||||
public function updateStatus(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers and managers can transition status
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can set approved estimate.
|
||||
*/
|
||||
public function setEstimate(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers and managers can set estimates
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can set forecasted effort.
|
||||
*/
|
||||
public function setForecast(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers and managers can set forecasts
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers and managers can restore projects
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Project $project): bool
|
||||
{
|
||||
// Only superusers can force delete projects
|
||||
return $user->role === 'superuser';
|
||||
}
|
||||
}
|
||||
237
backend/app/Services/ProjectService.php
Normal file
237
backend/app/Services/ProjectService.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectStatus;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Project Service
|
||||
*
|
||||
* Handles business logic for project operations.
|
||||
*/
|
||||
class ProjectService
|
||||
{
|
||||
public function __construct(protected ProjectStatusService $statusService) {}
|
||||
|
||||
/**
|
||||
* Get all projects with optional filtering.
|
||||
*
|
||||
* @param int|null $statusId Filter by status ID
|
||||
* @param int|null $typeId Filter by type ID
|
||||
* @return Collection<Project>
|
||||
*/
|
||||
public function getAll(?int $statusId = null, ?int $typeId = null): Collection
|
||||
{
|
||||
$query = Project::with([
|
||||
'status:id,name,order',
|
||||
'type:id,name',
|
||||
])
|
||||
->select('projects.*')
|
||||
->leftJoin('project_statuses', 'projects.status_id', '=', 'project_statuses.id');
|
||||
|
||||
if ($statusId !== null) {
|
||||
$query->where('projects.status_id', $statusId);
|
||||
}
|
||||
|
||||
if ($typeId !== null) {
|
||||
$query->where('projects.type_id', $typeId);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project by ID.
|
||||
*/
|
||||
public function findById(string $id): ?Project
|
||||
{
|
||||
return Project::with(['status', 'type', 'allocations', 'actuals'])->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $data): Project
|
||||
{
|
||||
$validator = Validator::make($data, [
|
||||
'code' => 'required|string|max:50|unique:projects,code',
|
||||
'title' => 'required|string|max:255',
|
||||
'type_id' => 'required|integer|exists:project_types,id',
|
||||
'status_id' => 'sometimes|integer|exists:project_statuses,id',
|
||||
], [
|
||||
'code.unique' => 'Project code must be unique',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
// Default to first status (Pre-sales) if not provided
|
||||
if (! isset($data['status_id'])) {
|
||||
$initialStatus = ProjectStatus::orderBy('order')->first();
|
||||
$data['status_id'] = $initialStatus?->id;
|
||||
}
|
||||
|
||||
$project = Project::create($data);
|
||||
$project->load(['status', 'type']);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Project $project, array $data): Project
|
||||
{
|
||||
$validator = Validator::make($data, [
|
||||
'code' => 'sometimes|string|max:50|unique:projects,code,'.$project->id,
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'type_id' => 'sometimes|integer|exists:project_types,id',
|
||||
'status_id' => 'sometimes|integer|exists:project_statuses,id',
|
||||
], [
|
||||
'code.unique' => 'Project code must be unique',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
$project->update($data);
|
||||
$project->load(['status', 'type']);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition project to a new status.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function transitionStatus(Project $project, int $newStatusId): Project
|
||||
{
|
||||
$newStatus = ProjectStatus::find($newStatusId);
|
||||
|
||||
if (! $newStatus) {
|
||||
throw new \RuntimeException('Invalid status', 422);
|
||||
}
|
||||
|
||||
$currentStatusName = $project->status->name;
|
||||
$newStatusName = $newStatus->name;
|
||||
|
||||
// Check if transition is valid
|
||||
if (! $this->statusService->canTransition($currentStatusName, $newStatusName)) {
|
||||
throw new \RuntimeException(
|
||||
"Cannot transition from {$currentStatusName} to {$newStatusName}",
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
// Special validation: Estimate Approved requires approved_estimate > 0
|
||||
if ($this->statusService->requiresEstimate($newStatusName)) {
|
||||
if (! $project->approved_estimate || $project->approved_estimate <= 0) {
|
||||
throw new \RuntimeException(
|
||||
'Cannot transition to Estimate Approved without an approved estimate',
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$project->update(['status_id' => $newStatusId]);
|
||||
$project->load(['status', 'type']);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the approved estimate for a project.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function setApprovedEstimate(Project $project, float $estimate): Project
|
||||
{
|
||||
if ($estimate <= 0) {
|
||||
throw new \RuntimeException('Approved estimate must be greater than 0', 422);
|
||||
}
|
||||
|
||||
$project->update(['approved_estimate' => $estimate]);
|
||||
$project->load(['status', 'type']);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the forecasted effort for a project.
|
||||
*
|
||||
* @param array $forecastedEffort ['2024-01' => 40, '2024-02' => 60, ...]
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function setForecastedEffort(Project $project, array $forecastedEffort): Project
|
||||
{
|
||||
// Calculate total forecasted hours
|
||||
$totalForecasted = array_sum($forecastedEffort);
|
||||
|
||||
// If project has approved estimate, validate within tolerance
|
||||
if ($project->approved_estimate && $project->approved_estimate > 0) {
|
||||
$approved = (float) $project->approved_estimate;
|
||||
$difference = $totalForecasted - $approved;
|
||||
$percentageDiff = ($difference / $approved) * 100;
|
||||
$tolerancePercent = 5;
|
||||
|
||||
if (abs($percentageDiff) > $tolerancePercent) {
|
||||
$lowerBound = max(0, round($approved * (1 - $tolerancePercent / 100), 2));
|
||||
$upperBound = round($approved * (1 + $tolerancePercent / 100), 2);
|
||||
$message = sprintf(
|
||||
'Forecasted effort (%s h) %s approved estimate (%s h) by %s hours (%s%%). Forecasted effort must be between %s and %s hours for a %s hour estimate.',
|
||||
number_format($totalForecasted, 2, '.', ''),
|
||||
$difference > 0 ? 'exceeds' : 'is below',
|
||||
number_format($approved, 2, '.', ''),
|
||||
number_format(abs($difference), 2, '.', ''),
|
||||
number_format(abs($percentageDiff), 2, '.', ''),
|
||||
number_format($lowerBound, 2, '.', ''),
|
||||
number_format($upperBound, 2, '.', ''),
|
||||
number_format($approved, 2, '.', '')
|
||||
);
|
||||
|
||||
throw new \RuntimeException($message, 422);
|
||||
}
|
||||
}
|
||||
|
||||
$project->update(['forecasted_effort' => $forecastedEffort]);
|
||||
$project->load(['status', 'type']);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project can be deleted.
|
||||
*
|
||||
* @return array{canDelete: bool, reason?: string}
|
||||
*/
|
||||
public function canDelete(Project $project): array
|
||||
{
|
||||
if ($project->allocations()->exists()) {
|
||||
return [
|
||||
'canDelete' => false,
|
||||
'reason' => 'Project has allocations',
|
||||
];
|
||||
}
|
||||
|
||||
if ($project->actuals()->exists()) {
|
||||
return [
|
||||
'canDelete' => false,
|
||||
'reason' => 'Project has actuals',
|
||||
];
|
||||
}
|
||||
|
||||
return ['canDelete' => true];
|
||||
}
|
||||
}
|
||||
61
backend/app/Services/ProjectStatusService.php
Normal file
61
backend/app/Services/ProjectStatusService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Encapsulates the project lifecycle state machine.
|
||||
*/
|
||||
class ProjectStatusService
|
||||
{
|
||||
/**
|
||||
* Valid status transitions for the project state machine.
|
||||
* Key = from status, Value = array of valid target statuses
|
||||
*/
|
||||
protected array $statusTransitions = [
|
||||
'Pre-sales' => ['SOW Approval'],
|
||||
'SOW Approval' => ['Estimation', 'Pre-sales'],
|
||||
'Estimation' => ['Estimate Approved', 'SOW Approval'],
|
||||
'Estimate Approved' => ['Resource Allocation', 'Estimate Rework'],
|
||||
'Resource Allocation' => ['Sprint 0', 'Estimate Approved'],
|
||||
'Sprint 0' => ['In Progress', 'Resource Allocation'],
|
||||
'In Progress' => ['UAT', 'Sprint 0', 'On Hold'],
|
||||
'UAT' => ['Handover / Sign-off', 'In Progress', 'On Hold'],
|
||||
'Handover / Sign-off' => ['Closed', 'UAT'],
|
||||
'Estimate Rework' => ['Estimation'],
|
||||
'On Hold' => ['In Progress', 'Cancelled'],
|
||||
'Cancelled' => [],
|
||||
'Closed' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* Return the valid target statuses for the provided current status.
|
||||
*/
|
||||
public function getValidTransitions(string $currentStatus): array
|
||||
{
|
||||
return $this->statusTransitions[$currentStatus] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a transition from the current status to the target is allowed.
|
||||
*/
|
||||
public function canTransition(string $currentStatus, string $targetStatus): bool
|
||||
{
|
||||
return in_array($targetStatus, $this->getValidTransitions($currentStatus), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return statuses that do not allow further transitions.
|
||||
*/
|
||||
public function getTerminalStatuses(): array
|
||||
{
|
||||
return array_keys(array_filter($this->statusTransitions, static fn (array $targets): bool => $targets === []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a status requires an approved estimate before entering.
|
||||
*/
|
||||
public function requiresEstimate(string $statusName): bool
|
||||
{
|
||||
return $statusName === 'Estimate Approved';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user