*/ 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]; } }