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:
2026-02-19 02:43:05 -05:00
parent 8f70e81d29
commit 8ed56c9f7c
19 changed files with 5126 additions and 173 deletions

View 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];
}
}

View 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';
}
}