OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
483
app/Domain/Reports/Services/CapacityAnalyzer.php
Normal file
483
app/Domain/Reports/Services/CapacityAnalyzer.php
Normal file
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Reports\Services;
|
||||
|
||||
use Leantime\Core\Resources\Models\ResourceSummary;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepo;
|
||||
|
||||
/**
|
||||
* Three-way capacity analysis for the stakeholder report's "Resource gaps &
|
||||
* risks" section.
|
||||
*
|
||||
* For each project in a plan, joins three independent estimates of the work
|
||||
* ahead and compares them against the capacity ResourceSummary already
|
||||
* exposes:
|
||||
*
|
||||
* 1. Budgeted hours — sum(planHours) on open tickets. Explicit but often blank.
|
||||
* 2. Effort hours — sum(storypoints) × hoursPerPoint on open tickets. Always
|
||||
* populated when sizing is used; a t-shirt/fibonacci proxy.
|
||||
* 3. Available hours — sum(person allocation to this project) × weeks in the
|
||||
* project's active window (or the report period as fallback).
|
||||
*
|
||||
* The output tells the board *how much* things are off, not just *that* they
|
||||
* are — sensitivity, not a flag. Recommendation triples ("extend by X weeks",
|
||||
* "add Y people", "cut Z points of scope") land alongside so the discussion
|
||||
* has levers, not just a colored dot.
|
||||
*
|
||||
* Trust signal: when budgeted and effort disagree materially (or coverage is
|
||||
* low), the analyzer says so. A resourcing decision shouldn't hinge on
|
||||
* numbers the team hasn't kept clean.
|
||||
*/
|
||||
final class CapacityAnalyzer
|
||||
{
|
||||
/**
|
||||
* Rough hours per fibonacci story point. A half-day-per-point default:
|
||||
* Effort labels are t-shirt sizes (XS=1, S=2, M=3, L=5, XL=8, XXL=13);
|
||||
* treating a point as ~4h puts M (3pts) at 12h — about a day and a
|
||||
* half — which matches how most Leantime teams size in practice.
|
||||
*/
|
||||
public const DEFAULT_HOURS_PER_POINT = 4.0;
|
||||
|
||||
/**
|
||||
* Trust bands for coverage (share of tickets that have planHours filled).
|
||||
*/
|
||||
private const COVERAGE_HIGH = 0.75;
|
||||
|
||||
private const COVERAGE_LOW = 0.30;
|
||||
|
||||
/**
|
||||
* Divergence threshold: when budgeted and effort disagree by more than
|
||||
* this ratio, we call the estimate unreliable.
|
||||
*/
|
||||
private const DIVERGENCE_THRESHOLD = 0.4;
|
||||
|
||||
public function __construct(
|
||||
private readonly TicketsRepo $ticketsRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Analyzes capacity for each project in the plan.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @param array<int, string> $projectNames projectId => display name (from ReportEngine summaries)
|
||||
* @return array<int, array{
|
||||
* projectId: int, name: string,
|
||||
* openTicketCount: int, coverage: float, budgetedHours: float,
|
||||
* effortPoints: float, effortHours: float, hoursPerPoint: float,
|
||||
* divergence: float, trustSignal: string,
|
||||
* peopleCount: int, weeklyHoursToProject: float,
|
||||
* weeksInWindow: int, availableHours: float,
|
||||
* gapVsBudgeted: float, gapVsEffort: float, gap: float,
|
||||
* verdict: string,
|
||||
* recommendations: array{extendWeeks: int, addPeople: int, cutHours: float, cutPoints: float}
|
||||
* }>
|
||||
*/
|
||||
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<int, array<string, mixed>> $projectRows Output of analyzeProjects()
|
||||
* @param array<int, int[]> $programChildMap programId => [childProjectIds]
|
||||
* @param array<int, array{id:int, name:string}> $programMeta
|
||||
* @return array<int, array<string, mixed>> 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<int, array<string, mixed>> $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<int, array<string, mixed>> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
737
app/Domain/Reports/Services/ReportEngine.php
Normal file
737
app/Domain/Reports/Services/ReportEngine.php
Normal file
@@ -0,0 +1,737 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Leantime\Domain\Reports\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvasService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reports\Models\ReportPeriod;
|
||||
use Leantime\Domain\Reports\Permissions\ReportsPermissions;
|
||||
use Leantime\Domain\Reports\Repositories\ReportEngine as ReportEngineRepository;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
|
||||
/**
|
||||
* Shared, period-aware reporting engine feeding the project, plan and strategy report screens.
|
||||
*
|
||||
* All methods operate on explicit project-id sets so higher levels (plan = child projects,
|
||||
* strategy = descendant projects, later company = all projects) compose the same building
|
||||
* blocks. Ids are filtered to projects the requesting user may view before any query runs,
|
||||
* and the repository re-applies the SQL access predicate — defense in depth.
|
||||
*/
|
||||
class ReportEngine extends BaseService
|
||||
{
|
||||
/**
|
||||
* A project with no status update for this many days counts as silent/stale.
|
||||
*/
|
||||
private const STALE_AFTER_DAYS = 30;
|
||||
|
||||
public function __construct(
|
||||
private ReportEngineRepository $reportEngineRepository,
|
||||
private TicketRepository $ticketRepository,
|
||||
private ProjectService $projectService,
|
||||
private GoalcanvasService $goalService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Builds the full report view model for a set of projects in one pass: needs-attention,
|
||||
* milestone buckets, slippage, goals, status updates, effort, prior-period deltas and
|
||||
* summary stats. This is the single entry point the report screens consume.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function buildReport(array $projectIds, ReportPeriod $period): array
|
||||
{
|
||||
$projectIds = $this->filterAuthorizedProjects($projectIds);
|
||||
|
||||
$summaries = $this->getProjectSummaries($projectIds);
|
||||
$milestoneReport = $this->getMilestoneReportForProjects($projectIds, $period);
|
||||
$goalReport = $this->getGoalReportForProjects($projectIds);
|
||||
$statusUpdates = $this->getStatusUpdatesForProjects($projectIds, $period);
|
||||
$effort = $this->getEffortForProjects($projectIds, $period);
|
||||
|
||||
$priorPeriod = $period->priorPeriod();
|
||||
$priorEffort = $this->getEffortForProjects($projectIds, $priorPeriod);
|
||||
$completedPrior = count(array_filter(
|
||||
$milestoneReport['allDone'],
|
||||
fn (object $milestone) => $milestone->completedOn !== null
|
||||
&& $priorPeriod->contains($milestone->completedOn)
|
||||
));
|
||||
|
||||
$completedCount = count($milestoneReport['completed']);
|
||||
$deltas = [
|
||||
'completedPrior' => $completedPrior,
|
||||
'completedDelta' => $completedCount - $completedPrior,
|
||||
'hoursPrior' => $priorEffort['total'],
|
||||
'hoursDelta' => round($effort['total'] - $priorEffort['total'], 2),
|
||||
'priorPeriodLabel' => $priorPeriod->label(),
|
||||
];
|
||||
|
||||
$needsAttention = $this->buildNeedsAttention($summaries, $milestoneReport, $goalReport);
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
'projectIds' => $projectIds,
|
||||
'summaries' => $summaries,
|
||||
'milestones' => $milestoneReport,
|
||||
'goals' => $goalReport,
|
||||
'statusUpdates' => $statusUpdates,
|
||||
'effort' => $effort,
|
||||
'deltas' => $deltas,
|
||||
'needsAttention' => $needsAttention,
|
||||
'stats' => [
|
||||
'completed' => $completedCount,
|
||||
// "In flight" = everything actively being worked, overdue included — matches
|
||||
// the report screens' In-flight sections, which merge both buckets.
|
||||
'inFlight' => count($milestoneReport['inProgress']) + count($milestoneReport['overdue']),
|
||||
'overdue' => count($milestoneReport['overdue']),
|
||||
'upcoming' => count($milestoneReport['upcoming']),
|
||||
'goalsOnTrack' => $goalReport['counts']['ontrack'],
|
||||
'goalsTotal' => count($goalReport['goals']),
|
||||
'hoursLogged' => $effort['total'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets the projects' milestones for the period.
|
||||
*
|
||||
* - completed: DONE status and completed inside the period (completion date derived from
|
||||
* the ticket status-change history, falling back to due date, then last modified)
|
||||
* - inProgress: not done, scheduled to overlap the period (or unscheduled), not overdue
|
||||
* - overdue: not done and due date in the past
|
||||
* - upcoming: starting after the period, within the next two quarters, grouped by quarter
|
||||
* - allDone: every done milestone with its completion date (feeds prior-period deltas)
|
||||
* - slippage: due dates pushed out of the period + milestones added mid-period
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getMilestoneReportForProjects(array $projectIds, ReportPeriod $period): array
|
||||
{
|
||||
$projectIds = $this->filterAuthorizedProjects($projectIds);
|
||||
[$userId, $clientId] = $this->requestContext();
|
||||
|
||||
$milestones = $this->reportEngineRepository->getMilestonesForProjects($projectIds, $userId, $clientId);
|
||||
$doneStatusesByProject = $this->getDoneStatusesByProject($projectIds);
|
||||
|
||||
$tasksByMilestone = $this->groupTasksByMilestone(
|
||||
$this->reportEngineRepository->getTasksForMilestones(array_map(fn ($m) => (int) $m->id, $milestones))
|
||||
);
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$horizon = $period->upcomingHorizon();
|
||||
|
||||
$completed = [];
|
||||
$inProgress = [];
|
||||
$overdue = [];
|
||||
$upcoming = [];
|
||||
$allDone = [];
|
||||
|
||||
$doneMilestoneIds = [];
|
||||
foreach ($milestones as $milestone) {
|
||||
if ($this->hasDoneStatus($milestone, $doneStatusesByProject)) {
|
||||
$doneMilestoneIds[] = (int) $milestone->id;
|
||||
}
|
||||
}
|
||||
$completionDates = $this->deriveCompletionDates($doneMilestoneIds, $milestones, $doneStatusesByProject);
|
||||
|
||||
foreach ($milestones as $milestone) {
|
||||
$milestoneId = (int) $milestone->id;
|
||||
$milestone->startDate = $this->parseDbDateOrNull($milestone->editFrom);
|
||||
$milestone->dueDate = $this->parseDbDateOrNull($milestone->editTo);
|
||||
$milestone->tags = $this->sanitizeCssColor((string) $milestone->tags);
|
||||
$milestone->taskStats = $this->buildTaskStats(
|
||||
$tasksByMilestone[$milestoneId] ?? [],
|
||||
$doneStatusesByProject[(int) $milestone->projectId] ?? []
|
||||
);
|
||||
$milestone->keyTasks = $this->pickKeyTasks(
|
||||
$tasksByMilestone[$milestoneId] ?? [],
|
||||
$doneStatusesByProject[(int) $milestone->projectId] ?? []
|
||||
);
|
||||
|
||||
if ($this->hasDoneStatus($milestone, $doneStatusesByProject)) {
|
||||
$milestone->completedOn = $completionDates[$milestoneId] ?? null;
|
||||
$milestone->percentDone = 100.0;
|
||||
$allDone[] = $milestone;
|
||||
|
||||
if ($milestone->completedOn !== null && $period->contains($milestone->completedOn)) {
|
||||
$completed[] = $milestone;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$milestone->completedOn = null;
|
||||
$milestone->percentDone = $this->calculateMilestoneProgress(
|
||||
$tasksByMilestone[$milestoneId] ?? [],
|
||||
$doneStatusesByProject[(int) $milestone->projectId] ?? []
|
||||
);
|
||||
|
||||
if ($milestone->dueDate !== null && $milestone->dueDate->lessThan($now)) {
|
||||
$overdue[] = $milestone;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($milestone->startDate !== null && $milestone->startDate->greaterThan($period->to)) {
|
||||
if ($milestone->startDate->lessThanOrEqualTo($horizon)) {
|
||||
$milestone->quarterLabel = $this->quarterLabel($milestone->startDate);
|
||||
$upcoming[] = $milestone;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overlapping the period or entirely unscheduled: active work.
|
||||
$inProgress[] = $milestone;
|
||||
}
|
||||
|
||||
usort($completed, fn ($a, $b) => ($b->completedOn?->getTimestamp() ?? 0) <=> ($a->completedOn?->getTimestamp() ?? 0));
|
||||
usort($overdue, fn ($a, $b) => ($a->dueDate?->getTimestamp() ?? 0) <=> ($b->dueDate?->getTimestamp() ?? 0));
|
||||
usort($upcoming, fn ($a, $b) => ($a->startDate?->getTimestamp() ?? 0) <=> ($b->startDate?->getTimestamp() ?? 0));
|
||||
|
||||
$slippage = $this->buildSlippage($milestones, $doneStatusesByProject, $period);
|
||||
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'inProgress' => $inProgress,
|
||||
'overdue' => $overdue,
|
||||
'upcoming' => $upcoming,
|
||||
'upcomingByQuarter' => $this->groupByQuarter($upcoming),
|
||||
'allDone' => $allDone,
|
||||
'slippage' => $slippage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Goals of the given projects with metric progress and roll-up values resolved.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return array{goals: array<int, object>, byProject: array<int, array<int, object>>, counts: array<string, int>}
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getGoalReportForProjects(array $projectIds): array
|
||||
{
|
||||
$projectIds = $this->filterAuthorizedProjects($projectIds);
|
||||
[$userId, $clientId] = $this->requestContext();
|
||||
|
||||
$goals = $this->reportEngineRepository->getGoalsForProjects($projectIds, $userId, $clientId);
|
||||
|
||||
$byProject = [];
|
||||
$counts = ['ontrack' => 0, 'atrisk' => 0, 'miss' => 0];
|
||||
|
||||
// Milestone name(s) come from the tracked_by edge graph (replacing the
|
||||
// removed stale milestoneId-column join). Hydrate them for the whole
|
||||
// goal set in ONE batched pass up front to avoid an N+1 across large
|
||||
// project sets.
|
||||
$milestonesByGoal = $this->goalService->getMilestonesForGoals(
|
||||
array_map(static fn ($goal) => (int) $goal->id, $goals)
|
||||
);
|
||||
|
||||
foreach ($goals as $goal) {
|
||||
if ($goal->setting === 'linkAndReport') {
|
||||
$goal->currentValue = $this->goalService->getChildGoalsForReporting((int) $goal->id);
|
||||
}
|
||||
|
||||
$goal->goalProgress = $this->calculateGoalProgress($goal);
|
||||
|
||||
$goalMilestones = $milestonesByGoal[(int) $goal->id] ?? [];
|
||||
$goal->milestoneHeadline = implode(', ', array_map(static fn ($m) => (string) $m['headline'], $goalMilestones));
|
||||
|
||||
$statusKey = str_replace('status_', '', (string) $goal->status);
|
||||
if (array_key_exists($statusKey, $counts)) {
|
||||
$counts[$statusKey]++;
|
||||
}
|
||||
|
||||
$byProject[(int) $goal->projectId][] = $goal;
|
||||
}
|
||||
|
||||
return [
|
||||
'goals' => $goals,
|
||||
'byProject' => $byProject,
|
||||
'counts' => $counts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Status updates posted within the period, grouped by project, newest first.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @param int $limitPerProject 0 = no limit
|
||||
* @return array<int, array<int, object>> projectId => updates
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getStatusUpdatesForProjects(array $projectIds, ReportPeriod $period, int $limitPerProject = 0): array
|
||||
{
|
||||
$projectIds = $this->filterAuthorizedProjects($projectIds);
|
||||
[$userId, $clientId] = $this->requestContext();
|
||||
|
||||
$updates = $this->reportEngineRepository->getStatusUpdatesForProjects(
|
||||
$projectIds,
|
||||
$period->fromDbString(),
|
||||
$period->toDbString(),
|
||||
$userId,
|
||||
$clientId
|
||||
);
|
||||
|
||||
$byProject = [];
|
||||
foreach ($updates as $update) {
|
||||
$projectId = (int) $update->projectId;
|
||||
if ($limitPerProject > 0 && count($byProject[$projectId] ?? []) >= $limitPerProject) {
|
||||
continue;
|
||||
}
|
||||
$update->dateParsed = $this->parseDbDateOrNull($update->date);
|
||||
$byProject[$projectId][] = $update;
|
||||
}
|
||||
|
||||
return $byProject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary header data per project: name, one-line description, progress, latest status
|
||||
* update (the status pill), staleness and timeline.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return array<int, object> Keyed by project id
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getProjectSummaries(array $projectIds): array
|
||||
{
|
||||
$projectIds = $this->filterAuthorizedProjects($projectIds);
|
||||
[$userId, $clientId] = $this->requestContext();
|
||||
|
||||
$meta = $this->reportEngineRepository->getProjectsMeta($projectIds, $userId, $clientId);
|
||||
$latestUpdates = $this->reportEngineRepository->getLatestStatusUpdateForProjects(array_keys($meta));
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
|
||||
foreach ($meta as $projectId => $project) {
|
||||
$project->descriptionExcerpt = $this->excerpt((string) ($project->details ?? ''));
|
||||
$project->progress = Cache::remember(
|
||||
'reportengine.progress.'.$projectId,
|
||||
600,
|
||||
fn () => $this->projectService->getProjectProgress($projectId)
|
||||
);
|
||||
|
||||
$latest = $latestUpdates[$projectId] ?? null;
|
||||
$project->latestStatus = $latest?->status ?: null;
|
||||
$project->latestStatusDate = $latest !== null ? $this->parseDbDateOrNull($latest->date) : null;
|
||||
$project->latestStatusText = $latest !== null ? $this->excerpt((string) $latest->text) : null;
|
||||
$project->isStale = $project->latestStatusDate === null
|
||||
|| $project->latestStatusDate->lessThan($now->subDays(self::STALE_AFTER_DAYS));
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hours logged in the period, totaled and broken down by project and milestone.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return array{total: float, byProject: array<int, float>, byMilestone: array<int, float>}
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getEffortForProjects(array $projectIds, ReportPeriod $period): array
|
||||
{
|
||||
$projectIds = $this->filterAuthorizedProjects($projectIds);
|
||||
|
||||
$rows = $this->reportEngineRepository->getHoursLoggedForProjects(
|
||||
$projectIds,
|
||||
$period->fromDbString(),
|
||||
$period->toDbString()
|
||||
);
|
||||
|
||||
$total = 0.0;
|
||||
$byProject = [];
|
||||
$byMilestone = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$hours = (float) $row->loggedHours;
|
||||
$total += $hours;
|
||||
$byProject[(int) $row->projectId] = ($byProject[(int) $row->projectId] ?? 0.0) + $hours;
|
||||
if ((int) $row->milestoneId > 0) {
|
||||
$byMilestone[(int) $row->milestoneId] = ($byMilestone[(int) $row->milestoneId] ?? 0.0) + $hours;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => round($total, 2),
|
||||
'byProject' => $byProject,
|
||||
'byMilestone' => $byMilestone,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts a set of project ids to those the requesting user may view reports for.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return int[]
|
||||
*/
|
||||
private function filterAuthorizedProjects(array $projectIds): array
|
||||
{
|
||||
$projectIds = array_values(array_unique(array_map('intval', $projectIds)));
|
||||
|
||||
return array_values(array_filter(
|
||||
$projectIds,
|
||||
fn (int $projectId) => $projectId > 0 && $this->can(ReportsPermissions::VIEW, $projectId)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The requesting user's id and client id, used by the repository access predicate. Reports
|
||||
* always run in a request context (web, HTMX or authenticated API — all populate session
|
||||
* userdata); cron/system paths must not call the engine.
|
||||
*
|
||||
* @return array{0: int, 1: int}
|
||||
*/
|
||||
private function requestContext(): array
|
||||
{
|
||||
return [
|
||||
(int) (session('userdata.id') ?? -1),
|
||||
(int) (session('userdata.clientId') ?? -1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* DONE-type status ids per project, from the (cached) project status label settings.
|
||||
*
|
||||
* @param int[] $projectIds
|
||||
* @return array<int, int[]>
|
||||
*/
|
||||
private function getDoneStatusesByProject(array $projectIds): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($projectIds as $projectId) {
|
||||
$doneStatuses = [];
|
||||
foreach ($this->ticketRepository->getStateLabels($projectId) as $statusId => $label) {
|
||||
if (($label['statusType'] ?? '') === 'DONE') {
|
||||
$doneStatuses[] = (int) $statusId;
|
||||
}
|
||||
}
|
||||
$map[$projectId] = $doneStatuses;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function hasDoneStatus(object $milestone, array $doneStatusesByProject): bool
|
||||
{
|
||||
return in_array((int) $milestone->status, $doneStatusesByProject[(int) $milestone->projectId] ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion dates for done milestones: the latest status-history transition into a
|
||||
* DONE-type status; for rows predating history coverage the due date, then last modified.
|
||||
*
|
||||
* @param int[] $doneMilestoneIds
|
||||
* @param array<int, object> $milestones
|
||||
* @return array<int, ?CarbonImmutable> milestoneId => completion date
|
||||
*/
|
||||
private function deriveCompletionDates(array $doneMilestoneIds, array $milestones, array $doneStatusesByProject): array
|
||||
{
|
||||
$milestonesById = [];
|
||||
foreach ($milestones as $milestone) {
|
||||
$milestonesById[(int) $milestone->id] = $milestone;
|
||||
}
|
||||
|
||||
$latestDoneTransition = [];
|
||||
foreach ($this->reportEngineRepository->getStatusHistoryForTickets($doneMilestoneIds) as $row) {
|
||||
$ticketId = (int) $row->ticketId;
|
||||
$milestone = $milestonesById[$ticketId] ?? null;
|
||||
if ($milestone === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$doneStatuses = $doneStatusesByProject[(int) $milestone->projectId] ?? [];
|
||||
if (in_array((int) $row->changeValue, $doneStatuses, true)) {
|
||||
// Rows arrive oldest-first, so the last hit per ticket wins.
|
||||
$latestDoneTransition[$ticketId] = $row->dateModified;
|
||||
}
|
||||
}
|
||||
|
||||
$completionDates = [];
|
||||
foreach ($doneMilestoneIds as $milestoneId) {
|
||||
$milestone = $milestonesById[$milestoneId];
|
||||
$completionDates[$milestoneId] = $this->parseDbDateOrNull($latestDoneTransition[$milestoneId] ?? null)
|
||||
?? $this->parseDbDateOrNull($milestone->editTo)
|
||||
?? $this->parseDbDateOrNull($milestone->modified);
|
||||
}
|
||||
|
||||
return $completionDates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commitment integrity: milestones whose due date was pushed past the period end during
|
||||
* the period, and milestones created mid-period.
|
||||
*
|
||||
* @param array<int, object> $milestones
|
||||
* @return array{pushedOut: array<int, object>, addedMidPeriod: array<int, object>}
|
||||
*/
|
||||
private function buildSlippage(array $milestones, array $doneStatusesByProject, ReportPeriod $period): array
|
||||
{
|
||||
$candidates = [];
|
||||
foreach ($milestones as $milestone) {
|
||||
if ($this->hasDoneStatus($milestone, $doneStatusesByProject)) {
|
||||
continue;
|
||||
}
|
||||
$dueDate = $milestone->dueDate ?? $this->parseDbDateOrNull($milestone->editTo);
|
||||
if ($dueDate !== null && $dueDate->greaterThan($period->to)) {
|
||||
$candidates[(int) $milestone->id] = $milestone;
|
||||
}
|
||||
}
|
||||
|
||||
$changes = $this->reportEngineRepository->getDueDateChangesForTickets(
|
||||
array_keys($candidates),
|
||||
$period->fromDbString(),
|
||||
$period->toDbString()
|
||||
);
|
||||
|
||||
$moveCounts = [];
|
||||
foreach ($changes as $change) {
|
||||
$moveCounts[(int) $change->ticketId] = ($moveCounts[(int) $change->ticketId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$pushedOut = [];
|
||||
foreach ($moveCounts as $milestoneId => $moves) {
|
||||
$milestone = $candidates[$milestoneId];
|
||||
$milestone->dueDateMoves = $moves;
|
||||
$pushedOut[] = $milestone;
|
||||
}
|
||||
|
||||
$addedMidPeriod = [];
|
||||
foreach ($milestones as $milestone) {
|
||||
$created = $this->parseDbDateOrNull($milestone->date);
|
||||
if ($created !== null && $created->greaterThan($period->from) && $created->lessThanOrEqualTo($period->to)) {
|
||||
$addedMidPeriod[] = $milestone;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'pushedOut' => $pushedOut,
|
||||
'addedMidPeriod' => $addedMidPeriod,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attention items across the project set: red/yellow projects, silent projects, overdue
|
||||
* milestones and at-risk goals — the "what needs my intervention" block.
|
||||
*
|
||||
* @param array<int, object> $summaries
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function buildNeedsAttention(array $summaries, array $milestoneReport, array $goalReport): array
|
||||
{
|
||||
$statusAlerts = [];
|
||||
$staleProjects = [];
|
||||
|
||||
foreach ($summaries as $project) {
|
||||
if ((int) $project->state === -1) {
|
||||
continue; // Closed projects don't need status nudging.
|
||||
}
|
||||
if (in_array($project->latestStatus, ['red', 'yellow'], true)) {
|
||||
$statusAlerts[] = $project;
|
||||
}
|
||||
if ($project->isStale) {
|
||||
$staleProjects[] = $project;
|
||||
}
|
||||
}
|
||||
|
||||
$goalsAtRisk = array_values(array_filter(
|
||||
$goalReport['goals'],
|
||||
fn (object $goal) => in_array($goal->status, ['status_atrisk', 'status_miss'], true)
|
||||
));
|
||||
|
||||
return [
|
||||
'statusAlerts' => $statusAlerts,
|
||||
'staleProjects' => $staleProjects,
|
||||
'overdueMilestones' => $milestoneReport['overdue'],
|
||||
'goalsAtRisk' => $goalsAtRisk,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted milestone progress over its tasks. The weights MUST stay in sync with
|
||||
* {@see \Leantime\Domain\Tickets\Services\Tickets::getMilestoneProgress} — this bulk
|
||||
* variant exists so report rollups don't re-query tasks per milestone.
|
||||
*
|
||||
* @param array<int, object> $tasks
|
||||
* @param int[] $doneStatuses
|
||||
*/
|
||||
private function calculateMilestoneProgress(array $tasks, array $doneStatuses): float
|
||||
{
|
||||
$priorityFactor = [1 => 2, 2 => 1.75, 3 => 1.5, 4 => 1.25, 5 => 1];
|
||||
|
||||
$totalScore = 0.0;
|
||||
$doneScore = 0.0;
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$effort = empty($task->storypoints) ? 3 : (float) $task->storypoints;
|
||||
$priority = empty($task->priority) ? 3 : (int) $task->priority;
|
||||
$score = $effort * ($priorityFactor[$priority] ?? 1);
|
||||
|
||||
$totalScore += $score;
|
||||
if (in_array((int) $task->status, $doneStatuses, true)) {
|
||||
$doneScore += $score;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalScore === 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $doneScore / $totalScore * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric progress percent, mirroring the goal dashboard math in
|
||||
* {@see \Leantime\Domain\Goalcanvas\Services\Goalcanvas::getCanvasItemsById}.
|
||||
*/
|
||||
private function calculateGoalProgress(object $goal): float
|
||||
{
|
||||
$total = (float) $goal->endValue - (float) $goal->startValue;
|
||||
if ($total == 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$progress = (float) $goal->currentValue - (float) $goal->startValue;
|
||||
|
||||
return min(100, max(0, round($progress / $total, 2) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, object> $tasks
|
||||
* @param int[] $doneStatuses
|
||||
* @return array{done: int, total: int}
|
||||
*/
|
||||
private function buildTaskStats(array $tasks, array $doneStatuses): array
|
||||
{
|
||||
$done = 0;
|
||||
foreach ($tasks as $task) {
|
||||
if (in_array((int) $task->status, $doneStatuses, true)) {
|
||||
$done++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['done' => $done, 'total' => count($tasks)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Up to five tasks per milestone for the drill-down list, completed work first.
|
||||
*
|
||||
* @param array<int, object> $tasks
|
||||
* @param int[] $doneStatuses
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function pickKeyTasks(array $tasks, array $doneStatuses, int $limit = 5): array
|
||||
{
|
||||
usort($tasks, function (object $a, object $b) use ($doneStatuses) {
|
||||
$aDone = in_array((int) $a->status, $doneStatuses, true) ? 0 : 1;
|
||||
$bDone = in_array((int) $b->status, $doneStatuses, true) ? 0 : 1;
|
||||
|
||||
return $aDone <=> $bDone;
|
||||
});
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$task->isDone = in_array((int) $task->status, $doneStatuses, true);
|
||||
}
|
||||
|
||||
return array_slice($tasks, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, object> $tasks
|
||||
* @return array<int, array<int, object>> milestoneId => tasks
|
||||
*/
|
||||
private function groupTasksByMilestone(array $tasks): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($tasks as $task) {
|
||||
$grouped[(int) $task->milestoneid][] = $task;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, object> $upcoming Milestones sorted by start date
|
||||
* @return array<string, array<int, object>> "Q3 2026" => milestones
|
||||
*/
|
||||
private function groupByQuarter(array $upcoming): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($upcoming as $milestone) {
|
||||
$grouped[$milestone->quarterLabel][] = $milestone;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
private function quarterLabel(CarbonImmutable $date): string
|
||||
{
|
||||
$userDate = $date->setToUserTimezone();
|
||||
|
||||
return 'Q'.$userDate->quarter.' '.$userDate->year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts a milestone color (the repurposed tags column) to safe CSS color forms —
|
||||
* hex, var(--token) or a bare color keyword. The templates inject it into inline
|
||||
* styles, so arbitrary text must not pass through.
|
||||
*/
|
||||
private function sanitizeCssColor(string $color): string
|
||||
{
|
||||
$color = trim($color);
|
||||
|
||||
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $color) === 1
|
||||
|| preg_match('/^var\(--[a-zA-Z0-9-]+\)$/', $color) === 1
|
||||
|| preg_match('/^[a-zA-Z]+$/', $color) === 1
|
||||
) {
|
||||
return $color;
|
||||
}
|
||||
|
||||
return 'var(--grey)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a db datetime string, treating null/empty/zero dates as absent.
|
||||
*/
|
||||
private function parseDbDateOrNull(mixed $dbDate): ?CarbonImmutable
|
||||
{
|
||||
if (empty($dbDate) || str_starts_with((string) $dbDate, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return dtHelper()->parseDbDateTime((string) $dbDate);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text one-liner from a possibly-HTML field.
|
||||
*/
|
||||
private function excerpt(string $html, int $length = 140): string
|
||||
{
|
||||
$text = trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
|
||||
|
||||
if (mb_strlen($text) <= $length) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return mb_substr($text, 0, $length - 1).'…';
|
||||
}
|
||||
}
|
||||
594
app/Domain/Reports/Services/Reports.php
Normal file
594
app/Domain/Reports/Services/Reports.php
Normal file
@@ -0,0 +1,594 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Reports\Services;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Configuration\AppSettings as AppSettingCore;
|
||||
use Leantime\Core\Configuration\Environment as EnvironmentCore;
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
|
||||
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
|
||||
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
|
||||
use Leantime\Domain\Ideas\Repositories\Ideas as IdeaRepository;
|
||||
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
|
||||
use Leantime\Domain\Reactions\Repositories\Reactions;
|
||||
use Leantime\Domain\Reports\Permissions\ReportsPermissions;
|
||||
use Leantime\Domain\Reports\Repositories\Reports as ReportRepository;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingsService;
|
||||
use Leantime\Domain\Sprints\Repositories\Sprints as SprintRepository;
|
||||
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
|
||||
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
|
||||
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
|
||||
use Leantime\Domain\Users\Repositories\Users as UserRepository;
|
||||
|
||||
/**
|
||||
* Reports service: per-project report aggregation (burndown, ticket-status history) plus the
|
||||
* system-level daily-ingestion and telemetry tasks.
|
||||
*
|
||||
* Authorization model: the three by-projectId @api reads carry a dispatch
|
||||
* #[RequiresPermission(reports.view, projectIdParam: 'projectId')] gate, authorized against the
|
||||
* REQUESTED project (closes the cross-project RPC IDOR). The system/cron methods (ingestion,
|
||||
* telemetry) are NOT @api — they run from the scheduler with no session user and must never be
|
||||
* RPC-reachable.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class Reports extends BaseService
|
||||
{
|
||||
private AppSettingCore $appSettings;
|
||||
|
||||
private EnvironmentCore $config;
|
||||
|
||||
private ProjectRepository $projectRepository;
|
||||
|
||||
private SprintRepository $sprintRepository;
|
||||
|
||||
private ReportRepository $reportRepository;
|
||||
|
||||
private SettingsService $settings;
|
||||
|
||||
private TicketRepository $ticketRepository;
|
||||
|
||||
private SprintService $sprintService;
|
||||
|
||||
public function __construct(
|
||||
AppSettingCore $appSettings,
|
||||
EnvironmentCore $config,
|
||||
ProjectRepository $projectRepository,
|
||||
SprintRepository $sprintRepository,
|
||||
ReportRepository $reportRepository,
|
||||
SettingsService $settings,
|
||||
TicketRepository $ticketRepository,
|
||||
SprintService $sprintService
|
||||
) {
|
||||
$this->appSettings = $appSettings;
|
||||
$this->config = $config;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->sprintRepository = $sprintRepository;
|
||||
$this->reportRepository = $reportRepository;
|
||||
$this->settings = $settings;
|
||||
$this->ticketRepository = $ticketRepository;
|
||||
$this->sprintService = $sprintService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which sprint burndown to display on the reports page.
|
||||
*
|
||||
* Mirrors the legacy controller selection order exactly:
|
||||
* 1. An explicitly requested sprint id (from the query string).
|
||||
* 2. Otherwise the project's current sprint.
|
||||
* 3. Otherwise the first available sprint.
|
||||
*
|
||||
* The returned 'currentSprintId' preserves the original behaviour:
|
||||
* when a sprint id is explicitly requested it is echoed back as-is
|
||||
* (even if the sprint cannot be loaded); when falling back to the
|
||||
* current/first sprint the resolved sprint object's id is used.
|
||||
* When the project has no sprints at all, both values are false.
|
||||
*
|
||||
* @param int $projectId Project to resolve the burndown for.
|
||||
* @param int|null $requestedSprintId Sprint id explicitly requested by the user, or null.
|
||||
* @return array{chart: false|array, currentSprintId: int|false} Burndown chart data and the resolved sprint id.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')]
|
||||
public function getSprintBurndownForReport(int $projectId, ?int $requestedSprintId): array
|
||||
{
|
||||
$allSprints = $this->sprintService->getAllSprints($projectId);
|
||||
|
||||
if (count($allSprints) === 0) {
|
||||
return ['chart' => false, 'currentSprintId' => false];
|
||||
}
|
||||
|
||||
$sprintChart = false;
|
||||
|
||||
if ($requestedSprintId !== null) {
|
||||
$sprintObject = $this->sprintService->getSprint($requestedSprintId);
|
||||
if ($sprintObject) {
|
||||
$sprintChart = $this->sprintService->getSprintBurndown($sprintObject);
|
||||
}
|
||||
|
||||
return ['chart' => $sprintChart, 'currentSprintId' => $requestedSprintId];
|
||||
}
|
||||
|
||||
$currentSprint = $this->sprintService->getCurrentSprintId($projectId);
|
||||
|
||||
if ($currentSprint !== false && $currentSprint !== 'all') {
|
||||
$sprintObject = $this->sprintService->getSprint((int) $currentSprint);
|
||||
if ($sprintObject) {
|
||||
$sprintChart = $this->sprintService->getSprintBurndown($sprintObject);
|
||||
|
||||
return ['chart' => $sprintChart, 'currentSprintId' => $sprintObject->id];
|
||||
}
|
||||
|
||||
return ['chart' => $sprintChart, 'currentSprintId' => false];
|
||||
}
|
||||
|
||||
$sprintChart = $this->sprintService->getSprintBurndown($allSprints[0]);
|
||||
|
||||
return ['chart' => $sprintChart, 'currentSprintId' => $allSprints[0]->id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the daily report ingestion for the session's current project.
|
||||
*
|
||||
* Not @api: an internal web-path helper (called by the gated Reports\Controllers\Show after
|
||||
* dispatch enforcement). It reads session('currentProject'), so an RPC caller would have no
|
||||
* meaningful project binding — and it was needlessly RPC-exposed before.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function dailyIngestion(): void
|
||||
{
|
||||
$this->runIngestionForProject(session('currentProject'));
|
||||
}
|
||||
|
||||
protected function runIngestionForProject(int $projectId): void
|
||||
{
|
||||
|
||||
if (Cache::has('dailyReports-'.$projectId) === false || Cache::get('dailyReports-'.$projectId) < dtHelper()->dbNow()->endOfDay()) {
|
||||
|
||||
// Check if the dailyingestion cycle was executed already. There should be one entry for backlog and one entry for current sprint (unless there is no current sprint
|
||||
// Get current Sprint Id, if no sprint available, dont run the sprint burndown
|
||||
|
||||
$lastEntries = $this->reportRepository->checkLastReportEntries($projectId);
|
||||
|
||||
// If we receive 2 entries we have a report already. If we have one entry then we ran the backlog one and that means there was no current sprint.
|
||||
if (count($lastEntries) == 0) {
|
||||
$currentSprint = $this->sprintRepository->getCurrentSprint($projectId);
|
||||
|
||||
if ($currentSprint !== false) {
|
||||
$sprintReport = $this->reportRepository->runTicketReport($projectId, $currentSprint->id);
|
||||
if ($sprintReport !== false) {
|
||||
$this->reportRepository->addReport($sprintReport);
|
||||
}
|
||||
}
|
||||
|
||||
$backlogReport = $this->reportRepository->runTicketReport($projectId, '');
|
||||
|
||||
if ($backlogReport !== false) {
|
||||
|
||||
$this->reportRepository->addReport($backlogReport);
|
||||
|
||||
Cache::put('dailyReports-'.$projectId, dtHelper()->dbNow()->endOfDay(), 14400); // 4hours
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public function cronDailyIngestion(): void
|
||||
{
|
||||
$projects = $this->projectRepository->getAll();
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$this->runIngestionForProject($project['id']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a project's stored report history, authorized against the requested project.
|
||||
*
|
||||
* $projectId is typed int so the param is REQUIRED and non-null: a JSON-RPC caller cannot
|
||||
* pass null to make PermissionEnforcer::resolveProjectId() fall back to the session project
|
||||
* (its isset() check treats explicit null as absent), which would dodge the per-target gate.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')]
|
||||
public function getFullReport(int $projectId): false|array
|
||||
{
|
||||
return $this->reportRepository->getFullReport($projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a project's current ticket report, authorized against the requested project.
|
||||
* The repository scopes by BOTH projectId and sprint, so a foreign sprint id yields no rows.
|
||||
*
|
||||
* $projectId is typed int for the same reason as getFullReport() — it keeps the dispatch gate
|
||||
* bound to the requested project (no null → session fallback). $sprintId stays mixed because
|
||||
* the empty string is the meaningful "backlog" selector.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
#[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')]
|
||||
public function getRealtimeReport(int $projectId, $sprintId): array|bool
|
||||
{
|
||||
return $this->reportRepository->runTicketReport($projectId, $sprintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the anonymous telemetry payload (instance-wide usage counts + company GUID).
|
||||
*
|
||||
* Not @api: system-level data source for sendAnonymousTelemetry() only. Exposing it over
|
||||
* JSON-RPC let any authenticated user read instance-wide aggregates (user/project/feature
|
||||
* counts) — reconnaissance, not a user capability.
|
||||
*/
|
||||
public function getAnonymousTelemetry(
|
||||
IdeaRepository $ideaRepository,
|
||||
UserRepository $userRepository,
|
||||
ClientRepository $clientRepository,
|
||||
CommentRepository $commentsRepository,
|
||||
TimesheetRepository $timesheetRepo,
|
||||
BlueprintsRepository $blueprintsRepo
|
||||
): array {
|
||||
|
||||
// Get anonymous company guid
|
||||
$companyId = $this->settings->getCompanyId();
|
||||
|
||||
self::dispatch_event('beforeTelemetrySend', ['companyId' => $companyId]);
|
||||
|
||||
$companyLang = $this->settings->getSetting('companysettings.language');
|
||||
if ($companyLang != '' && $companyLang !== false) {
|
||||
$currentLanguage = $companyLang;
|
||||
} else {
|
||||
$currentLanguage = $this->config->language;
|
||||
}
|
||||
|
||||
$projectStatusCount = $this->getProjectStatusReport();
|
||||
|
||||
$taskSentiment = $this->generateTicketReactionsReport();
|
||||
|
||||
$telemetry = [
|
||||
'date' => '',
|
||||
'companyId' => $companyId,
|
||||
'env' => 'oss',
|
||||
'version' => $this->appSettings->appVersion,
|
||||
'language' => $currentLanguage,
|
||||
'numUsers' => $userRepository->getNumberOfUsers(),
|
||||
'lastUserLogin' => $userRepository->getLastLogin(),
|
||||
|
||||
'numProjects' => $this->projectRepository->getNumberOfProjects(null, 'project'),
|
||||
'numProjectsGreen' => $projectStatusCount['green'] ?? 0,
|
||||
'numProjectsYellow' => $projectStatusCount['yellow'] ?? 0,
|
||||
'numProjectsRed' => $projectStatusCount['red'] ?? 0,
|
||||
'numProjectsNone' => $projectStatusCount['none'] ?? 0,
|
||||
|
||||
'numStrategies' => $this->projectRepository->getNumberOfProjects(null, 'strategy'),
|
||||
'numPrograms' => $this->projectRepository->getNumberOfProjects(null, 'program'),
|
||||
'numClients' => $clientRepository->getNumberOfClients(),
|
||||
'numComments' => $commentsRepository->countComments(),
|
||||
'numMilestones' => $this->ticketRepository->getNumberOfMilestones(),
|
||||
'numTickets' => $this->ticketRepository->getNumberOfAllTickets(),
|
||||
|
||||
'numBoards' => $ideaRepository->getNumberOfBoards(),
|
||||
|
||||
'numIdeaItems' => $ideaRepository->getNumberOfIdeas(),
|
||||
'numHoursBooked' => $timesheetRepo->getHoursBooked(),
|
||||
|
||||
'numResearchBoards' => $blueprintsRepo->getNumberOfBoards(null, 'leancanvas'),
|
||||
'numResearchItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'leancanvas'),
|
||||
|
||||
'numRetroBoards' => $blueprintsRepo->getNumberOfBoards(null, 'retroscanvas'),
|
||||
'numRetroItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'retroscanvas'),
|
||||
|
||||
'numGoalBoards' => $blueprintsRepo->getNumberOfBoards(null, 'goalcanvas'),
|
||||
'numGoalItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'goalcanvas'),
|
||||
|
||||
'numValueCanvasBoards' => $blueprintsRepo->getNumberOfBoards(null, 'valuecanvas'),
|
||||
'numValueCanvasItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'valuecanvas'),
|
||||
|
||||
'numMinEmpathyBoards' => $blueprintsRepo->getNumberOfBoards(null, 'minempathycanvas'),
|
||||
'numMinEmpathyItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'minempathycanvas'),
|
||||
|
||||
'numOBMBoards' => $blueprintsRepo->getNumberOfBoards(null, 'obmcanvas'),
|
||||
'numOBMItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'obmcanvas'),
|
||||
|
||||
'numSWOTBoards' => $blueprintsRepo->getNumberOfBoards(null, 'swotcanvas'),
|
||||
'numSWOTItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'swotcanvas'),
|
||||
|
||||
'numSBBoards' => $blueprintsRepo->getNumberOfBoards(null, 'sbcanvas'),
|
||||
'numSBItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'sbcanvas'),
|
||||
|
||||
'numRISKSBoards' => $blueprintsRepo->getNumberOfBoards(null, 'riskscanvas'),
|
||||
'numRISKSItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'riskscanvas'),
|
||||
|
||||
'numEABoards' => $blueprintsRepo->getNumberOfBoards(null, 'eacanvas'),
|
||||
'numEAItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'eacanvas'),
|
||||
|
||||
'numINSIGHTSBoards' => $blueprintsRepo->getNumberOfBoards(null, 'insightscanvas'),
|
||||
'numINSIGHTSItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'insightscanvas'),
|
||||
|
||||
'numWikiBoards' => $blueprintsRepo->getNumberOfBoards(null, 'wiki'),
|
||||
'numWikiItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'wiki'),
|
||||
|
||||
'numTaskSentimentAngry' => $taskSentiment['🤬'] ?? 0,
|
||||
'numTaskSentimentDisgust' => $taskSentiment['🤢'] ?? 0,
|
||||
'numTaskSentimentUnhappy' => $taskSentiment['🙁'] ?? 0,
|
||||
'numTaskSentimentNeutral' => $taskSentiment['😐'] ?? 0,
|
||||
'numTaskSentimentHappy' => $taskSentiment['🙂'] ?? 0,
|
||||
'numTaskSentimentLove' => $taskSentiment['😍'] ?? 0,
|
||||
'numTaskSentimenUnicorn' => $taskSentiment['🦄'] ?? 0,
|
||||
|
||||
'serverSoftware' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
|
||||
'phpUname' => php_uname(),
|
||||
'isDocker' => $this->isRunningInDocker(),
|
||||
'phpSapiName' => php_sapi_name(),
|
||||
'phpOs' => PHP_OS,
|
||||
|
||||
];
|
||||
|
||||
$telemetry = self::dispatch_filter('beforeReturnTelemetry', $telemetry);
|
||||
|
||||
return $telemetry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the daily anonymous telemetry ping (throttled to once a day, opt-out respected).
|
||||
*
|
||||
* Not @api: a system task invoked by the scheduler (register.php cron, no session user) and
|
||||
* the dashboard's lazy trigger — never a user-invokable RPC method (it makes an outbound
|
||||
* HTTP request).
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function sendAnonymousTelemetry(): bool|PromiseInterface
|
||||
{
|
||||
|
||||
// Only send once a day
|
||||
|
||||
$allowTelemetry = app('config')->allowTelemetry ?? true;
|
||||
|
||||
if ($allowTelemetry === true) {
|
||||
$date_utc = new DateTime('now', new DateTimeZone('UTC'));
|
||||
$today = $date_utc->format('Y-m-d');
|
||||
$lastUpdate = $this->settings->getSetting('companysettings.telemetry.lastUpdate');
|
||||
|
||||
if ($lastUpdate != $today) {
|
||||
$telemetry = app()->call([$this, 'getAnonymousTelemetry']);
|
||||
$telemetry['date'] = $today;
|
||||
|
||||
// Do the curl
|
||||
$httpClient = new Client;
|
||||
|
||||
try {
|
||||
|
||||
$data_string = json_encode($telemetry);
|
||||
|
||||
$promise = $httpClient->postAsync('https://telemetry.leantime.io', [
|
||||
'form_params' => [
|
||||
'telemetry' => $data_string,
|
||||
],
|
||||
// Short connect timeout so an offline/air-gapped server (or a
|
||||
// CI runner with no egress) fails fast instead of blocking the
|
||||
// dashboard's Welcome widget — and saturating PHP-FPM workers —
|
||||
// for minutes. The previous 480s total timeout hung the page
|
||||
// when telemetry was unreachable. (#3372/#3373)
|
||||
'connect_timeout' => 2,
|
||||
'timeout' => 5,
|
||||
])->then(function ($response) use ($today) {
|
||||
$this->settings->saveSetting('companysettings.telemetry.lastUpdate', $today);
|
||||
});
|
||||
|
||||
return $promise;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opts the whole instance out of telemetry (writes companysettings.telemetry.active=false).
|
||||
*
|
||||
* Not @api: an instance-wide settings MUTATION. It is invoked internally by the admin-gated
|
||||
* company-settings save (Setting::saveCompanySettings) — over JSON-RPC it previously let ANY
|
||||
* authenticated user flip the company-wide telemetry setting.
|
||||
*
|
||||
* @return false|void
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function optOutTelemetry()
|
||||
{
|
||||
$date_utc = new DateTime('now', new DateTimeZone('UTC'));
|
||||
$today = $date_utc->format('Y-m-d');
|
||||
|
||||
$companyId = $this->settings->getCompanyId();
|
||||
|
||||
$telemetry = [
|
||||
'date' => '',
|
||||
'companyId' => $companyId,
|
||||
'version' => $this->appSettings->appVersion,
|
||||
'language' => '',
|
||||
'numUsers' => 0,
|
||||
'lastUserLogin' => 0,
|
||||
'numProjects' => 0,
|
||||
'numClients' => 0,
|
||||
'numComments' => 0,
|
||||
'numMilestones' => 0,
|
||||
'numTickets' => 0,
|
||||
|
||||
'numBoards' => 0,
|
||||
|
||||
'numIdeaItems' => 0,
|
||||
'numHoursBooked' => 0,
|
||||
|
||||
'numResearchBoards' => 0,
|
||||
'numResearchItems' => 0,
|
||||
|
||||
'numRetroBoards' => 0,
|
||||
'numRetroItems' => 0,
|
||||
|
||||
'numGoalBoards' => 0,
|
||||
'numGoalItems' => 0,
|
||||
|
||||
'numValueCanvasBoards' => 0,
|
||||
'numValueCanvasItems' => 0,
|
||||
|
||||
'numMinEmpathyBoards' => 0,
|
||||
'numMinEmpathyItems' => 0,
|
||||
|
||||
'numOBMBoards' => 0,
|
||||
'numOBMItems' => 0,
|
||||
|
||||
'numSWOTBoards' => 0,
|
||||
'numSWOTItems' => 0,
|
||||
|
||||
'numSBBoards' => 0,
|
||||
'numSBItems' => 0,
|
||||
|
||||
'numRISKSBoards' => 0,
|
||||
'numRISKSItems' => 0,
|
||||
|
||||
'numEABoards' => 0,
|
||||
'numEAItems' => 0,
|
||||
|
||||
'numINSIGHTSBoards' => 0,
|
||||
];
|
||||
|
||||
$telemetry['date'] = $today;
|
||||
|
||||
// Do the curl
|
||||
$httpClient = new Client;
|
||||
|
||||
try {
|
||||
$data_string = json_encode($telemetry);
|
||||
|
||||
$promise = $httpClient->postAsync('https://telemetry.leantime.io', [
|
||||
'form_params' => [
|
||||
'telemetry' => $data_string,
|
||||
],
|
||||
'timeout' => 5,
|
||||
])->then(function ($response) use ($today) {
|
||||
|
||||
$this->settings->saveSetting('companysettings.telemetry.lastUpdate', $today);
|
||||
session(['skipTelemetry' => true]);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
report($e);
|
||||
|
||||
session(['skipTelemetry' => true]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->settings->saveSetting('companysettings.telemetry.active', false);
|
||||
|
||||
session(['skipTelemetry' => true]);
|
||||
|
||||
try {
|
||||
$promise->wait();
|
||||
} catch (\Exception $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts ALL projects in the instance by status color — a telemetry data source.
|
||||
*
|
||||
* Not @api: instance-wide aggregation with no project scope; over JSON-RPC it disclosed the
|
||||
* whole company's project-health summary to any authenticated user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getProjectStatusReport()
|
||||
{
|
||||
|
||||
$projectStatus = $this->projectRepository->getAll();
|
||||
|
||||
$statusList = ['green' => 0, 'yellow' => 0, 'red' => 0, 'none' => 0];
|
||||
foreach ($projectStatus as $project) {
|
||||
if (isset($statusList[$project['status']])) {
|
||||
$statusList[$project['status']]++;
|
||||
} else {
|
||||
$statusList['none']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $statusList;
|
||||
}
|
||||
|
||||
public function generateTicketReactionsReport()
|
||||
{
|
||||
$reactionsRepo = app()->make(Reactions::class);
|
||||
$collectedReactions = $reactionsRepo->getReactionsByModule('ticketSentiment');
|
||||
|
||||
$reactions = [
|
||||
'🤬' => 0,
|
||||
'🤢' => 0,
|
||||
'🙁' => 0,
|
||||
'😐' => 0,
|
||||
'🙂' => 0,
|
||||
'😍' => 0,
|
||||
'🦄' => 0,
|
||||
'other' => 0,
|
||||
];
|
||||
|
||||
foreach ($collectedReactions as $reaction) {
|
||||
if (isset($reactions[$reaction['reaction']])) {
|
||||
$reactions[$reaction['reaction']] = $reactions[$reaction['reaction']] + $reaction['reactionCount'];
|
||||
}
|
||||
}
|
||||
|
||||
return $reactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Leantime is running in a Docker environment
|
||||
* Uses multiple detection methods and handles errors gracefully
|
||||
*/
|
||||
private function isRunningInDocker(): bool
|
||||
{
|
||||
// Method 1: Check for /.dockerenv file
|
||||
try {
|
||||
if (is_file('/.dockerenv')) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail if file access is restricted
|
||||
}
|
||||
|
||||
// Method 2: Check for Docker-specific environment variables
|
||||
if (getenv('DOCKER_CONTAINER') !== false || getenv('IS_DOCKER') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Method 3: Check cgroup info (works on Linux hosts)
|
||||
try {
|
||||
return strpos(file_get_contents('/proc/1/cgroup'), 'docker') !== false;
|
||||
} catch (\Exception $e) {
|
||||
return false; // Return false if all detection methods fail
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user