- 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
238 lines
7.3 KiB
PHP
238 lines
7.3 KiB
PHP
<?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];
|
|
}
|
|
}
|