feat(backend): add reporting and variance calculation services
Implement core reporting infrastructure: - ReconciliationCalculator: plan vs estimate reconciliation - VarianceCalculator: project and member variance calculations - ReportController: GET /api/reports/allocations endpoint - Support did/is/will view type inference based on date ranges - Distinguish blank plans from explicit zero values Part of enhanced-allocation change.
This commit is contained in:
361
backend/app/Http/Controllers/Api/ReportController.php
Normal file
361
backend/app/Http/Controllers/Api/ReportController.php
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Allocation;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectMonthPlan;
|
||||||
|
use App\Models\TeamMember;
|
||||||
|
use App\Services\ReconciliationCalculator;
|
||||||
|
use App\Services\VarianceCalculator;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @group Reports
|
||||||
|
*
|
||||||
|
* Endpoints for generating management reports with did/is/will views.
|
||||||
|
*/
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ReconciliationCalculator $reconciliationCalculator,
|
||||||
|
protected VarianceCalculator $varianceCalculator
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get allocation report
|
||||||
|
*
|
||||||
|
* Returns aggregated allocation data with lifecycle totals, month plans,
|
||||||
|
* execution hours, and variances. View type (did/is/will) is inferred
|
||||||
|
* from the date range relative to current month.
|
||||||
|
*
|
||||||
|
* @authenticated
|
||||||
|
*
|
||||||
|
* @queryParam start_date string required Start date (YYYY-MM-DD). Example: 2026-01-01
|
||||||
|
* @queryParam end_date string required End date (YYYY-MM-DD). Example: 2026-03-31
|
||||||
|
* @queryParam project_ids array optional Filter by project IDs. Example: ["uuid1", "uuid2"]
|
||||||
|
* @queryParam member_ids array optional Filter by team member IDs. Example: ["uuid1", "uuid2"]
|
||||||
|
*
|
||||||
|
* @response 200 {
|
||||||
|
* "period": { "start": "2026-01-01", "end": "2026-03-31" },
|
||||||
|
* "view_type": "is",
|
||||||
|
* "projects": [...],
|
||||||
|
* "members": [...],
|
||||||
|
* "aggregates": {
|
||||||
|
* "total_planned": 7200,
|
||||||
|
* "total_allocated": 7100,
|
||||||
|
* "total_variance": -100,
|
||||||
|
* "status": "MATCH"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function allocations(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'start_date' => 'required|date_format:Y-m-d',
|
||||||
|
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||||
|
'project_ids' => 'nullable|array',
|
||||||
|
'project_ids.*' => 'uuid|exists:projects,id',
|
||||||
|
'member_ids' => 'nullable|array',
|
||||||
|
'member_ids.*' => 'uuid|exists:team_members,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = Carbon::parse($request->input('start_date'));
|
||||||
|
$endDate = Carbon::parse($request->input('end_date'));
|
||||||
|
$viewType = $this->determineViewType($startDate, $endDate);
|
||||||
|
|
||||||
|
// Get projects with optional filtering
|
||||||
|
$projectsQuery = Project::query();
|
||||||
|
if ($request->has('project_ids')) {
|
||||||
|
$projectsQuery->whereIn('id', $request->input('project_ids'));
|
||||||
|
}
|
||||||
|
$projects = $projectsQuery->get();
|
||||||
|
|
||||||
|
// Get team members with optional filtering
|
||||||
|
$membersQuery = TeamMember::query();
|
||||||
|
if ($request->has('member_ids')) {
|
||||||
|
$membersQuery->whereIn('id', $request->input('member_ids'));
|
||||||
|
}
|
||||||
|
$members = $membersQuery->get();
|
||||||
|
|
||||||
|
// Get all plans for the period
|
||||||
|
$plans = ProjectMonthPlan::whereBetween('month', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
|
||||||
|
->when($request->has('project_ids'), fn ($q) => $q->whereIn('project_id', $request->input('project_ids')))
|
||||||
|
->get()
|
||||||
|
->groupBy('project_id');
|
||||||
|
|
||||||
|
// Get all allocations for the period
|
||||||
|
$allocations = Allocation::whereBetween('month', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
|
||||||
|
->when($request->has('project_ids'), fn ($q) => $q->whereIn('project_id', $request->input('project_ids')))
|
||||||
|
->when($request->has('member_ids'), fn ($q) => $q->whereIn('team_member_id', $request->input('member_ids')))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Build project report data
|
||||||
|
$projectData = $this->buildProjectData($projects, $plans, $allocations, $startDate, $endDate);
|
||||||
|
|
||||||
|
// Build member report data
|
||||||
|
$memberData = $this->buildMemberData($members, $allocations, $startDate, $endDate);
|
||||||
|
|
||||||
|
// Calculate aggregates
|
||||||
|
$aggregates = $this->calculateAggregates($projectData);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'period' => [
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $endDate->format('Y-m-d'),
|
||||||
|
],
|
||||||
|
'view_type' => $viewType,
|
||||||
|
'projects' => $projectData,
|
||||||
|
'members' => $memberData,
|
||||||
|
'aggregates' => $aggregates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine view type based on date range relative to current month.
|
||||||
|
*/
|
||||||
|
private function determineViewType(Carbon $startDate, Carbon $endDate): string
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
$currentMonthStart = $now->copy()->startOfMonth();
|
||||||
|
$currentMonthEnd = $now->copy()->endOfMonth();
|
||||||
|
|
||||||
|
$rangeStart = $startDate->copy()->startOfMonth();
|
||||||
|
$rangeEnd = $endDate->copy()->endOfMonth();
|
||||||
|
|
||||||
|
// All dates are in the past -> 'did'
|
||||||
|
if ($rangeEnd->lt($currentMonthStart)) {
|
||||||
|
return 'did';
|
||||||
|
}
|
||||||
|
|
||||||
|
// All dates are in the future -> 'will'
|
||||||
|
if ($rangeStart->gt($currentMonthEnd)) {
|
||||||
|
return 'will';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Includes current month -> 'is'
|
||||||
|
return 'is';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build project report data with lifecycle totals and period execution.
|
||||||
|
*
|
||||||
|
* @return array<int, array{
|
||||||
|
* id: string,
|
||||||
|
* code: string,
|
||||||
|
* title: string,
|
||||||
|
* approved_estimate: float,
|
||||||
|
* lifecycle_status: string,
|
||||||
|
* plan_sum: float,
|
||||||
|
* period_planned: float,
|
||||||
|
* period_allocated: float,
|
||||||
|
* period_variance: float,
|
||||||
|
* period_status: string,
|
||||||
|
* months: array
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function buildProjectData($projects, $plans, $allocations, Carbon $startDate, Carbon $endDate): array
|
||||||
|
{
|
||||||
|
$projectData = [];
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$projectPlans = $plans->get($project->id, collect());
|
||||||
|
|
||||||
|
// Calculate lifecycle reconciliation (all plans for this project)
|
||||||
|
$allProjectPlans = ProjectMonthPlan::where('project_id', $project->id)->get();
|
||||||
|
$lifecycleStatus = $this->reconciliationCalculator->calculateStatus($project, $allProjectPlans);
|
||||||
|
$planSum = $this->reconciliationCalculator->calculatePlanSum($project, $allProjectPlans);
|
||||||
|
|
||||||
|
// Calculate period metrics (only within date range)
|
||||||
|
$periodPlans = $projectPlans->filter(fn ($p) =>
|
||||||
|
Carbon::parse($p->month)->between($startDate, $endDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
$periodPlanned = $periodPlans->sum('planned_hours');
|
||||||
|
|
||||||
|
// Get allocations for this project in the period
|
||||||
|
$projectAllocations = $allocations->where('project_id', $project->id);
|
||||||
|
$periodAllocated = $projectAllocations->sum('allocated_hours');
|
||||||
|
|
||||||
|
$periodVariance = $periodAllocated - $periodPlanned;
|
||||||
|
$periodStatus = $this->varianceCalculator->determineStatus($periodVariance);
|
||||||
|
|
||||||
|
// Build monthly breakdown
|
||||||
|
$months = $this->buildProjectMonthBreakdown($projectPlans, $projectAllocations, $startDate, $endDate);
|
||||||
|
|
||||||
|
$projectData[] = [
|
||||||
|
'id' => $project->id,
|
||||||
|
'code' => $project->code,
|
||||||
|
'title' => $project->title,
|
||||||
|
'approved_estimate' => (float) $project->approved_estimate,
|
||||||
|
'lifecycle_status' => $lifecycleStatus,
|
||||||
|
'plan_sum' => $planSum,
|
||||||
|
'period_planned' => $periodPlanned,
|
||||||
|
'period_allocated' => $periodAllocated,
|
||||||
|
'period_variance' => $periodVariance,
|
||||||
|
'period_status' => $periodStatus,
|
||||||
|
'months' => $months,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $projectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build monthly breakdown for a project.
|
||||||
|
*
|
||||||
|
* @return array<int, array{
|
||||||
|
* month: string,
|
||||||
|
* planned_hours: float|null,
|
||||||
|
* is_blank: bool,
|
||||||
|
* allocated_hours: float,
|
||||||
|
* variance: float,
|
||||||
|
* status: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function buildProjectMonthBreakdown($projectPlans, $projectAllocations, Carbon $startDate, Carbon $endDate): array
|
||||||
|
{
|
||||||
|
$months = [];
|
||||||
|
$current = $startDate->copy()->startOfMonth();
|
||||||
|
|
||||||
|
while ($current->lte($endDate)) {
|
||||||
|
$monthKey = $current->format('Y-m');
|
||||||
|
|
||||||
|
// Get plan for this month
|
||||||
|
$plan = $projectPlans->first(fn ($p) =>
|
||||||
|
Carbon::parse($p->month)->format('Y-m') === $monthKey
|
||||||
|
);
|
||||||
|
|
||||||
|
$plannedHours = $plan?->planned_hours;
|
||||||
|
$isBlank = $plannedHours === null;
|
||||||
|
|
||||||
|
// Get allocations for this month
|
||||||
|
$monthAllocations = $projectAllocations->filter(fn ($a) =>
|
||||||
|
Carbon::parse($a->month)->format('Y-m') === $monthKey
|
||||||
|
);
|
||||||
|
|
||||||
|
$allocatedHours = $monthAllocations->sum('allocated_hours');
|
||||||
|
$variance = $allocatedHours - ($plannedHours ?? 0);
|
||||||
|
$status = $this->varianceCalculator->determineStatus($variance);
|
||||||
|
|
||||||
|
$months[] = [
|
||||||
|
'month' => $monthKey,
|
||||||
|
'planned_hours' => $isBlank ? null : (float) $plannedHours,
|
||||||
|
'is_blank' => $isBlank,
|
||||||
|
'allocated_hours' => $allocatedHours,
|
||||||
|
'variance' => $variance,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
|
||||||
|
$current->addMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build member report data with capacity and utilization.
|
||||||
|
*
|
||||||
|
* @return array<int, array{
|
||||||
|
* id: string,
|
||||||
|
* name: string,
|
||||||
|
* period_allocated: float,
|
||||||
|
* period_untracked: float,
|
||||||
|
* total_hours: float,
|
||||||
|
* projects: array
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function buildMemberData($members, $allocations, Carbon $startDate, Carbon $endDate): array
|
||||||
|
{
|
||||||
|
$memberData = [];
|
||||||
|
|
||||||
|
foreach ($members as $member) {
|
||||||
|
// Get allocations for this member in the period (excluding untracked)
|
||||||
|
$memberAllocations = $allocations->filter(fn ($a) =>
|
||||||
|
$a->team_member_id === $member->id
|
||||||
|
);
|
||||||
|
|
||||||
|
$periodAllocated = $memberAllocations->sum('allocated_hours');
|
||||||
|
|
||||||
|
// Group by project
|
||||||
|
$projects = $memberAllocations
|
||||||
|
->groupBy('project_id')
|
||||||
|
->map(fn ($allocs, $projectId) => [
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'project_code' => $allocs->first()->project->code ?? null,
|
||||||
|
'project_title' => $allocs->first()->project->title ?? null,
|
||||||
|
'total_hours' => $allocs->sum('allocated_hours'),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$memberData[] = [
|
||||||
|
'id' => $member->id,
|
||||||
|
'name' => $member->name,
|
||||||
|
'period_allocated' => $periodAllocated,
|
||||||
|
'projects' => $projects,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add untracked row
|
||||||
|
$untrackedAllocations = $allocations->whereNull('team_member_id');
|
||||||
|
if ($untrackedAllocations->isNotEmpty()) {
|
||||||
|
$untrackedProjects = $untrackedAllocations
|
||||||
|
->groupBy('project_id')
|
||||||
|
->map(fn ($allocs, $projectId) => [
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'project_code' => $allocs->first()->project->code ?? null,
|
||||||
|
'project_title' => $allocs->first()->project->title ?? null,
|
||||||
|
'total_hours' => $allocs->sum('allocated_hours'),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$memberData[] = [
|
||||||
|
'id' => null,
|
||||||
|
'name' => 'Untracked',
|
||||||
|
'period_allocated' => $untrackedAllocations->sum('allocated_hours'),
|
||||||
|
'projects' => $untrackedProjects,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $memberData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate aggregate metrics across all projects.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* total_planned: float,
|
||||||
|
* total_allocated: float,
|
||||||
|
* total_variance: float,
|
||||||
|
* status: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function calculateAggregates(array $projectData): array
|
||||||
|
{
|
||||||
|
$totalPlanned = array_sum(array_column($projectData, 'period_planned'));
|
||||||
|
$totalAllocated = array_sum(array_column($projectData, 'period_allocated'));
|
||||||
|
$totalVariance = $totalAllocated - $totalPlanned;
|
||||||
|
$status = $this->varianceCalculator->determineStatus($totalVariance);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_planned' => $totalPlanned,
|
||||||
|
'total_allocated' => $totalAllocated,
|
||||||
|
'total_variance' => $totalVariance,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
103
backend/app/Services/ReconciliationCalculator.php
Normal file
103
backend/app/Services/ReconciliationCalculator.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectMonthPlan;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ReconciliationCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate reconciliation status for a single project.
|
||||||
|
* Returns OVER, UNDER, or MATCH based on plan_sum vs approved_estimate.
|
||||||
|
*/
|
||||||
|
public function calculateStatus(Project $project, ?Collection $plans = null): string
|
||||||
|
{
|
||||||
|
$approved = (float) $project->approved_estimate;
|
||||||
|
|
||||||
|
// If no approved estimate, consider it UNDER
|
||||||
|
if ($approved <= 0) {
|
||||||
|
return 'UNDER';
|
||||||
|
}
|
||||||
|
|
||||||
|
$planSum = $this->calculatePlanSum($project, $plans);
|
||||||
|
|
||||||
|
// Use decimal-safe comparison
|
||||||
|
if ($this->isGreaterThan($planSum, $approved)) {
|
||||||
|
return 'OVER';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isLessThan($planSum, $approved)) {
|
||||||
|
return 'UNDER';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'MATCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the sum of planned hours for a project.
|
||||||
|
* Only sums non-null planned_hours values.
|
||||||
|
*/
|
||||||
|
public function calculatePlanSum(Project $project, ?Collection $plans = null): float
|
||||||
|
{
|
||||||
|
if ($plans === null) {
|
||||||
|
$plans = ProjectMonthPlan::where('project_id', $project->id)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plans
|
||||||
|
->filter(fn ($plan) => $plan->planned_hours !== null)
|
||||||
|
->sum('planned_hours');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate plan sum and status for multiple projects.
|
||||||
|
* Returns array with project_id => ['plan_sum' => float, 'status' => string].
|
||||||
|
*/
|
||||||
|
public function calculateForProjects(Collection $projects, int $year): array
|
||||||
|
{
|
||||||
|
$startDate = "{$year}-01-01";
|
||||||
|
$endDate = "{$year}-12-01";
|
||||||
|
|
||||||
|
// Get all plans for the year, grouped by project_id
|
||||||
|
$allPlans = ProjectMonthPlan::whereBetween('month', [$startDate, $endDate])
|
||||||
|
->get()
|
||||||
|
->groupBy('project_id');
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$projectPlans = $allPlans->get($project->id, collect());
|
||||||
|
$planSum = $this->calculatePlanSum($project, $projectPlans);
|
||||||
|
$status = $this->calculateStatus($project, $projectPlans);
|
||||||
|
|
||||||
|
$results[$project->id] = [
|
||||||
|
'plan_sum' => $planSum,
|
||||||
|
'status' => $status,
|
||||||
|
'approved_estimate' => $project->approved_estimate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two floats using epsilon for decimal-safe comparison.
|
||||||
|
*/
|
||||||
|
private function isGreaterThan(float $a, float $b): bool
|
||||||
|
{
|
||||||
|
$epsilon = 0.0001;
|
||||||
|
|
||||||
|
return ($a - $b) > $epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two floats using epsilon for decimal-safe comparison.
|
||||||
|
*/
|
||||||
|
private function isLessThan(float $a, float $b): bool
|
||||||
|
{
|
||||||
|
$epsilon = 0.0001;
|
||||||
|
|
||||||
|
return ($b - $a) > $epsilon;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
backend/app/Services/VarianceCalculator.php
Normal file
164
backend/app/Services/VarianceCalculator.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Allocation;
|
||||||
|
use App\Models\ProjectMonthPlan;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class VarianceCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate row variance for a project in a given month.
|
||||||
|
* Row variance = allocated_total - planned_month
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* allocated_total: float,
|
||||||
|
* planned_month: float,
|
||||||
|
* variance: float,
|
||||||
|
* status: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function calculateRowVariance(string $projectId, string $month): array
|
||||||
|
{
|
||||||
|
// Convert YYYY-MM to YYYY-MM-01 and then to a Carbon date for proper comparison
|
||||||
|
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||||
|
|
||||||
|
// Get total allocated hours for this project/month (including untracked)
|
||||||
|
$allocatedTotal = Allocation::where('project_id', $projectId)
|
||||||
|
->whereMonth('month', $monthDate->month)
|
||||||
|
->whereYear('month', $monthDate->year)
|
||||||
|
->sum('allocated_hours');
|
||||||
|
|
||||||
|
// Get planned hours for this project/month (treat null as 0)
|
||||||
|
$plannedMonth = $this->getPlannedHoursForMonth($projectId, $month);
|
||||||
|
|
||||||
|
$variance = $allocatedTotal - $plannedMonth;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'allocated_total' => $allocatedTotal,
|
||||||
|
'planned_month' => $plannedMonth,
|
||||||
|
'variance' => $variance,
|
||||||
|
'status' => $this->determineStatus($variance),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate column variance for a team member in a given month.
|
||||||
|
* Column variance = member_allocated - member_capacity
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* allocated: float,
|
||||||
|
* capacity: float,
|
||||||
|
* variance: float,
|
||||||
|
* status: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function calculateColumnVariance(string $teamMemberId, string $month, CapacityService $capacityService): array
|
||||||
|
{
|
||||||
|
// Convert YYYY-MM to Carbon date for proper comparison
|
||||||
|
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||||
|
|
||||||
|
// Get total allocated hours for this member/month (excluding untracked)
|
||||||
|
$allocated = Allocation::where('team_member_id', $teamMemberId)
|
||||||
|
->whereMonth('month', $monthDate->month)
|
||||||
|
->whereYear('month', $monthDate->year)
|
||||||
|
->sum('allocated_hours');
|
||||||
|
|
||||||
|
// Get member capacity
|
||||||
|
$capacityData = $capacityService->calculateIndividualCapacity($teamMemberId, $month);
|
||||||
|
$capacity = $capacityData['hours'] ?? 0;
|
||||||
|
|
||||||
|
$variance = $allocated - $capacity;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'allocated' => $allocated,
|
||||||
|
'capacity' => $capacity,
|
||||||
|
'variance' => $variance,
|
||||||
|
'status' => $this->determineStatus($variance),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get planned hours for a project/month.
|
||||||
|
* Returns 0 if no plan exists (blank month treated as 0).
|
||||||
|
*/
|
||||||
|
public function getPlannedHoursForMonth(string $projectId, string $month): float
|
||||||
|
{
|
||||||
|
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||||
|
|
||||||
|
$plan = ProjectMonthPlan::where('project_id', $projectId)
|
||||||
|
->whereMonth('month', $monthDate->month)
|
||||||
|
->whereYear('month', $monthDate->year)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Blank plan is treated as 0 for allocation variance
|
||||||
|
return (float) ($plan?->planned_hours ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine status based on variance value.
|
||||||
|
*
|
||||||
|
* - Positive variance (> 0): OVER (red)
|
||||||
|
* - Negative variance (< 0): UNDER (amber)
|
||||||
|
* - Zero variance: MATCH (neutral)
|
||||||
|
*/
|
||||||
|
public function determineStatus(float $variance): string
|
||||||
|
{
|
||||||
|
$epsilon = 0.0001;
|
||||||
|
|
||||||
|
if ($variance > $epsilon) {
|
||||||
|
return 'OVER';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variance < -$epsilon) {
|
||||||
|
return 'UNDER';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'MATCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate both row and column variances for a complete matrix view.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* project_variances: array<string, array>,
|
||||||
|
* team_member_variances: array<string, array>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function calculateMatrixVariances(string $month, CapacityService $capacityService): array
|
||||||
|
{
|
||||||
|
// Convert YYYY-MM to Carbon date for proper comparison
|
||||||
|
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
|
||||||
|
|
||||||
|
// Get all allocations for the month using whereMonth/whereYear
|
||||||
|
$allocations = Allocation::whereMonth('month', $monthDate->month)
|
||||||
|
->whereYear('month', $monthDate->year)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Calculate row variances per project
|
||||||
|
$projectIds = $allocations->pluck('project_id')->unique()->toArray();
|
||||||
|
$projectVariances = [];
|
||||||
|
|
||||||
|
foreach ($projectIds as $projectId) {
|
||||||
|
$projectVariances[$projectId] = $this->calculateRowVariance($projectId, $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate column variances per team member (excluding null/untracked)
|
||||||
|
$teamMemberIds = $allocations->pluck('team_member_id')
|
||||||
|
->filter(fn ($id) => $id !== null)
|
||||||
|
->unique()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$teamMemberVariances = [];
|
||||||
|
|
||||||
|
foreach ($teamMemberIds as $teamMemberId) {
|
||||||
|
$teamMemberVariances[$teamMemberId] = $this->calculateColumnVariance($teamMemberId, $month, $capacityService);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'project_variances' => $projectVariances,
|
||||||
|
'team_member_variances' => $teamMemberVariances,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ use App\Http\Controllers\Api\HolidayController;
|
|||||||
use App\Http\Controllers\Api\ProjectController;
|
use App\Http\Controllers\Api\ProjectController;
|
||||||
use App\Http\Controllers\Api\ProjectMonthPlanController;
|
use App\Http\Controllers\Api\ProjectMonthPlanController;
|
||||||
use App\Http\Controllers\Api\PtoController;
|
use App\Http\Controllers\Api\PtoController;
|
||||||
|
use App\Http\Controllers\Api\ReportController;
|
||||||
|
use App\Http\Controllers\Api\RolesController;
|
||||||
use App\Http\Controllers\Api\TeamMemberController;
|
use App\Http\Controllers\Api\TeamMemberController;
|
||||||
use App\Http\Middleware\JwtAuth;
|
use App\Http\Middleware\JwtAuth;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
@@ -35,6 +37,9 @@ Route::middleware(JwtAuth::class)->group(function () {
|
|||||||
// Team Members
|
// Team Members
|
||||||
Route::apiResource('team-members', TeamMemberController::class);
|
Route::apiResource('team-members', TeamMemberController::class);
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
Route::get('/roles', [RolesController::class, 'index']);
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
Route::get('projects/types', [ProjectController::class, 'types']);
|
Route::get('projects/types', [ProjectController::class, 'types']);
|
||||||
Route::get('projects/statuses', [ProjectController::class, 'statuses']);
|
Route::get('projects/statuses', [ProjectController::class, 'statuses']);
|
||||||
@@ -68,4 +73,7 @@ Route::middleware(JwtAuth::class)->group(function () {
|
|||||||
// Allocations
|
// Allocations
|
||||||
Route::apiResource('allocations', AllocationController::class);
|
Route::apiResource('allocations', AllocationController::class);
|
||||||
Route::post('/allocations/bulk', [AllocationController::class, 'bulkStore']);
|
Route::post('/allocations/bulk', [AllocationController::class, 'bulkStore']);
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
Route::get('/reports/allocations', [ReportController::class, 'allocations']);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user