Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Api;
use Espo\Core\Acl;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\SearchParamsFetcher;
use Espo\Core\Utils\Json;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\PreviewReportProvider;
use Espo\Modules\Advanced\Tools\Report\Service;
use JsonException;
use stdClass;
/**
* @noinspection PhpUnused
*/
class GetRunListPreview implements Action
{
public function __construct(
private Service $service,
private Acl $acl,
private User $user,
private SearchParamsFetcher $searchParamsFetcher,
private PreviewReportProvider $previewReportProvider,
) {}
public function process(Request $request): Response
{
$this->checkAccess();
$data = $this->fetchData($request);
$report = $this->previewReportProvider->get($data);
if ($report->getType() !== Report::TYPE_LIST) {
throw new BadRequest("Non-list type.");
}
$searchParams = $this->searchParamsFetcher->fetch($request);
// Passing the user is important.
$result = $this->service->reportRunList($report, $searchParams, $this->user);
return ResponseComposer::json([
'list' => $result->getCollection()->getValueMapList(),
'total' => $result->getTotal(),
'columns' => $result->getColumns(),
'columnsData' => $result->getColumnsData(),
]);
}
/**
* @throws BadRequest
*/
private function fetchData(Request $request): stdClass
{
try {
$data = Json::decode($request->getQueryParam('data'));
} catch (JsonException) {
throw new BadRequest("Bad data.");
}
if (!$data instanceof stdClass) {
throw new BadRequest("No data.");
}
return $data;
}
/**
* @throws Forbidden
*/
private function checkAccess(): void
{
if (!$this->acl->checkScope(Report::ENTITY_TYPE, AclTable::ACTION_CREATE)) {
throw new Forbidden("No 'create' access.");
}
if ($this->user->isPortal()) {
throw new Forbidden("No access from portal.");
}
}
}

View File

@@ -0,0 +1,114 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Api;
use Espo\Core\Acl;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Json;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\PreviewReportProvider;
use Espo\Modules\Advanced\Tools\Report\Service;
use JsonException;
use stdClass;
/**
* @noinspection PhpUnused
*/
class PostRunGridPreview implements Action
{
public function __construct(
private Service $service,
private Acl $acl,
private User $user,
private PreviewReportProvider $previewReportProvider,
) {}
public function process(Request $request): Response
{
$this->checkAccess();
$data = $this->fetchData($request);
$report = $this->previewReportProvider->get($data);
if (!in_array($report->getType(), [Report::TYPE_GRID, Report::TYPE_JOINT_GRID])) {
throw new BadRequest("Bad report type.");
}
$where = $request->getParsedBody()->where ?? null;
$whereItem = null;
if ($where) {
$whereItem = WhereItem::fromRawAndGroup(self::normalizeWhere($where));
}
// Passing the user is important.
$result = $this->service->reportRunGridOrJoint($report, $whereItem, $this->user);
return ResponseComposer::json($result->toRaw());
}
/**
* @throws BadRequest
*/
private function fetchData(Request $request): stdClass
{
$data = $request->getParsedBody()->data ?? null;
if (!$data instanceof stdClass) {
throw new BadRequest("No data.");
}
return $data;
}
/**
* @throws BadRequest
*/
private static function normalizeWhere(mixed $where): mixed
{
try {
return Json::decode(Json::encode($where), true);
} catch (JsonException) {
throw new BadRequest("Bad where");
}
}
/**
* @throws Forbidden
*/
private function checkAccess(): void
{
if (!$this->acl->checkScope(Report::ENTITY_TYPE, AclTable::ACTION_CREATE)) {
throw new Forbidden("No 'create' access.");
}
if ($this->user->isPortal()) {
throw new Forbidden("No access from portal.");
}
}
}

View File

@@ -0,0 +1,124 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Select\SearchParams;
use Espo\Core\Utils\Config;
use Espo\Entities\Attachment;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Entities\Report as ReportEntity;
use Espo\ORM\EntityManager;
class ExportService
{
private const LIST_REPORT_MAX_SIZE = 3000;
public function __construct(
private EntityManager $entityManager,
private Config $config,
private Service $service,
private SendingService $sendingService,
) {}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function prepareExportAttachment(Report $report, ?User $user = null): Attachment
{
$result = $this->prepareResult($report, $user);
$attachmentId = $this->sendingService->getExportAttachmentId($report, $result, null, $user);
if (!$attachmentId) {
throw new Error("Could not generate an export file for report {$report->getId()}.");
}
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($attachmentId);
if (!$attachment) {
throw new Error("Could not fetch the export attachment.");
}
$this->prepareAttachmentFields($attachment);
return $attachment;
}
private function getSendingListMaxCount(): int
{
return $this->config->get('reportSendingListMaxCount', self::LIST_REPORT_MAX_SIZE);
}
private function prepareListSearchParams(ReportEntity $report): SearchParams
{
$searchParams = SearchParams::create()
->withMaxSize($this->getSendingListMaxCount());
$orderByList = $report->getOrderByList();
if ($orderByList) {
$arr = explode(':', $orderByList);
/**
* @var 'ASC'|'DESC' $orderDirection
* @noinspection PhpRedundantVariableDocTypeInspection
*/
$orderDirection = strtoupper($arr[0]);
$searchParams = $searchParams
->withOrderBy($arr[1])
->withOrder($orderDirection);
}
return $searchParams;
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
private function prepareResult(ReportEntity $report, ?User $user): ListType\Result|GridType\Result
{
if ($report->getType() === ReportEntity::TYPE_LIST) {
$searchParams = $this->prepareListSearchParams($report);
return $this->service->runList($report->getId(), $searchParams, $user);
}
return $this->service->runGrid($report->getId(), null, $user);
}
private function prepareAttachmentFields(Attachment $attachment): void
{
$attachment->setRole(Attachment::ROLE_EXPORT_FILE);
$attachment->setParent(null);
$this->entityManager->saveEntity($attachment);
}
}

View File

@@ -0,0 +1,100 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Exceptions\Error\Body;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Metadata;
class FormulaChecker
{
/** @var string[] */
private array $allowedFunctionList = [
'ifThen',
'ifThenElse',
'env\\userAttribute',
'record\\attribute',
];
/** @var string[] */
private array $allowedNamespaceList = [
'datetime',
'number',
'string',
];
public function __construct(
private Metadata $metadata
) {}
public function sanitize(string $script): string
{
$script = str_replace('record\\attribute', 'report\\recordAttribute', $script);
if (!class_exists("Espo\\Core\\Formula\\Functions\\EnvGroup\\UserAttributeSafeType")) {
return $script;
}
return str_replace('env\\userAttribute', 'env\\userAttributeSafe', $script);
}
/**
* Check a formula script for a complex expression.
*
* @throws Forbidden
*/
public function check(string $script): void
{
$script = str_replace(["\n", "\r", "\t", ' '], '', $script);
$script = str_replace(';', ' ', $script);
preg_match_all('/[a-zA-Z1-9\\\\]*\(/', $script, $matches);
/** @phpstan-ignore-next-line */
if (!$matches) {
return;
}
$allowedFunctionList = array_merge(
$this->allowedFunctionList,
$this->metadata->get('app.advancedReport.allowedFilterFormulaFunctionList', [])
);
foreach ($matches[0] as $part) {
$part = substr($part, 0, -1);
if (in_array($part, $allowedFunctionList)) {
continue;
}
foreach ($this->allowedNamespaceList as $namespace) {
if (str_starts_with($part, $namespace . '\\')) {
continue 2;
}
}
throw Forbidden::createWithBody(
"Not allowed formula in filter.",
Body::create()
->withMessageTranslation('notAllowedFormulaInFilter', 'Report')
->encode()
);
}
}
}

View File

@@ -0,0 +1,634 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\AclManager;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Attachment;
use Espo\Entities\Template;
use Espo\Entities\User;
use Espo\Modules\Advanced\Core\Report\ExportXlsx;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\GridType\Helper as GridHelper;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use Espo\Modules\Advanced\Tools\Report\GridType\Util as GridUtil;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\ORM\EntityManager;
use Espo\Tools\Pdf\Data;
use Espo\Tools\Pdf\Service as PdfService;
use PhpOffice\PhpSpreadsheet\Writer\Exception;
use RuntimeException;
class GridExportService
{
private const STUB_KEY = '__STUB__';
public function __construct(
private EntityManager $entityManager,
private AclManager $aclManager,
private Metadata $metadata,
private Config $config,
private Language $language,
private Service $service,
private GridHelper $gridHelper,
private GridUtil $gridUtil,
private InjectableFactory $injectableFactory
) {}
/**
* @throws Forbidden
* @throws NotFound
* @throws Error
*/
public function exportXlsx(string $id, ?WhereItem $where, ?User $user = null): string
{
/** @var ?Report $report */
$report = $this->entityManager->getEntityById(Report::ENTITY_TYPE, $id);
if (!$report) {
throw new NotFound();
}
if ($user && !$this->aclManager->checkEntityRead($user, $report)) {
throw new Forbidden();
}
$contents = $this->buildXlsxContents($id, $where, $user);
$name = preg_replace("/([^\w\s\d\-_~,;:\[\]().])/u", '_', $report->getName()) . ' ' . date('Y-m-d');
$mimeType = $this->metadata->get(['app', 'export', 'formatDefs', 'xlsx', 'mimeType']);
$fileExtension = $this->metadata->get(['app', 'export', 'formatDefs', 'xlsx', 'fileExtension']);
$fileName = $name . '.' . $fileExtension;
$attachment = $this->entityManager->getNewEntity(Attachment::ENTITY_TYPE);
$attachment->set('name', $fileName);
$attachment->set('role', 'Export File');
$attachment->set('type', $mimeType);
$attachment->set('contents', $contents);
$attachment->set([
'relatedType' => Report::ENTITY_TYPE,
'relatedId' => $id,
]);
$this->entityManager->saveEntity($attachment);
return $attachment->getId();
}
/**
* @throws Error
* @throws Forbidden
* @throws NotFound
* @throws Exception
* @throws BadRequest
*/
public function buildXlsxContents(string $id, ?WhereItem $where, ?User $user = null): string
{
/** @var ?Report $report */
$report = $this->entityManager->getEntityById(Report::ENTITY_TYPE, $id);
if (!$report) {
throw new NotFound();
}
$entityType = $report->getTargetEntityType();
$groupCount = count($report->getGroupBy());
$columnList = $report->getColumns();
$groupByList = $report->getGroupBy();
$reportResult = null;
if (
$report->getType() === Report::TYPE_JOINT_GRID ||
!$report->getGroupBy()
) {
$reportResult = $this->service->runGrid($id, $where, $user);
$columnList = $reportResult->getColumnList();
$groupByList = $reportResult->getGroupByList();
$groupCount = count($groupByList);
}
if (!$reportResult) {
$reportResult = $this->service->runGrid($id, $where, $user);
}
$result = [];
if ($groupCount === 2) {
foreach ($reportResult->getSummaryColumnList() as $column) {
$result[] = $this->getGridReportResultForExport($id, $where, $column, $user, $reportResult);
}
} else {
$result[] = $this->getGridReportResultForExport($id, $where, null, $user, $reportResult);
}
$columnTypes = [];
foreach ($columnList as $item) {
$columnData = $this->gridHelper->getDataFromColumnName($entityType, $item, $reportResult);
$type = $this->metadata
->get(['entityDefs', $columnData->entityType, 'fields', $columnData->field, 'type']);
if (
$entityType === Opportunity::ENTITY_TYPE &&
$columnData->field === 'amountWeightedConverted'
) {
$type = 'currencyConverted';
}
if ($columnData->function === 'COUNT') {
$type = 'int';
}
$columnTypes[$item] = $type;
}
$columnLabels = [];
if ($groupCount === 2) {
$columnNameMap = $reportResult->getColumnNameMap() ?? [];
foreach ($columnList as $column) {
$columnLabels[$column] = $columnNameMap[$column];
}
}
$exportParams = [
'exportName' => $report->getName(),
'columnList' => $columnList,
'columnTypes' => $columnTypes,
'chartType' => $report->get('chartType'),
'groupByList' => $groupByList,
'columnLabels' => $columnLabels,
'reportResult' => $reportResult,
'groupLabel' => '',
];
if ($groupCount) {
$group = $groupByList[$groupCount - 1];
$exportParams['groupLabel'] = $this->gridUtil->translateGroupName($entityType, $group);
}
$export = $this->injectableFactory->create(ExportXlsx::class);
return $export->process($entityType, $exportParams, $result);
}
/**
* @throws Forbidden
* @throws NotFound
* @throws Error
*/
public function exportCsv(
string $id,
?WhereItem $where,
?string $column = null,
?User $user = null
): string {
/** @var ?Report $report */
$report = $this->entityManager->getEntityById(Report::ENTITY_TYPE, $id);
if (!$report) {
throw new NotFound();
}
if ($user && !$this->aclManager->checkEntityRead($user, $report)) {
throw new Forbidden();
}
$contents = $this->getGridReportCsv($id, $where, $column, $user);
$name = preg_replace("/([^\w\s\d\-_~,;:\[\]().])/u", '_', $report->getName()) . ' ' . date('Y-m-d');
$mimeType = $this->metadata->get(['app', 'export', 'formatDefs', 'csv', 'mimeType']);
$fileExtension = $this->metadata->get(['app', 'export', 'formatDefs', 'csv', 'fileExtension']);
$fileName = $name . '.' . $fileExtension;
$attachment = $this->entityManager->getEntity('Attachment');
$attachment->set('name', $fileName);
$attachment->set('role', 'Export File');
$attachment->set('type', $mimeType);
$attachment->set('contents', $contents);
$attachment->set([
'relatedType' => Report::ENTITY_TYPE,
'relatedId' => $id,
]);
$this->entityManager->saveEntity($attachment);
return $attachment->getId();
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
* @throws BadRequest
*/
private function getGridReportCsv(
string $id,
?WhereItem $where,
?string $column = null,
?User $user = null
): string {
$result = $this->getGridReportResultForExport($id, $where, $column, $user);
$delimiter = $this->config->get('exportDelimiter', ';');
$fp = fopen('php://temp', 'w');
if ($fp === false) {
throw new RuntimeException("Could not open temp.");
}
foreach ($result as $row) {
fputcsv($fp, $row, $delimiter);
}
rewind($fp);
$csv = stream_get_contents($fp);
fclose($fp);
if ($csv === false) {
throw new RuntimeException("Could not get from stream.");
}
return $csv;
}
/**
* @return array<int, mixed>[]
* @throws Error
* @throws Forbidden
* @throws NotFound
* @throws BadRequest
*/
private function getGridReportResultForExport(
string $id,
?WhereItem $where,
?string $currentColumn = null,
?User $user = null,
?GridResult $reportResult = null
): array {
if (!$reportResult) {
$reportResult = $this->service->runGrid($id, $where, $user);
}
$depth = count($reportResult->getGroupByList());
$reportData = $reportResult->getReportData();
$result = [];
if ($depth == 2) {
$groupName1 = $reportResult->getGroupByList()[0];
$groupName2 = $reportResult->getGroupByList()[1];
$group1NonSummaryColumnList = [];
$group2NonSummaryColumnList = [];
if ($reportResult->getGroup1NonSummaryColumnList() !== null) {
$group1NonSummaryColumnList = $reportResult->getGroup1NonSummaryColumnList();
}
if ($reportResult->getGroup2NonSummaryColumnList() !== null) {
$group2NonSummaryColumnList = $reportResult->getGroup2NonSummaryColumnList();
}
$row = [];
$row[] = '';
foreach ($group2NonSummaryColumnList as $column) {
$text = $reportResult->getColumnNameMap()[$column];
$row[] = $text;
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$label = $gr1;
if (empty($label)) {
$label = $this->language->translate('-Empty-', 'labels', 'Report');
}
else if (!empty($reportResult->getGroupValueMap()[$groupName1][$gr1])) {
$label = $reportResult->getGroupValueMap()[$groupName1][$gr1];
}
$row[] = $label;
}
$result[] = $row;
foreach ($reportResult->getGrouping()[1] ?? [] as $gr2) {
$row = [];
$label = $gr2;
if (empty($label)) {
$label = $this->language->translate('-Empty-', 'labels', 'Report');
}
else if (!empty($reportResult->getGroupValueMap()[$groupName2][$gr2])) {
$label = $reportResult->getGroupValueMap()[$groupName2][$gr2];
}
$row[] = $label;
foreach ($group2NonSummaryColumnList as $column) {
$row[] = $this->getCellDisplayValueFromResult(1, $gr2, $column, $reportResult);
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$value = 0;
if (!empty($reportData->$gr1) && !empty($reportData->$gr1->$gr2)) {
if (!empty($reportData->$gr1->$gr2->$currentColumn)) {
$value = $reportData->$gr1->$gr2->$currentColumn;
}
}
$row[] = $value;
}
$result[] = $row;
}
$row = [];
$row[] = $this->language->translate('Total', 'labels', 'Report');
foreach ($group2NonSummaryColumnList as $ignored) {
$row[] = '';
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$sum = 0;
if (!empty($reportResult->getGroup1Sums()->$gr1)) {
if (!empty($reportResult->getGroup1Sums()->$gr1->$currentColumn)) {
$sum = $reportResult->getGroup1Sums()->$gr1->$currentColumn;
}
}
$row[] = $sum;
}
$result[] = $row;
if (count($group1NonSummaryColumnList)) {
$result[] = [];
}
foreach ($group1NonSummaryColumnList as $column) {
$row = [];
$text = $reportResult->getColumnNameMap()[$column];
$row[] = $text;
foreach ($group2NonSummaryColumnList as $ignored) {
$row[] = '';
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$row[] = $this->getCellDisplayValueFromResult(0, $gr1, $column, $reportResult);
}
$result[] = $row;
}
} else if ($depth === 1 || $depth === 0) {
$aggregatedColumnList = $reportResult->getAggregatedColumnList();
if ($depth === 1) {
$groupName = $reportResult->getGroupByList()[0];
} else {
$groupName = self::STUB_KEY;
}
$row = [];
$row[] = '';
foreach ($aggregatedColumnList as $column) {
$label = $column;
if (!empty($reportResult->getColumnNameMap()[$column])) {
$label = $reportResult->getColumnNameMap()[$column];
}
$row[] = $label;
}
$result[] = $row;
foreach ($reportResult->getGrouping()[0] ?? [] as $gr) {
$row = [];
$label = $gr;
if (empty($label)) {
$label = $this->language->translate('-Empty-', 'labels', 'Report');
}
else if (
!empty($reportResult->getGroupValueMap()[$groupName]) &&
array_key_exists($gr, $reportResult->getGroupValueMap()[$groupName])
) {
$label = $reportResult->getGroupValueMap()[$groupName][$gr];
}
$row[] = $label;
foreach ($aggregatedColumnList as $column) {
if (in_array($column, $reportResult->getNumericColumnList())) {
$value = 0;
if (!empty($reportData->$gr)) {
if (!empty($reportData->$gr->$column)) {
$value = $reportData->$gr->$column;
}
}
}
else {
$value = '';
if (property_exists($reportData, $gr) && property_exists($reportData->$gr, $column)) {
$value = $reportData->$gr->$column;
if (
!is_null($value) &&
property_exists($reportResult->getCellValueMaps(), $column) &&
property_exists($reportResult->getCellValueMaps()->$column, $value)
) {
$value = $reportResult->getCellValueMaps()->$column->$value;
}
}
}
$row[] = $value;
}
$result[] = $row;
}
if ($depth) {
$row = [];
$row[] = $this->language->translate('Total', 'labels', 'Report');
foreach ($aggregatedColumnList as $column) {
if (!in_array($column, $reportResult->getNumericColumnList())) {
$row[] = '';
continue;
}
$sum = 0;
if (!empty($reportResult->getSums()->$column)) {
$sum = $reportResult->getSums()->$column;
}
$row[] = $sum;
}
$result[] = $row;
}
}
return $result;
}
/**
* @return mixed
*/
public function getCellDisplayValueFromResult(
int $groupIndex,
string $gr1,
string $column,
GridResult $reportResult
) {
$groupName = $reportResult->getGroupByList()[$groupIndex];
$dataMap = $reportResult->getNonSummaryData()->$groupName;
$value = '';
if ($this->gridHelper->isColumnNumeric($column, $reportResult)) {
$value = 0;
}
if (
property_exists($dataMap, $gr1) &&
property_exists($dataMap->$gr1, $column)
) {
$value = $dataMap->$gr1->$column;
}
if (
!$this->gridHelper->isColumnNumeric($column, $reportResult) &&
!is_null($value)
) {
if (property_exists($reportResult->getCellValueMaps(), $column)) {
if (property_exists($reportResult->getCellValueMaps()->$column, $value)) {
$value = $reportResult->getCellValueMaps()->$column->$value;
}
}
}
if (is_null($value)) {
$value = '';
}
return $value;
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function exportPdf(
string $id,
?WhereItem $where,
string $templateId,
?User $user = null
): string {
$report = $this->entityManager->getEntityById(Report::ENTITY_TYPE, $id);
$template = $this->entityManager->getEntityById(Template::ENTITY_TYPE, $templateId);
if (!$report || !$template) {
throw new NotFound();
}
if ($user) {
if (!$this->aclManager->checkEntityRead($user, $report)) {
throw new Forbidden("No access to report.");
}
if (!$this->aclManager->checkEntityRead($user, $template)) {
throw new Forbidden("No access to template.");
}
}
$additionalData = [
'user' => $user,
'reportWhere' => $where,
];
$pdfService = $this->injectableFactory->create(PdfService::class);
$contents = $pdfService
->generate(
Report::ENTITY_TYPE,
$report->getId(),
$template->getId(),
null,
Data::create()->withAdditionalTemplateData((object) $additionalData)
)
->getString();
$attachment = $this->entityManager->createEntity(Attachment::ENTITY_TYPE, [
'contents' => $contents,
'role' => 'Export File',
'type' => 'application/pdf',
'relatedId' => $id,
'relatedType' => Report::ENTITY_TYPE,
]);
return $attachment->getId();
}
}

View File

@@ -0,0 +1,42 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
class ColumnData
{
public ?string $function;
public string $field;
public ?string $entityType;
public ?string $link;
public ?string $fieldType;
public function __construct(
?string $function,
string $field,
?string $entityType,
?string $link,
?string $fieldType
) {
$this->function = $function;
$this->field = $field;
$this->entityType = $entityType;
$this->link = $link;
$this->fieldType = $fieldType;
}
}

View File

@@ -0,0 +1,216 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
use Espo\Core\Select\Where\Item as WhereItem;
use stdClass;
class Data
{
public const COLUMN_TYPE_SUMMARY = 'Summary';
private string $entityType;
private ?string $success;
/** @var string[] */
private array $columns;
/** @var string[] */
private array $groupBy;
/** @var string[] */
private array $orderBy;
private bool $applyAcl;
private ?WhereItem $filtersWhere;
private ?string $chartType;
/** @var ?array<string, string> */
private ?array $chartColors;
private ?string $chartColor;
/** @var ?stdClass[] */
private ?array $chartDataList;
/** @var string[] */
private array $aggregatedColumns = [];
private stdClass $columnsData;
/**
* @param string[] $columns
* @param string[] $groupBy
* @param string[] $orderBy
* @param ?string[] $chartColors
* @param ?stdClass[] $chartDataList
*/
public function __construct(
string $entityType,
array $columns,
array $groupBy,
array $orderBy,
bool $applyAcl,
?WhereItem $filtersWhere,
?string $chartType,
?array $chartColors,
?string $chartColor,
?array $chartDataList,
?string $success,
?stdClass $columnsData
) {
$this->entityType = $entityType;
$this->columns = $columns;
$this->groupBy = $groupBy;
$this->orderBy = $orderBy;
$this->applyAcl = $applyAcl;
$this->filtersWhere = $filtersWhere;
$this->chartType = $chartType;
$this->chartColors = $chartColors;
$this->chartColor = $chartColor;
$this->chartDataList = $chartDataList;
$this->success = $success;
$this->columnsData = $columnsData;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function getSuccess(): ?string
{
return $this->success;
}
/**
* @return string[]
*/
public function getOrderBy(): array
{
return $this->orderBy;
}
/**
* @return string[]
*/
public function getColumns(): array
{
return $this->columns;
}
/**
* @return string[]
*/
public function getGroupBy(): array
{
return $this->groupBy;
}
public function applyAcl(): bool
{
return $this->applyAcl;
}
public function getFiltersWhere(): ?WhereItem
{
return $this->filtersWhere;
}
public function getChartType(): ?string
{
return $this->chartType;
}
/**
* @return ?string[]
*/
public function getChartColors(): ?array
{
return $this->chartColors;
}
public function getChartColor(): ?string
{
return $this->chartColor;
}
/**
* @return ?stdClass[]
*/
public function getChartDataList(): ?array
{
return $this->chartDataList;
}
/**
* @return string[]
*/
public function getAggregatedColumns(): array
{
return $this->aggregatedColumns;
}
public function getColumnLabel(string $column): ?string
{
if (!isset($this->columnsData->$column)) {
return null;
}
$item = $this->columnsData->$column;
if (!is_object($item)) {
return null;
}
return $item->label ?? null;
}
public function getColumnType(string $column): ?string
{
if (!isset($this->columnsData->$column)) {
return null;
}
$item = $this->columnsData->$column;
if (!is_object($item)) {
return null;
}
return $item->type ?? null;
}
public function getColumnDecimalPlaces(string $column): ?int
{
if (!isset($this->columnsData->$column)) {
return null;
}
$item = $this->columnsData->$column;
if (!is_object($item)) {
return null;
}
return $item->decimalPlaces ?? null;
}
/**
* @param string[] $aggregatedColumns
*/
public function withAggregatedColumns(array $aggregatedColumns): self
{
$obj = clone $this;
$obj->aggregatedColumns = $aggregatedColumns;
return $obj;
}
}

View File

@@ -0,0 +1,332 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
use Espo\Modules\Advanced\Tools\Report\GridType\Data as GridData;
use stdClass;
class GridBuilder
{
private const ROUND_PRECISION = 4;
private const STUB_KEY = '__STUB__';
public function __construct(
private Util $util,
private Helper $helper
) {}
/**
* @param array<string, mixed>[] $rows
* @param string[] $groupList
* @param string[] $columns
* @param array<string, numeric> $sums
* @param string[] $groups
*/
public function build(
Data $data,
array $rows,
array $groupList,
array $columns,
array &$sums,
stdClass $cellValueMaps,
array $groups = [],
int $number = 0
): stdClass {
$gridData = $this->buildInternal(
$data,
$rows,
$groupList,
$columns,
$sums,
$cellValueMaps,
$groups,
$number
);
foreach ($gridData as $k => $v) {
$gridData[$k] = (object) $v;
/** @var array<string, mixed> $v */
foreach ($v as $k1 => $v1) {
if (is_array($v1)) {
$gridData[$k]->$k1 = (object) $v1;
}
}
}
return (object) $gridData;
}
/**
* @param array<string, mixed>[] $rows
* @param string[] $groupList
* @param string[] $columns
* @param array<string, numeric> $sums
* @param string[] $groups
* @return array<string|int, array<string|int, mixed>|numeric>
*/
public function buildInternal(
Data $data,
array $rows,
array $groupList,
array $columns,
array &$sums,
stdClass $cellValueMaps,
array $groups,
int $number
): array {
$entityType = $data->getEntityType();
if (count($data->getGroupBy()) === 0) {
$groupList = [self::STUB_KEY];
}
$k = count($groups);
$gridData = [];
if ($k <= count($groupList) - 1) {
$groupColumn = $groupList[$k];
$keys = [];
foreach ($rows as $row) {
foreach ($groups as $i => $g) {
$groupAlias = $this->util->sanitizeSelectAlias($groupList[$i]);
if ($row[$groupAlias] !== $g) {
continue 2;
}
}
$groupAlias = $this->util->sanitizeSelectAlias($groupColumn);
$key = $row[$groupAlias];
if (!in_array($key, $keys)) {
$keys[] = $key;
}
}
foreach ($keys as $number => $key) {
$gr = $groups;
$gr[] = $key;
$gridData[$key] = $this->buildInternal(
$data,
$rows,
$groupList,
$columns,
$sums,
$cellValueMaps,
$gr,
$number + 1
);
}
return $gridData;
}
$s = &$sums;
for ($i = 0; $i < count($groups) - 1; $i++) {
/** @var array<string, mixed> $s */
$group = $groups[$i];
if (!array_key_exists($group, $s)) {
$s[$group] = [];
}
$s = &$s[$group];
}
foreach ($rows as $row) {
foreach ($groups as $i => $g) {
$groupAlias = $this->util->sanitizeSelectAlias($groupList[$i]);
if ($row[$groupAlias] != $g) {
continue 2;
}
}
foreach ($columns as $column) {
$selectAlias = $this->util->sanitizeSelectAlias($column);
if ($this->helper->isColumnNumeric($column, $data)) {
if (empty($s[$column])) {
$s[$column] = 0;
if (str_starts_with($column, 'MIN:')) {
$s[$column] = null;
}
else if (str_starts_with($column, 'MAX:')) {
$s[$column] = null;
}
}
$value = str_starts_with($column, 'COUNT:') ?
intval($row[$selectAlias]) :
floatval($row[$selectAlias]);
if (str_starts_with($column, 'MIN:')) {
if (is_null($s[$column]) || $s[$column] >= $value) {
$s[$column] = $value;
}
}
else if (str_starts_with($column, 'MAX:')) {
if (is_null($s[$column]) || $s[$column] < $value) {
$s[$column] = $value;
}
}
else if (str_starts_with($column, 'AVG:')) {
$s[$column] = $s[$column] + ($value - $s[$column]) / floatval($number);
}
else {
$s[$column] = $s[$column] + $value;
}
if (is_float($s[$column])) {
$s[$column] = round($s[$column], self::ROUND_PRECISION);
}
$gridData[$column] = $value;
continue;
}
$columnData = $this->helper->getDataFromColumnName($entityType, $column);
if (!property_exists($cellValueMaps, $column)) {
$cellValueMaps->$column = (object) [];
}
$fieldType = $columnData->fieldType;
$value = null;
if (array_key_exists($selectAlias, $row)) {
$value = $row[$selectAlias];
}
if ($fieldType === 'link') {
$selectAlias = $this->util->sanitizeSelectAlias($column . 'Id');
$value = $row[$selectAlias];
}
$gridData[$column] = $value;
if (!is_null($value) && !property_exists($cellValueMaps->$column, $value)) {
$displayValue = $this->util->getCellDisplayValue($value, $columnData);
if (!is_null($displayValue)) {
$cellValueMaps->$column->$value = $displayValue;
}
}
}
}
return $gridData;
}
/**
* @param string[] $columnList
* @param string[] $summaryColumnList
* @param array<string, array<string, mixed>> $rows
* @param string[] $groupList
*/
public function buildNonSummary(
array $columnList,
array $summaryColumnList,
GridData $data,
array $rows,
array $groupList,
stdClass $cellValueMaps,
stdClass $nonSummaryColumnGroupMap
): ?stdClass {
if (count($data->getGroupBy()) !== 2) {
return null;
}
if (count($columnList) <= count($summaryColumnList)) {
return (object) [];
}
$nonSummaryData = (object) [];
foreach ($data->getGroupBy() as $i => $groupColumn) {
$nonSummaryData->$groupColumn = (object) [];
$groupAlias = $this->util->sanitizeSelectAlias($groupList[$i]);
foreach ($columnList as $column) {
if (in_array($column, $summaryColumnList)) {
continue;
}
if (!str_starts_with($column, $groupColumn . '.')) {
continue;
}
$nonSummaryColumnGroupMap->$column = $groupColumn;
$columnData = $this->helper->getDataFromColumnName($data->getEntityType(), $column);
$columnKey = $column;
if ($columnData->fieldType === 'link') {
$columnKey .= 'Id';
}
$columnAlias = $this->util->sanitizeSelectAlias($columnKey);
foreach ($rows as $row) {
$groupValue = $row[$groupAlias];
if (!property_exists($nonSummaryData->$groupColumn, $groupValue)) {
$nonSummaryData->$groupColumn->$groupValue = (object) [];
}
$value = $row[$columnAlias] ?? null;
if (is_null($value)) {
continue;
}
$nonSummaryData->$groupColumn->$groupValue->$column = $value;
if (!property_exists($cellValueMaps, $column)) {
$cellValueMaps->$column = (object) [];
}
if (!property_exists($cellValueMaps->$column, $value)) {
$cellValueMaps->$column->$value = $this->util->getCellDisplayValue($value, $columnData);
}
}
}
}
return $nonSummaryData;
}
}

View File

@@ -0,0 +1,367 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
use Espo\Core\AclManager;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\QueryComposer\Util as QueryComposerUtil;
class Helper
{
/** @var string[] */
private array $numericFieldTypeList = [
'currency',
'currencyConverted',
'int',
'float',
'enumInt',
'enumFloat',
'duration',
'decimal',
];
public function __construct(
private Metadata $metadata,
private AclManager $aclManager,
private EntityManager $entityManager
) {}
public function getDataFromColumnName(string $entityType, string $column, ?GridResult $result = null): ColumnData
{
if ($result && $result->isJoint()) {
$entityType = $result->getColumnEntityTypeMap()[$column];
$column = $result->getColumnOriginalMap()[$column];
}
$field = $column;
$link = null;
$function = null;
if (str_contains($field, ':')) {
[$function, $field] = explode(':', $field, 2);
}
if (str_contains($field, ':') || str_contains($field, '(') || substr_count($field, '.') > 2) {
if (substr_count($field, '.') === 1 && !str_contains($field, ',')) {
$attrs = QueryComposerUtil::getAllAttributesFromComplexExpression($column);
if (count($attrs) === 1) {
$attr = $attrs[0];
[$link, $field] = explode('.', $attr);
return new ColumnData(
function: $function,
field: $field,
entityType: null,
link: $link,
fieldType: null,
);
}
}
return new ColumnData(
function: $function,
field: '',
entityType: null,
link: null,
fieldType: null,
);
}
$fieldEntityType = $entityType;
if (str_contains($field, '.')) {
[$link, $field] = explode('.', $field, 2);
$fieldEntityType = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']);
}
$fieldType = $this->metadata->get(['entityDefs', $fieldEntityType, 'fields', $field, 'type']);
return new ColumnData(
function: $function,
field: $field,
entityType: $fieldEntityType,
link: $link,
fieldType: $fieldType,
);
}
/**
* @param Data|Result $data
*/
public function isColumnNumeric(string $item, $data): bool
{
if ($data instanceof Result) {
if (in_array($item, $data->getNumericColumnList())) {
return true;
}
}
else if ($data instanceof Data) {
$type = $data->getColumnType($item);
if ($type !== null) {
return $type == Data::COLUMN_TYPE_SUMMARY;
}
}
$columnData = $this->getDataFromColumnName($data->getEntityType(), $item);
if (in_array($columnData->function, ['COUNT', 'SUM', 'AVG'])) {
return true;
}
if (in_array($columnData->fieldType, $this->numericFieldTypeList)) {
return true;
}
return false;
}
public function isColumnEligibleForSubList(string $item, Data $data): bool
{
$groupBy = $data->getGroupBy()[0] ?? null;
$columnData = $this->getDataFromColumnName($data->getEntityType(), $item);
if (!str_contains($item, '.')) {
return true;
}
if (!$groupBy) {
return true;
}
if ($columnData->link === $groupBy) {
return false;
}
return true;
}
public function isColumnSummary(string $item, Data $data): bool
{
$type = $data->getColumnType($item);
if ($type !== null) {
return $type === Data::COLUMN_TYPE_SUMMARY;
}
$function = null;
if (strpos($item, ':') > 0) {
[$function] = explode(':', $item);
}
if (in_array($function, ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'])) {
return true;
}
return false;
}
public function isColumnDateFunction(string $column): bool
{
$list = [
'MONTH:',
'YEAR:',
'DAY:',
'MONTH:',
'YEAR:',
'DAY:',
'QUARTER:',
'QUARTER_',
'WEEK_0:',
'WEEK_1:',
'YEAR_',
'QUARTER_FISCAL:',
'YEAR_FISCAL:',
];
foreach ($list as $item) {
if (str_starts_with($column, $item)) {
return true;
}
}
return false;
}
public function isColumnSubListAggregated(string $item): bool
{
if (!str_contains($item, ':')) {
return false;
}
if (str_contains($item, ',')) {
return false;
}
if (str_contains($item, '.')) {
return false;
}
if (str_contains($item, '(')) {
return false;
}
$function = explode(':', $item)[0];
if ($function === 'COUNT') {
return false;
}
if (in_array($function, ['SUM', 'MAX', 'MIN', 'AVG'])) {
return true;
}
return false;
}
/**
* @param string[] $itemList
* @throws Forbidden
*/
public function checkColumnsAvailability(string $entityType, array $itemList): void
{
foreach ($itemList as $item) {
$this->checkColumnAvailability($entityType, $item);
}
}
/**
* @throws Forbidden
*/
private function checkColumnAvailability(string $entityType, string $item): void
{
if (str_contains($item, ':')) {
$argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($item);
foreach ($argumentList as $argument) {
$this->checkColumnAvailability($entityType, $argument);
}
return;
}
$field = $item;
if (str_contains($field, '.')) {
[$link, $field] = explode('.', $field);
$entityType = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']);
if (!$entityType) {
return;
}
}
if (
in_array($field, $this->aclManager->getScopeRestrictedFieldList($entityType, 'onlyAdmin')) ||
in_array($field, $this->aclManager->getScopeRestrictedFieldList($entityType, 'internal')) ||
in_array($field, $this->aclManager->getScopeRestrictedFieldList($entityType, 'forbidden'))
) {
throw new Forbidden;
}
}
/**
* @todo Check whether it's working.
* @return string[]
*/
public function obtainLinkColumnList(Data $data): array
{
$list = [];
foreach ($data->getGroupBy() as $item) {
$columnData = $this->getDataFromColumnName($data->getEntityType(), $item);
if ($columnData->function) {
continue;
}
if (!$columnData->link) {
if (in_array($columnData->fieldType, ['link', 'file', 'image'])) {
$list[] = $item;
}
continue;
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($data->getEntityType());
if (!$entityDefs->hasRelation($columnData->link)) {
continue;
}
$relationType = $entityDefs
->getRelation($columnData->link)
->getType();
if (
(
$relationType === Entity::BELONGS_TO ||
$relationType === Entity::HAS_ONE
) &&
in_array($columnData->fieldType, ['link', 'file', 'image'])
) {
$list[] = $item;
}
}
return $list;
}
/**
* @param string[] $columns
* @return string[]
*/
public function obtainLinkColumnListFromColumns(Data $data, array $columns): array
{
$typeList = [
'link',
'file',
'image',
'linkOne',
'linkParent',
];
$list = [];
foreach ($columns as $item) {
$columnData = $this->getDataFromColumnName($data->getEntityType(), $item);
if ($columnData->function || $columnData->link) {
continue;
}
if (in_array($columnData->fieldType, $typeList)) {
$list[] = $item;
}
}
return $list;
}
}

View File

@@ -0,0 +1,51 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
class JointData
{
/** @var ?array<int, object{id: string, label: string}> */
private ?array $joinedReportDataList;
private ?string $chartType;
/**
* @param ?array<int, object{id: string, label: string}> $joinedReportDataList
* @param string|null $chartType
*/
public function __construct(
?array $joinedReportDataList,
?string $chartType
) {
$this->joinedReportDataList = $joinedReportDataList;
$this->chartType = $chartType;
}
/**
* @return array<int, object{id: string, label: string}>
*/
public function getJoinedReportDataList(): array
{
return $this->joinedReportDataList;
}
public function getChartType(): ?string
{
return $this->chartType;
}
}

View File

@@ -0,0 +1,180 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Report\SelectHelper;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Select;
use Espo\ORM\Query\SelectBuilder;
class QueryPreparator
{
private const WHERE_TYPE_AND = 'and';
public function __construct(
private SelectHelper $selectHelper,
private SelectBuilderFactory $selectBuilderFactory,
private Helper $helper
) {}
/**
* @throws Forbidden
* @throws BadRequest
*/
public function prepare(Data $data, ?WhereItem $where, ?User $user): Select
{
[$whereItem, $havingItem] = $this->obtainWhereAndHavingItems($data);
$queryBuilder = SelectBuilder::create()
->from($data->getEntityType(), lcfirst($data->getEntityType()));
$this->selectHelper->handleGroupBy($data->getGroupBy(), $queryBuilder);
$this->selectHelper->handleColumns($data->getAggregatedColumns(), $queryBuilder);
$this->selectHelper->handleOrderBy($data->getOrderBy(), $queryBuilder);
$this->selectHelper->handleFiltersHaving($havingItem, $queryBuilder, true);
$preFilterQuery = $queryBuilder->build();
$queryBuilder = $this->cloneWithAccessControlAndWhere($data, $where, $user, $preFilterQuery);
$this->selectHelper->handleFiltersWhere($whereItem, $queryBuilder/*, true*/);
$this->handleAdditional($queryBuilder);
if (!$this->useSubQuery($queryBuilder)) {
return $queryBuilder->build();
}
// @todo Remove when v8.5 is min. supported.
$subQuery = $queryBuilder
->select(['id'])
->group([])
->order([])
->having([])
->build();
return SelectBuilder::create()
->clone($preFilterQuery)
->where(
Cond::in(Expr::column('id'), $subQuery)
)
->build();
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function cloneWithAccessControlAndWhere(
Data $data,
?WhereItem $where,
?User $user,
Select $preFilterQuery
): SelectBuilder {
$selectBuilder = $this->selectBuilderFactory
->create()
->clone($preFilterQuery);
if ($user) {
$selectBuilder
->forUser($user)
->withWherePermissionCheck();
}
if ($user && $data->applyAcl()) {
$selectBuilder->withAccessControlFilter();
}
// @todo Revise.
$selectBuilder->buildQueryBuilder();
if ($where) {
$selectBuilder->withWhere($where);
}
/** @noinspection PhpUnnecessaryLocalVariableInspection */
$queryBuilder = $selectBuilder->buildQueryBuilder();
/*if ($where) {
// Supposed to be already applied by the scanner.
$this->selectHelper->applyLeftJoinsFromWhere($where, $queryBuilder);
}*/
return $queryBuilder;
}
/**
* @param Data $data
* @return array{0: WhereItem, 1: WhereItem}
*/
private function obtainWhereAndHavingItems(Data $data): array
{
return $data->getFiltersWhere() ?
$this->selectHelper->splitHavingItem($data->getFiltersWhere()) :
[
WhereItem::createBuilder()
->setType(self::WHERE_TYPE_AND)
->setItemList([])
->build(),
WhereItem::createBuilder()
->setType(self::WHERE_TYPE_AND)
->setItemList([])
->build()
];
}
private function useSubQuery(SelectBuilder $queryBuilder): bool
{
$isDistinct = $queryBuilder->build()->isDistinct();
if (!$isDistinct) {
return false;
}
foreach ($queryBuilder->build()->getSelect() as $selectItem) {
$itemExpr = $selectItem->getExpression()->getValue();
if (
str_starts_with($itemExpr, 'SUM:') ||
str_starts_with($itemExpr, 'AVG:')
) {
return true;
}
}
return false;
}
private function handleAdditional(SelectBuilder $queryBuilder): void
{
foreach ($queryBuilder->build()->getGroup() as $groupBy) {
$groupColumn = $groupBy->getValue();
if ($this->helper->isColumnDateFunction($groupColumn)) {
$queryBuilder->where(["$groupColumn!=" => null]);
}
}
}
}

View File

@@ -0,0 +1,689 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
use stdClass;
class Result
{
private bool $isJoint = false;
/** @var string[] */
private array $subListColumnList;
private stdClass $nonSummaryColumnGroupMap;
private stdClass $subListData;
private stdClass $sums;
/** @var array<string, array<string, mixed>> */
private array $groupValueMap;
private stdClass $cellValueMaps;
private stdClass $reportData;
private stdClass $nonSummaryData;
/** @var ?string */
private ?string $success = null;
private stdClass $chartColors;
/** @var ?string */
private ?string $chartColor = null;
private stdClass $columnDecimalPlacesMap;
/** @var ?string[] */
private ?array $group1NonSummaryColumnList = null;
/** @var ?string[] */
private ?array $group2NonSummaryColumnList = null;
private ?stdClass $group1Sums = null;
private ?stdClass $group2Sums = null;
/** @var array<string, string> */
private array $columnEntityTypeMap = [];
/** @var array<string, string> */
private array $columnOriginalMap = [];
/** @var ?string[] */
private ?array $entityTypeList = null;
/** @var array<string, string> */
private array $columnReportIdMap = [];
/** @var array<string, string> */
private array $columnSubReportLabelMap = [];
/**
* @param ?string $entityType
* @param string[] $groupByList
* @param string[] $columnList
* @param string[] $numericColumnList
* @param string[] $summaryColumnList
* @param string[] $nonSummaryColumnList
* @param string[] $subListColumnList
* @param string[] $aggregatedColumnList
* @param ?stdClass $nonSummaryColumnGroupMap
* @param ?stdClass $subListData
* @param ?stdClass $sums
* @param ?array<string, array<string, mixed>> $groupValueMap
* @param ?string[] $columnNameMap
* @param ?string[] $columnTypeMap
* @param ?stdClass $cellValueMaps
* @param array{0?: string[], 1?: string[]} $grouping
* @param ?stdClass $reportData
* @param ?stdClass $nonSummaryData
* @param ?string $chartType
* @param ?stdClass[] $chartDataList
* @param ?stdClass $columnDecimalPlacesMap
*/
public function __construct(
private ?string $entityType,
private array $groupByList,
private array $columnList,
private array $numericColumnList = [],
private array $summaryColumnList = [],
private array $nonSummaryColumnList = [],
?array $subListColumnList = null,
private array $aggregatedColumnList = [],
?stdClass $nonSummaryColumnGroupMap = null,
?stdClass $subListData = null,
?stdClass $sums = null,
?array $groupValueMap = null,
private ?array $columnNameMap = null,
private ?array $columnTypeMap = null,
?stdClass $cellValueMaps = null,
private array $grouping = [],
?stdClass $reportData = null,
?stdClass $nonSummaryData = null,
private ?string $chartType = null,
private ?array $chartDataList = null,
?stdClass $columnDecimalPlacesMap = null,
private bool $emptyStringGroupExcluded = false
) {
$this->subListColumnList = $subListColumnList ?? [];
$this->nonSummaryColumnGroupMap = $nonSummaryColumnGroupMap ?? (object) [];
$this->subListData = $subListData ?? (object) [];
$this->sums = $sums ?? (object) [];
$this->groupValueMap = $groupValueMap ?? [];
$this->cellValueMaps = $cellValueMaps ?? (object) [];
$this->reportData = $reportData ?? (object) [];
$this->nonSummaryData = $nonSummaryData ?? (object) [];
$this->columnDecimalPlacesMap = $columnDecimalPlacesMap ?? (object) [];
$this->chartColors = (object) [];
}
public function getEntityType(): ?string
{
return $this->entityType;
}
/**
* @return string[]
*/
public function getGroupByList(): array
{
return $this->groupByList;
}
/**
* @return string[]
*/
public function getColumnList(): array
{
return $this->columnList;
}
/**
* @return string[]
*/
public function getNumericColumnList(): array
{
return $this->numericColumnList;
}
/**
* @return string[]
*/
public function getSummaryColumnList(): array
{
return $this->summaryColumnList;
}
/**
* @return string[]
*/
public function getNonSummaryColumnList(): array
{
return $this->nonSummaryColumnList;
}
/**
* @return string[]
*/
public function getSubListColumnList(): array
{
return $this->subListColumnList;
}
/**
* @return string[]
*/
public function getAggregatedColumnList(): array
{
return $this->aggregatedColumnList;
}
public function getNonSummaryColumnGroupMap(): stdClass
{
return $this->nonSummaryColumnGroupMap;
}
public function getSubListData(): stdClass
{
return $this->subListData;
}
public function getSums(): stdClass
{
return $this->sums;
}
/**
* @return array<string, array<string, mixed>>
*/
public function getGroupValueMap(): array
{
return $this->groupValueMap;
}
/**
* @return array<string, string>|null
*/
public function getColumnNameMap(): ?array
{
return $this->columnNameMap;
}
/**
* @return string[]|null
*/
public function getColumnTypeMap(): ?array
{
return $this->columnTypeMap;
}
public function getCellValueMaps(): stdClass
{
return $this->cellValueMaps;
}
/**
* @return array{0?: string[], 1?: string[]}
*/
public function getGrouping(): array
{
return $this->grouping;
}
public function getReportData(): stdClass
{
return $this->reportData;
}
public function getNonSummaryData(): stdClass
{
return $this->nonSummaryData;
}
public function getChartType(): ?string
{
return $this->chartType;
}
public function getSuccess(): ?string
{
return $this->success;
}
public function getChartColors(): stdClass
{
return $this->chartColors;
}
public function getChartColor(): ?string
{
return $this->chartColor;
}
/**
* @return ?stdClass[]
*/
public function getChartDataList(): ?array
{
return $this->chartDataList;
}
public function getColumnDecimalPlacesMap(): stdClass
{
return $this->columnDecimalPlacesMap;
}
/**
* @return ?string[]
*/
public function getGroup1NonSummaryColumnList(): ?array
{
return $this->group1NonSummaryColumnList;
}
/**
* @return ?string[]
*/
public function getGroup2NonSummaryColumnList(): ?array
{
return $this->group2NonSummaryColumnList;
}
public function getGroup1Sums(): ?stdClass
{
return $this->group1Sums;
}
public function getGroup2Sums(): ?stdClass
{
return $this->group2Sums;
}
public function isJoint(): bool
{
return $this->isJoint;
}
public function setEntityType(?string $entityType): Result
{
$this->entityType = $entityType;
return $this;
}
/**
* @param string[] $groupByList
*/
public function setGroupByList(array $groupByList): Result
{
$this->groupByList = $groupByList;
return $this;
}
/**
* @param string[] $columnList
*/
public function setColumnList(array $columnList): Result
{
$this->columnList = $columnList;
return $this;
}
/**
* @param string[] $numericColumnList
*/
public function setNumericColumnList(array $numericColumnList): Result
{
$this->numericColumnList = $numericColumnList;
return $this;
}
/**
* @param string[] $summaryColumnList
*/
public function setSummaryColumnList(array $summaryColumnList): Result
{
$this->summaryColumnList = $summaryColumnList;
return $this;
}
/**
* @param string[] $nonSummaryColumnList
*/
public function setNonSummaryColumnList(array $nonSummaryColumnList): Result
{
$this->nonSummaryColumnList = $nonSummaryColumnList;
return $this;
}
/**
* @param string[] $subListColumnList
*/
public function setSubListColumnList(array $subListColumnList): Result
{
$this->subListColumnList = $subListColumnList;
return $this;
}
/**
* @param string[] $aggregatedColumnList
*/
public function setAggregatedColumnList(array $aggregatedColumnList): Result
{
$this->aggregatedColumnList = $aggregatedColumnList;
return $this;
}
public function setNonSummaryColumnGroupMap(stdClass $nonSummaryColumnGroupMap): Result
{
$this->nonSummaryColumnGroupMap = $nonSummaryColumnGroupMap;
return $this;
}
public function setSubListData(stdClass $subListData): Result
{
$this->subListData = $subListData;
return $this;
}
public function setSums(stdClass $sums): Result
{
$this->sums = $sums;
return $this;
}
/**
* @param ?array<string, array<string, mixed>> $groupValueMap
* @return Result
*/
public function setGroupValueMap(?array $groupValueMap): Result
{
$this->groupValueMap = $groupValueMap;
return $this;
}
/**
* @param ?string[] $columnNameMap
*/
public function setColumnNameMap(?array $columnNameMap): Result
{
$this->columnNameMap = $columnNameMap;
return $this;
}
/**
* @param ?string[] $columnTypeMap
*/
public function setColumnTypeMap(?array $columnTypeMap): Result
{
$this->columnTypeMap = $columnTypeMap;
return $this;
}
public function setCellValueMaps(?stdClass $cellValueMaps): Result
{
$this->cellValueMaps = $cellValueMaps;
return $this;
}
/**
* @param array{0?: string[], 1?: string[]} $grouping
*/
public function setGrouping(array $grouping): Result
{
$this->grouping = $grouping;
return $this;
}
public function setReportData(stdClass $reportData): Result
{
$this->reportData = $reportData;
return $this;
}
public function setNonSummaryData(stdClass $nonSummaryData): Result
{
$this->nonSummaryData = $nonSummaryData;
return $this;
}
public function setChartType(?string $chartType): Result
{
$this->chartType = $chartType;
return $this;
}
public function setSuccess(?string $success): Result
{
$this->success = $success;
return $this;
}
public function setChartColors(?stdClass $chartColors): Result
{
$this->chartColors = $chartColors ?? (object) [];
return $this;
}
public function setChartColor(?string $chartColor): Result
{
$this->chartColor = $chartColor;
return $this;
}
/**
* @param ?stdClass[] $chartDataList
*/
public function setChartDataList(?array $chartDataList): Result
{
$this->chartDataList = $chartDataList;
return $this;
}
public function setColumnDecimalPlacesMap(stdClass $columnDecimalPlacesMap): Result
{
$this->columnDecimalPlacesMap = $columnDecimalPlacesMap;
return $this;
}
/**
* @param ?string[] $group1NonSummaryColumnList
* @return Result
*/
public function setGroup1NonSummaryColumnList(?array $group1NonSummaryColumnList): Result
{
$this->group1NonSummaryColumnList = $group1NonSummaryColumnList;
return $this;
}
/**
* @param ?string[] $group2NonSummaryColumnList
*/
public function setGroup2NonSummaryColumnList(?array $group2NonSummaryColumnList): Result
{
$this->group2NonSummaryColumnList = $group2NonSummaryColumnList;
return $this;
}
public function setGroup1Sums(?stdClass $group1Sums): Result
{
$this->group1Sums = $group1Sums;
foreach (get_object_vars($this->group1Sums) as $k => $v) {
if (is_array($v)) {
$this->group1Sums->$k = (object) $v;
}
}
return $this;
}
public function setGroup2Sums(?stdClass $group2Sums): Result
{
$this->group2Sums = $group2Sums;
foreach (get_object_vars($this->group2Sums) as $k => $v) {
if (is_array($v)) {
$this->group2Sums->$k = (object) $v;
}
}
return $this;
}
public function setIsJoint(bool $isJoint): void
{
$this->isJoint = $isJoint;
}
/**
* @return array<string, string>
*/
public function getColumnEntityTypeMap(): array
{
return $this->columnEntityTypeMap;
}
/**
* @param array<string, string> $columnEntityTypeMap
* @return Result
*/
public function setColumnEntityTypeMap(array $columnEntityTypeMap): Result
{
$this->columnEntityTypeMap = $columnEntityTypeMap;
return $this;
}
/**
* @return array<string, string>
*/
public function getColumnOriginalMap(): array
{
return $this->columnOriginalMap;
}
/**
* @param array<string, string> $columnOriginalMap
* @return Result
*/
public function setColumnOriginalMap(array $columnOriginalMap): Result
{
$this->columnOriginalMap = $columnOriginalMap;
return $this;
}
/**
* @return ?string[]
*/
public function getEntityTypeList(): ?array
{
return $this->entityTypeList;
}
/**
* @param ?string[] $entityTypeList
* @return Result
*/
public function setEntityTypeList(?array $entityTypeList): Result
{
$this->entityTypeList = $entityTypeList;
return $this;
}
/**
* @return array<string, string>
*/
public function getColumnReportIdMap(): array
{
return $this->columnReportIdMap;
}
/**
* @param array<string, string> $columnReportIdMap
* @return Result
*/
public function setColumnReportIdMap(array $columnReportIdMap): Result
{
$this->columnReportIdMap = $columnReportIdMap;
return $this;
}
/**
* @return array<string, string>
*/
public function getColumnSubReportLabelMap(): array
{
return $this->columnSubReportLabelMap;
}
/**
* @param array<string, string> $columnSubReportLabelMap
*/
public function setColumnSubReportLabelMap(array $columnSubReportLabelMap): Result
{
$this->columnSubReportLabelMap = $columnSubReportLabelMap;
return $this;
}
/** @noinspection PhpUnused */
public function isEmptyStringGroupExcluded(): bool
{
return $this->emptyStringGroupExcluded;
}
public function toRaw(): stdClass
{
return (object) [
'type' => 'Grid',
'entityType' => $this->entityType, // string
'depth' => count($this->groupByList), // int
'columnList' => $this->columnList, // string[]
'groupByList' => $this->groupByList, // string[]
'numericColumnList' => $this->numericColumnList,
'summaryColumnList' => $this->summaryColumnList,
'nonSummaryColumnList' => $this->nonSummaryColumnList,
'subListColumnList' => $this->subListColumnList, // string[]
'aggregatedColumnList' => $this->aggregatedColumnList, // string[]
'nonSummaryColumnGroupMap' => $this->nonSummaryColumnGroupMap, // stdClass
'subListData' => $this->subListData, // object<stdClass[]>
'sums' => $this->sums, // object<int|float>
'groupValueMap' => $this->groupValueMap, // array<string, array<string, mixed>>
'columnNameMap' => $this->columnNameMap, // array<string, string>
'columnTypeMap' => $this->columnTypeMap, // array<string, string>
'cellValueMaps' => $this->cellValueMaps, // object<object> (when grouping by link)
'grouping' => $this->grouping, // array{string[]}|array{string[], string[]}
'reportData' => $this->reportData, // object<object>|object<object<object>>
// group => (group-value => value-map, only for grid-2
'nonSummaryData' => $this->nonSummaryData, // object<object<object>>
'success' => $this->success,
'chartColors' => $this->chartColors, // stdClass
'chartColor' => $this->chartColor, // ?string
'chartType' => $this->chartType, // ?string
'chartDataList' => $this->chartDataList, // stdClass[]
'columnDecimalPlacesMap' => $this->columnDecimalPlacesMap, // object<?int>
'group1NonSummaryColumnList' => $this->group1NonSummaryColumnList,
'group2NonSummaryColumnList' => $this->group2NonSummaryColumnList,
'group1Sums' => $this->group1Sums,
'group2Sums' => $this->group2Sums,
'isJoint' => $this->isJoint,
'entityTypeList' => $this->entityTypeList,
'columnEntityTypeMap' => (object) $this->columnEntityTypeMap,
'columnOriginalMap' => (object) $this->columnOriginalMap,
'columnReportIdMap' => (object) $this->columnReportIdMap,
'columnSubReportLabelMap' => (object) $this->columnSubReportLabelMap,
'emptyStringGroupExcluded' => $this->emptyStringGroupExcluded,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
class RunParams
{
private bool $skipRuntimeFiltersCheck;
public function __construct(
bool $skipRuntimeFiltersCheck = false
) {
$this->skipRuntimeFiltersCheck = $skipRuntimeFiltersCheck;
}
public function skipRuntimeFiltersCheck(): bool
{
return $this->skipRuntimeFiltersCheck;
}
public function withSkipRuntimeFiltersCheck(bool $value = true): self
{
$obj = clone $this;
$obj->skipRuntimeFiltersCheck = $value;
return $obj;
}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,204 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\GridType;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Metadata;
use Espo\ORM\EntityManager;
use Exception;
class Util
{
private int $aliasMaxLength = 128;
private Metadata $metadata;
private EntityManager $entityManager;
private Language $language;
private DateTime $dateTime;
public function __construct(
Metadata $metadata,
EntityManager $entityManager,
Language $language,
DateTime $dateTime,
Config $config
) {
$this->metadata = $metadata;
$this->entityManager = $entityManager;
$this->language = $language;
$this->dateTime = $dateTime;
if ($config->get('database.platform') === 'Postgresql') {
$this->aliasMaxLength = 63;
}
}
public function sanitizeSelectAlias(string $alias): string
{
$alias = preg_replace('/[^A-Za-z\r\n0-9_:\'" .,\-()]+/', '', $alias) ?? '';
if (strlen($alias) > $this->aliasMaxLength) {
$alias = preg_replace('!\s+!', ' ', $alias);
}
if (strlen($alias) > $this->aliasMaxLength) {
$alias = substr($alias, 0, $this->aliasMaxLength);
}
return $alias;
}
/**
* @todo Use ColumnData object.
* @param scalar|string[] $value
* @return scalar|string[]
*/
public function getCellDisplayValue($value, object $columnData)
{
/** @var ColumnData $columnData */
$displayValue = $value;
$fieldType = $columnData->fieldType;
if ($fieldType === 'link') {
if ($value && is_string($value)) {
try {
/** @var ?string $foreignEntityType */
$foreignEntityType = $this->metadata->get(
['entityDefs', $columnData->entityType, 'links', $columnData->field, 'entity']);
if ($foreignEntityType) {
$e = $this->entityManager->getEntityById($foreignEntityType, $value);
if ($e) {
$displayValue = $e->get('name');
}
}
} catch (Exception) {}
}
} else if ($fieldType === 'enum') {
$displayValue = is_string($value) ?
$this->language->translateOption($value, $columnData->field, $columnData->entityType) :
'';
$translation = $this->metadata
->get(['entityDefs', $columnData->entityType, 'fields', $columnData->field, 'translation']);
$optionsReference = $this->metadata
->get(['entityDefs', $columnData->entityType, 'fields', $columnData->field, 'optionsReference']);
if (!$translation && $optionsReference) {
$translation = str_replace('.', '.options.', $optionsReference);
}
if ($translation && (is_string($value) || is_int($value))) {
$translationMap = $this->language->get(explode('.', $translation));
if (is_array($translationMap) && array_key_exists($value, $translationMap)) {
$displayValue = $translationMap[$value];
}
}
} else if ($fieldType === 'datetime' || $fieldType === 'datetimeOptional') {
if ($value && is_string($value)) {
$displayValue = $this->dateTime->convertSystemDateTime($value);
}
} else if ($fieldType === 'date') {
if ($value && is_string($value)) {
$displayValue = $this->dateTime->convertSystemDate($value);
}
} else if ($fieldType === 'multiEnum' || $fieldType === 'checklist' || $fieldType === 'array') {
if (is_array($value)) {
$displayValue = array_map(
function ($item) use ($columnData) {
return $this->language->translateOption(
$item,
$columnData->field,
$columnData->entityType
);
},
$value
);
}
}
if (is_null($displayValue)) {
$displayValue = $value;
}
return $displayValue;
}
public function translateGroupName(string $entityType, string $item): string
{
if (str_contains($item, ':(')) {
return '';
}
return $this->translateColumnName($entityType, $item);
}
public function translateColumnName(string $entityType, string $item): string
{
if (str_contains($item, ':(')) {
return $item;
}
$field = $item;
$function = null;
if (str_contains($item, ':')) {
[$function, $field] = explode(':', $item);
}
$groupLabel = '';
$entityTypeLocal = $entityType;
if (str_contains($field, '.')) {
[$link, $field] = explode('.', $field);
$entityTypeLocal = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']);
//$groupLabel .= $this->language->translate($link, 'links', $entityType);
//$groupLabel .= '.';
}
if ($this->metadata->get(['entityDefs', $entityTypeLocal, 'fields', $field, 'type']) == 'currencyConverted') {
$field = str_replace('Converted', '', $field);
}
$groupLabel .= $this->language->translateLabel($field, 'fields', $entityTypeLocal);
if ($function) {
$functionLabel = $this->language->translateLabel($function, 'functions', 'Report');
if ($function === 'COUNT' && $field === 'id') {
return $functionLabel;
}
if ($function !== 'SUM') {
$groupLabel = $functionLabel . ': ' . $groupLabel;
}
}
return $groupLabel;
}
}

View File

@@ -0,0 +1,159 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Jobs;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Select\SearchParams;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Entities\User;
use Espo\Modules\Advanced\Business\Report\EmailBuilder;
use Espo\Modules\Advanced\Entities\Report as ReportEntity;
use Espo\Modules\Advanced\Tools\Report\ListType\Result as ListResult;
use Espo\Modules\Advanced\Tools\Report\SendingService;
use Espo\Modules\Advanced\Tools\Report\Service;
use Espo\ORM\EntityManager;
use LogicException;
use RuntimeException;
class Send implements Job
{
private const LIST_REPORT_MAX_SIZE = 3000;
private Config $config;
private Service $service;
private EntityManager $entityManager;
private SendingService $sendingService;
private EmailBuilder $emailBuilder;
private Log $log;
public function __construct(
Config $config,
Service $service,
EntityManager $entityManager,
SendingService $sendingService,
EmailBuilder $emailBuilder,
Log $log
) {
$this->config = $config;
$this->service = $service;
$this->entityManager = $entityManager;
$this->sendingService = $sendingService;
$this->emailBuilder = $emailBuilder;
$this->log = $log;
}
/**
* @throws BadRequest
* @throws Error
* @throws Forbidden
* @throws NotFound
*/
public function run(Data $data): void
{
$data = $data->getRaw();
$reportId = $data->reportId;
$userId = $data->userId;
/** @var ?ReportEntity $report */
$report = $this->entityManager->getEntityById(ReportEntity::ENTITY_TYPE, $reportId);
if (!$report) {
throw new RuntimeException("Report Sending: No report $reportId.");
}
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
throw new RuntimeException("Report Sending: No user $userId.");
}
if ($report->getType() === ReportEntity::TYPE_LIST) {
$searchParams = SearchParams::create()
->withMaxSize($this->getSendingListMaxCount());
$orderByList = $report->getOrderByList();
if ($orderByList) {
$arr = explode(':', $orderByList);
/**
* @var 'ASC'|'DESC' $orderDirection
* @noinspection PhpRedundantVariableDocTypeInspection
*/
$orderDirection = strtoupper($arr[0]);
$searchParams = $searchParams
->withOrderBy($arr[1])
->withOrder($orderDirection);
}
$result = $this->service->runList($reportId, $searchParams, $user);
}
else {
$result = $this->service->runGrid($reportId, null, $user);
}
$reportResult = $result;
if ($result instanceof ListResult) {
$reportResult = [];
foreach ($result->getCollection() as $e) {
$reportResult[] = get_object_vars($e->getValueMap());
}
if (
count($reportResult) === 0 &&
$report->get('emailSendingDoNotSendEmptyReport')
) {
$this->log->debug('Report Sending: Report ' . $report->get('name') . ' is empty and was not sent.');
return;
}
}
if ($reportResult instanceof ListResult) {
throw new LogicException();
}
$attachmentId = $this->sendingService->getExportAttachmentId($report, $result, null, $user);
$this->emailBuilder->buildEmailData($data, $reportResult, $report);
$this->emailBuilder->sendEmail(
$data->userId,
$data->emailSubject,
$data->emailBody,
$attachmentId
);
}
private function getSendingListMaxCount(): int
{
return $this->config->get('reportSendingListMaxCount', self::LIST_REPORT_MAX_SIZE);
}
}

View File

@@ -0,0 +1,569 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Language;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Reports\GridReport;
use Espo\Modules\Advanced\Tools\Report\GridType\Helper as GridHelper;
use Espo\Modules\Advanced\Tools\Report\GridType\JointData;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use Espo\Modules\Advanced\Tools\Report\GridType\ResultHelper;
use Espo\Modules\Advanced\Tools\Report\GridType\Util as GridUtil;
use Espo\ORM\EntityManager;
use LogicException;
class JointGridExecutor
{
private const STUB_KEY = '__STUB__';
private EntityManager $entityManager;
private ReportHelper $reportHelper;
private GridHelper $gridHelper;
private ResultHelper $resultHelper;
private Language $language;
private GridUtil $gridUtil;
private Service $service;
public function __construct(
EntityManager $entityManager,
ReportHelper $reportHelper,
GridHelper $gridHelper,
ResultHelper $resultHelper,
Language $language,
GridUtil $gridUtil,
Service $service
) {
$this->entityManager = $entityManager;
$this->reportHelper = $reportHelper;
$this->gridHelper = $gridHelper;
$this->resultHelper = $resultHelper;
$this->language = $language;
$this->gridUtil = $gridUtil;
$this->service = $service;
}
/**
* @param ?array<string, ?WhereItem> $idWhereMap
* @throws Error
* @throws Forbidden
*/
public function execute(
JointData $data,
?User $user = null,
?array $idWhereMap = null
): GridResult {
if ($data->getJoinedReportDataList() === []) {
throw new Error("Bad report.");
}
$result = null;
$groupColumn = null;
$reportList = [];
$groupCount = null;
foreach ($data->getJoinedReportDataList() as $item) {
if (empty($item->id)) {
throw new Error("Bad report.");
}
/** @var ?Report $report */
$report = $this->entityManager->getEntity(Report::ENTITY_TYPE, $item->id);
if (!$report) {
throw new Error("Sub-report $item->id doesn't exist.");
}
$reportList[] = $report;
}
foreach ($data->getJoinedReportDataList() as $i => $item) {
$report = $reportList[$i];
$where = null;
if ($idWhereMap && isset($idWhereMap[$item->id])) {
$where = $idWhereMap[$item->id];
}
if ($report->isInternal()) {
$reportObj = $this->reportHelper->createInternalReport($report);
if (!$reportObj instanceof GridReport) {
throw new Error("Bad report class.");
}
$subReportResult = $reportObj->run($where, $user);
}
else {
if ($report->getType() !== Report::TYPE_GRID) {
throw new Error("Bad sub-report.");
}
$this->reportHelper->checkReportCanBeRun($report);
$subReportResult = $this->service->executeGridReport(
$this->reportHelper->fetchGridDataFromReport($report),
$where,
$user
);
}
$subReportNumericColumnList = $subReportResult->getNumericColumnList();
$subReportAggregatedColumnList = $subReportResult->getAggregatedColumnList();
$subReportResult->setColumnOriginalMap([]);
$subReportResult->setNumericColumnList([]);
$subReportResult->setAggregatedColumnList([]);
$columnToUnsetList = [];
$columnOriginalMap = $subReportResult->getColumnOriginalMap();
$subReportColumnList = $subReportResult->getColumnList();
foreach ($subReportColumnList as &$columnPointer) {
$originalColumnName = $columnPointer;
$newColumnName = $columnPointer . '@'. $i;
$columnOriginalMap[$newColumnName] = $columnPointer;
if (in_array($originalColumnName, $subReportNumericColumnList)) {
$subReportResult->setNumericColumnList(
array_merge(
$subReportResult->getNumericColumnList(),
[$newColumnName]
)
);
}
if (
$subReportAggregatedColumnList &&
in_array($originalColumnName, $subReportAggregatedColumnList)
) {
$subReportResult->setAggregatedColumnList(
array_merge(
$subReportResult->getAggregatedColumnList(),
[$newColumnName]
)
);
}
if (!in_array($columnPointer, $subReportAggregatedColumnList)) {
$columnToUnsetList[] = $newColumnName;
}
$columnPointer = $newColumnName;
}
$subReportColumnList = array_values(array_filter(
$subReportColumnList,
function (string $item) use ($columnToUnsetList) {
return !in_array($item, $columnToUnsetList);
}
));
$subReportResult->setColumnList($subReportColumnList);
$subReportResult->setColumnOriginalMap($columnOriginalMap);
$sums = [];
foreach (get_object_vars($subReportResult->getSums()) as $key => $sum) {
$sums[$key . '@'. $i] = $sum;
}
$subReportResult->setSums((object) $sums);
$columnNameMap = [];
foreach ($subReportResult->getColumnNameMap() as $key => $name) {
if (!str_contains($key, '.')) {
if (!empty($item->label)) {
$name = $item->label . ' · ' . $name;
}
}
$columnNameMap[$key . '@'. $i] = $name;
}
$subReportResult->setColumnNameMap($columnNameMap);
$columnTypeMap = [];
foreach ($subReportResult->getColumnTypeMap() as $key => $type) {
$columnTypeMap[$key . '@'. $i] = $type;
}
$subReportResult->setColumnTypeMap($columnTypeMap);
$columnDecimalPlacesMap = [];
foreach (get_object_vars($subReportResult->getColumnDecimalPlacesMap()) as $key => $type) {
$columnDecimalPlacesMap[$key . '@'. $i] = $type;
}
$subReportResult->setColumnDecimalPlacesMap((object) $columnDecimalPlacesMap);
$chartColors = [];
if ($subReportResult->getChartColor()) {
$chartColors[$subReportResult->getColumnList()[0]] = $subReportResult->getChartColor();
}
foreach (get_object_vars($subReportResult->getChartColors()) as $key => $color) {
$chartColors[$key . '@'. $i] = $color;
}
$subReportResult->setChartColors((object) $chartColors);
$cellValueMaps = (object) [];
foreach (get_object_vars($subReportResult->getCellValueMaps()) as $column => $map) {
$cellValueMaps->{$column . '@'. $i} = $map;
}
$subReportResult->setCellValueMaps($cellValueMaps);
$reportData = $subReportResult->getReportData();
foreach (get_object_vars($subReportResult->getReportData()) as $key => $dataItem) {
$newDataItem = (object) [];
foreach (get_object_vars($dataItem) as $key1 => $value) {
$newDataItem->{$key1 . '@'. $i} = $value;
}
$reportData->$key = $newDataItem;
}
$subReportResult->setReportData($reportData);
if ($i === 0) {
$groupCount = count($report->getGroupBy());
if ($groupCount) {
$groupColumn = $report->getGroupBy()[0];
}
if ($groupCount > 2) {
throw new Error("Grouping by 2 columns is not supported in joint reports.");
}
$result = $subReportResult;
$result->setEntityTypeList([$report->getTargetEntityType()]);
$result->setColumnEntityTypeMap([]);
$result->setColumnReportIdMap([]);
$result->setColumnSubReportLabelMap([]);
} else {
if ($groupCount === null) {
throw new LogicException();
}
if (count($report->getGroupBy()) !== $groupCount) {
throw new Error("Sub-reports must have the same Group By number.");
}
foreach ($subReportResult->getColumnList() as $column) {
$columnList = $result->getColumnList();
$columnList[] = $column;
$result->setColumnList($columnList);
}
foreach (get_object_vars($subReportResult->getSums()) as $key => $value) {
$sums = $result->getSums();
$sums->$key = $value;
$result->setSums($sums);
}
foreach (($subReportResult->getColumnNameMap() ?? []) as $key => $name) {
$map = $result->getColumnNameMap();
$map[$key] = $name;
$result->setColumnNameMap($map);
}
foreach (($subReportResult->getColumnTypeMap() ?? []) as $key => $type) {
$map = $result->getColumnTypeMap();
$map[$key] = $type;
$result->setColumnTypeMap($map);
}
foreach (get_object_vars($subReportResult->getChartColors()) as $key => $value) {
$map = $result->getChartColors();
$map->$key = $value;
$result->setChartColors($map);
}
foreach ($subReportResult->getColumnOriginalMap() as $key => $value) {
$map = $result->getColumnOriginalMap();
$map[$key] = $value;
$result->setColumnOriginalMap($map);
}
foreach (get_object_vars($subReportResult->getCellValueMaps()) as $column => $value) {
$map = $result->getCellValueMaps();
$map->$column = $value;
$result->setCellValueMaps($map);
}
foreach ($subReportResult->getGroupValueMap() as $group => $v) {
$map = $result->getGroupValueMap();
if (!array_key_exists($group, $map)) {
continue;
}
$map[$group] = array_replace($map[$group], $v);
$result->setGroupValueMap($map);
}
foreach ($subReportResult->getNumericColumnList() as $item1) {
$list = $result->getNumericColumnList();
$list[] = $item1;
$result->setNumericColumnList($list);
}
foreach ($subReportResult->getAggregatedColumnList() as $item1) {
$list = $result->getAggregatedColumnList();
$list[] = $item1;
$result->setAggregatedColumnList($list);
}
foreach (($subReportResult->getGrouping()[0] ?? []) as $groupName) {
if (in_array($groupName, $result->getGrouping()[0] ?? [])) {
continue;
}
$list = $result->getGrouping()[0] ?? [];
$list[] = $groupName;
$result->setGrouping([$list]);
}
foreach (get_object_vars($subReportResult->getReportData()) as $key => $dataItem) {
$reportData = $result->getReportData();
if (property_exists($reportData, $key)) {
foreach (get_object_vars($dataItem) as $key1 => $value) {
$reportData->$key->$key1 = $value;
}
} else {
$reportData->$key = $dataItem;
}
$result->setReportData($reportData);
}
$entityTypeList = $result->getEntityTypeList() ?? [];
$entityTypeList[] = $report->getTargetEntityType();
$result->setEntityTypeList($entityTypeList);
}
foreach ($subReportResult->getColumnList() as $column) {
$columnEntityTypeMap = $result->getColumnEntityTypeMap();
$columnReportIdMap = $result->getColumnReportIdMap();
$columnSubReportLabelMap = $result->getColumnSubReportLabelMap();
$columnEntityTypeMap[$column] = $report->getTargetEntityType();
$columnReportIdMap[$column] = $report->getId();
$columnSubReportLabelMap[$column] = !empty($item->label) ?
$item->label :
$this->language->translateLabel($report->getTargetEntityType(), 'scopeNamesPlural');
$result->setColumnEntityTypeMap($columnEntityTypeMap);
$result->setColumnReportIdMap($columnReportIdMap);
$result->setColumnSubReportLabelMap($columnSubReportLabelMap);
}
}
if (
$groupColumn &&
isset($result->getGrouping()[0])
) {
$list = $result->getGrouping()[0];
$this->resultHelper->prepareGroupingRange($groupColumn, $list);
$result->setGrouping([$list]);
}
foreach (get_object_vars($result->getReportData()) as $key => $dataItem) {
foreach ($result->getColumnList() as $column) {
if (property_exists($dataItem, $column)) {
continue;
}
$originalColumn = $result->getColumnOriginalMap()[$column];
$originalEntityType = $result->getColumnEntityTypeMap()[$column];
[, $i] = explode('@', $column);
$i = (int) $i;
$report = $reportList[$i];
$gridData = $this->reportHelper->fetchGridDataFromReport($report);
$reportData = $result->getReportData();
if ($this->gridHelper->isColumnNumeric($originalColumn, $gridData)) {
$reportData->$key->$column = 0;
continue;
}
$value = null;
if ($groupColumn && $groupColumn !== self::STUB_KEY) {
$subReportGroupColumn = $report->getGroupBy()[0];
if (str_starts_with($originalColumn, $subReportGroupColumn)) {
$displayValue = null;
$columnData = $this->gridHelper->getDataFromColumnName($originalEntityType, $originalColumn);
$e = $this->entityManager->getEntity($columnData->entityType, $key);
if ($e) {
$value = $e->get($columnData->field);
if ($columnData->fieldType === 'link') {
$value = $e->get($columnData->field . 'Id');
$displayValue = $e->get($columnData->field . 'Name');
} else {
$displayValue = $this->gridUtil->getCellDisplayValue($value, $columnData);
}
}
if (!is_null($displayValue)) {
$maps = $result->getCellValueMaps();
if (!property_exists($maps, $column)) {
$maps->$column = (object) [];
}
$maps->$column->$value = $displayValue;
$result->setCellValueMaps($maps);
}
}
}
$reportData->$key->$column = $value;
$result->setReportData($reportData);
}
}
$this->setSummaryColumnList($result, $reportList);
$result->setSubListColumnList([]);
$result->setChartType($data->getChartType());
$result->setIsJoint(true);
$this->setChartColors($result);
$this->setChartDataList($reportList, $result);
return $result;
}
/**
* @param Report[] $reportList
*/
private function setChartDataList(array $reportList, GridResult $result): void
{
$chartColumnList = [];
$chartY2ColumnList = [];
foreach ($reportList as $i => $report) {
$gridData = $this->reportHelper->fetchGridDataFromReport($report);
$itemDataList = $gridData->getChartDataList();
if ($itemDataList && count($itemDataList)) {
foreach ($itemDataList[0]->columnList ?? [] as $item) {
$chartColumnList[] = $item . '@' . $i;
}
foreach ($itemDataList[0]->y2ColumnList ?? [] as $item) {
$chartY2ColumnList[] = $item . '@' . $i;
}
}
}
if ($chartColumnList === [] && $chartY2ColumnList === []) {
return;
}
$result->setChartDataList([
(object) [
'columnList' => $chartColumnList,
'y2ColumnList' => $chartY2ColumnList,
]
]);
}
private function setChartColors(GridResult $result): void
{
$colorList = [];
$chartColors = $result->getChartColors();
foreach (get_object_vars($chartColors) as $key => $value) {
if (in_array($value, $colorList)) {
unset($chartColors->$key);
}
$colorList[] = $value;
}
if (array_keys(get_object_vars($chartColors)) === []) {
$chartColors = null;
}
$result->setChartColors($chartColors);
}
/**
* @param Report[] $reportList
*/
private function setSummaryColumnList(GridResult $result, array $reportList): void
{
$summaryColumnList = [];
foreach ($result->getColumnList() as $column) {
[, $i] = explode('@', $column);
$report = $reportList[$i];
$gridData = $this->reportHelper->fetchGridDataFromReport($report);
if ($this->gridHelper->isColumnSummary($column, $gridData)) {
$summaryColumnList[] = $column;
}
}
$result->setSummaryColumnList($summaryColumnList);
}
}

View File

@@ -0,0 +1,214 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\AclManager;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\ListType\ExportParams;
use Espo\Modules\Advanced\Tools\Report\ListType\RunParams as ListRunParams;
use Espo\Modules\Advanced\Tools\Report\ListType\SubReportParams;
use Espo\ORM\Defs as OrmDefs;
use Espo\ORM\EntityManager;
use Espo\Tools\Export\Export as ExportTool;
use Espo\Tools\Export\Params as ExportToolParams;
class ListExportService
{
private AclManager $aclManager;
private InjectableFactory $injectableFactory;
private Service $service;
private OrmDefs $ormDefs;
private EntityManager $entityManager;
public function __construct(
AclManager $aclManager,
InjectableFactory $injectableFactory,
Service $service,
OrmDefs $ormDefs,
EntityManager $entityManager
) {
$this->aclManager = $aclManager;
$this->injectableFactory = $injectableFactory;
$this->service = $service;
$this->ormDefs = $ormDefs;
$this->entityManager = $entityManager;
}
/**
* @throws Error
* @throws Forbidden
* @throws NotFound
*/
public function export(
string $id,
SearchParams $searchParams,
ExportParams $exportParams,
?SubReportParams $subReportParams = null,
?User $user = null
): string {
$runParams = ListRunParams::create()->withIsExport();
if (
$user &&
$this->aclManager->getPermissionLevel($user, 'exportPermission') !== AclTable::LEVEL_YES
) {
throw new Forbidden("Export is forbidden.");
}
if ($exportParams->getFieldList() === null) {
$runParams = $runParams->withFullSelect();
}
else {
$customColumnList = [];
foreach ($exportParams->getFieldList() as $item) {
$value = $item;
if (strpos($item, '_') !== false) {
$value = str_replace('_', '.', $item);
}
$customColumnList[] = $value;
}
$runParams = $runParams->withCustomColumnList($customColumnList);
}
if ($exportParams->getIds()) {
$searchParams = $searchParams->withWhereAdded(
WhereItem::createBuilder()
->setAttribute('id')
->setType('equals')
->setValue($exportParams->getIds())
->build()
);
}
if ($subReportParams) {
$searchParams = $searchParams->withSelect($exportParams->getAttributeList());
}
$reportResult = $subReportParams ?
$this->service->runSubReportList(
$id,
$searchParams,
$subReportParams,
$user,
$runParams
) :
$this->service->runList(
$id,
$searchParams,
$user,
$runParams
);
$collection = $reportResult->getCollection();
/** @var ?Report $report */
$report = $this->entityManager->getEntityById(Report::ENTITY_TYPE, $id);
if (!$report) {
throw new NotFound("Report $id not found.");
}
$entityType = $report->getTargetEntityType();
if (
$user &&
!$this->aclManager->checkScope($user, $entityType, AclTable::ACTION_READ)
) {
throw new Forbidden("No 'read' access for '$entityType' scope.");
}
$attributeList = null;
if ($exportParams->getAttributeList()) {
$attributeList = [];
foreach ($exportParams->getAttributeList() as $attribute) {
if (strpos($attribute, '_')) {
[$link, $field] = explode('_', $attribute);
$foreignType = $this->getForeignFieldType($entityType, $link, $field);
if ($foreignType === 'link') {
$attributeList[] = $attribute . 'Id';
$attributeList[] = $attribute . 'Name';
continue;
}
}
$attributeList[] = $attribute;
}
}
$export = $this->injectableFactory->create(ExportTool::class);
$exportParamsNew = ExportToolParams::create($entityType)
->withAttributeList($attributeList)
->withFieldList($exportParams->getFieldList())
->withFormat($exportParams->getFormat())
->withName($report->getName())
->withFileName($report->getName() . ' ' . date('Y-m-d'));
foreach ($exportParams->getParams() as $k => $v) {
$exportParamsNew = $exportParamsNew->withParam($k, $v);
}
return $export
->setParams($exportParamsNew)
->setCollection($collection)
->run()
->getAttachmentId();
}
private function getForeignFieldType(string $entityType, string $link, string $field): ?string
{
$entityDefs = $this->ormDefs->getEntity($entityType);
if (!$entityDefs->hasRelation($link)) {
return null;
}
$relationDefs = $entityDefs->getRelation($link);
if (!$relationDefs->hasForeignEntityType()) {
return null;
}
$entityDefs = $this->ormDefs->getEntity($relationDefs->getForeignEntityType());
if (!$entityDefs->hasField($field)) {
return null;
}
return $entityDefs->getField($field)->getType();
}
}

View File

@@ -0,0 +1,84 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
use Espo\Core\Select\Where\Item as WhereItem;
use stdClass;
class Data
{
/** @var string[] */
private array $columns;
/** @var ?string */
private ?string $orderBy;
/**
* @param string[] $columns
*/
public function __construct(
private string $entityType,
array $columns,
?string $orderBy,
private ?stdClass $columnsData,
private ?WhereItem $filtersWhere
) {
$this->columns = $columns;
$this->orderBy = $orderBy;
}
public function getEntityType(): string
{
return $this->entityType;
}
/**
* @return string[]
*/
public function getColumns(): array
{
return $this->columns;
}
public function getOrderBy(): ?string
{
return $this->orderBy;
}
public function getColumnsData(): ?stdClass
{
return $this->columnsData;
}
/**
* @param string[] $columns
*/
public function withColumns(array $columns): self
{
$obj = clone $this;
$obj->columns = $columns;
return $obj;
}
public function getFiltersWhere(): ?WhereItem
{
return $this->filtersWhere;
}
}

View File

@@ -0,0 +1,74 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
class ExportParams
{
/**
* @param ?string[] $attributeList
* @param ?string[] $fieldList
* @param ?string[] $ids
* @param ?array<string, mixed> $params
*/
public function __construct(
private ?array $attributeList,
private ?array $fieldList,
private ?string $format,
private ?array $ids,
private ?array $params
) {}
/**
* @return ?string[]
*/
public function getAttributeList(): ?array
{
return $this->attributeList;
}
/**
* @return ?string[]
*/
public function getFieldList(): ?array
{
return $this->fieldList;
}
public function getFormat(): ?string
{
return $this->format;
}
/**
* @return ?string[]
*/
public function getIds(): ?array
{
return $this->ids;
}
/**
* @return ?array<string, mixed>
*/
public function getParams(): ?array
{
return $this->params;
}
}

View File

@@ -0,0 +1,196 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Select\Where\ItemBuilder as WhereItemBuilder;
use Espo\Core\Utils\Config;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Report\SelectHelper;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Selection;
use Espo\ORM\Query\SelectBuilder;
class QueryPreparator
{
public function __construct(
private SelectHelper $selectHelper,
private SelectBuilderFactory $selectBuilderFactory,
private Config $config,
private EntityManager $entityManager
) {}
/**
* Complex expression check is not applied for search parameters as it's supposed
* to be checked the by runtime filter checker.
*
* @throws Forbidden
* @throws BadRequest
*/
public function prepare(
Data $data,
?SearchParams $searchParams = null,
?User $user = null
): SelectBuilder {
$searchParams = $searchParams ?? SearchParams::create();
$orderBy = $searchParams->getOrderBy();
$order = $searchParams->getOrder();
if ($orderBy && str_contains($orderBy, '_')) {
$searchParams = $searchParams
->withOrderBy(null);
}
if ($searchParams->getWhere() && $user) {
$searchParams = $this->applyTimeZoneToSearchParams($searchParams, $user);
}
$selectBuilder = $this->selectBuilderFactory
->create()
->from($data->getEntityType())
->withSearchParams($searchParams->withSelect(['id']));
if ($user) {
$selectBuilder
->forUser($user)
->withWherePermissionCheck()
->withAccessControlFilter();
}
// Applies access control check.
$intermediateQuery = $selectBuilder->build();
$selectBuilder = $this->selectBuilderFactory
->create()
->from($data->getEntityType())
->withSearchParams($searchParams);
if ($user) {
$selectBuilder
->forUser($user)
->withAccessControlFilter();
}
$queryBuilder = $selectBuilder
->buildQueryBuilder()
->from($data->getEntityType(), lcfirst($data->getEntityType()));
if ($data->getColumns() !== []) {
// Add columns applied from order-by.
$queryBuilder->select(
// Prevent issue in ORM (as of v7.5).
array_map(function (Selection $selection) {
return !$selection->getAlias() ?
$selection->getExpression() :
$selection;
}, $intermediateQuery->getSelect())
);
$this->selectHelper->handleColumns($data->getColumns(), $queryBuilder);
}
if ($data->getFiltersWhere()) {
[$whereItem, $havingItem] = $this->selectHelper->splitHavingItem($data->getFiltersWhere());
$this->selectHelper->handleFiltersWhere($whereItem, $queryBuilder);
$this->selectHelper->handleFiltersHaving($havingItem, $queryBuilder);
}
if ($orderBy) {
$this->selectHelper->handleOrderByForList($orderBy, $order, $queryBuilder);
}
if ($searchParams->getWhere()) {
/** @noinspection PhpDeprecationInspection */
$this->selectHelper->applyDistinctFromWhere($searchParams->getWhere(), $queryBuilder);
}
return $queryBuilder;
}
private function applyTimeZoneToSearchParams(SearchParams $searchParams, User $user): SearchParams
{
$where = $searchParams->getWhere();
if (!$where) {
return $searchParams;
}
return $searchParams->withWhere(
$this->addUserTimeZoneToWhere($where, $user)
);
}
private function addUserTimeZoneToWhere(WhereItem $item, User $user, ?string $timeZone = null): WhereItem
{
$timeZone ??= $this->getUserTimeZone($user);
if (
$item->getType() === WhereItem\Type::AND ||
$item->getType() === WhereItem\Type::OR
) {
$items = [];
foreach ($item->getItemList() as $subItem) {
$items[] = $this->addUserTimeZoneToWhere($subItem, $user, $timeZone);
}
return WhereItemBuilder::create()
->setType($item->getType())
->setItemList($items)
->build();
}
$data = $item->getData();
if (!$data) {
return $item;
}
if (
!$data instanceof WhereItem\Data\DateTime &&
!method_exists($data, 'withTimeZone')
) {
return $item;
}
return $item->withData(
$data->withTimeZone($timeZone)
);
}
private function getUserTimeZone(User $user): string
{
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $user->getId());
if ($preferences->get('timeZone')) {
return $preferences->get('timeZone');
}
return $this->config->get('timeZone') ?? 'UTC';
}
}

View File

@@ -0,0 +1,75 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use stdClass;
class Result
{
/** @var Collection<Entity> */
private Collection $collection;
private int $total;
/** @var ?string[] */
private ?array $columns;
private ?stdClass $columnsData;
/**
* @param Collection<Entity> $collection
* @param ?string[] $columns
*/
public function __construct(
Collection $collection,
int $total,
?array $columns = null,
?stdClass $columnsData = null
) {
$this->collection = $collection;
$this->total = $total;
$this->columns = $columns;
$this->columnsData = $columnsData;
}
/**
* @return Collection<Entity>
*/
public function getCollection(): Collection
{
return $this->collection;
}
public function getTotal(): int
{
return $this->total;
}
/**
* @return ?string[]
*/
public function getColumns(): ?array
{
return $this->columns;
}
public function getColumnsData(): ?stdClass
{
return $this->columnsData;
}
}

View File

@@ -0,0 +1,107 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
class RunParams
{
private bool $skipRuntimeFiltersCheck = false;
private bool $returnSthCollection = false;
private bool $isExport = false;
private bool $fullSelect = false;
/** @var ?string[] */
private ?array $customColumnList = null;
private function __construct() {}
public function skipRuntimeFiltersCheck(): bool
{
return $this->skipRuntimeFiltersCheck;
}
public function withSkipRuntimeFiltersCheck(bool $value = true): self
{
$obj = clone $this;
$obj->skipRuntimeFiltersCheck = $value;
return $obj;
}
public static function create(): self
{
return new self();
}
public function withReturnSthCollection(bool $value = true): self
{
$obj = clone $this;
$obj->returnSthCollection = $value;
return $obj;
}
public function withIsExport(bool $value = true): self
{
$obj = clone $this;
$obj->isExport = $value;
return $obj;
}
public function withFullSelect(bool $value = true): self
{
$obj = clone $this;
$obj->fullSelect = $value;
return $obj;
}
/**
* @param ?string[] $value
*/
public function withCustomColumnList(?array $value): self
{
$obj = clone $this;
$obj->customColumnList = $value;
return $obj;
}
public function returnSthCollection(): bool
{
return $this->returnSthCollection;
}
public function isExport(): bool
{
return $this->isExport;
}
public function isFullSelect(): bool
{
return $this->fullSelect;
}
/**
* @return ?string[]
*/
public function getCustomColumnList(): ?array
{
return $this->customColumnList;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Espo\Modules\Advanced\Tools\Report\ListType;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Report\GridType\Data as Data;
use Espo\Modules\Advanced\Tools\Report\SelectHelper;
use Espo\ORM\Query\Select;
class SubListQueryPreparator
{
public function __construct(
private SubReportQueryPreparator $subReportQueryPreparator,
private SelectHelper $selectHelper
) {}
/**
* @param ?scalar $groupValue
* @param string[] $columnList
* @param string[] $realColumnList
*
* @throws Forbidden
* @throws BadRequest
*/
public function prepare(
Data $data,
$groupValue,
array $columnList,
array $realColumnList,
?WhereItem $where,
?User $user,
): Select {
$searchParams = SearchParams::create()->withSelect(['id']);
if ($where) {
$searchParams = $searchParams->withWhere($where);
}
$queryBuilder = $this->subReportQueryPreparator->prepare(
data: $data,
searchParams: $searchParams,
subReportParams: new SubReportParams(0, $groupValue),
user: $user,
);
$this->selectHelper->handleColumns($realColumnList, $queryBuilder);
$newOrderBy = [];
foreach ($data->getOrderBy() as $orderByItem) {
$orderByColumn = explode(':', $orderByItem)[1] ?? null;
if (in_array($orderByColumn, $columnList)) {
$newOrderBy[] = $orderByItem;
}
}
if ($newOrderBy !== []) {
$queryBuilder->order([]);
}
$this->selectHelper->handleOrderBy($newOrderBy, $queryBuilder);
return $queryBuilder->build();
}
}

View File

@@ -0,0 +1,67 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
class SubReportParams
{
/** @var ?scalar */
private $groupValue;
/** @var ?scalar */
private $groupValue2;
/**
* @param ?scalar $groupValue
* @param ?scalar $groupValue2
*/
public function __construct(
private int $groupIndex,
$groupValue,
private bool $hasGroupValue2 = false,
$groupValue2 = null
) {
$this->groupValue = $groupValue;
$this->groupValue2 = $groupValue2;
}
public function getGroupIndex(): int
{
return $this->groupIndex;
}
/**
* @return ?scalar
*/
public function getGroupValue()
{
return $this->groupValue;
}
public function hasGroupValue2(): bool
{
return $this->hasGroupValue2;
}
/**
* @return ?scalar
*/
public function getGroupValue2()
{
return $this->groupValue2;
}
}

View File

@@ -0,0 +1,347 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\ListType;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Report\GridType\Data as GridData;
use Espo\Modules\Advanced\Tools\Report\GridType\Helper as GridHelper;
use Espo\Modules\Advanced\Tools\Report\GridType\QueryPreparator as GridQueryPreparator;
use Espo\Modules\Advanced\Tools\Report\SelectHelper;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Selection;
use Espo\ORM\Query\SelectBuilder;
use RuntimeException;
class SubReportQueryPreparator
{
public function __construct(
private Metadata $metadata,
private SelectHelper $selectHelper,
private GridHelper $gridHelper,
private SelectBuilderFactory $selectBuilderFactory,
private GridQueryPreparator $gridQueryPreparator
) {}
/**
* A sub-report query preparator.
*
* Complex expression check is not applied for search parameters as it's supposed
* to be checked the by runtime filter checker.
*
* @throws Forbidden
* @throws BadRequest
*/
public function prepare(
GridData $data,
SearchParams $searchParams,
SubReportParams $subReportParams,
?User $user = null,
): SelectBuilder {
$entityType = $data->getEntityType();
$selectBuilder = $this->selectBuilderFactory
->create()
->from($data->getEntityType())
->withSearchParams($searchParams);
if ($user) {
$selectBuilder
->withWherePermissionCheck()
->forUser($user);
}
if ($user && $data->applyAcl()) {
$selectBuilder->withAccessControlFilter();
}
$queryBuilder = $selectBuilder->buildQueryBuilder();
$selectColumns = $queryBuilder->build()->getSelect();
$this->gridHelper->checkColumnsAvailability($entityType, $data->getGroupBy());
[$groupBy, $groupByOther] = $this->handleGroupBy($data, $subReportParams, $queryBuilder);
// Prevent issue in ORM (not needed as of v7.5).
$selectColumns = array_map(function (Selection $selection) {
return !$selection->getAlias() ?
$selection->getExpression() :
$selection;
}, $selectColumns);
$queryBuilder
->from($data->getEntityType(), lcfirst($data->getEntityType()))
->select($selectColumns);
if ($data->getFiltersWhere()) {
[$whereItem,] = $this->selectHelper->splitHavingItem($data->getFiltersWhere());
$this->selectHelper->handleFiltersWhere($whereItem, $queryBuilder);
$this->handleHaving(
data: $data,
subReportParams: $subReportParams,
where: $searchParams->getWhere(),
user: $user,
groupBy: $groupBy,
groupByOther: $groupByOther,
queryBuilder: $queryBuilder,
);
}
if ($searchParams->getWhere()) {
/** @noinspection PhpDeprecationInspection */
$this->selectHelper->applyDistinctFromWhere($searchParams->getWhere(), $queryBuilder);
}
$this->applyGroupWhereAll(
data: $data,
subReportParams: $subReportParams,
groupBy: $groupBy,
groupByOther: $groupByOther,
queryBuilder: $queryBuilder,
);
return $queryBuilder;
}
/**
* @return array{?string, ?string}
*/
private function handleGroupBy(
GridData $data,
SubReportParams $subReportParams,
SelectBuilder $queryBuilder
): array {
if (!$data->getGroupBy()) {
return [null, null];
}
$groupIndex = $subReportParams->getGroupIndex();
$this->selectHelper->handleGroupBy($data->getGroupBy(), $queryBuilder);
$groupByExpressions = $queryBuilder->build()->getGroup();
if (!isset($groupByExpressions[$groupIndex])) {
throw new RuntimeException('No group by.');
}
$groupBy = $groupByExpressions[$groupIndex]->getValue();
$queryBuilder->group([]);
if (count($data->getGroupBy()) === 1) {
return [$groupBy, null];
}
$groupBy1Type = $this->metadata
->get(['entityDefs', $data->getEntityType(), 'fields', $data->getGroupBy()[0], 'type']);
if ($groupIndex === 1) {
$groupByOther = $groupByExpressions[0]->getValue();
if ($groupBy1Type === 'linkParent') {
$groupBy = $groupByExpressions[2]->getValue();
}
return [$groupBy, $groupByOther];
}
$groupByOther = $groupBy1Type === 'linkParent' ?
$groupByExpressions[2]->getValue() :
$groupByExpressions[1]->getValue();
return [$groupBy, $groupByOther];
}
private function applyGroupWhereAll(
GridData $data,
SubReportParams $subReportParams,
?string $groupBy,
?string $groupByOther,
SelectBuilder $queryBuilder
): void {
if ($groupBy !== null) {
$this->applyGroupWhere($data, $subReportParams, $groupBy, $queryBuilder);
}
if (!$groupByOther) {
return;
}
if (!$subReportParams->hasGroupValue2()) {
return;
}
$this->applyGroup2Where(
$data,
$subReportParams,
$groupByOther,
$queryBuilder
);
}
private function applyGroupWhere(
GridData $data,
SubReportParams $subReportParams,
string $groupBy,
SelectBuilder $queryBuilder
): void {
$index = $subReportParams->getGroupIndex();
$value = $subReportParams->getGroupValue();
$this->applyGroupByWhereValue(
$data,
$index,
$value,
$groupBy,
$queryBuilder
);
}
private function applyGroup2Where(
GridData $data,
SubReportParams $subReportParams,
string $groupBy,
SelectBuilder $queryBuilder
): void {
$value = $subReportParams->getGroupValue2();
$this->applyGroupByWhereValue(
$data,
1,
$value,
$groupBy,
$queryBuilder
);
}
/**
* @param ?scalar $value
*/
private function applyGroupByWhereValue(
GridData $data,
int $index,
$value,
string $groupBy,
SelectBuilder $queryBuilder
): void {
$fieldType = $this->metadata
->get(['entityDefs', $data->getEntityType(), 'fields', $data->getGroupBy()[$index], 'type']);
if ($fieldType === 'linkParent') {
if ($value === null) {
$queryBuilder->where([
$data->getGroupBy()[$index] . 'Id' => null,
]);
return;
}
$arr = explode(':,:', (string) $value);
$valueType = $arr[0];
$valueId = null;
if (count($arr)) {
$valueId = $arr[1];
}
if (!$valueId) {
$valueId = null;
}
$queryBuilder->where([
$data->getGroupBy()[$index] . 'Type' => $valueType,
$data->getGroupBy()[$index] . 'Id' => $valueId,
]);
return;
}
if ($value === null) {
$queryBuilder->where([
'OR' => [
[$groupBy => null],
]
]);
return;
}
$queryBuilder->where([$groupBy => $value]);
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function handleHaving(
GridData $data,
SubReportParams $subReportParams,
?WhereItem $where,
?User $user,
?string $groupBy,
?string $groupByOther,
SelectBuilder $queryBuilder
): void {
[, $havingItem] = $this->selectHelper->splitHavingItem($data->getFiltersWhere());
if ($havingItem->getItemList() === []) {
return;
}
$gridQuery = $this->gridQueryPreparator->prepare($data, $where, $user);
$subQueryBuilder = SelectBuilder::create()
->clone($gridQuery);
$this->applyGroupWhereAll(
$data,
$subReportParams,
$groupBy,
$groupByOther,
$subQueryBuilder
);
if (!method_exists(Cond::class, 'exists')) { /** @phpstan-ignore-line */
return;
}
$queryBuilder->where(
Cond::exists($subQueryBuilder->build())
);
}
}

View File

@@ -0,0 +1,112 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Acl;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\ServiceContainer;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\ORM\EntityManager;
use stdClass;
class PreviewReportProvider
{
public function __construct(
private Service $service,
private Acl $acl,
private EntityManager $entityManager,
private ServiceContainer $serviceContainer,
private User $user,
private ReportHelper $reportHelper,
) {}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function get(stdClass $data): Report
{
$report = $this->entityManager->getRDBRepositoryByClass(Report::class)->getNew();
unset($data->isInternal);
$attributeList = [
'entityType',
'type',
'data',
'columns',
'groupBy',
'orderBy',
'orderByList',
'filters',
'filtersDataList',
'runtimeFilters',
'filtersData',
'columnsData',
'chartColors',
'chartDataList',
'chartOneColumns',
'chartOneY2Columns',
'chartType',
'joinedReports',
'joinedReportLabel',
'joinedReportDataList',
];
foreach (array_keys(get_object_vars($data)) as $attribute) {
if (!in_array($attribute, $attributeList)) {
unset($data->$attribute);
}
}
$report->setMultiple($data);
$report->setApplyAcl();
$report->setName('Unnamed');
$this->serviceContainer->getByClass(Report::class)->processValidation($report, $data);
foreach ($report->getJoinedReportIdList() as $subReportId) {
$subReport = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($subReportId);
if (!$subReport) {
continue;
}
$this->reportHelper->checkReportCanBeRun($subReport);
if (!$this->acl->checkEntityRead($subReport)) {
throw new Forbidden("No access to sub-report.");
}
}
$this->reportHelper->checkReportCanBeRun($report);
if (
$report->getTargetEntityType() &&
!$this->acl->checkScope($report->getTargetEntityType(), AclTable::ACTION_READ)
) {
throw new Forbidden("No 'read' access to target entity.");
}
return $report;
}
}

View File

@@ -0,0 +1,504 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\Formula\Manager as FormulaManager;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Preferences;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Reports\GridReport;
use Espo\Modules\Advanced\Reports\ListReport;
use Espo\Modules\Advanced\Tools\Report\GridType\Data as GridData;
use Espo\Modules\Advanced\Tools\Report\GridType\JointData;
use Espo\Modules\Advanced\Tools\Report\ListType\Data as ListData;
use stdClass;
class ReportHelper
{
private const WHERE_TYPE_AND = 'and';
private const WHERE_TYPE_OR = 'or';
private const WHERE_TYPE_HAVING = 'having';
private const WHERE_TYPE_NOT = 'not';
private const WHERE_TYPE_SUB_QUERY_IN = 'subQueryIn';
private const WHERE_TYPE_SUB_QUERY_NOT_IN = 'subQueryNotIn';
private const ATTR_HAVING = '_having';
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private FormulaManager $formulaManager,
private Config $config,
private Preferences $preferences,
private FormulaChecker $formulaChecker
) {}
/**
* @throws Error
* @return ListReport|GridReport
*/
public function createInternalReport(Report $report): object
{
$className = $report->get('internalClassName');
if ($className && stripos($className, ':') !== false) {
[$moduleName, $reportName] = explode(':', $className);
if ($moduleName === 'Custom') {
$className = "Espo\\Custom\\Reports\\$reportName";
} else {
$className = "Espo\\Modules\\$moduleName\\Reports\\$reportName";
}
}
if (!$className) {
throw new Error('No class name specified for internal report.');
}
/** @var class-string<ListReport|GridReport> $className */
return $this->injectableFactory->create($className);
}
/**
* @throws Forbidden
*/
public function checkReportCanBeRun(Report $report): void
{
if (
in_array(
$report->getTargetEntityType(),
$this->metadata->get('entityDefs.Report.entityListToIgnore', [])
)
) {
throw new Forbidden("Entity type is not allowed.");
}
}
/**
* @throws Error
* @throws Forbidden
*/
public function fetchGridDataFromReport(Report $report): GridData
{
if ($report->getType() !== Report::TYPE_GRID) {
throw new Error("Non-grid report.");
}
return new GridData(
$report->getTargetEntityType(),
$report->getColumns(),
$report->getGroupBy(),
$report->getOrderBy(),
$report->getApplyAcl(),
$this->fetchFiltersWhereFromReport($report),
$report->get('chartType'),
get_object_vars($report->get('chartColors') ?? (object) []),
$report->get('chartColor'),
$report->get('chartDataList'),
($report->get('data') ?? (object) [])->success ?? null,
$report->getColumnsData(),
);
}
/**
* @throws Error
* @throws Forbidden
*/
public function fetchListDataFromReport(Report $report): ListData
{
if ($report->getType() !== Report::TYPE_LIST) {
throw new Error("Non-list report.");
}
return new ListData(
$report->getTargetEntityType(),
$report->getColumns(),
$report->getOrderByList(),
$report->getColumnsData(),
$this->fetchFiltersWhereFromReport($report)
);
}
/**
* @throws Error
*/
public function fetchJointDataFromReport(Report $report): JointData
{
if ($report->getType() !== Report::TYPE_JOINT_GRID) {
throw new Error("Non-joint-grid report.");
}
return new JointData(
$report->get('joinedReportDataList') ?? [],
$report->get('chartType')
);
}
/**
* @throws Error
* @throws Forbidden
*/
public function fetchFiltersWhereFromReport(Report $report): ?WhereItem
{
$isNotList = $report->getType() !== Report::TYPE_LIST;
$raw = $report->get('filtersData') && !$report->get('filtersDataList') ?
$this->convertFiltersData($report->get('filtersData')) :
$this->convertFiltersDataList($report->get('filtersDataList') ?? [], $isNotList);
if (!$raw) {
return null;
}
$raw = json_decode(
/** @phpstan-ignore-next-line */
json_encode($raw),
true
);
return WhereItem::fromRawAndGroup($raw);
}
/**
* @param array<string, object{
* where?: mixed,
* field?: string,
* type?: string,
* dateTime?: string,
* value?: mixed,
* }>|null $filtersData
* @return stdClass[]|null
*/
private function convertFiltersData(?array $filtersData): ?array
{
if (empty($filtersData)) {
return null;
}
$arr = [];
foreach ($filtersData as $name => $defs) {
$field = $name;
if (empty($defs)) {
continue;
}
if (isset($defs->where)) {
$arr[] = $defs->where;
} else {
if (isset($defs->field)) {
$field = $defs->field;
}
$type = $defs->type ?? null;
if (!empty($defs->dateTime)) {
$arr[] = $this->fixDateTimeWhere(
$type,
$field,
$defs->value ?? null,
false
);
} else {
$o = new stdClass();
$o->type = $type;
$o->field = $field;
$o->value = $defs->value ?? null;
$arr[] = $o;
}
}
}
return $arr;
}
/**
* @param object{
* type?: string,
* name?: ?string,
* params?: object{
* type?: string,
* where?: mixed,
* attribute?: string,
* field?: string,
* dateTime?: string,
* value?: mixed,
* function?: string,
* expression?: string,
* operator?: string,
* },
* }[] $filtersDataList
* @return stdClass[]|null
* @throws Error
* @throws Forbidden
*/
private function convertFiltersDataList(array $filtersDataList, bool $useSystemTimeZone): ?array
{
if (empty($filtersDataList)) {
return null;
}
$arr = [];
foreach ($filtersDataList as $defs) {
$field = null;
if (isset($defs->name)) {
$field = $defs->name;
}
if (empty($defs) || empty($defs->params)) {
continue;
}
$params = $defs->params;
$type = $defs->type ?? null;
if (
in_array($type, [
self::WHERE_TYPE_OR,
self::WHERE_TYPE_AND,
self::WHERE_TYPE_NOT,
self::WHERE_TYPE_SUB_QUERY_IN,
self::WHERE_TYPE_SUB_QUERY_NOT_IN,
self::WHERE_TYPE_HAVING,
])
) {
if (empty($params->value)) {
continue;
}
$o = new stdClass();
$o->type = $params->type ?? null;
if ($o->type === self::WHERE_TYPE_NOT) {
$o->type = self::WHERE_TYPE_SUB_QUERY_NOT_IN;
}
if ($o->type === self::WHERE_TYPE_HAVING) {
$o->type = self::WHERE_TYPE_AND;
$o->attribute = self::ATTR_HAVING;
}
$o->value = $this->convertFiltersDataList($params->value, $useSystemTimeZone);
$arr[] = $o;
continue;
}
if ($type === 'complexExpression') {
$o = (object) [];
$function = $params->function ?? null;
if ($function === 'custom') {
if (empty($params->expression)) {
continue;
}
$o->attribute = $params->expression;
$o->type = 'expression';
} else if ($function === 'customWithOperator') {
if (empty($params->expression)) {
continue;
}
if (empty($params->operator)) {
continue;
}
$o->attribute = $params->expression;
$o->type = $params->operator;
} else {
if (empty($params->attribute)) {
continue;
}
if (empty($params->operator)) {
continue;
}
$o->attribute = $params->attribute;
if ($function) {
$o->attribute = $function . ':' . $o->attribute;
}
$o->type = $params->operator;
}
if (isset($params->value) && is_string($params->value) && strlen($params->value)) {
try {
$o->value = $this->runFormula($params->value);
}
catch (FormulaError $e) {
throw new Error($e->getMessage());
}
}
$arr[] = $o;
continue;
}
if (isset($params->where)) {
$arr[] = $params->where;
continue;
}
if (isset($params->field)) {
$field = $params->field;
}
if (empty($params->type)) {
continue;
}
$type = $params->type;
if (!empty($params->dateTime)) {
$arr[] = $this->fixDateTimeWhere(
$type,
$field,
$params->value ?? null,
$useSystemTimeZone
);
continue;
}
$o = new stdClass();
$o->type = $type;
$o->field = $field;
$o->attribute = $field;
$o->value = $params->value ?? null;
$arr[] = $o;
}
return $arr;
}
/**
* @param mixed $value
*/
private function fixDateTimeWhere(string $type, string $field, $value, bool $useSystemTimeZone): object
{
$timeZone = null;
if (!$useSystemTimeZone) {
$timeZone = $this->preferences->get('timeZone');
}
if (!$timeZone) {
$timeZone = $this->config->get('timeZone') ?? 'UTC';
}
return (object) [
'attribute' => $field,
'type' => $type,
'value' => $value,
'dateTime' => true,
'timeZone' => $timeZone,
];
}
/**
* @throws Forbidden
*/
public function checkRuntimeFilters(WhereItem $where, Report $report): void
{
$this->checkRuntimeFiltersItem($where, $report->getRuntimeFilters());
}
/**
* @param string[] $allowedFilterList
* @throws Forbidden
*/
private function checkRuntimeFiltersItem(WhereItem $item, array $allowedFilterList): void
{
$type = $item->getType();
if ($type === self::WHERE_TYPE_AND || $type === self::WHERE_TYPE_OR) {
foreach ($item->getItemList() as $subItem) {
$this->checkRuntimeFiltersItem($subItem, $allowedFilterList);
}
return;
}
$attribute = $item->getAttribute();
if (!$attribute) {
throw new Forbidden("Not allowed runtime filter item.");
}
if ($attribute === 'id') {
return;
}
if (str_contains($attribute, ':')) {
throw new Forbidden("Expressions are not allowed in runtime filter.");
}
if (!str_contains($attribute, '.')) {
return;
}
$isAllowed = in_array($attribute, $allowedFilterList);
if (!$isAllowed && str_ends_with($attribute, 'Id')) {
$isAllowed = in_array(substr($attribute, 0, -2), $allowedFilterList);
}
if (!$isAllowed) {
throw new Forbidden("Not allowed runtime filter $attribute.");
}
}
/**
* @return mixed
* @throws Forbidden
* @throws FormulaError
*/
private function runFormula(string $script)
{
$this->formulaChecker->check($script);
$script = $this->formulaChecker->sanitize($script);
return $this->formulaManager->run($script);
}
}

View File

@@ -0,0 +1,889 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use DateTime;
use DateTimeZone;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\Where\Converter;
use Espo\Core\Select\Where\ConverterFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Select\Where\ItemBuilder as WhereItemBuilder;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Report\GridType\Helper as GridHelper;
use Espo\Modules\Advanced\Tools\Report\GridType\Util;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Query\Part\Selection;
use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\QueryComposer\Util as QueryComposerUtil;
use Exception;
use LogicException;
use RuntimeException;
class SelectHelper
{
private const WHERE_TYPE_AND = 'and';
private const WHERE_TYPE_OR = 'or';
private const ATTR_HAVING = '_having';
public function __construct(
private Config $config,
private Metadata $metadata,
private Util $gridUtil,
private EntityManager $entityManager,
private GridHelper $gridHelper,
private FieldUtil $fieldUtil,
private User $user,
private ConverterFactory $converterFactory
) {}
/**
* @return array{0: WhereItem, 1: WhereItem}
*/
public function splitHavingItem(WhereItem $andItem): array
{
$whereItemList = [];
$havingItemList = [];
foreach ($andItem->getItemList() as $item) {
if (
$item->getType() === self::WHERE_TYPE_AND &&
$item->getAttribute() === self::ATTR_HAVING
) {
foreach ($item->getItemList() as $subItem) {
$havingItemList[] = $subItem;
}
continue;
}
$whereItemList[] = $item;
}
$whereItem = WhereItemBuilder::create()
->setType(self::WHERE_TYPE_AND)
->setItemList($whereItemList)
->build();
$havingItem = WhereItemBuilder::create()
->setType(self::WHERE_TYPE_AND)
->setItemList($havingItemList)
->build();
return [$whereItem, $havingItem];
}
/**
* @throws Forbidden
*/
public function handleOrderByForList(string $orderBy, string $order, SelectBuilder $queryBuilder): void
{
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException("No from.");
}
$entityDefs = $this->entityManager->getDefs()->getEntity($entityType);
$fieldType = $entityDefs->hasField($orderBy) ?
$entityDefs->getField($orderBy)->getType() :
null;
if (
in_array($fieldType, ['link', 'file', 'image']) &&
!$queryBuilder->hasLeftJoinAlias($orderBy)
) {
$queryBuilder->leftJoin($orderBy);
}
if (str_contains($orderBy, '_')) {
if (str_contains($orderBy, ':')) {
throw new Forbidden("Functions are not allowed in orderBy.");
}
$orderBy = $this->getRealForeignOrderColumn($entityType, $orderBy);
$this->addSelect($orderBy, $queryBuilder);
/** @var 'ASC'|'DESC' $order */
$queryBuilder
->order([])
->order($orderBy, $order)
->order('id', $order);
return;
}
foreach ($this->fieldUtil->getAttributeList($entityType, $orderBy) as $attribute) {
if (!$entityDefs->hasAttribute($attribute)) {
continue;
}
$this->addSelect($attribute, $queryBuilder);
}
}
private function getRealForeignOrderColumn(string $entityType, string $item): string
{
$item = str_replace('_', '.', $item);
$data = $this->gridHelper->getDataFromColumnName($entityType, $item);
if (!$data->entityType) {
throw new RuntimeException("Bad foreign order by '$item'.");
}
if (in_array($data->fieldType, ['link', 'linkParent', 'image', 'file'])) {
return $item . 'Id';
}
return $item;
}
/**
* @param string[] $groupBy
*/
public function handleGroupBy(array $groupBy, SelectBuilder $queryBuilder): void
{
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException("No from.");
}
foreach ($groupBy as $item) {
$this->handleGroupByItem($item, $entityType, $queryBuilder);
}
}
private function handleGroupByItem(string $item, string $entityType, SelectBuilder $queryBuilder): void
{
$alias = $this->gridUtil->sanitizeSelectAlias($item);
$function = null;
$argument = $item;
if (str_contains($item, ':')) {
[$function, $argument] = explode(':', $item);
}
if (str_contains($item, '(') && str_contains($item, ':')) {
$this->handleLeftJoins($item, $entityType, $queryBuilder, true);
$queryBuilder
->select($item, $alias)
->group($item);
return;
}
if ($function === 'YEAR_FISCAL') {
$fiscalYearShift = $this->config->get('fiscalYearShift', 0);
$function = $fiscalYearShift ?
'YEAR_' . $fiscalYearShift :
'YEAR';
$item = $function . ':' . $argument;
}
else if ($function === 'QUARTER_FISCAL') {
$fiscalYearShift = $this->config->get('fiscalYearShift', 0);
$function = $fiscalYearShift ?
'QUARTER_' . $fiscalYearShift :
'QUARTER';
$item = $function . ':' . $argument;
}
else if ($function === 'WEEK') {
$function = $this->config->get('weekStart') ?
'WEEK_1' :
'WEEK_0';
$item = $function . ':' . $argument;
}
if (!str_contains($item, '.')) {
$fieldType = $this->metadata->get(['entityDefs', $entityType, 'fields', $argument, 'type']);
if (in_array($fieldType, ['link', 'file', 'image'])) {
if (!$queryBuilder->hasLeftJoinAlias($item)) {
$queryBuilder->leftJoin($item);
}
$queryBuilder
->select($item . 'Id')
->group($item . 'Id');
return;
}
if ($fieldType === 'linkParent') {
if (!$queryBuilder->hasLeftJoinAlias($item)) {
// @todo Revise
$queryBuilder->leftJoin($item);
}
$queryBuilder
->select($item . 'Id')
->select($item . 'Type')
->group($item . 'Id')
->group($item . 'Type');
return;
}
if ($function && in_array($fieldType, ['datetime', 'datetimeOptional'])) {
$tzOffset = (string) $this->getTimeZoneOffset();
if ($tzOffset) {
$groupBy = "$function:TZ:($argument,$tzOffset)";
$queryBuilder
->select($groupBy)
->group($groupBy);
return;
}
$queryBuilder
->select($item)
->group($item);
return;
}
$queryBuilder
->select($item)
->group($item);
return;
}
[$link, $field] = explode('.', $argument);
$skipSelect = false;
$entityDefs = $this->entityManager->getDefs()->getEntity($entityType);
if ($entityDefs->hasRelation($link)) {
$relationType = $entityDefs->getRelation($link)->getType();
$foreignEntityType = $entityDefs->getRelation($link)->hasForeignEntityType() ?
$entityDefs->getRelation($link)->getForeignEntityType() : null;
$foreignEntityDefs = $this->entityManager->getDefs()->getEntity($foreignEntityType);
$foreignFieldType = $foreignEntityDefs->hasField($field) ?
$foreignEntityDefs->getField($field)->getType() : null;
if ($foreignEntityDefs->hasRelation($field)) {
$foreignRelationType = $foreignEntityDefs->getRelation($field)->getType();
if (
(
$relationType === Entity::BELONGS_TO ||
$relationType === Entity::HAS_ONE
) &&
$foreignRelationType === Entity::BELONGS_TO
) {
$queryBuilder
->select($item . 'Id')
->group($item . 'Id');
$skipSelect = true;
}
}
if ($function && in_array($foreignFieldType, ['datetime', 'datetimeOptional'])) {
$tzOffset = (string) $this->getTimeZoneOffset();
if ($tzOffset) {
$skipSelect = true;
$groupBy = "$function:TZ:($link.$field,$tzOffset)";
$queryBuilder
->select($groupBy)
->group($groupBy);
}
}
}
$this->handleLeftJoins($item, $entityType, $queryBuilder, true);
if ($skipSelect) {
return;
}
$queryBuilder
->select($item)
->group($item);
}
private function handleLeftJoins(
string $item,
string $entityType,
SelectBuilder $queryBuilder,
bool $skipDistinct = false
): void {
if (str_contains($item, ':')) {
$argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($item);
foreach ($argumentList as $argument) {
$this->handleLeftJoins($argument, $entityType, $queryBuilder, $skipDistinct);
}
return;
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entityType);
if (str_contains($item, '.')) {
[$relation,] = explode('.', $item);
if ($queryBuilder->hasLeftJoinAlias($relation)) {
return;
}
$queryBuilder->leftJoin($relation);
if (!$entityDefs->hasRelation($relation)) {
return;
}
$relationType = $entityDefs->getRelation($relation)->getType();
if (
!$skipDistinct &&
in_array($relationType, [
Entity::HAS_MANY,
Entity::MANY_MANY,
Entity::HAS_CHILDREN,
])
) {
// @todo Remove when v8.5 is min. supported.
$queryBuilder->distinct();
}
return;
}
if (!$entityDefs->hasAttribute($item)) {
return;
}
$attributeDefs = $entityDefs->getAttribute($item);
if ($attributeDefs->getType() === Entity::FOREIGN) {
$relation = $attributeDefs->getParam('relation');
if ($relation && !$queryBuilder->hasLeftJoinAlias($relation)) {
$queryBuilder->leftJoin($relation);
}
}
}
/**
* @param string[] $columns
*/
public function handleColumns(array $columns, SelectBuilder $queryBuilder): void
{
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException("No from.");
}
foreach ($columns as $item) {
$this->handleColumnsItem($item, $entityType, $queryBuilder);
}
}
/**
* @todo Use the selectDefs attribute dependency map.
*/
private function handleColumnsItem(
string $item,
string $entityType,
SelectBuilder $queryBuilder
): void {
$columnData = $this->gridHelper->getDataFromColumnName($entityType, $item);
if ($columnData->function && !$columnData->link && $columnData->field) {
$this->addSelect($item, $queryBuilder);
return;
}
if ($columnData->link) {
$this->handleLeftJoins($item, $entityType, $queryBuilder);
if (in_array($columnData->fieldType, ['link', 'file', 'image'])) {
$this->addSelect($item . 'Id', $queryBuilder);
return;
}
$this->addSelect($item, $queryBuilder);
return;
}
if (str_contains($item, ':') && str_contains($item, '.')) {
$this->handleLeftJoins($item, $entityType, $queryBuilder);
}
$type = $columnData->fieldType;
if (in_array($type, ['link', 'file', 'image', 'linkOne'])) {
$this->addSelect($item . 'Name', $queryBuilder);
$this->addSelect($item . 'Id', $queryBuilder);
if (!$queryBuilder->hasLeftJoinAlias($item)) {
$queryBuilder->leftJoin($item);
}
return;
}
if ($type === 'linkParent') {
$this->addSelect($item . 'Type', $queryBuilder);
$this->addSelect($item . 'Id', $queryBuilder);
return;
}
if ($type === 'currency') {
$this->addSelect($item, $queryBuilder);
$this->addSelect($item . 'Currency', $queryBuilder);
$this->addSelect($item . 'Converted', $queryBuilder);
return;
}
if ($type === 'duration') {
$start = $this->metadata->get(['entityDefs', $entityType, 'fields', $item, 'start']);
$end = $this->metadata->get(['entityDefs', $entityType , 'fields', $item, 'end']);
$this->addSelect($start, $queryBuilder);
$this->addSelect($end, $queryBuilder);
$this->addSelect($item, $queryBuilder);
return;
}
if ($type === 'personName') {
$this->addSelect($item, $queryBuilder);
$this->addSelect('first' . ucfirst($item), $queryBuilder);
$this->addSelect('last' . ucfirst($item), $queryBuilder);
return;
}
if ($type === 'address') {
$pList = ['city', 'country', 'postalCode', 'street', 'state'];
foreach ($pList as $p) {
$this->addSelect($item . ucfirst($p), $queryBuilder);
}
return;
}
if ($type === 'datetimeOptional') {
$this->addSelect($item, $queryBuilder);
$this->addSelect($item . 'Date', $queryBuilder);
return;
}
if ($type === 'linkMultiple' || $type === 'attachmentMultiple') {
return;
}
$this->addSelect($item, $queryBuilder);
}
private function isInSelect(string $item, SelectBuilder $queryBuilder): bool
{
$currentList = array_map(
function (Selection $selection): string {
return $selection->getExpression()->getValue();
},
$queryBuilder->build()->getSelect()
);
return in_array($item, $currentList);
}
private function addSelect(string $item, SelectBuilder $queryBuilder): void
{
if ($this->isInSelect($item, $queryBuilder)) {
return;
}
$alias = $this->gridUtil->sanitizeSelectAlias($item);
$queryBuilder->select($item, $alias);
}
/**
* @param string[] $orderBy
*/
public function handleOrderBy(array $orderBy, SelectBuilder $queryBuilder): void
{
$entityType = $queryBuilder->build()->getFrom();
foreach ($orderBy as $item) {
$this->handleOrderByItem($item, $entityType, $queryBuilder);
}
}
private function handleOrderByItem(string $item, string $entityType, SelectBuilder $queryBuilder): void
{
$entityDefs = $this->entityManager->getDefs()->getEntity($entityType);
if (str_contains($item, 'LIST:')) {
// @todo Check is actual as processed afterwards.
$orderBy = substr($item, 5);
if (str_contains($orderBy, '.')) {
[$rel, $field] = explode('.', $orderBy);
if (!$entityDefs->hasRelation($rel)) {
return;
}
$relationDefs = $entityDefs->getRelation($rel);
$foreignEntityType = $relationDefs->hasForeignEntityType() ?
$relationDefs->getForeignEntityType() : null;
if (!$foreignEntityType) {
return;
}
$optionList = $this->metadata
->get(['entityDefs', $foreignEntityType, 'fields', $field, 'options']) ?? [];
}
else {
$optionList = $this->metadata->get(['entityDefs', $entityType, 'fields', $orderBy, 'options']) ?? [];
}
if (!$optionList) {
return;
}
$queryBuilder->order(
Order::createByPositionInList(Expression::column($orderBy), $optionList)
);
return;
}
if (str_contains($item, 'ASC:')) {
$orderBy = substr($item, 4);
$order = 'ASC';
}
else if (str_contains($item, 'DESC:')) {
$orderBy = substr($item, 5);
$order = 'DESC';
}
else {
return;
}
$field = $orderBy;
$orderEntityType = $entityType;
$link = null;
if (str_contains($orderBy, '.')) {
[$link, $field] = explode('.', $orderBy);
if (!$entityDefs->hasRelation($link)) {
return;
}
$relationDefs = $entityDefs->getRelation($link);
$orderEntityType = $relationDefs->hasForeignEntityType() ?
$relationDefs->getForeignEntityType() : null;
if (!$orderEntityType) {
return;
}
}
$entityDefs = $this->entityManager->getDefs()->getEntity($orderEntityType);
$fieldType = $entityDefs->hasField($field) ?
$entityDefs->getField($field)->getType() : null;
if (in_array($fieldType, ['link', 'file', 'image'])) {
/*if ($link) {
continue;
}*/
// MariaDB issue with ONLY_FULL_GROUP_BY.
/*$orderBy = $orderBy . 'Name';
if (!in_array($orderBy, $params['select'])) {
$params['select'][] = $orderBy;
}*/
return;
}
if ($fieldType === 'linkParent') {
if ($link) {
return;
}
$orderBy = $orderBy . 'Type';
}
if (!$this->isInSelect($orderBy, $queryBuilder)) {
return;
}
$queryBuilder->order($orderBy, $order);
}
/**
* @throws BadRequest
*/
public function handleFiltersWhere(
WhereItem $whereItem,
SelectBuilder $queryBuilder/*,
bool $isGrid = false*/
): void {
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException("No from.");
}
// Supposed to be applied by the scanner.
//$this->applyLeftJoinsFromWhere($whereItem, $queryBuilder);
$params = $this->supportsHasManySubQuery() ?
new Converter\Params(useSubQueryIfMany: true) : null;
$whereClause = $this->createConverter($entityType)
->convert($queryBuilder, $whereItem, $params);
$queryBuilder->where($whereClause);
/*if (!$isGrid) {
// Distinct is already supposed to be applied by the scanner.
$this->applyDistinctFromWhere($whereItem, $queryBuilder);
}*/
}
private function supportsHasManySubQuery(): bool
{
return class_exists("Espo\\Core\\Select\\Where\\Converter\\Params");
}
/**
* @throws BadRequest
*/
public function handleFiltersHaving(
WhereItem $havingItem,
SelectBuilder $queryBuilder,
bool $isGrid = false
): void {
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException("No from.");
}
if ($havingItem->getItemList() === []) {
return;
}
$converter = $this->createConverter($entityType);
if ($isGrid) {
// Supposed to be applied by the scanner.
//$this->applyLeftJoinsFromWhere($havingItem, $queryBuilder);
$havingClause = $converter->convert($queryBuilder, $havingItem);
$queryBuilder->having($havingClause);
return;
}
$subQueryBuilder = SelectBuilder::create()
->from($entityType, lcfirst($entityType))
->select('id')
->group('id');
$havingClause = $converter->convert($subQueryBuilder, $havingItem);
$subQueryBuilder->having($havingClause);
// Supposed to be applied by the scanner.
//$this->applyLeftJoinsFromWhere($havingItem, $subQueryBuilder);
$queryBuilder->where(['id=s' => $subQueryBuilder->build()->getRaw()]);
}
/*public function applyLeftJoinsFromWhere(WhereItem $item, SelectBuilder $queryBuilder): void
{
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException();
}
//if ($queryBuilder->build()->isDistinct()) {
// return;
//}
if (in_array($item->getType(), [self::WHERE_TYPE_OR, self::WHERE_TYPE_AND])) {
foreach ($item->getItemList() as $listItem) {
$this->applyLeftJoinsFromWhere($listItem, $queryBuilder);
}
return;
}
if (!$item->getAttribute()) {
return;
}
$this->handleLeftJoins($item->getAttribute(), $entityType, $queryBuilder, true);
}*/
/**
* @deprecated As of v3.4.7.
* @todo Remove when v8.5 is min. supported.
*/
public function applyDistinctFromWhere(WhereItem $item, SelectBuilder $queryBuilder): void
{
if ($this->supportsHasManySubQuery()) {
return;
}
if ($queryBuilder->build()->isDistinct()) {
return;
}
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new LogicException();
}
if (in_array($item->getType(), [self::WHERE_TYPE_OR, self::WHERE_TYPE_AND])) {
foreach ($item->getItemList() as $listItem) {
/** @noinspection PhpDeprecationInspection */
$this->applyDistinctFromWhere($listItem, $queryBuilder);
}
return;
}
if (!$item->getAttribute()) {
return;
}
$this->handleDistinct($item->getAttribute(), $entityType, $queryBuilder);
}
private function handleDistinct(string $item, string $entityType, SelectBuilder $queryBuilder): void
{
if (str_contains($item, ':')) {
$argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($item);
foreach ($argumentList as $argument) {
$this->handleDistinct($argument, $entityType, $queryBuilder);
}
return;
}
if (!str_contains($item, '.')) {
return;
}
[$relation,] = explode('.', $item);
$entityDefs = $this->entityManager->getDefs()->getEntity($entityType);
if (!$entityDefs->hasRelation($relation)) {
return;
}
$relationsDefs = $entityDefs->getRelation($relation);
if (in_array($relationsDefs->getType(), [Entity::HAS_MANY, Entity::MANY_MANY])) {
$queryBuilder->distinct();
}
}
/**
* @return float|int
*/
private function getTimeZoneOffset()
{
$timeZone = $this->config->get('timeZone', 'UTC');
if ($timeZone === 'UTC') {
return 0;
}
try {
$dateTimeZone = new DateTimeZone($timeZone);
$dateTime = new DateTime('now', $dateTimeZone);
$dateTime->modify('first day of january');
$tzOffset = $dateTimeZone->getOffset($dateTime) / 3600;
}
catch (Exception) {
return 0;
}
return $tzOffset;
}
private function createConverter(string $entityType): Converter
{
return $this->converterFactory->create($entityType, $this->user);
}
}

View File

@@ -0,0 +1,435 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\Entities\Job;
use Espo\Entities\User;
use Espo\Modules\Advanced\Business\Report\EmailBuilder;
use Espo\Modules\Advanced\Entities\Report as ReportEntity;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use Espo\Modules\Advanced\Tools\Report\Jobs\Send;
use Espo\Modules\Advanced\Tools\Report\ListType\Result as ListResult;
use Espo\ORM\EntityManager;
use Espo\Tools\Export\Export;
use Espo\Tools\Export\Params as ExportToolParams;
use Exception;
use LogicException;
use RuntimeException;
use DateTime;
use DateTimeZone;
use stdClass;
class SendingService
{
private const LIST_REPORT_MAX_SIZE = 3000;
public function __construct(
private EntityManager $entityManager,
private User $user,
private Metadata $metadata,
private Config $config,
private FieldUtil $fieldUtil,
private InjectableFactory $injectableFactory,
private EmailBuilder $emailBuilder
) {}
private function getSendingListMaxCount(): int
{
return $this->config->get('reportSendingListMaxCount', self::LIST_REPORT_MAX_SIZE);
}
/**
* @return array<string, mixed>
* @throws Error
* @throws NotFound
* @throws Forbidden
* @throws BadRequest
*/
public function getEmailAttributes(string $id, ?WhereItem $where = null, ?User $user = null): array
{
/** @var ?ReportEntity $report */
$report = $this->entityManager->getEntityById(ReportEntity::ENTITY_TYPE, $id);
if (!$report) {
throw new NotFound();
}
$service = $this->injectableFactory->create(Service::class);
if ($report->getType() === ReportEntity::TYPE_LIST) {
$searchParams = SearchParams::create()
->withMaxSize($this->getSendingListMaxCount());
$orderByList = $report->getOrderByList();
if ($orderByList) {
$arr = explode(':', $orderByList);
/**
* @var 'ASC'|'DESC' $orderDirection
* @noinspection PhpRedundantVariableDocTypeInspection
*/
$orderDirection = strtoupper($arr[0]);
$searchParams = $searchParams
->withOrderBy($arr[1])
->withOrder($orderDirection);
}
if ($where) {
$searchParams = $searchParams->withWhere($where);
}
$result = $service->runList($id, $searchParams, $user);
} else {
$result = $service->runGrid($id, $where, $user);
}
$reportResult = $result;
if ($result instanceof ListResult) {
$reportResult = [];
foreach ($result->getCollection() as $e) {
$reportResult[] = get_object_vars($e->getValueMap());
}
}
$data = (object) [
'userId' => $user ? $user->getId() : $this->user->getId(),
];
if ($reportResult instanceof ListResult) {
// For static analysis.
throw new LogicException();
}
$this->emailBuilder->buildEmailData($data, $reportResult, $report);
$attachmentId = $this->getExportAttachmentId($report, $result, $where, $user);
if ($attachmentId) {
$data->attachmentId = $attachmentId;
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $attachmentId);
if ($attachment) {
$attachment->set([
'role' => 'Attachment',
'parentType' => Email::ENTITY_TYPE,
'relatedId' => $id,
'relatedType' => ReportEntity::ENTITY_TYPE,
]);
$this->entityManager->saveEntity($attachment);
}
}
$userIdList = $report->getLinkMultipleIdList('emailSendingUsers');
$nameHash = (object) [];
$toArr = [];
if ($report->get('emailSendingInterval') && count($userIdList)) {
$userList = $this
->entityManager
->getRDBRepositoryByClass(User::class)
->where(['id' => $userIdList])
->find();
foreach ($userList as $user) {
$emailAddress = $user->getEmailAddress();
if ($emailAddress) {
$toArr[] = $emailAddress;
$nameHash->$emailAddress = $user->getName();
}
}
}
$attributes = [
'isHtml' => true,
'body' => $data->emailBody,
'name' => $data->emailSubject,
'nameHash' => $nameHash,
'to' => implode(';', $toArr),
];
if ($attachmentId) {
$attributes['attachmentsIds'] = [$attachmentId];
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $attachmentId);
if ($attachment) {
$attributes['attachmentsNames'] = [
$attachmentId => $attachment->get('name')
];
}
}
return $attributes;
}
/**
* @param GridResult|ListResult $result
*/
public function getExportAttachmentId(
ReportEntity $report,
$result,
?WhereItem $where = null,
?User $user = null
): ?string {
$entityType = $report->getTargetEntityType();
if ($report->getType() === ReportEntity::TYPE_LIST) {
if (!$result instanceof ListResult) {
throw new RuntimeException("Bad result.");
}
$fieldList = $report->getColumns();
foreach ($fieldList as $key => $field) {
if (strpos($field, '.')) {
$fieldList[$key] = str_replace('.', '_', $field);
}
}
$attributeList = [];
foreach ($fieldList as $field) {
$fieldAttributeList = $this->fieldUtil->getAttributeList($report->getTargetEntityType(), $field);
if (count($fieldAttributeList) > 0) {
$attributeList = array_merge($attributeList, $fieldAttributeList);
} else {
$attributeList[] = $field;
}
}
$exportParams = ExportToolParams::create($entityType)
->withFieldList($fieldList)
->withAttributeList($attributeList)
->withFormat('xlsx')
->withName($report->getName())
->withFileName($report->getName() . ' ' . date('Y-m-d'));
$export = $this->injectableFactory->create(Export::class);
try {
return $export
->setParams($exportParams)
->setCollection($result->getCollection())
->run()
->getAttachmentId();
} catch (Exception $e) {
$GLOBALS['log']->error("Report export fail, {$report->getId()}: {$e->getMessage()}");
return null;
}
}
$name = preg_replace("/([^\w\s\d\-_~,;:\[\]().])/u", '_', $report->getName()) . ' ' . date('Y-m-d');
$mimeType = $this->metadata->get(['app', 'export', 'formatDefs', 'xlsx', 'mimeType']);
$fileExtension = $this->metadata->get(['app', 'export', 'formatDefs', 'xlsx', 'fileExtension']);
$fileName = "$name.$fileExtension";
try {
$service = $this->injectableFactory->create(GridExportService::class);
$contents = $service->buildXlsxContents($report->getId(), $where, $user);
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew();
$attachment
->setName($fileName)
->setType($mimeType)
->setContents($contents)
->setRole(Attachment::ROLE_ATTACHMENT);
$attachment->set('parentType', Email::ENTITY_TYPE);
$this->entityManager->saveEntity($attachment);
return $attachment->getId();
} catch (Exception $e) {
$GLOBALS['log']->error("Report export fail, {$report->getId()}: {$e->getMessage()}");
return null;
}
}
public function scheduleEmailSending(): void
{
$reports = $this->entityManager
->getRDBRepositoryByClass(ReportEntity::class)
->where([[
'AND' => [
['emailSendingInterval!=' => ''],
['emailSendingInterval!=' => NULL],
]]
])
->find();
$utcTZ = new DateTimeZone('UTC');
$now = new DateTime("now", $utcTZ);
$defaultTz = $this->config->get('timeZone');
$espoTimeZone = new DateTimeZone($defaultTz);
foreach ($reports as $report) {
$scheduleSending = false;
$check = false;
$nowCopy = clone $now;
$nowCopy->setTimezone($espoTimeZone);
switch ($report->get('emailSendingInterval')) {
case 'Daily':
$check = true;
break;
case 'Weekly':
$check = (strpos($report->get('emailSendingSettingWeekdays'), $nowCopy->format('w')) !== false);
break;
case 'Monthly':
$check =
$nowCopy->format('j') == $report->get('emailSendingSettingDay') ||
$nowCopy->format('j') == $nowCopy->format('t') &&
$nowCopy->format('t') < $report->get('emailSendingSettingDay');
break;
case 'Yearly':
$check =
(
$nowCopy->format('j') == $report->get('emailSendingSettingDay') ||
$nowCopy->format('j') == $nowCopy->format('t') &&
$nowCopy->format('t') < $report->get('emailSendingSettingDay')
) &&
$nowCopy->format('n') == $report->get('emailSendingSettingMonth');
break;
}
if ($check) {
if ($report->get('emailSendingLastDateSent')) {
$lastSent = new DateTime($report->get('emailSendingLastDateSent'), $utcTZ);
$lastSent->setTimezone($espoTimeZone);
$nowCopy->setTime(0, 0);
$lastSent->setTime(0, 0);
$diff = $lastSent->diff($nowCopy);
if (!empty($diff)) {
$dayDiff = (int) ((($diff->invert) ? '-' : '') . $diff->days);
if ($dayDiff > 0) {
$scheduleSending = true;
}
}
} else {
$scheduleSending = true;
}
}
if (!$scheduleSending) {
continue;
}
$report->loadLinkMultipleField('emailSendingUsers');
$users = $report->get('emailSendingUsersIds');
if (empty($users)) {
continue;
}
$executeTime = clone $now;
if ($report->get('emailSendingTime')) {
$time = explode(':', $report->get('emailSendingTime'));
if (empty($time[0]) || $time[0] < 0 || $time[0] > 23) {
$time[0] = 0;
}
if (empty($time[1]) || $time[1] < 0 || $time[1] > 59) {
$time[1] = 0;
}
$executeTime->setTimezone($espoTimeZone);
$executeTime->setTime(intval($time[0]), intval($time[1]));
$executeTime->setTimezone($utcTZ);
}
$report->set('emailSendingLastDateSent', $executeTime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT));
$this->entityManager->saveEntity($report);
foreach ($users as $userId) {
$jobEntity = $this->entityManager->getEntity(Job::ENTITY_TYPE);
$data = (object) [
'userId' => $userId,
'reportId' => $report->getId(),
];
$jobEntity->set([
'name' => Send::class,
'className' => Send::class,
'executeTime' => $executeTime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT),
'data' => $data,
]);
$this->entityManager->saveEntity($jobEntity);
}
}
}
/**
* @param stdClass $data
* @param GridResult|array<int, mixed> $result
* @throws Error
*/
public function buildData($data, $result, ReportEntity $report): void
{
$this->emailBuilder->buildEmailData($data, $result, $report, true);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
<?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-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\Modules\Crm\Tools\TargetList\RecordService;
use Espo\ORM\EntityManager;
class TargetListSyncService
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private Metadata $metadata,
private ServiceContainer $serviceContainer,
private Service $service,
private InjectableFactory $injectableFactory
) {}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function syncTargetListWithReportsById(string $targetListId): void
{
/** @var ?TargetList $targetList */
$targetList = $this->entityManager->getEntity(TargetList::ENTITY_TYPE, $targetListId);
if (!$targetList) {
throw new NotFound();
}
if (!$targetList->get('syncWithReportsEnabled')) {
throw new Error("Sync with reports not enabled for target list $targetListId.");
}
$this->syncTargetListWithReports($targetList);
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function syncTargetListWithReports(TargetList $targetList): void
{
if (!$this->acl->checkEntityEdit($targetList)) {
throw new Forbidden();
}
/** @var \Espo\Modules\Crm\Tools\TargetList\RecordService $targetListService */
$targetListService = class_exists("Espo\\Modules\\Crm\\Tools\\TargetList\\RecordService") ?
$this->injectableFactory->create(RecordService::class) :
$this->serviceContainer->get(TargetList::ENTITY_TYPE);
if ($targetList->get('syncWithReportsUnlink')) {
$linkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ??
['contacts', 'leads', 'accounts', 'users'];
foreach ($linkList as $link) {
$targetListService->unlinkAll($targetList->getId(), $link);
}
}
$reportList = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, 'syncWithReports')
->find();
foreach ($reportList as $report) {
$this->populateTargetList($report->getId(), $targetList->getId());
}
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function populateTargetList(string $id, string $targetListId): void
{
/** @var ?Report $report */
$report = $this->entityManager->getEntityById(Report::ENTITY_TYPE, $id);
if (!$report) {
throw new NotFound();
}
if (!$this->acl->checkEntityRead($report)) {
throw new Forbidden();
}
$targetList = $this->entityManager->getEntity(TargetList::ENTITY_TYPE, $targetListId);
if (!$targetList) {
throw new NotFound();
}
if (!$this->acl->checkEntityEdit($targetList)) {
throw new Forbidden();
}
if ($report->getType() !== Report::TYPE_LIST) {
throw new Error("Report is not of 'List' type.");
}
$entityType = $report->getTargetEntityType();
$linkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ??
['contacts', 'leads', 'accounts', 'users'];
$link = null;
foreach ($linkList as $itemLink) {
if (
$this->metadata->get(['entityDefs', 'TargetList', 'links', $itemLink, 'entity']) === $entityType
) {
$link = $itemLink;
break;
}
}
if (!$link) {
throw new Error("Not supported entity type '$entityType' for target list sync.");
}
$query = $this->service
->prepareSelectBuilder($report)
->build();
$this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link)
->massRelate($query);
}
}