Reports:
Non-aggregated columns in Grid report export.
Normalized table mode for 2-dimensional Grid reports.
Ability to create internal reports via the UI.
Ability to show/hide and resize columns in the list report result view.
491 lines
15 KiB
PHP
491 lines
15 KiB
PHP
<?php
|
|
/***********************************************************************************
|
|
* The contents of this file are subject to the Extension License Agreement
|
|
* ("Agreement") which can be viewed at
|
|
* https://www.espocrm.com/extension-license-agreement/.
|
|
* By copying, installing downloading, or using this file, You have unconditionally
|
|
* agreed to the terms and conditions of the Agreement, and You may not use this
|
|
* file except in compliance with the Agreement. Under the terms of the Agreement,
|
|
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
|
|
* redistribute, market, publish, commercialize, or otherwise transfer rights or
|
|
* usage to the software or any modified version or derivative work of the software
|
|
* created by or for you.
|
|
*
|
|
* Copyright (C) 2015-2026 EspoCRM, Inc.
|
|
*
|
|
* License ID: 19bc86a68a7bb01f458cb391d43a9212
|
|
************************************************************************************/
|
|
|
|
namespace Espo\Modules\Advanced\Reports;
|
|
|
|
use Espo\Core\Exceptions\BadRequest;
|
|
use Espo\Core\Exceptions\Forbidden;
|
|
use Espo\Core\Field\DateTime;
|
|
use Espo\Core\Select\SearchParams;
|
|
use Espo\Core\Select\SelectBuilderFactory;
|
|
use Espo\Core\Select\Where\Item as WhereItem;
|
|
use Espo\Core\Utils\Language;
|
|
use Espo\Core\Utils\Metadata;
|
|
use Espo\Entities\Team;
|
|
use Espo\Entities\User;
|
|
use Espo\Modules\Advanced\Entities\Report;
|
|
use Espo\Modules\Advanced\Tools\Report\GridType\Result;
|
|
use Espo\Modules\Advanced\Tools\Report\ListType\Result as ListResult;
|
|
use Espo\Modules\Advanced\Tools\Report\ListType\SubReportParams;
|
|
use Espo\Modules\Crm\Entities\Call;
|
|
use Espo\Modules\Crm\Entities\Lead;
|
|
use Espo\Modules\Crm\Entities\Meeting;
|
|
use Espo\ORM\EntityManager;
|
|
use Espo\ORM\Name\Attribute;
|
|
use Espo\ORM\Query\Part\Condition as C;
|
|
use Espo\ORM\Query\Part\Expression as E;
|
|
use Espo\ORM\Query\Part\Order;
|
|
use Espo\ORM\Query\Part\WhereItem as WherePart;
|
|
use Espo\ORM\Query\SelectBuilder;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* @noinspection PhpUnused
|
|
*/
|
|
class LeadsByLastActivity implements GridReport
|
|
{
|
|
/** @var array<int, ?array{int, ?int}> */
|
|
private array $rangeList = [
|
|
[0, 7],
|
|
[7, 15],
|
|
[15, 30],
|
|
[30, 60],
|
|
[60, 120],
|
|
[120, null],
|
|
null,
|
|
];
|
|
|
|
/** @var string[] */
|
|
private array $ignoreStatusList;
|
|
|
|
public function __construct(
|
|
private EntityManager $entityManager,
|
|
private Metadata $metadata,
|
|
private Language $language,
|
|
private SelectBuilderFactory $selectBuilderFactory,
|
|
private Report $report,
|
|
) {
|
|
$this->ignoreStatusList = $this->metadata->get("entityDefs.Lead.fields.status.notActualOptions") ?? [];
|
|
}
|
|
|
|
private function executeSubReport(
|
|
SearchParams $searchParams,
|
|
SubReportParams $subReportParams,
|
|
?User $user,
|
|
): ListResult {
|
|
|
|
/** @var string $groupValue */
|
|
$groupValue = $subReportParams->getGroupValue();
|
|
|
|
$groupIndex = $subReportParams->getGroupIndex();
|
|
|
|
$selectBuilder = $this->selectBuilderFactory
|
|
->create()
|
|
->from(Lead::ENTITY_TYPE)
|
|
->withStrictAccessControl()
|
|
->withSearchParams($searchParams);
|
|
|
|
if ($user) {
|
|
$selectBuilder->forUser($user);
|
|
}
|
|
|
|
try {
|
|
$queryBuilder = $selectBuilder->buildQueryBuilder();
|
|
} catch (BadRequest|Forbidden $e) {
|
|
throw new RuntimeException($e->getMessage(), 0, $e);
|
|
}
|
|
|
|
if (!$groupIndex) {
|
|
if ($groupValue === '-') {
|
|
$range = null;
|
|
} else {
|
|
$range = explode('-', $groupValue);
|
|
|
|
if (empty($range[1])) {
|
|
$range[1] = null;
|
|
}
|
|
|
|
foreach ($range as $i => $it) {
|
|
if ($it !== null) {
|
|
$range[$i] = intval($it);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$groupIndex) {
|
|
/** @var ?array{?int, ?int} $range */
|
|
|
|
$queryBuilder->where(
|
|
$this->getWherePart($range)
|
|
);
|
|
|
|
$queryBuilder->where(['status!=' => $this->ignoreStatusList]);
|
|
|
|
if ($subReportParams->hasGroupValue2()) {
|
|
$queryBuilder->where(['status' => $subReportParams->getGroupValue2()]);
|
|
}
|
|
} else {
|
|
$queryBuilder->where(['status' => $groupValue]);
|
|
}
|
|
|
|
$this->applyTeamFilter($queryBuilder);
|
|
|
|
$query = $queryBuilder->build();
|
|
|
|
$collection = $this->entityManager
|
|
->getRDBRepository(Lead::ENTITY_TYPE)
|
|
->clone($query)
|
|
->find();
|
|
|
|
$count = $this->entityManager
|
|
->getRDBRepository(Lead::ENTITY_TYPE)
|
|
->clone($query)
|
|
->count();
|
|
|
|
return new ListResult($collection, $count);
|
|
}
|
|
|
|
public function runSubReport(SearchParams $searchParams, SubReportParams $subReportParams, ?User $user): ListResult
|
|
{
|
|
return $this->executeSubReport($searchParams, $subReportParams, $user);
|
|
}
|
|
|
|
public function run(?WhereItem $where, ?User $user): Result
|
|
{
|
|
$reportData = $this->getDataResults();
|
|
|
|
$columns = ['COUNT:id'];
|
|
$groupBy = ['RANGE', 'status'];
|
|
|
|
$group1Sums = [];
|
|
|
|
$grouping = [[], []];
|
|
|
|
foreach ($this->rangeList as $i => $range) {
|
|
$grouping[0][] = $this->getStringRange($i);
|
|
}
|
|
|
|
foreach ($reportData as $range => $d1) {
|
|
$group1Sums[$range] = [
|
|
'COUNT:id' => 0
|
|
];
|
|
|
|
foreach ($d1 as $d2) {
|
|
$group1Sums[$range]['COUNT:id'] += $d2['COUNT:id'];
|
|
}
|
|
}
|
|
|
|
$statusList = $this->metadata->get('entityDefs.Lead.fields.status.options', []);
|
|
|
|
foreach ($statusList as $status) {
|
|
if (!in_array($status, $this->ignoreStatusList)) {
|
|
$grouping[1][] = $status;
|
|
}
|
|
}
|
|
|
|
$columnNameMap = [
|
|
'COUNT:id' => $this->language->translateLabel('COUNT', 'functions', 'Report'),
|
|
];
|
|
|
|
$groupValueMap = [
|
|
'RANGE' => [],
|
|
'status' => [],
|
|
];
|
|
|
|
foreach ($this->rangeList as $i => $r) {
|
|
$groupValueMap['RANGE'][$this->getStringRange($i)] = $this->getRangeTranslation($i);
|
|
}
|
|
|
|
foreach ($grouping[1] as $status) {
|
|
/** @var string $status */
|
|
|
|
$groupValueMap['status'][$status] = $this->language->translateOption($status, 'status', 'Lead');
|
|
}
|
|
|
|
$sums = (object) [];
|
|
|
|
$sum = 0;
|
|
|
|
foreach ($grouping[0] as $group) {
|
|
if (
|
|
!isset($group1Sums[$group]) ||
|
|
/** @phpstan-ignore-next-line */
|
|
!isset($group1Sums[$group][$columns[0]])
|
|
) {
|
|
$group1Sums[$group][$columns[0]] = 0;
|
|
}
|
|
|
|
$sum += $group1Sums[$group][$columns[0]];
|
|
}
|
|
|
|
$sums->{$columns[0]} = $sum;
|
|
|
|
foreach ($reportData as $k => $v) {
|
|
$reportData[$k] = (object) $v;
|
|
|
|
foreach ($v as $k1 => $v1) {
|
|
if (is_array($v1)) {
|
|
$reportData[$k]->$k1 = (object) $v1;
|
|
}
|
|
}
|
|
}
|
|
|
|
$reportData = (object) $reportData;
|
|
|
|
$result = new Result(
|
|
entityType: Lead::ENTITY_TYPE,
|
|
groupByList: $groupBy,
|
|
columnList: $columns,
|
|
numericColumnList: $columns,
|
|
summaryColumnList: $columns,
|
|
nonSummaryColumnList: [],
|
|
subListColumnList: [],
|
|
aggregatedColumnList: [],
|
|
sums: $sums,
|
|
groupValueMap: $groupValueMap,
|
|
columnNameMap: $columnNameMap,
|
|
columnTypeMap: [],
|
|
grouping: $grouping,
|
|
reportData: $reportData,
|
|
chartType: 'BarVertical',
|
|
noSubReport: false,
|
|
);
|
|
|
|
$result->setGroup1Sums((object) $group1Sums);
|
|
$result->setGroup1NonSummaryColumnList([]);
|
|
$result->setGroup2NonSummaryColumnList([]);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param int $i
|
|
* @return string
|
|
*/
|
|
private function getStringRange($i): string
|
|
{
|
|
$range = $this->rangeList[$i];
|
|
|
|
if (!$range) {
|
|
return '-';
|
|
}
|
|
|
|
return $range[0] . '-' . $range[1];
|
|
}
|
|
|
|
/**
|
|
* @param int $i
|
|
*/
|
|
private function getRangeTranslation($i): string
|
|
{
|
|
$range = $this->rangeList[$i];
|
|
|
|
if ($range === null) {
|
|
return $this->language->translateLabel('never', 'labels', 'Report');
|
|
}
|
|
|
|
if (empty($range[1])) {
|
|
return '>' . $range[0] . ' ' . $this->language->translateLabel('days', 'labels', 'Report');
|
|
}
|
|
|
|
return $range[0] . '-' . $range[1] . ' ' . $this->language->translateLabel('days', 'labels', 'Report');
|
|
}
|
|
|
|
/**
|
|
* @param ?array{?int, ?int} $range
|
|
*/
|
|
private function getWherePart(?array $range): WherePart
|
|
{
|
|
$completedStatusList1 = $this->metadata->get(['scopes', 'Call', 'completedStatusList']) ?? [];
|
|
$completedStatusList2 = $this->metadata->get(['scopes', 'Meeting', 'completedStatusList']) ?? [];
|
|
|
|
$subQueryBuilder1 = SelectBuilder::create()
|
|
->from(Call::ENTITY_TYPE, 'event')
|
|
->join('CallLead', 'm',
|
|
C::and(
|
|
C::equal(
|
|
E::column('event.id'),
|
|
E::column('m.callId')
|
|
),
|
|
C::equal(E::column('m.deleted'), false)
|
|
)
|
|
)
|
|
->where(
|
|
C::equal(
|
|
E::column('m.leadId'),
|
|
E::column('lead.id')
|
|
)
|
|
)
|
|
->where(['status' => $completedStatusList1])
|
|
->limit(1);
|
|
|
|
$subQueryBuilder2 = SelectBuilder::create()
|
|
->from(Meeting::ENTITY_TYPE, 'event')
|
|
->join('LeadMeeting', 'm',
|
|
C::and(
|
|
C::equal(
|
|
E::column('event.id'),
|
|
E::column('m.meetingId')
|
|
),
|
|
C::equal(E::column('m.deleted'), false)
|
|
)
|
|
)
|
|
->where(
|
|
C::equal(
|
|
E::column('m.leadId'),
|
|
E::column('lead.id')
|
|
)
|
|
)
|
|
->where(['status' => $completedStatusList2])
|
|
->limit(1);
|
|
|
|
$subQueryExists1 = SelectBuilder::create()
|
|
->clone($subQueryBuilder1->build())
|
|
->select(E::column('id'))
|
|
->build();
|
|
|
|
$subQueryExists2 = SelectBuilder::create()
|
|
->clone($subQueryBuilder2->build())
|
|
->select(E::column('id'))
|
|
->build();
|
|
|
|
if (!$range) {
|
|
return C::and(
|
|
C::not(C::exists($subQueryExists1)),
|
|
C::not(C::exists($subQueryExists2))
|
|
);
|
|
}
|
|
|
|
$select = E::max(E::column('dateStart'));
|
|
|
|
$subQuery1 = $subQueryBuilder1
|
|
->select($select)
|
|
->order($select, Order::DESC)
|
|
->build();
|
|
|
|
$subQuery2 = $subQueryBuilder2
|
|
->select($select)
|
|
->order($select, Order::DESC)
|
|
->build();
|
|
|
|
if (!$range[1]) {
|
|
$day = DateTime::createNow()
|
|
->addDays(- $range[0])
|
|
->toString();
|
|
|
|
return C::or(
|
|
C::and(
|
|
C::exists($subQueryExists1),
|
|
C::greaterOrEqual(E::value($day), $subQuery1),
|
|
),
|
|
C::and(
|
|
C::exists($subQueryExists2),
|
|
C::greaterOrEqual(E::value($day), $subQuery2),
|
|
)
|
|
);
|
|
}
|
|
|
|
$day1 = DateTime::createNow()
|
|
->addDays(- $range[0])
|
|
->toString();
|
|
|
|
$day2 = DateTime::createNow()
|
|
->addDays(- $range[1])
|
|
->toString();
|
|
|
|
return C::or(
|
|
C::and(
|
|
C::exists($subQueryExists1),
|
|
C::lessOrEqual(E::value($day2), $subQuery1),
|
|
C::greater(E::value($day1), $subQuery1)
|
|
),
|
|
C::and(
|
|
C::exists($subQueryExists2),
|
|
C::lessOrEqual(E::value($day2), $subQuery2),
|
|
C::greater(E::value($day1), $subQuery2)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<string, array<string, int>>>
|
|
*/
|
|
private function getDataResults(): array
|
|
{
|
|
$resultData = [];
|
|
|
|
foreach ($this->rangeList as $i => $range) {
|
|
$where = $this->getWherePart($range);
|
|
|
|
$queryBuilder = SelectBuilder::create()
|
|
->from(Lead::ENTITY_TYPE)
|
|
->select(
|
|
E::count(E::column('id')),
|
|
'COUNT:id'
|
|
)
|
|
->select('status')
|
|
->where(['status!=' => $this->ignoreStatusList])
|
|
->where($where)
|
|
->group('status');
|
|
|
|
$this->applyTeamFilter($queryBuilder);
|
|
|
|
$query = $queryBuilder->build();
|
|
|
|
$sth = $this->entityManager->getQueryExecutor()->execute($query);
|
|
|
|
$dateString = $this->getStringRange($i);
|
|
|
|
foreach ($sth->fetchAll() as $row) {
|
|
if (!array_key_exists($dateString, $resultData)) {
|
|
$resultData[$dateString] = [];
|
|
}
|
|
|
|
/** @var string $status */
|
|
$status = $row['status'];
|
|
|
|
$resultData[$dateString][$status] = [
|
|
'COUNT:id' => (int) $row['COUNT:id'],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $resultData;
|
|
}
|
|
|
|
private function getFilterTeamId(): ?string
|
|
{
|
|
return $this->report->getInternalParams()->teamId ?? null;
|
|
}
|
|
|
|
private function applyTeamFilter(SelectBuilder $queryBuilder): void
|
|
{
|
|
$teamId = $this->getFilterTeamId();
|
|
|
|
if (!$teamId) {
|
|
return;
|
|
}
|
|
|
|
$queryBuilder->where(
|
|
C::in(
|
|
E::column(Attribute::ID),
|
|
SelectBuilder::create()
|
|
->select('entityId')
|
|
->from(Team::RELATIONSHIP_ENTITY_TEAM)
|
|
->where([
|
|
'entityType' => Lead::ENTITY_TYPE,
|
|
'teamId' => $teamId,
|
|
'deleted' => false,
|
|
])
|
|
->build()
|
|
)
|
|
);
|
|
}
|
|
}
|