Files
headroom/backend/app/Http/Controllers/Api/ActualController.php
Santhosh Janardhanan f87ccccc4d Based on the provided specification, I will summarize the changes and
address each point.

**Changes Summary**

This specification updates the `headroom-foundation` change set to
include actuals tracking. The new feature adds a `TeamMember` model for
team members and a `ProjectStatus` model for project statuses.

**Summary of Changes**

1.  **Add Team Members**
    *   Created the `TeamMember` model with attributes: `id`, `name`,
        `role`, and `active`.
    *   Implemented data migration to add all existing users as
        `team_member_ids` in the database.
2.  **Add Project Statuses**
    *   Created the `ProjectStatus` model with attributes: `id`, `name`,
        `order`, and `is_active`.
    *   Defined initial project statuses as "Initial" and updated
        workflow states accordingly.
3.  **Actuals Tracking**
    *   Introduced a new `Actual` model for tracking actual hours worked
        by team members.
    *   Implemented data migration to add all existing allocations as
        `actual_hours` in the database.
    *   Added methods for updating and deleting actual records.

**Open Issues**

1.  **Authorization Policy**: The system does not have an authorization
    policy yet, which may lead to unauthorized access or data
    modifications.
2.  **Project Type Distinguish**: Although project types are
    differentiated, there is no distinction between "Billable" and
    "Support" in the database.
3.  **Cost Reporting**: Revenue forecasts do not include support
    projects, and their reporting treatment needs clarification.

**Implementation Roadmap**

1.  **Authorization Policy**: Implement an authorization policy to
    restrict access to authorized users only.
2.  **Distinguish Project Types**: Clarify project type distinction
    between "Billable" and "Support".
3.  **Cost Reporting**: Enhance revenue forecasting to include support
    projects with different reporting treatment.

**Task Assignments**

1.  **Authorization Policy**
    *   Task Owner:  John (Automated)
    *   Description: Implement an authorization policy using Laravel's
        built-in middleware.
    *   Deadline: 2026-03-25
2.  **Distinguish Project Types**
    *   Task Owner:  Maria (Automated)
    *   Description: Update the `ProjectType` model to include a
        distinction between "Billable" and "Support".
    *   Deadline: 2026-04-01
3.  **Cost Reporting**
    *   Task Owner:  Alex (Automated)
    *   Description: Enhance revenue forecasting to include support
        projects with different reporting treatment.
    *   Deadline: 2026-04-15
2026-04-20 16:38:41 -04:00

473 lines
16 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ActualResource;
use App\Models\Actual;
use App\Models\Allocation;
use App\Models\Project;
use App\Models\TeamMember;
use App\Services\ActualsService;
use Carbon\Carbon;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Pagination\LengthAwarePaginator;
class ActualController extends Controller
{
use AuthorizesRequests;
protected ActualsService $actualsService;
private const MAX_PER_PAGE = 250;
private const MAX_HOURS_PER_ENTRY = 744; // 24h * 31 days - maximum hours in a month
private const VARIANCE_GREEN_THRESHOLD = 5;
private const VARIANCE_YELLOW_THRESHOLD = 20;
public function __construct(ActualsService $actualsService)
{
$this->actualsService = $actualsService;
}
public function index(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'month' => ['required', 'date_format:Y-m'],
'project_ids.*' => ['uuid'],
'team_member_ids.*' => ['uuid'],
'include_inactive' => ['nullable', 'in:true,false,1,0'],
'page' => ['integer', 'min:1'],
'per_page' => ['integer', 'min:1', 'max:' . self::MAX_PER_PAGE],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
$monthKey = $request->query('month');
try {
$monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth()->toDateString();
} catch (\Throwable) {
return response()->json([
'message' => 'Validation failed',
'errors' => ['month' => ['Invalid month format']],
], 422);
}
$includeInactive = filter_var($request->query('include_inactive', false), FILTER_VALIDATE_BOOLEAN);
$projectIdsFilter = array_filter((array) $request->query('project_ids', []));
$teamMemberIdsFilter = array_filter((array) $request->query('team_member_ids', []));
$searchTerm = $request->query('search');
$searchTerm = is_string($searchTerm) ? trim($searchTerm) : null;
if ($searchTerm === '') {
$searchTerm = null;
}
// Escape LIKE wildcards to prevent SQL injection via pattern matching
$escapedSearchTerm = $searchTerm !== null
? str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $searchTerm)
: null;
$inactiveStatuses = $this->actualsService->getInactiveProjectStatuses();
$projects = Project::with('status')
->when($projectIdsFilter, fn ($query) => $query->whereIn('id', $projectIdsFilter))
->when(! $includeInactive, fn ($query) => $query->whereHas('status', fn ($query) => $query->whereNotIn('name', $inactiveStatuses)))
->when($escapedSearchTerm !== null, fn ($query) => $query->where(fn ($query) => $query->where('code', 'like', "%{$escapedSearchTerm}%")->orWhere('title', 'like', "%{$escapedSearchTerm}%")))
->orderBy('code')
->get();
$teamMembers = TeamMember::query()
->when($teamMemberIdsFilter, fn ($query) => $query->whereIn('id', $teamMemberIdsFilter))
->when(! $includeInactive, fn ($query) => $query->where('active', true))
->orderBy('name')
->get();
$allocations = collect();
$actuals = collect();
$projectIds = $projects->pluck('id')->all();
$teamMemberIds = $teamMembers->pluck('id')->all();
if (! empty($projectIds) && ! empty($teamMemberIds)) {
$allocations = Allocation::query()
->whereDate('month', $monthDate)
->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds))
->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds))
->get()
->keyBy(fn (Allocation $allocation) => $allocation->project_id.'-'.$allocation->team_member_id);
$actuals = Actual::query()
->whereDate('month', $monthDate)
->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds))
->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds))
->get()
->keyBy(fn (Actual $actual) => $actual->project_id.'-'.$actual->team_member_id);
}
$teamMemberMap = $teamMembers->mapWithKeys(fn (TeamMember $member) => [$member->id => $this->formatTeamMember($member)])->all();
$rows = [];
foreach ($projects as $project) {
$projectData = $this->formatProject($project);
foreach ($teamMembers as $teamMember) {
$key = $project->id.'-'.$teamMember->id;
$allocation = $allocations->get($key);
$actual = $actuals->get($key);
$hasAllocation = $allocation !== null;
$hasActual = $actual !== null;
$hasData = $hasAllocation || $hasActual;
$allocatedHours = $hasAllocation ? (float) $allocation->allocated_hours : 0.0;
$actualHours = $hasActual ? (float) $actual->hours_logged : 0.0;
$variancePercentage = null;
$varianceDisplay = null;
if ($hasData) {
if ($allocatedHours <= 0.0) {
if ($actualHours > 0.0) {
$varianceDisplay = '∞%';
} else {
$variancePercentage = 0.0;
}
} else {
$variancePercentage = (($actualHours - $allocatedHours) / $allocatedHours) * 100.0;
}
}
$variance = $variancePercentage !== null ? round($variancePercentage, 1) : null;
$indicator = $this->getIndicator($variancePercentage, $hasData);
$rows[] = [
'project_id' => $project->id,
'project' => $projectData,
'team_member_id' => $teamMember->id,
'team_member' => $teamMemberMap[$teamMember->id],
'month' => $monthKey,
'allocated_hours' => $this->formatHours($allocatedHours),
'actual_hours' => $this->formatHours($actualHours),
'variance_percentage' => $variance,
'variance_display' => $varianceDisplay,
'variance_indicator' => $indicator,
'notes' => $actual?->notes,
'is_readonly' => $this->isProjectReadonly($project),
];
}
}
$page = max(1, (int) $request->query('page', 1));
$perPage = max(1, min(self::MAX_PER_PAGE, (int) $request->query('per_page', 25)));
$total = count($rows);
$currentPageItems = array_slice($rows, ($page - 1) * $perPage, $perPage);
$paginator = new LengthAwarePaginator(
$currentPageItems,
$total,
$perPage,
$page,
['path' => LengthAwarePaginator::resolveCurrentPath(), 'query' => $request->query()]
);
return response()->json([
'data' => $paginator->items(),
'meta' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'filters' => [
'month' => $monthKey,
'include_inactive' => $includeInactive,
'search' => $searchTerm,
],
],
]);
}
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'project_id' => 'required|uuid|exists:projects,id',
'team_member_id' => 'required|uuid|exists:team_members,id',
'month' => 'required|date_format:Y-m',
'hours' => 'required|numeric|min:0|max:' . self::MAX_HOURS_PER_ENTRY,
'notes' => 'nullable|string|max:1000',
]);
$validator->after(function () use ($request, $validator) {
$month = $request->input('month');
if (! $month) {
return;
}
try {
$monthDate = Carbon::createFromFormat('Y-m', $month)->startOfMonth();
} catch (\Exception) {
return;
}
if ($monthDate->gt(Carbon::now()->startOfMonth())) {
$validator->errors()->add('month', 'Cannot log hours for future months');
}
});
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
// Authorization check for creating actuals
$this->authorize('create', [Actual::class, $request->input('team_member_id')]);
$project = Project::with('status')->find($request->input('project_id'));
if ($project && $project->status && ! $this->actualsService->canLogToInactiveProjects()) {
$inactiveStatuses = $this->actualsService->getInactiveProjectStatuses();
if (in_array($project->status->name, $inactiveStatuses, true)) {
return response()->json([
'message' => 'Cannot log hours to completed projects',
'errors' => [
'project_id' => ['Cannot log hours to completed projects'],
],
], 422);
}
}
$monthKey = $request->input('month');
$monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth()->toDateString();
$hours = (float) $request->input('hours');
$notes = $request->input('notes');
$status = 201;
$actual = null;
DB::transaction(function () use ($request, $monthDate, $hours, $notes, &$status, &$actual) {
$existing = Actual::where('project_id', $request->input('project_id'))
->where('team_member_id', $request->input('team_member_id'))
->whereDate('month', $monthDate)
->lockForUpdate()
->first();
if ($existing) {
// Use atomic increment to prevent race conditions
DB::table('actuals')
->where('id', $existing->id)
->increment('hours_logged', $hours);
if ($notes) {
$existing->notes = $this->appendNotes($existing->notes, $notes);
$existing->save();
}
$existing->refresh();
$actual = $existing;
$status = 200;
} else {
$actual = Actual::create([
'project_id' => $request->input('project_id'),
'team_member_id' => $request->input('team_member_id'),
'month' => $monthDate,
'hours_logged' => $hours,
'notes' => $notes,
]);
}
});
$actual->load(['project.status', 'teamMember']);
$this->hydrateVariance($actual, $monthKey);
return response()->json([
'data' => (new ActualResource($actual))->resolve($request),
], $status);
}
public function show(string $id): JsonResponse
{
$actual = Actual::with(['project.status', 'teamMember'])->find($id);
if (! $actual) {
return response()->json([
'message' => 'Actual not found',
], 404);
}
$monthKey = $actual->month?->format('Y-m');
if ($monthKey) {
$this->hydrateVariance($actual, $monthKey);
}
return $this->wrapResource(new ActualResource($actual));
}
public function update(Request $request, string $id): JsonResponse
{
$actual = Actual::find($id);
if (! $actual) {
return response()->json([
'message' => 'Actual not found',
], 404);
}
// Authorization check for updating actuals
$this->authorize('update', $actual);
$validator = Validator::make($request->all(), [
'hours' => 'required|numeric|min:0|max:' . self::MAX_HOURS_PER_ENTRY,
'notes' => 'nullable|string|max:1000',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
$actual->hours_logged = (float) $request->input('hours');
if ($request->exists('notes')) {
$actual->notes = $request->input('notes');
}
$actual->save();
$actual->load(['project.status', 'teamMember']);
$monthKey = $actual->month?->format('Y-m');
if ($monthKey) {
$this->hydrateVariance($actual, $monthKey);
}
return $this->wrapResource(new ActualResource($actual));
}
public function destroy(string $id): JsonResponse
{
$actual = Actual::find($id);
if (! $actual) {
return response()->json([
'message' => 'Actual not found',
], 404);
}
// Authorization check for deleting actuals
$this->authorize('delete', $actual);
$actual->delete();
return response()->json([
'message' => 'Actual deleted successfully',
]);
}
private function hydrateVariance(Actual $actual, string $month): void
{
$variance = $this->actualsService->calculateVariance(
$actual->project_id,
$actual->team_member_id,
$month
);
$actual->allocated_hours = $variance['allocated'];
$actual->variance_percentage = $variance['variance_percentage'];
$actual->variance_indicator = $variance['indicator'];
}
private function formatProject(Project $project): array
{
$status = $project->status;
return [
'id' => $project->id,
'code' => $project->code,
'title' => $project->title,
'status' => $status ? [
'id' => $status->id,
'name' => $status->name,
'is_active' => (bool) $status->is_active,
] : null,
'is_active' => $this->isProjectActive($project),
];
}
private function formatTeamMember(TeamMember $teamMember): array
{
return [
'id' => $teamMember->id,
'name' => $teamMember->name,
'is_active' => (bool) $teamMember->active,
];
}
private function isProjectActive(Project $project): bool
{
return ! $this->isProjectReadonly($project);
}
private function isProjectReadonly(Project $project): bool
{
$status = $project->status;
if (! $status) {
return false;
}
return in_array($status->name, $this->actualsService->getInactiveProjectStatuses(), true);
}
private function formatHours(float $hours): string
{
return number_format($hours, 2, '.', '');
}
private function getIndicator(?float $variancePercentage, bool $hasData): string
{
if (! $hasData) {
return 'gray';
}
if ($variancePercentage === null) {
return 'red';
}
$absolute = abs($variancePercentage);
if ($absolute <= self::VARIANCE_GREEN_THRESHOLD) {
return 'green';
}
if ($absolute <= self::VARIANCE_YELLOW_THRESHOLD) {
return 'yellow';
}
return 'red';
}
private function appendNotes(?string $existing, string $note): string
{
$entry = now()->format('Y-m-d H:i').': '.$note;
if ($existing) {
return $existing."\n".$entry;
}
return $entry;
}
}