OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Reports\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
/**
* Bulk, session-free queries backing the report engine.
*
* Every method takes an explicit set of project ids plus the requesting user's context
* (user id, client id) so the engine can serve project, plan, strategy and — later —
* company-level reports without depending on session('currentProject'). The access
* predicate mirrors Tickets\Repositories\Tickets::getAllMilestones: assigned via
* zp_relationuserproject, open to all, open to the user's client, or requestor role >= 40.
*/
class ReportEngine
{
private ConnectionInterface $connection;
private DatabaseHelper $dbHelper;
public function __construct(DbCore $db, DatabaseHelper $dbHelper)
{
$this->connection = $db->getConnection();
$this->dbHelper = $dbHelper;
}
/**
* All milestones of the given projects the requesting user may see. No date filtering —
* the service buckets rows into completed/in-progress/overdue/upcoming.
*
* @param int[] $projectIds
* @return array<int, object>
*/
public function getMilestonesForProjects(array $projectIds, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_tickets')
->select([
'zp_tickets.id',
'zp_tickets.headline',
'zp_tickets.description',
'zp_tickets.outcomeImpact',
'zp_tickets.date',
'zp_tickets.projectId',
'zp_tickets.status',
'zp_tickets.editFrom',
'zp_tickets.editTo',
'zp_tickets.modified',
'zp_projects.name as projectName',
])
->selectRaw("'milestone' AS ".$this->dbHelper->wrapColumn('type'))
->selectRaw("CASE WHEN (zp_tickets.tags IS NULL OR zp_tickets.tags = '') THEN 'var(--grey)' ELSE zp_tickets.tags END AS tags")
->leftJoin('zp_projects', 'zp_tickets.projectId', '=', 'zp_projects.id')
->where('zp_tickets.type', '=', 'milestone')
->whereIn('zp_tickets.projectId', $projectIds)
->orderBy('zp_tickets.editTo');
$this->applyAccessPredicate($query, $userId, $clientId);
return $query->get()->all();
}
/**
* Status-change history rows for the given tickets, oldest first. The service derives
* completion dates from the latest transition into a DONE-type status.
*
* @param int[] $ticketIds
* @return array<int, object> Rows with ticketId, changeValue (new status id), dateModified
*/
public function getStatusHistoryForTickets(array $ticketIds): array
{
if ($ticketIds === []) {
return [];
}
return $this->connection->table('zp_tickethistory')
->select(['ticketId', 'changeValue', 'dateModified'])
->whereIn('ticketId', $ticketIds)
->where('changeType', '=', 'status')
->orderBy('dateModified')
->get()
->all();
}
/**
* Due-date change history ("toDate") for the given tickets within a window, oldest first.
* Feeds the commitment-integrity view (milestones pushed out of the period).
*
* @param int[] $ticketIds
* @return array<int, object> Rows with ticketId, changeValue (new due date), dateModified
*/
public function getDueDateChangesForTickets(array $ticketIds, string $fromDb, string $toDb): array
{
if ($ticketIds === []) {
return [];
}
return $this->connection->table('zp_tickethistory')
->select(['ticketId', 'changeValue', 'dateModified'])
->whereIn('ticketId', $ticketIds)
->where('changeType', '=', 'toDate')
->whereBetween('dateModified', [$fromDb, $toDb])
->orderBy('dateModified')
->get()
->all();
}
/**
* Non-milestone tickets belonging to the given milestones (for key-task lists and
* milestone progress computation).
*
* @param int[] $milestoneIds
* @return array<int, object>
*/
public function getTasksForMilestones(array $milestoneIds): array
{
if ($milestoneIds === []) {
return [];
}
return $this->connection->table('zp_tickets')
->select([
'id',
'headline',
'status',
'projectId',
'milestoneid',
'storypoints',
'priority',
'editTo',
'dateToFinish',
])
->whereIn('milestoneid', $milestoneIds)
->where('type', '<>', 'milestone')
->orderBy('milestoneid')
->get()
->all();
}
/**
* Project status updates (comments on module "project" carrying a green/yellow/red status)
* within the given window, newest first.
*
* @param int[] $projectIds
* @return array<int, object>
*/
public function getStatusUpdatesForProjects(array $projectIds, string $fromDb, string $toDb, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_comment')
->select([
'zp_comment.id',
'zp_comment.moduleId as projectId',
'zp_comment.text',
'zp_comment.date',
'zp_comment.status',
'zp_user.firstname as authorFirstname',
'zp_user.lastname as authorLastname',
'zp_user.profileId as authorProfileId',
])
->leftJoin('zp_user', 'zp_comment.userId', '=', 'zp_user.id')
->leftJoin('zp_projects', 'zp_comment.moduleId', '=', 'zp_projects.id')
->where('zp_comment.module', '=', 'project')
->whereIn('zp_comment.moduleId', $projectIds)
->whereBetween('zp_comment.date', [$fromDb, $toDb])
->orderByDesc('zp_comment.date');
$this->applyAccessPredicate($query, $userId, $clientId);
return $query->get()->all();
}
/**
* The most recent status update per project regardless of period (feeds the status pill).
*
* @param int[] $projectIds
* @return array<int, object> Keyed by projectId
*/
public function getLatestStatusUpdateForProjects(array $projectIds): array
{
if ($projectIds === []) {
return [];
}
$latest = $this->connection->table('zp_comment')
->selectRaw('moduleId, MAX(date) as maxDate')
->where('module', '=', 'project')
->whereIn('moduleId', $projectIds)
->groupBy('moduleId');
$rows = $this->connection->table('zp_comment')
->select([
'zp_comment.moduleId as projectId',
'zp_comment.text',
'zp_comment.date',
'zp_comment.status',
'zp_user.firstname as authorFirstname',
'zp_user.lastname as authorLastname',
])
->joinSub($latest, 'latest', function ($join) {
$join->on('zp_comment.moduleId', '=', 'latest.moduleId')
->on('zp_comment.date', '=', 'latest.maxDate');
})
->leftJoin('zp_user', 'zp_comment.userId', '=', 'zp_user.id')
->where('zp_comment.module', '=', 'project')
->get()
->all();
$byProject = [];
foreach ($rows as $row) {
$byProject[(int) $row->projectId] = $row;
}
return $byProject;
}
/**
* Goals (goal-board canvas items) of the given projects.
*
* @param int[] $projectIds
* @return array<int, object>
*/
public function getGoalsForProjects(array $projectIds, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.title',
'zp_canvas_items.description',
'zp_canvas_items.status',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.setting',
'zp_canvas_items.kpi',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.canvasId',
'zp_canvas.projectId',
'zp_canvas.title as boardTitle',
])
->join('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->leftJoin('zp_projects', 'zp_canvas.projectId', '=', 'zp_projects.id')
->where('zp_canvas.type', '=', 'goalcanvas')
->where('zp_canvas_items.box', '=', 'goal')
->whereIn('zp_canvas.projectId', $projectIds)
->orderBy('zp_canvas.projectId')
->orderBy('zp_canvas_items.sortindex');
$this->applyAccessPredicate($query, $userId, $clientId);
return $query->get()->all();
}
/**
* Hours logged against the given projects within a window, grouped by project and
* milestone (tasks roll up to their milestone; unassigned work groups under 0).
*
* @param int[] $projectIds
* @return array<int, object> Rows with projectId, milestoneId, loggedHours
*/
public function getHoursLoggedForProjects(array $projectIds, string $fromDb, string $toDb): array
{
if ($projectIds === []) {
return [];
}
return $this->connection->table('zp_timesheets')
->selectRaw('zp_tickets.projectId AS '.$this->dbHelper->wrapColumn('projectId'))
->selectRaw('COALESCE(zp_tickets.milestoneid, 0) AS '.$this->dbHelper->wrapColumn('milestoneId'))
->selectRaw('SUM(zp_timesheets.hours) AS '.$this->dbHelper->wrapColumn('loggedHours'))
->join('zp_tickets', 'zp_timesheets.ticketId', '=', 'zp_tickets.id')
->whereIn('zp_tickets.projectId', $projectIds)
->whereBetween('zp_timesheets.workDate', [$fromDb, $toDb])
->groupBy('zp_tickets.projectId')
->groupByRaw('COALESCE(zp_tickets.milestoneid, 0)')
->get()
->all();
}
/**
* Core metadata of the given projects the requesting user may see.
*
* @param int[] $projectIds
* @return array<int, object> Keyed by project id
*/
public function getProjectsMeta(array $projectIds, int $userId, int $clientId): array
{
if ($projectIds === []) {
return [];
}
$query = $this->connection->table('zp_projects')
->select([
'zp_projects.id',
'zp_projects.name',
'zp_projects.details',
'zp_projects.clientId',
'zp_projects.state',
'zp_projects.start',
'zp_projects.end',
'zp_projects.type',
'zp_projects.parent',
'zp_clients.name as clientName',
])
->leftJoin('zp_clients', 'zp_projects.clientId', '=', 'zp_clients.id')
->whereIn('zp_projects.id', $projectIds);
$this->applyAccessPredicate($query, $userId, $clientId);
$byId = [];
foreach ($query->get()->all() as $row) {
$byId[(int) $row->id] = $row;
}
return $byId;
}
/**
* Applies the standard project access predicate: project assigned to the user, open to
* everyone, open to the user's client, or the requesting user is admin/owner (role >= 40).
* The query must already join zp_projects.
*/
private function applyAccessPredicate(Builder $query, int $userId, int $clientId): void
{
$query->leftJoin('zp_user as requestor', function ($join) use ($userId) {
$join->on('requestor.id', '=', $this->connection->raw((string) $userId));
});
$query->where(function ($q) use ($userId, $clientId) {
$q->whereIn('zp_projects.id', function ($subquery) use ($userId) {
$subquery->select('projectId')
->from('zp_relationuserproject')
->where('zp_relationuserproject.userId', $userId);
})
->orWhere('zp_projects.psettings', 'all')
->orWhere(function ($q2) use ($clientId) {
$q2->where('zp_projects.psettings', 'clients')
->where('zp_projects.clientId', $clientId);
})
->orWhere('requestor.role', '>=', 40);
});
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace Leantime\Domain\Reports\Repositories;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Reports\Models\Reports as ReportsModel;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
class Reports
{
private ConnectionInterface $db;
private DatabaseHelper $dbHelper;
/**
* __construct - get database connection
*/
public function __construct(DbCore $db, DatabaseHelper $dbHelper)
{
$this->db = $db->getConnection();
$this->dbHelper = $dbHelper;
}
/**
* Run ticket report for a project and optionally a sprint
*
* @throws BindingResolutionException
*/
public function runTicketReport(int $projectId, string $sprintId): array|bool
{
$ticketRepo = app()->make(TicketRepository::class);
$statusGroupsSQL = $ticketRepo->getStatusListGroupedByType($projectId);
// Parse status groups from SQL format to arrays for cross-database compatibility
$statusGroups = $this->dbHelper->parseStatusGroups($statusGroupsSQL);
// Build cross-database compatible date expression
$yesterdayDate = $this->dbHelper->yesterdayDate();
// Build cross-database compatible string aggregation
$ticketIds = $this->dbHelper->stringAggregate('zp_tickets.id');
// Build query with query builder
$query = $this->db->table('zp_tickets');
// Select sprint column or -1 based on whether sprintId is provided
if ($sprintId !== '') {
$query->selectRaw('sprint AS '.$this->dbHelper->wrapColumn('sprintId'));
} else {
$query->selectRaw('-1 AS '.$this->dbHelper->wrapColumn('sprintId'));
}
// Build the select statement with cross-database functions
$query->selectRaw($this->dbHelper->wrapColumn('projectId'))
->selectRaw("{$yesterdayDate} AS date")
->selectRaw('COUNT(DISTINCT zp_tickets.id) AS sum_todos')
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['NEW'] ?: [0]).') THEN 1 ELSE 0 END) AS sum_open_todos'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['INPROGRESS'] ?: [0]).') THEN 1 ELSE 0 END) AS sum_progres_todos'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['DONE'] ?: [0]).') THEN 1 ELSE 0 END) AS sum_closed_todos'
)
->selectRaw('SUM('.$this->dbHelper->wrapColumn('planHours').') AS sum_planned_hours')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('hourRemaining').') AS sum_estremaining_hours')
->selectRaw('SUM(zp_tickets.storypoints) AS sum_points')
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['NEW'] ?: [0]).') THEN zp_tickets.storypoints ELSE 0 END) AS sum_points_open'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['INPROGRESS'] ?: [0]).') THEN zp_tickets.storypoints ELSE 0 END) AS sum_points_progress'
)
->selectRaw(
'SUM(CASE WHEN status IN ('.implode(',', $statusGroups['DONE'] ?: [0]).') THEN zp_tickets.storypoints ELSE 0 END) AS sum_points_done'
)
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 1 THEN 1 ELSE 0 END) AS sum_todos_xs')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 2 THEN 1 ELSE 0 END) AS sum_todos_s')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 3 THEN 1 ELSE 0 END) AS sum_todos_m')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 5 THEN 1 ELSE 0 END) AS sum_todos_l')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 8 THEN 1 ELSE 0 END) AS sum_todos_xl')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints = 13 THEN 1 ELSE 0 END) AS sum_todos_xxl')
->selectRaw('SUM(CASE WHEN zp_tickets.storypoints IS NULL OR zp_tickets.storypoints = 0 THEN 1 ELSE 0 END) AS sum_todos_none')
->selectRaw("{$ticketIds} AS tickets")
->selectRaw('SUM('.$this->dbHelper->wrapColumn('planHours').') / COUNT(zp_tickets.id) AS daily_avg_hours_planned_todo')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('planHours').') / NULLIF(SUM(zp_tickets.storypoints), 0) AS daily_avg_hours_planned_point')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('hourRemaining').') / COUNT(zp_tickets.id) AS daily_avg_hours_remaining_todo')
->selectRaw('SUM('.$this->dbHelper->wrapColumn('hourRemaining').') / NULLIF(SUM(zp_tickets.storypoints), 0) AS daily_avg_hours_remaining_point')
->where('projectId', $projectId)
->where('zp_tickets.type', '<>', 'subtask')
->where('zp_tickets.type', '<>', 'milestone');
// Add sprint filter if provided
if ($sprintId !== '') {
$query->where('sprint', $sprintId)
->groupBy('projectId', 'sprint');
} else {
$query->groupBy('projectId');
}
$result = $query->first();
$valuesTickets = $result ? (array) $result : false;
$storyPoints = isset($valuesTickets['sum_points']) && $valuesTickets['sum_points'] > 0
? $valuesTickets['sum_points']
: 1;
// Timesheet Reports using query builder
$timesheetQuery = $this->db->table('zp_tickets')
->selectRaw('ROUND(CAST(SUM(zp_timesheets.hours) AS DECIMAL(10,2)), 2) AS sum_logged_hours')
->selectRaw('ROUND(CAST(SUM(zp_timesheets.hours) / NULLIF(COUNT(DISTINCT zp_tickets.id), 0) AS DECIMAL(10,2)), 2) AS daily_avg_hours_booked_todo')
->selectRaw('ROUND(CAST(SUM(zp_timesheets.hours) / ? AS DECIMAL(10,2)), 2) AS daily_avg_hours_booked_point', [$storyPoints])
->leftJoin('zp_timesheets', 'zp_tickets.id', '=', 'zp_timesheets.ticketId')
->where('projectId', $projectId)
->where('zp_tickets.type', '<>', 'subtask')
->where('zp_tickets.type', '<>', 'milestone');
if ($sprintId !== '') {
$timesheetQuery->where('sprint', $sprintId)
->groupBy('projectId', 'sprint');
} else {
$timesheetQuery->groupBy('projectId');
}
$timesheetResult = $timesheetQuery->first();
$valueTimesheets = $timesheetResult ? (array) $timesheetResult : false;
// Number of users
$projectService = app()->make(ProjectRepository::class);
$users = $projectService->getUsersAssignedToProject($projectId);
$numberOfUsers = is_array($users) ? count($users) : 0;
if (is_array($valuesTickets) && is_array($valueTimesheets)) {
$values = array_merge($valuesTickets, $valueTimesheets);
$values['sum_teammembers'] = $numberOfUsers;
} else {
$values = false;
}
return $values;
}
public function checkLastReportEntries(int $projectId): false|array
{
$results = $this->db->table('zp_stats')
->whereRaw($this->dbHelper->isYesterday('date'))
->where('projectId', $projectId)
->limit(2)
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
public function addReport(array|object $report): void
{
$report = (object) $report;
$this->db->table('zp_stats')->insert([
'sprintId' => $report->sprintId,
'projectId' => $report->projectId,
'date' => $report->date,
'sum_todos' => $report->sum_todos,
'sum_open_todos' => $report->sum_open_todos,
'sum_progres_todos' => $report->sum_progres_todos,
'sum_closed_todos' => $report->sum_closed_todos,
'sum_planned_hours' => $report->sum_planned_hours,
'sum_estremaining_hours' => $report->sum_estremaining_hours,
'sum_logged_hours' => $report->sum_logged_hours,
'sum_points' => $report->sum_points,
'sum_points_done' => $report->sum_points_done,
'sum_points_progress' => $report->sum_points_progress,
'sum_points_open' => $report->sum_points_open,
'sum_todos_xs' => $report->sum_todos_xs,
'sum_todos_s' => $report->sum_todos_s,
'sum_todos_m' => $report->sum_todos_m,
'sum_todos_l' => $report->sum_todos_l,
'sum_todos_xl' => $report->sum_todos_xl,
'sum_todos_xxl' => $report->sum_todos_xxl,
'sum_todos_none' => $report->sum_todos_none,
'tickets' => $report->tickets,
'daily_avg_hours_booked_todo' => $report->daily_avg_hours_booked_todo,
'daily_avg_hours_booked_point' => $report->daily_avg_hours_booked_point,
'daily_avg_hours_planned_todo' => $report->daily_avg_hours_planned_todo,
'daily_avg_hours_planned_point' => $report->daily_avg_hours_planned_point,
'daily_avg_hours_remaining_point' => $report->daily_avg_hours_remaining_point,
'daily_avg_hours_remaining_todo' => $report->daily_avg_hours_remaining_todo,
'sum_teammembers' => $report->sum_teammembers,
]);
}
/**
* @return array
*/
public function getSprintReport(int $sprint): array|false
{
$results = $this->db->table('zp_stats')
->where('sprintId', $sprint)
->orderBy('date', 'asc')
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
public function getBacklogReport(int $project): false|array
{
$results = $this->db->table('zp_stats')
->where('projectId', $project)
->where('sprintId', 0)
->orderBy('date', 'asc')
->limit(95)
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
public function getFullReport(int $project): false|array
{
$results = $this->db->table('zp_stats')
->select(
'date',
$this->db->raw('SUM(sum_todos) AS sum_todos'),
$this->db->raw('SUM(sum_open_todos) AS sum_open_todos'),
$this->db->raw('SUM(sum_progres_todos) AS sum_progres_todos'),
$this->db->raw('SUM(sum_closed_todos) AS sum_closed_todos'),
$this->db->raw('SUM(sum_planned_hours) AS sum_planned_hours'),
$this->db->raw('SUM(sum_estremaining_hours) AS sum_estremaining_hours'),
$this->db->raw('ROUND(CAST(SUM(sum_logged_hours) AS DECIMAL(10,2)), 2) AS sum_logged_hours'),
$this->db->raw('SUM(sum_points) AS sum_points'),
$this->db->raw('SUM(sum_points_done) AS sum_points_done'),
$this->db->raw('SUM(sum_points_progress) AS sum_points_progress'),
$this->db->raw('SUM(sum_points_open) AS sum_points_open'),
$this->db->raw('SUM(sum_todos_xs) AS sum_todos_xs'),
$this->db->raw('SUM(sum_todos_s) AS sum_todos_s'),
$this->db->raw('SUM(sum_todos_m) AS sum_todos_m'),
$this->db->raw('SUM(sum_todos_l) AS sum_todos_l'),
$this->db->raw('SUM(sum_todos_xl) AS sum_todos_xl'),
$this->db->raw('SUM(sum_todos_xxl) AS sum_todos_xxl'),
$this->db->raw('SUM(sum_todos_none) AS sum_todos_none'),
$this->db->raw('SUM('.$this->dbHelper->castAs('tickets', 'integer').') AS tickets'),
$this->db->raw('SUM(daily_avg_hours_booked_todo) AS daily_avg_hours_booked_todo'),
$this->db->raw('SUM(daily_avg_hours_booked_point) AS daily_avg_hours_booked_point'),
$this->db->raw('SUM(daily_avg_hours_planned_todo) AS daily_avg_hours_planned_todo'),
$this->db->raw('SUM(daily_avg_hours_planned_point) AS daily_avg_hours_planned_point'),
$this->db->raw('SUM(daily_avg_hours_remaining_point) AS daily_avg_hours_remaining_point'),
$this->db->raw('SUM(daily_avg_hours_remaining_todo) AS daily_avg_hours_remaining_todo')
)
->where('projectId', $project)
->where(function ($query) {
$query->where('sprintId', '<', 1)
->orWhereNull('sprintId');
})
->groupBy('date')
->orderBy('date', 'desc')
->limit(120)
->get();
return $results->map(function ($row) {
$report = new ReportsModel;
foreach ((array) $row as $key => $value) {
if (property_exists($report, $key)) {
$report->$key = $value;
}
}
return $report;
})->toArray();
}
}