$projectNames projectId => display name (from ReportEngine summaries) * @return array */ public function analyzeProjects( array $projectIds, ReportPeriod $period, ResourceSummary $resources, array $projectNames = [], float $hoursPerPoint = self::DEFAULT_HOURS_PER_POINT, ): array { $out = []; $weeksInPeriod = $this->weeksBetween($period->from, $period->to); // Resolve the set of projects we'll actually analyze first (respecting // the reportable-only skip below), then pull every project's tickets in // ONE query instead of one round-trip per project. $idsToAnalyze = []; foreach ($projectIds as $pid) { $pid = (int) $pid; // Skip projects the report doesn't consider "reportable" — those // that fell out of the resource walk but aren't in ReportEngine's // summaries (typically program containers themselves, not real // work projects). Only analyze what the report tracks. if (! empty($projectNames) && ! isset($projectNames[$pid])) { continue; } $idsToAnalyze[] = $pid; } $ticketsByProject = $this->ticketsRepo->getAllByProjectIds($idsToAnalyze); foreach ($idsToAnalyze as $pid) { $rawTickets = $ticketsByProject[$pid] ?? []; // Normalize — the repo hydrates rows into Tickets model objects; we // want plain array rows for uniform key access downstream. $tickets = array_map(static fn ($t) => (array) $t, $rawTickets); // Filter to open tickets only — DONE work doesn't need capacity. DONE is // resolved per project from the status labels (custom statuses can mark // any id as done), not from the default 0/-1 convention. $doneStatuses = $this->doneStatusesForProject($pid); $openTickets = array_values(array_filter($tickets, fn ($t) => ! $this->isDone($t, $doneStatuses))); [$budgetedHours, $ticketsWithBudget] = $this->sumBudgetedHours($openTickets); [$effortPoints, $ticketsWithEffort] = $this->sumEffort($openTickets); $effortHours = $effortPoints * $hoursPerPoint; $openCount = count($openTickets); $coverage = $openCount > 0 ? $ticketsWithBudget / $openCount : 0.0; // Divergence between the two estimates. Only meaningful when both are non-trivial. $divergence = ($budgetedHours > 0 && $effortHours > 0) ? abs($budgetedHours - $effortHours) / max($budgetedHours, $effortHours) : 0.0; $trustSignal = $this->trustSignal($coverage, $divergence, $budgetedHours, $effortHours); // Capacity side — from ResourceSummary. Sum weekly allocation to THIS project only. $peopleCount = 0; $weeklyHoursToProject = 0.0; foreach ($resources->people as $person) { $hrs = (float) ($person->allocations[$pid] ?? 0.0); if ($hrs > 0) { $peopleCount++; $weeklyHoursToProject += $hrs; } } $availableHours = $weeklyHoursToProject * $weeksInPeriod; // The "reference demand" for a gap: prefer whichever estimate we trust more. // When trust is 'effort' (coverage is low), use the effort projection; // when it's 'budgeted', use the explicit hours; when mixed, use the // higher of the two (conservative — surface risk, don't hide it). $referenceDemand = match ($trustSignal) { 'budgeted' => $budgetedHours, 'effort' => $effortHours, default => max($budgetedHours, $effortHours), }; $gap = $referenceDemand - $availableHours; $verdict = $this->verdict($gap, $availableHours, $referenceDemand); $recommendations = $gap > 0 && $weeklyHoursToProject > 0 ? $this->recommend($gap, $weeklyHoursToProject, $peopleCount, $weeksInPeriod, $hoursPerPoint) : ['extendWeeks' => 0, 'addPeople' => 0, 'cutHours' => 0.0, 'cutPoints' => 0.0]; // Skip noise rows: projects walked by the resource gateway but with // nothing meaningful to say (typically program containers themselves, // which have no direct tickets or allocations). Reporting these as // "no capacity / no work" is just clutter. if ($openCount === 0 && $peopleCount === 0 && $budgetedHours === 0.0 && $effortHours === 0.0) { continue; } $out[$pid] = [ 'projectId' => $pid, 'name' => $projectNames[$pid] ?? ('#'.$pid), 'openTicketCount' => $openCount, 'coverage' => $coverage, 'budgetedHours' => $budgetedHours, 'ticketsWithBudget' => $ticketsWithBudget, 'effortPoints' => $effortPoints, 'ticketsWithEffort' => $ticketsWithEffort, 'effortHours' => $effortHours, 'hoursPerPoint' => $hoursPerPoint, 'divergence' => $divergence, 'trustSignal' => $trustSignal, 'peopleCount' => $peopleCount, 'weeklyHoursToProject' => $weeklyHoursToProject, 'weeksInWindow' => $weeksInPeriod, 'availableHours' => $availableHours, 'referenceDemand' => $referenceDemand, 'gapVsBudgeted' => $budgetedHours - $availableHours, 'gapVsEffort' => $effortHours - $availableHours, 'gap' => $gap, 'verdict' => $verdict, 'recommendations' => $recommendations, ]; } return $out; } /** * Rolls per-project capacity rows into per-program aggregates for the * strategy report. Every derived field (coverage, divergence, verdict, * recommendations) is recomputed against the aggregate — a program that * has three tight projects reads as tight overall, not "critical because * one child was critical". * * @param array> $projectRows Output of analyzeProjects() * @param array $programChildMap programId => [childProjectIds] * @param array $programMeta * @return array> One row per program with an added 'children' key */ public function aggregateByProgram( array $projectRows, array $programChildMap, array $programMeta, \Leantime\Core\Resources\Models\ResourceSummary $resources, ReportPeriod $period, float $hoursPerPoint = self::DEFAULT_HOURS_PER_POINT, ): array { $out = []; $weeksInPeriod = $this->weeksBetween($period->from, $period->to); foreach ($programMeta as $progId => $progInfo) { $childIds = $programChildMap[$progId] ?? []; if ($childIds === []) { continue; } $children = array_values(array_filter( array_map(fn ($cid) => $projectRows[$cid] ?? null, $childIds), fn ($r) => $r !== null, )); $openTicketCount = 0; $ticketsWithBudget = 0; $ticketsWithEffort = 0; $budgetedHours = 0.0; $effortPoints = 0.0; foreach ($children as $c) { $openTicketCount += (int) $c['openTicketCount']; $ticketsWithBudget += (int) $c['ticketsWithBudget']; $ticketsWithEffort += (int) $c['ticketsWithEffort']; $budgetedHours += (float) $c['budgetedHours']; $effortPoints += (float) $c['effortPoints']; } $effortHours = $effortPoints * $hoursPerPoint; $coverage = $openTicketCount > 0 ? $ticketsWithBudget / $openTicketCount : 0.0; $divergence = ($budgetedHours > 0 && $effortHours > 0) ? abs($budgetedHours - $effortHours) / max($budgetedHours, $effortHours) : 0.0; $trustSignal = $this->trustSignal($coverage, $divergence, $budgetedHours, $effortHours); // Capacity aggregation: unique people across the program (a person // on two child projects still counts once). // // Supply is each person's CAPACITY, not the hours already // allocated here. Allocation answers "what have we committed", // capacity answers "what could we actually do" — and only the // second one can say whether there is room. Summing allocations // made the two halves of the report disagree: the headline tile // reads allocated/capacity while this block read demand against // allocated, so a program with real headroom and nothing booked // yet reported no_capacity. // // A person's capacity is not dedicated to this program, so // commitments to projects OUTSIDE it are deducted. Only projects // inside this ResourceSummary are visible, so for a strategy // report that means siblings within the same strategy; work on // other strategies is not visible here and this therefore reads // as an upper bound. $childIdSet = array_flip(array_map('intval', $childIds)); $peopleSet = []; $weeklyCapacityToProgram = 0.0; foreach ($resources->people as $person) { $touched = false; $committedElsewhere = 0.0; foreach ($person->allocations as $allocPid => $allocHrs) { $allocHrs = (float) $allocHrs; if ($allocHrs <= 0) { continue; } if (isset($childIdSet[(int) $allocPid])) { $touched = true; } else { $committedElsewhere += $allocHrs; } } if ($touched) { $peopleSet[$person->itemId] = 1; $weeklyCapacityToProgram += max(0.0, $person->capacity - $committedElsewhere); } } $peopleCount = count($peopleSet); $availableHours = $weeklyCapacityToProgram * $weeksInPeriod; // recommend() reasons in weekly hours; keep its input consistent // with the supply figure the verdict was derived from. $weeklyHoursToProgram = $weeklyCapacityToProgram; $referenceDemand = match ($trustSignal) { 'budgeted' => $budgetedHours, 'effort' => $effortHours, default => max($budgetedHours, $effortHours), }; $gap = $referenceDemand - $availableHours; $verdict = $this->verdict($gap, $availableHours, $referenceDemand); $recommendations = $gap > 0 && $weeklyHoursToProgram > 0 ? $this->recommend($gap, $weeklyHoursToProgram, $peopleCount, $weeksInPeriod, $hoursPerPoint) : ['extendWeeks' => 0, 'addPeople' => 0, 'cutHours' => 0.0, 'cutPoints' => 0.0]; // Order child project rows worst-first for the expand view. $verdictRank = ['critical' => 0, 'tight' => 1, 'balanced' => 2, 'buffer' => 3, 'no_capacity' => 4, 'no_work' => 5]; usort($children, fn ($a, $b) => ($verdictRank[$a['verdict']] ?? 9) <=> ($verdictRank[$b['verdict']] ?? 9)); $out[$progId] = [ 'projectId' => $progId, 'name' => $progInfo['name'], 'isProgram' => true, 'childCount' => count($children), 'children' => $children, 'openTicketCount' => $openTicketCount, 'coverage' => $coverage, 'budgetedHours' => $budgetedHours, 'ticketsWithBudget' => $ticketsWithBudget, 'effortPoints' => $effortPoints, 'ticketsWithEffort' => $ticketsWithEffort, 'effortHours' => $effortHours, 'hoursPerPoint' => $hoursPerPoint, 'divergence' => $divergence, 'trustSignal' => $trustSignal, 'peopleCount' => $peopleCount, 'weeklyHoursToProject' => $weeklyHoursToProgram, 'weeksInWindow' => $weeksInPeriod, 'availableHours' => $availableHours, 'referenceDemand' => $referenceDemand, 'gapVsBudgeted' => $budgetedHours - $availableHours, 'gapVsEffort' => $effortHours - $availableHours, 'gap' => $gap, 'verdict' => $verdict, 'recommendations' => $recommendations, ]; } return $out; } /** * @param array> $tickets * @return array{0: float, 1: int} [totalHours, ticketsWithNonZeroPlanHours] */ private function sumBudgetedHours(array $tickets): array { $sum = 0.0; $withBudget = 0; foreach ($tickets as $t) { $ph = (float) ($t['planHours'] ?? 0); if ($ph > 0) { $sum += $ph; $withBudget++; } } return [$sum, $withBudget]; } /** * @param array> $tickets * @return array{0: float, 1: int} [totalPoints, ticketsWithNonZeroPoints] */ private function sumEffort(array $tickets): array { $sum = 0.0; $withEffort = 0; foreach ($tickets as $t) { $sp = (float) ($t['storypoints'] ?? 0); if ($sp > 0) { $sum += $sp; $withEffort++; } } return [$sum, $withEffort]; } /** * @param int[] $doneStatuses Status ids whose statusType is DONE in the ticket's project */ private function isDone(array $t, array $doneStatuses): bool { return in_array((int) ($t['status'] ?? 0), $doneStatuses, true); } /** * DONE-type status ids for a project from its (cached) status label settings — * covers custom statuses with positive ids, plus the default 0/-1 pair. * * @return int[] */ private function doneStatusesForProject(int $projectId): array { $doneStatuses = []; foreach ($this->ticketsRepo->getStateLabels($projectId) as $statusId => $label) { if (($label['statusType'] ?? '') === 'DONE') { $doneStatuses[] = (int) $statusId; } } return $doneStatuses; } private function weeksBetween(\DateTimeInterface $from, \DateTimeInterface $to): int { $seconds = $to->getTimestamp() - $from->getTimestamp(); return max(1, (int) ceil($seconds / (60 * 60 * 24 * 7))); } /** * 'budgeted' → coverage is high AND estimates agree. Trust the explicit hours. * 'effort' → coverage is low. Fall back on story-point projection. * 'mixed' → coverage okay but estimates disagree meaningfully. Flag the ambiguity. */ private function trustSignal(float $coverage, float $divergence, float $budgeted, float $effort): string { if ($budgeted === 0.0 && $effort === 0.0) { return 'none'; } if ($coverage < self::COVERAGE_LOW) { return $effort > 0 ? 'effort' : 'budgeted'; } if ($coverage >= self::COVERAGE_HIGH && $divergence < self::DIVERGENCE_THRESHOLD) { return 'budgeted'; } return 'mixed'; } private function verdict(float $gap, float $available, float $demand): string { if ($demand === 0.0) { return 'no_work'; } if ($available === 0.0) { return 'no_capacity'; } $ratio = $gap / $available; if ($ratio > 0.25) { return 'critical'; } if ($ratio > 0) { return 'tight'; } if ($ratio < -0.5) { return 'buffer'; } return 'balanced'; } /** * Compute the three levers: extend timeline / add people / cut scope. * * @return array{extendWeeks: int, addPeople: int, cutHours: float, cutPoints: float} */ private function recommend( float $gapHours, float $weeklyHoursToProject, int $peopleCount, int $weeksInWindow, float $hoursPerPoint, ): array { $avgWeeklyPerPerson = $peopleCount > 0 ? $weeklyHoursToProject / $peopleCount : 20.0; return [ 'extendWeeks' => (int) ceil($gapHours / max(1.0, $weeklyHoursToProject)), 'addPeople' => (int) ceil($gapHours / max(1.0, $weeksInWindow * $avgWeeklyPerPerson)), 'cutHours' => $gapHours, 'cutPoints' => $hoursPerPoint > 0 ? $gapHours / $hoursPerPoint : 0.0, ]; } }