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,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\Bpmn\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Modules\Advanced\Core\Bpmn\BpmnManager;
use Espo\Modules\Advanced\Entities\BpmnProcess;
use Espo\ORM\EntityManager;
use Espo\ORM\Repository\Option\SaveOption;
use RuntimeException;
/**
* @noinspection PhpUnused
*/
class ProcessRootProcessFlows implements Job
{
public function __construct(
private EntityManager $entityManager,
private BpmnManager $bpmnManager,
) {}
public function run(Data $data): void
{
$processId = $data->getTargetId() ?? throw new RuntimeException();
$process = $this->getProcess($processId);
if (!$process) {
return;
}
$this->bpmnManager->processPendingFlows($processId);
$this->updateProcess($processId);
}
private function updateProcess(string $processId): void
{
$process = $this->entityManager->getRDBRepositoryByClass(BpmnProcess::class)->getById($processId);
if (!$process) {
return;
}
// If the job was running for long, this will ensure the process won't be processed
// too soon the next time.
$process->setVisitTimestampNow();
$process->setIsLocked(false);
$this->entityManager->saveEntity($process, [SaveOption::SKIP_ALL => true]);
}
private function getProcess(string $processId): ?BpmnProcess
{
$this->entityManager->getTransactionManager()->start();
$process = $this->entityManager
->getRDBRepositoryByClass(BpmnProcess::class)
->forUpdate()
->where(['id' => $processId])
->findOne();
if (!$process) {
$this->entityManager->getTransactionManager()->commit();
return null;
}
if (!$process->isLocked()) {
// Can happen if jobs were not running for long and the process got unlocked.
throw new RuntimeException("BPM: Process $processId is not locked.");
}
// If the job ran late, this will prevent the process from unlocking while it's being processed.
$process->setVisitTimestampNow();
$this->entityManager->saveEntity($process, [SaveOption::SKIP_ALL => true]);
$this->entityManager->getTransactionManager()->commit();
return $process;
}
}

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);
}
}

View File

@@ -0,0 +1,235 @@
<?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\ReportFilter;
use Espo\Core\DataManager;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Advanced\Classes\Select\Common\PrimaryFilters\ReportFilter as ReportPrimaryFilter;
use Espo\Modules\Advanced\Core\ReportFilter as ReportFilterUtil;
use Espo\Modules\Advanced\Entities\ReportFilter;
use Espo\ORM\EntityManager;
class Service
{
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private DataManager $dataManager,
private Config $config
) {}
public function rebuild(?string $specificEntityType = null): void
{
$scopeData = $this->metadata->get(['scopes'], []);
$entityTypeList = [];
$language = $this->injectableFactory->createWith(Language::class, ['language' => 'en_US']);
$isAnythingChanged = false;
if ($specificEntityType) {
$entityTypeList[] = $specificEntityType;
} else {
foreach ($scopeData as $scope => $item) {
if (empty($item['entity'])) {
continue;
}
if (empty($item['object'])) {
continue;
}
if (!empty($item['disabled'])) {
continue;
}
$entityTypeList[] = $scope;
}
}
foreach ($entityTypeList as $entityType) {
/** @var array<string, mixed> $removedHash */
$removedHash = [];
$isChanged = false;
$clientDefs = $this->metadata->getCustom('clientDefs', $entityType, (object) []);
$filterList = [];
$toAppend = true;
if (isset($clientDefs->filterList)) {
$toAppend = false;
$filterList = $clientDefs->filterList;
}
foreach ($filterList as $i => $item) {
if (is_string($item)) {
if ($item === '__APPEND__') {
unset($filterList[$i]);
$toAppend = true;
}
continue;
}
if (!empty($item->isReportFilter)) {
unset($filterList[$i]);
$isChanged = true;
}
}
$filterList = array_values($filterList);
$entityDefs = $this->metadata->getCustom('entityDefs', $entityType, (object) []);
$filtersData = (object) [];
if (isset($entityDefs->collection) && isset($entityDefs->collection->filters)) {
$filtersData = $entityDefs->collection->filters;
if (is_array($filtersData)) {
$filtersData = (object) [];
}
}
foreach ($filtersData as $filter => $item) {
if (!empty($item->isReportFilter)) {
unset($filtersData->$filter);
$removedHash[$filter] = true;
$isChanged = true;
}
}
$reportFilterList = $this->entityManager
->getRDBRepositoryByClass(ReportFilter::class)
->where([
'isActive' => true,
'entityType' => $entityType,
])
->order('order')
->find();
$supportsFilterNames = $this->supportsFilterNames();
foreach ($reportFilterList as $reportFilter) {
$isChanged = true;
$name = 'reportFilter' . $reportFilter->getId();
$o = (object) [
'isReportFilter' => true,
'name' => $name,
];
if (count($reportFilter->getLinkMultipleIdList('teams'))) {
$o->accessDataList = [
(object) ['teamIdList' => $reportFilter->getLinkMultipleIdList('teams')]
];
}
$filterList[] = $o;
unset($removedHash[$name]);
$filtersData->$name = (object) [
'isReportFilter' => true,
'className' => ReportFilterUtil::class,
'id' => $reportFilter->getId(),
];
if ($supportsFilterNames) {
unset($filtersData->$name->className);
}
$language->set($entityType, 'presetFilters', $name, $reportFilter->get('name'));
}
if ($isChanged) {
$isAnythingChanged = true;
$clientDefs = $this->metadata->getCustom('clientDefs', $entityType, (object) []);
if (!empty($filterList)) {
if ($toAppend) {
array_unshift($filterList, '__APPEND__');
}
$clientDefs->filterList = $filterList;
} else {
unset($clientDefs->filterList);
}
$this->metadata->saveCustom('clientDefs', $entityType, $clientDefs);
if ($supportsFilterNames) {
$selectDefs = $this->metadata->getCustom('selectDefs', $entityType, (object) []);
if (!isset($selectDefs->primaryFilterClassNameMap)) {
$selectDefs->primaryFilterClassNameMap = (object) [];
}
}
$entityDefs = $this->metadata->getCustom('entityDefs', $entityType, (object) []);
if (!isset($entityDefs->collection)) {
$entityDefs->collection = (object) [];
}
$entityDefs->collection->filters = $filtersData;
$this->metadata->saveCustom('entityDefs', $entityType, $entityDefs);
if ($supportsFilterNames && isset($selectDefs)) {
foreach (get_object_vars($filtersData) as $name => $ignored) {
$selectDefs->primaryFilterClassNameMap->$name = ReportPrimaryFilter::class;
}
}
foreach ($removedHash as $name => $item) {
$language->delete($entityType, 'presetFilters', $name);
if ($supportsFilterNames && isset($selectDefs)) {
unset($selectDefs->primaryFilterClassNameMap->$name);
}
}
if ($supportsFilterNames && isset($selectDefs)) {
$this->metadata->saveCustom('selectDefs', $entityType, $selectDefs);
}
}
}
if ($isAnythingChanged) {
$language->save();
$this->dataManager->clearCache();
}
}
private function supportsFilterNames(): bool
{
$version = $this->config->get('version');
return version_compare($version, '7.5.0') >= 0;
}
}

View File

@@ -0,0 +1,719 @@
<?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\ReportPanel;
use Espo\Core\Acl;
use Espo\Core\DataManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
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\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Entities\ReportPanel;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use Espo\Modules\Advanced\Tools\Report\GridType\RunParams as GridRunParams;
use Espo\Modules\Advanced\Tools\Report\ListType\Result as ListResult;
use Espo\Modules\Advanced\Tools\Report\ListType\RunParams as ListRunParams;
use Espo\Modules\Advanced\Tools\Report\ListType\SubReportParams;
use Espo\Modules\Advanced\Tools\Report\Service as ReportService;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use LogicException;
use stdClass;
class Service
{
private const TYPE_LIST = 'List';
private const TYPE_GRID = 'Grid';
private const TYPE_SUB_REPORT_LIST = 'SubReportList';
public function __construct(
private Metadata $metadata,
private Acl $acl,
private User $user,
private EntityManager $entityManager,
private InjectableFactory $injectableFactory,
private DataManager $dataManager,
private Config $config,
) {}
/**
* @throws Error
*/
public function rebuild(?string $specificEntityType = null): void
{
$scopeData = $this->metadata->get(['scopes'], []);
$entityTypeList = [];
$isAnythingChanged = false;
if ($specificEntityType) {
$entityTypeList[] = $specificEntityType;
}
else {
foreach ($scopeData as $scope => $item) {
if (empty($item['entity'])) {
continue;
}
if (empty($item['object'])) {
continue;
}
if (!empty($item['disabled'])) {
continue;
}
$entityTypeList[] = $scope;
}
}
$typeList = ['bottom', 'side'];
foreach ($entityTypeList as $entityType) {
$clientDefs = $this->metadata->getCustom('clientDefs', $entityType, (object) []);
$panelListData = [];
$dynamicLogicToRemoveHash = [];
$dynamicLogicHash = [];
foreach ($typeList as $type) {
$isChanged = false;
$toAppend = true;
$panelListData[$type] = [];
$key = $type . 'Panels';
if (isset($clientDefs->$key->detail)) {
$toAppend = false;
$panelListData[$type] = $clientDefs->$key->detail;
}
foreach ($panelListData[$type] as $i => $item) {
if (is_string($item)) {
if ($item === '__APPEND__') {
unset($panelListData[$type][$i]);
$toAppend = true;
}
continue;
}
if (!empty($item->isReportPanel)) {
if (isset($item->name)) {
$dynamicLogicToRemoveHash[$item->name] = true;
}
unset($panelListData[$type][$i]);
$isChanged = true;
}
}
$panelListData[$type] = array_values($panelListData[$type]);
$reportPanels = $this->entityManager
->getRDBRepositoryByClass(ReportPanel::class)
->where([
'isActive' => true,
'entityType' => $entityType,
'type' => $type
])
->order('name')
->find();
foreach ($reportPanels as $reportPanel) {
$reportId = $reportPanel->get('reportId');
if (!$reportId) {
continue;
}
$report = $this->entityManager
->getRDBRepositoryByClass(Report::class)
->getById($reportId);
if (!$report) {
continue;
}
$isChanged = true;
$name = 'reportPanel' . $reportPanel->get('id');
$o = (object) [
'isReportPanel' => true,
'name' => $name,
'label' => $reportPanel->get('name'),
'view' => 'advanced:views/report-panel/record/panels/report-panel-' . $type,
'reportPanelId' => $reportPanel->getId(),
'reportType' => $report->getType(),
'reportEntityType' => $report->getTargetEntityType(),
'displayType' => $reportPanel->get('displayType'),
'displayTotal' => $reportPanel->get('displayTotal'),
'displayOnlyTotal' => $reportPanel->get('displayOnlyTotal'),
'useSiMultiplier' => $reportPanel->get('useSiMultiplier'),
'accessDataList' => [
(object) ['scope' => $report->getTargetEntityType()]
],
];
if ($type === 'bottom') {
$o->order = $reportPanel->get('order');
if ($o->order <= 2) {
$o->sticked = true;
}
}
if ($reportPanel->get('dynamicLogicVisible')) {
$dynamicLogicHash[$name] = (object) [
'visible' => $reportPanel->get('dynamicLogicVisible')
];
unset($dynamicLogicToRemoveHash[$name]);
}
if ($report->get('type') === 'Grid') {
$o->column = $reportPanel->get('column');
}
if (count($reportPanel->getLinkMultipleIdList('teams'))) {
$o->accessDataList[] = (object) ['teamIdList' => $reportPanel->getLinkMultipleIdList('teams')];
}
$panelListData[$type][] = $o;
}
if ($isChanged) {
$isAnythingChanged = true;
$clientDefs = $this->metadata->getCustom('clientDefs', $entityType, (object) []);
if ($this->hasLogicDefs()) {
$logicDefs = $this->metadata->getCustom('logicDefs', $entityType, (object) []);
} else {
$clientDefs->dynamicLogic ??= (object) [];
$logicDefs = $clientDefs->dynamicLogic;
}
foreach (array_keys($dynamicLogicToRemoveHash) as $name) {
if (isset($logicDefs->panels)) {
unset($logicDefs->panels->$name);
}
}
if ($dynamicLogicHash) {
$logicDefs->panels ??= (object) [];
foreach ($dynamicLogicHash as $name => $item) {
$logicDefs->panels->$name = $item;
}
}
if (!empty($panelListData[$type])) {
if ($toAppend) {
array_unshift($panelListData[$type], '__APPEND__');
}
if (!isset($clientDefs->$key)) {
$clientDefs->$key = (object) [];
}
$clientDefs->$key->detail = $panelListData[$type];
} else {
if (isset($clientDefs->$key)) {
unset($clientDefs->$key->detail);
}
}
$this->metadata->saveCustom('clientDefs', $entityType, $clientDefs);
if ($this->hasLogicDefs()) {
$this->metadata->saveCustom('logicDefs', $entityType, $logicDefs);
}
}
}
}
if ($isAnythingChanged) {
$this->dataManager->clearCache();
}
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function runList(
string $id,
?string $parentType,
?string $parentId,
SearchParams $searchParams
): ListResult {
$result = $this->run(self::TYPE_LIST, $id, $parentType, $parentId, $searchParams);
if (!$result instanceof ListResult) {
throw new Error("Bad report result.");
}
return $result;
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function runSubReportList(
string $id,
?string $parentType,
?string $parentId,
SearchParams $searchParams,
SubReportParams $subReportParams,
?string $subReportId = null
): ListResult {
$result = $this->run(
self::TYPE_SUB_REPORT_LIST,
$id,
$parentType,
$parentId,
$searchParams,
$subReportParams,
$subReportId
);
if (!$result instanceof ListResult) {
throw new Error("Bad report result.");
}
return $result;
}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function runGrid(string $id, ?string $parentType, ?string $parentId): GridResult
{
$result = $this->run(self::TYPE_GRID, $id, $parentType, $parentId);
if (!$result instanceof GridResult) {
throw new LogicException("Bad report result.");
}
return $result;
}
/**
* @return ListResult|GridResult
*
* @throws BadRequest
* @throws Error
* @throws Forbidden
* @throws NotFound
*/
public function run(
string $type,
string $id,
?string $parentType,
?string $parentId,
?SearchParams $searchParams = null,
?SubReportParams $subReportParams = null,
?string $subReportId = null
) {
$reportPanel = $this->entityManager
->getRDBRepositoryByClass(ReportPanel::class)
->getById($id);
if (!$reportPanel) {
throw new NotFound('Report Panel not found.');
}
if (!$this->acl->checkScope($reportPanel->get('reportEntityType'))) {
throw new Forbidden();
}
if (!$parentId || !$parentType) {
throw new BadRequest();
}
$parent = $this->entityManager->getEntity($parentType, $parentId);
if (!$parent) {
throw new NotFound();
}
if (!$this->acl->checkEntityRead($parent)) {
throw new Forbidden();
}
if (!$reportPanel->getReportId()) {
throw new Error('Bad Report Panel.');
}
if ($reportPanel->getTargetEntityType() !== $parentType) {
throw new Forbidden();
}
$teamIdList = $reportPanel->getLinkMultipleIdList('teams');
if (count($teamIdList) && !$this->user->isAdmin()) {
$isInTeam = false;
$userTeamIdList = $this->user->getLinkMultipleIdList('teams');
foreach ($userTeamIdList as $teamId) {
if (in_array($teamId, $teamIdList)) {
$isInTeam = true;
break;
}
}
if (!$isInTeam) {
throw new Forbidden("Access denied to Report Panel.");
}
}
$report = $this->entityManager
->getRDBRepositoryByClass(Report::class)
->getById($reportPanel->getReportId());
if (!$report) {
throw new NotFound("Report not found.");
}
if (
$type === self::TYPE_SUB_REPORT_LIST &&
$report->getType() === Report::TYPE_JOINT_GRID
) {
if (!$subReportId) {
throw new BadRequest("No 'subReportId'.");
}
$joinedReportDataList = $report->get('joinedReportDataList');
if (empty($joinedReportDataList)) {
throw new Error("No joinedReportDataList.");
}
$subReport = null;
foreach ($joinedReportDataList as $subReportItem) {
if ($subReportId === $subReportItem->id) {
$subReport = $this->entityManager
->getRDBRepositoryByClass(Report::class)
->getById($subReportItem->id);
break;
}
}
if (!$subReport) {
throw new Error("No report found.");
}
$report = $subReport;
}
$where = null;
$idWhereMap = null;
if ($report->getType() === Report::TYPE_JOINT_GRID) {
$idWhereMap = [];
/** @var stdClass[] $joinedReportDataList */
$joinedReportDataList = $report->get('joinedReportDataList') ?? [];
foreach ($joinedReportDataList as $subReportItem) {
/** @var ?string $subReportId */
$subReportId = $subReportItem->id ?? null;
if (!is_string($subReportId)) {
continue;
}
$subReport = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($subReportId);
if (!$subReport) {
throw new Error('Sub report not found.');
}
$idWhereMap[$subReportId] = $this->getWhere($parent, $subReport);
}
} else {
$where = $this->getWhere($parent, $report);
}
$service = $this->injectableFactory->create(ReportService::class);
if ($type === self::TYPE_GRID) {
return $service->runGrid(
$report->getId(),
$where,
$this->user,
GridRunParams::create()->withSkipRuntimeFiltersCheck(),
$idWhereMap
);
}
$searchParams = $searchParams->withWhereAdded($where);
if ($type === self::TYPE_LIST) {
return $service->runList(
$report->getId(),
$searchParams,
$this->user,
ListRunParams::create()->withSkipRuntimeFiltersCheck()
);
}
if ($type === self::TYPE_SUB_REPORT_LIST) {
if (!$subReportParams) {
throw new LogicException();
}
return $service->runSubReportList(
$report->getId(),
$searchParams,
$subReportParams,
$this->user,
ListRunParams::create()->withSkipRuntimeFiltersCheck()
);
}
throw new Error("Not supported panel type.");
}
private function getWhere(Entity $parent, Report $report): WhereItem
{
$where = null;
foreach ($report->getRuntimeFilters() as $item) {
$field = $item;
$entityType = $report->getTargetEntityType();
if (strpos($item, '.')) {
[$link, $field] = explode('.', $item);
$entityType = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']);
if (!$entityType) {
continue;
}
}
$linkType = $this->metadata->get(['entityDefs', $entityType, 'links', $field, 'type']);
if ($linkType === Entity::BELONGS_TO || $linkType === Entity::HAS_MANY) {
$foreignEntityType = $this->metadata
->get(['entityDefs', $entityType, 'links', $field, 'entity']);
if ($foreignEntityType !== $parent->getEntityType()) {
continue;
}
if ($linkType === Entity::BELONGS_TO) {
$where = WhereItem::createBuilder()
->setAttribute($item . 'Id')
->setType('equals')
->setValue($parent->getId())
->build();
}
else {
$where = WhereItem::createBuilder()
->setAttribute($item)
->setType('linkedWith')
->setValue([$parent->getId()])
->build();
}
}
else if ($linkType === Entity::BELONGS_TO_PARENT) {
$entityTypeList = $this->metadata
->get(['entityDefs', $entityType, 'fields', $field, 'entityList'], []);
if (!in_array($parent->getEntityType(), $entityTypeList)) {
continue;
}
$where = WhereItem::createBuilder()
->setType('and')
->setItemList([
WhereItem::createBuilder()
->setAttribute($item . 'Id')
->setType('equals')
->setValue($parent->getId())
->build(),
WhereItem::createBuilder()
->setAttribute($item . 'Type')
->setType('equals')
->setValue($parent->getEntityType())
->build(),
])
->build();
}
}
if ($where) {
return $where;
}
$entityType = $report->getTargetEntityType();
/** @var string[] $linkList */
$linkList = array_keys($this->metadata->get(['entityDefs', $entityType, 'links'], []));
$foundBelongsToList = [];
$foundHasManyList = [];
$foundBelongsToParentList = [];
$foundBelongsToParentEmptyList = [];
foreach ($linkList as $link) {
$linkType = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'type']);
if ($linkType === Entity::BELONGS_TO || $linkType === Entity::HAS_MANY) {
$foreignEntityType = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']);
if ($foreignEntityType !== $parent->getEntityType()) {
continue;
}
if ($linkType === Entity::BELONGS_TO) {
$foundBelongsToList[] = $link;
} else {
$foundHasManyList[] = $link;
}
continue;
}
if ($linkType === Entity::BELONGS_TO_PARENT) {
$entityTypeList = $this->metadata->get(['entityDefs', $entityType, 'fields', $link, 'entityList'], []);
if (!in_array($parent->getEntityType(), $entityTypeList)) {
if (empty($entityTypeList)) {
$foundBelongsToParentEmptyList[] = $link;
}
continue;
}
$foundBelongsToParentList[] = $link;
}
}
if (count($foundBelongsToList)) {
$link = $foundBelongsToList[0];
return WhereItem::createBuilder()
->setAttribute($link . 'Id')
->setType('equals')
->setValue($parent->getId())
->build();
}
if (count($foundBelongsToParentList)) {
$link = $foundBelongsToParentList[0];
return WhereItem::createBuilder()
->setType('and')
->setItemList([
WhereItem::createBuilder()
->setAttribute($link . 'Id')
->setType('equals')
->setValue($parent->getId())
->build(),
WhereItem::createBuilder()
->setAttribute($link . 'Type')
->setType('equals')
->setValue($parent->getEntityType())
->build(),
])
->build();
}
if (count($foundHasManyList)) {
$link = $foundHasManyList[0];
return WhereItem::createBuilder()
->setAttribute($link)
->setType('linkedWith')
->setValue([$parent->getId()])
->build();
}
if (count($foundBelongsToParentEmptyList)) {
$link = $foundBelongsToParentEmptyList[0];
return WhereItem::createBuilder()
->setType('and')
->setItemList([
WhereItem::createBuilder()
->setAttribute($link . 'Id')
->setType('equals')
->setValue($parent->getId())
->build(),
WhereItem::createBuilder()
->setAttribute($link . 'Type')
->setType('equals')
->setValue($parent->getEntityType())
->build(),
])
->build();
}
return WhereItem::createBuilder()
->setAttribute('id')
->setType('equals')
->setValue(null)
->build();
}
private function hasLogicDefs(): bool
{
$version = $this->config->get('version');
if ($version === '@@version') {
return true;
}
return version_compare($version, '9.1.0') >= 0;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Workflow\Action\RunAction;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
*/
interface ServiceAction
{
/**
* @param Entity $entity
*/
public function run(Entity $entity, mixed $data): mixed;
}

View File

@@ -0,0 +1,28 @@
<?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\Workflow;
class Alert
{
public function __construct(
public string $message,
public ?string $type = null,
public bool $autoClose = false,
) {}
}

View File

@@ -0,0 +1,464 @@
<?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\Workflow\Core;
use Espo\Core\Container;
use Espo\Core\FieldProcessing\SpecificFieldLoader;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Exception;
use stdClass;
class EntityHelper
{
/**
* For bc the type is in the docblock.
*
* @var ?SpecificFieldLoader
*/
private $specificFieldLoader = null;
public function __construct(
private Container $container,
private EntityManager $entityManager,
private ServiceContainer $serviceContainer,
private Metadata $metadata,
private FieldUtil $fieldUtil,
) {}
private function getSpecificFieldLoader(): ?SpecificFieldLoader
{
if (!class_exists("Espo\\Core\\FieldProcessing\\SpecificFieldLoader")) {
return null;
}
if (!$this->specificFieldLoader) {
$this->specificFieldLoader = $this->container
->getByClass(InjectableFactory::class)
->create(SpecificFieldLoader::class);
}
return $this->specificFieldLoader;
}
/**
* @param string $fieldName
* @return string
*/
private function normalizeRelatedFieldName(CoreEntity $entity, $fieldName)
{
if ($entity->hasRelation($fieldName)) {
$type = $entity->getRelationType($fieldName);
$key = $entity->getRelationParam($fieldName, 'key');
$foreignKey = $entity->getRelationParam($fieldName, 'foreignKey');
switch ($type) {
case Entity::HAS_CHILDREN:
if ($foreignKey) {
$fieldName = $foreignKey;
}
break;
case Entity::BELONGS_TO:
if ($key) {
$fieldName = $key;
}
break;
case Entity::HAS_MANY:
case Entity::MANY_MANY:
$fieldName .= 'Ids';
break;
}
}
return $fieldName;
}
/**
* Get actual attribute list w/o additional.
*
* @param Entity $entity
* @param string $field
* @return string[]
*/
public function getActualAttributes(Entity $entity, string $field): array
{
$entityType = $entity->getEntityType();
$list = [];
$actualList = $this->fieldUtil->getActualAttributeList($entityType, $field);
$additionalList = $this->fieldUtil->getAdditionalActualAttributeList($entityType, $field);
foreach ($actualList as $item) {
if (!in_array($item, $additionalList)) {
$list[] = $item;
}
}
return $list;
}
/**
* Get field value for a field/related field. If this field has a relation, get value from the relation.
*/
public function getFieldValues(
CoreEntity $fromEntity,
CoreEntity $toEntity,
string $fromField,
string $toField
): stdClass {
$entity = $fromEntity;
$field = $fromField;
$values = (object) [];
if (str_contains($field, '.')) {
[$relation, $foreignField] = explode('.', $field);
$relatedEntity = $this->getRelatedEntity($entity, $relation);
if (!$relatedEntity instanceof CoreEntity) {
$GLOBALS['log']->debug(
"Workflow EntityHelper:getFieldValues: No related record for '$field', entity " .
"{$entity->getEntityType()}.");
return (object) [];
}
$entity = $relatedEntity;
$field = $foreignField;
}
if ($entity->hasRelation($field) && !$entity->isNew()) {
$this->loadLink($entity, $field);
}
$fromType = $this->getFieldType($entity, $field);
$toType = $this->getFieldType($toEntity, $toField);
if (
$fromType === 'link' &&
$toType === 'linkParent'
) {
return $this->getFieldValuesLinkToLinkParent($entity, $field, $toField);
}
if (
$fromField === 'id' &&
$toType === 'linkParent'
) {
return $this->getFieldValuesIdToLinkParent($entity, $toField);
}
$attributeMap = $this->getRelevantAttributeMap($entity, $toEntity, $field, $toField);
$service = $this->serviceContainer->get($entity->getEntityType());
$toAttribute = null;
$this->loadFieldForAttributes($entity, $field, array_keys($attributeMap));
foreach ($attributeMap as $fromAttribute => $toAttribute) {
// @todo Revise.
$getCopiedMethodName = 'getCopied' . ucfirst($fromAttribute);
if (method_exists($entity, $getCopiedMethodName)) {
$values->$toAttribute = $entity->$getCopiedMethodName();
continue;
}
// @todo Revise.
$getCopiedMethodName = 'getCopiedEntityAttribute' . ucfirst($fromAttribute);
if (method_exists($service, $getCopiedMethodName)) {
$values->$toAttribute = $service->$getCopiedMethodName($entity);
continue;
}
$values->$toAttribute = $entity->get($fromAttribute);
}
$toFieldType = $this->getFieldType($toEntity, $toField);
if ($toFieldType === 'personName' && $toAttribute) {
$this->handlePersonName($toAttribute, $values, $toField);
}
// Correct field types. E.g. set teamsIds from defaultTeamId.
if ($toEntity->hasRelation($toField)) {
$normalizedFieldName = $this->normalizeRelatedFieldName($toEntity, $toField);
if (
$toEntity->getRelationType($toField) === Entity::MANY_MANY &&
isset($values->$normalizedFieldName) &&
!is_array($values->$normalizedFieldName)
) {
$values->$normalizedFieldName = (array) $values->$normalizedFieldName;
}
}
return $values;
}
/**
* @return array<string, string>
*/
private function getRelevantAttributeMap(
Entity $fromEntity,
Entity $toEntity,
string $fromField,
string $toField
): array {
$fromAttributeList = $this->getActualAttributes($fromEntity, $fromField);
$toAttributeList = $this->getActualAttributes($toEntity, $toField);
$fromType = $this->getFieldType($fromEntity, $fromField);
$toType = $this->getFieldType($toEntity, $toField);
$ignoreActualAttributesOnValueCopyFieldList = $this->metadata
->get(['entityDefs', 'Workflow', 'ignoreActualAttributesOnValueCopyFieldList'], []);
if (in_array($fromType, $ignoreActualAttributesOnValueCopyFieldList)) {
$fromAttributeList = [$fromField];
}
if (in_array($toType, $ignoreActualAttributesOnValueCopyFieldList)) {
$toAttributeList = [$toField];
}
$attributeMap = [];
if (count($fromAttributeList) == count($toAttributeList)) {
if (
$fromType === 'datetimeOptional' &&
$toType === 'datetimeOptional'
) {
if ($fromEntity->get($fromAttributeList[1])) {
$attributeMap[$fromAttributeList[1]] = $toAttributeList[1];
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
return $attributeMap;
}
foreach ($fromAttributeList as $key => $name) {
$attributeMap[$name] = $toAttributeList[$key];
}
return $attributeMap;
}
if (
$fromType === 'datetimeOptional' ||
$toType === 'datetimeOptional'
) {
if (count($toAttributeList) > count($fromAttributeList)) {
if ($fromType === 'date') {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[1];
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
return $attributeMap;
}
if ($toType === 'date') {
if ($fromEntity->get($fromAttributeList[1])) {
$attributeMap[$fromAttributeList[1]] = $toAttributeList[0];
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
}
return $attributeMap;
}
private function handlePersonName(string $toAttribute, stdClass $values, string $toField): void
{
if (empty($values->$toAttribute)) {
return;
}
$fullNameValue = trim($values->$toAttribute);
$firstNameAttribute = 'first' . ucfirst($toField);
$lastNameAttribute = 'last' . ucfirst($toField);
if (!str_contains($fullNameValue, ' ')) {
$lastNameValue = $fullNameValue;
$firstNameValue = null;
} else {
$index = strrpos($fullNameValue, ' ');
$firstNameValue = substr($fullNameValue, 0, $index ?: 0);
$lastNameValue = substr($fullNameValue, $index + 1);
}
$values->$firstNameAttribute = $firstNameValue;
$values->$lastNameAttribute = $lastNameValue;
}
private function loadLink(Entity $entity, string $field): void
{
if (!$entity instanceof CoreEntity) {
return;
}
switch ($entity->getRelationType($field)) { // ORM types
case Entity::MANY_MANY:
case Entity::HAS_CHILDREN:
try {
$entity->loadLinkMultipleField($field);
} catch (Exception) {}
break;
case Entity::BELONGS_TO:
case Entity::HAS_ONE:
try {
$entity->loadLinkField($field);
} catch (Exception) {}
break;
}
}
public function getFieldType(Entity $entity, string $field): ?string
{
return $this->metadata->get(['entityDefs', $entity->getEntityType(), 'fields', $field, 'type']);
}
private function getRelatedEntity(CoreEntity $entity, string $relation): ?Entity
{
if (!$entity->hasRelation($relation)) {
return null;
}
$relatedEntity = null;
if ($entity->hasId()) {
$relatedEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
if ($relatedEntity) {
return $relatedEntity;
}
}
// If the entity is just created and doesn't have relations yet.
$foreignEntityType = $entity->getRelationParam($relation, 'entity');
$idAttribute = $this->normalizeRelatedFieldName($entity, $relation);
if (
$foreignEntityType &&
$entity->hasAttribute($idAttribute) &&
$entity->get($idAttribute)
) {
$relatedEntity = $this->entityManager->getEntityById($foreignEntityType, $entity->get($idAttribute));
}
return $relatedEntity;
}
private function getFieldValuesLinkToLinkParent(
CoreEntity $fromEntity,
string $fromField,
string $toField
): stdClass {
$sourceRecordId = $fromEntity->get($fromField . 'Id');
$foreignEntityType = $fromEntity->getRelationParam($fromField, 'entity');
if (!$sourceRecordId || !$foreignEntityType) {
return (object) [
$toField . 'Id' => null,
$toField . 'Type' => null,
$toField . 'Name' => null,
];
}
return (object) [
$toField . 'Id' => $sourceRecordId,
$toField . 'Type' => $foreignEntityType,
$toField . 'Name' => $fromEntity->get($fromField . 'Name'),
];
}
private function getFieldValuesIdToLinkParent(CoreEntity $fromEntity, string $toField): stdClass
{
return (object) [
$toField . 'Id' => $fromEntity->getId(),
$toField . 'Type' => $fromEntity->getEntityType(),
$toField . 'Name' => $fromEntity->get('name'),
];
}
/**
* @param string[] $attributes
*/
private function loadFieldForAttributes(CoreEntity $entity, string $field, array $attributes): void
{
$hasNotSet = $this->hasNotSetAttribute($entity, $attributes);
if (!$hasNotSet) {
return;
}
$this->getSpecificFieldLoader()->process($entity, $field);
}
/**
* @param string[] $attributes
*/
private function hasNotSetAttribute(CoreEntity $entity, array $attributes): bool
{
$hasNotSet = false;
foreach ($attributes as $it) {
if (!$entity->has($it)) {
$hasNotSet = true;
break;
}
}
return $hasNotSet;
}
}

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\Workflow\Core;
use Espo\Core\FieldProcessing\SpecificFieldLoader;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\FieldUtil;
use Espo\ORM\Entity;
class FieldLoaderHelper
{
/**
* For bc the type is in the docblock.
*
* @var ?SpecificFieldLoader
*/
private $specificFieldLoader = null;
public function __construct(
private InjectableFactory $injectableFactory,
private FieldUtil $fieldUtil,
) {}
public function load(Entity $entity, string $path): void
{
/** @phpstan-ignore-next-line function.alreadyNarrowedType */
if (!method_exists($this->fieldUtil, 'getFieldOfAttribute')) {
return;
}
$field = $this->fieldUtil->getFieldOfAttribute($entity->getEntityType(), $path);
if (!$field) {
return;
}
$loader = $this->getSpecificFieldLoader();
if (!$loader) {
return;
}
$loader->process($entity, $field);
}
private function getSpecificFieldLoader(): ?SpecificFieldLoader
{
if (!class_exists("Espo\\Core\\FieldProcessing\\SpecificFieldLoader")) {
return null;
}
if (!$this->specificFieldLoader) {
$this->specificFieldLoader = $this->injectableFactory->create(SpecificFieldLoader::class);
}
return $this->specificFieldLoader;
}
}

View File

@@ -0,0 +1,268 @@
<?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\Workflow\Core;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Utils\Log;
use Espo\Modules\Advanced\Core\Workflow\Utils;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use RuntimeException;
use stdClass;
class FieldValueHelper
{
public function __construct(
private EntityManager $entityManager,
private Log $log,
private FieldLoaderHelper $fieldLoaderHelper,
) {}
/**
* Get field value for a field/related field. If this field has a relation, get the value from the relation.
*
* @param ?string $path A field path.
*/
public function getValue(
CoreEntity $entity,
?string $path,
bool $returnEntity = false,
?stdClass $createdEntitiesData = null
): mixed {
if (str_starts_with($path, 'created:')) {
[$alias, $field] = explode('.', substr($path, 8));
if (!$createdEntitiesData || !isset($createdEntitiesData->$alias)) {
return null;
}
$entityTypeValue = $createdEntitiesData->$alias->entityType ?? null;
$entityIdValue = $createdEntitiesData->$alias->entityId ?? null;
if (!$entityTypeValue || !$entityIdValue) {
return null;
}
$entity = $this->entityManager->getEntityById($entityTypeValue, $entityIdValue);
if (!$entity) {
return null;
}
$path = $field;
} else if (str_contains($path, '.')) {
[$first, $foreignName] = explode('.', $path);
$relatedEntity = $this->getRelatedEntity($entity, $first);
if ($relatedEntity instanceof CoreEntity) {
$entity = $relatedEntity;
$path = $foreignName;
} else {
$this->log->warning("Workflow: Could not get related entity by path '$path'.");
return null;
}
}
if (!$entity instanceof CoreEntity) {
throw new RuntimeException();
}
if ($path && $entity->hasRelation($path)) {
$relatedEntity = $this->getRelatedEntityForRelation($entity, $path);
if ($relatedEntity instanceof CoreEntity) {
$foreignKey = $entity->getRelationParam($path, 'foreignKey') ?? 'id';
return $returnEntity ? $relatedEntity : $relatedEntity->get($foreignKey);
}
if (!$relatedEntity) {
$normalizedFieldName = Utils::normalizeFieldName($entity, $path);
if (!$entity->isNew() && $entity->hasLinkMultipleField($path)) {
$entity->loadLinkMultipleField($path);
}
if ($entity->getRelationType($path) === Entity::BELONGS_TO_PARENT && !$returnEntity) {
return null;
}
$fieldValue = $returnEntity ?
$this->getParentEntity($entity, $path) :
$this->getParentValue($entity, $normalizedFieldName);
if (isset($fieldValue)) {
return $fieldValue;
}
}
if ($entity->hasLinkMultipleField($path)) {
$entity->loadLinkMultipleField($path);
}
if ($relatedEntity) {
return null;
}
return $entity->get($path . 'Ids');
}
switch ($entity->getAttributeType($path)) {
// @todo Revise.
case 'linkParent':
$path .= 'Id';
break;
}
if ($returnEntity) {
return $entity;
}
if (!$entity->hasAttribute($path)) {
return null;
}
if (!$entity->has($path)) {
$this->fieldLoaderHelper->load($entity, $path);
}
return $entity->get($path);
}
/**
* @return CoreEntity|Entity|null
*/
private function getParentEntity(CoreEntity $entity, string $fieldName)
{
if (!$entity->hasRelation($fieldName)) {
return $entity;
}
$normalizedFieldName = Utils::normalizeFieldName($entity, $fieldName);
$fieldValue = $this->getParentValue($entity, $normalizedFieldName);
if (isset($fieldValue) && is_string($fieldValue)) {
$fieldEntityDefs = $this->entityManager->getMetadata()->get($entity->getEntityType());
if (isset($fieldEntityDefs['relations'][$fieldName]['entity'])) {
$fieldEntity = $fieldEntityDefs['relations'][$fieldName]['entity'];
return $this->entityManager->getEntityById($fieldEntity, $fieldValue);
}
}
return null;
}
/**
* Get parent field value. Works for parent and regular fields,
*
* @param string|string[] $normalizedFieldName
* @return mixed
*/
private function getParentValue(Entity $entity, $normalizedFieldName)
{
if (is_array($normalizedFieldName)) {
$value = [];
foreach ($normalizedFieldName as $fieldName) {
if ($entity->hasAttribute($fieldName)) {
$value[$fieldName] = $entity->get($fieldName);
}
}
return $value;
}
if ($entity->hasAttribute($normalizedFieldName)) {
return $entity->get($normalizedFieldName);
}
return null;
}
private function getRelatedEntityForRelation(CoreEntity $entity, string $relation): ?Entity
{
if ($entity->getRelationType($relation) === Entity::BELONGS_TO_PARENT) {
$valueType = $entity->get($relation . 'Type');
$valueId = $entity->get($relation . 'Id');
if ($valueType && $valueId) {
return $this->entityManager->getEntityById($valueType, $valueId);
}
return null;
}
if (in_array($entity->getRelationType($relation), [Entity::BELONGS_TO, Entity::HAS_ONE])) {
return $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
}
return null;
}
private function getRelatedEntity(CoreEntity $entity, string $relation): ?Entity
{
if (!$entity->hasRelation($relation)) {
return null;
}
if (
in_array($entity->getRelationType($relation), [
Entity::BELONGS_TO,
Entity::HAS_ONE,
Entity::BELONGS_TO_PARENT,
])
) {
$relatedEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
if ($relatedEntity) {
return $relatedEntity;
}
}
// If the entity is just created and doesn't have added relations.
$foreignEntityType = $entity->getRelationParam($relation, 'entity');
$idAttribute = Utils::normalizeFieldName($entity, $relation);
if (
!$foreignEntityType ||
!$entity->hasAttribute($idAttribute) ||
!$entity->get($idAttribute)
) {
return null;
}
return $this->entityManager->getEntityById($foreignEntityType, $entity->get($idAttribute));
}
}

View File

@@ -0,0 +1,37 @@
<?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\Workflow\Core;
class PlaceholderHelper
{
public function __construct(
private SecretProvider $secretProvider,
) {}
public function applySecrets(string $content): string
{
return preg_replace_callback('/{#secrets\.([A-Za-z0-9_]+)}/', function ($matches) {
$name = trim($matches[1]);
$secret = $this->secretProvider->get($name);
return $secret ?? '';
}, $content);
}
}

View File

@@ -0,0 +1,49 @@
<?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\Workflow\Core;
class RecipientIds
{
/**
* @param string[] $ids
*/
public function __construct(
private ?string $entityType = null,
private array $ids = [],
private bool $isOne = false,
) {}
/**
* @return string[]
*/
public function getIds(): array
{
return $this->ids;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
public function isOne(): bool
{
return $this->isOne;
}
}

View File

@@ -0,0 +1,146 @@
<?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\Workflow\Core;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceFactory;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Workflow\Core\FieldValueHelper;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\Stream\Service;
use RuntimeException;
class RecipientProvider
{
public function __construct(
private EntityManager $entityManager,
private InjectableFactory $injectableFactory,
private ServiceFactory $serviceFactory,
private FieldValueHelper $fieldValueHelper,
) {}
public function get(Entity $entity, string $target): RecipientIds
{
if (!$entity instanceof CoreEntity) {
return new RecipientIds();
}
$link = $target;
$targetEntity = $entity;
if (str_starts_with($link, 'link:')) {
$link = substr($link, 5);
}
if (strpos($link, '.')) {
[$firstLink, $link] = explode('.', $link);
$relationType = $entity->getRelationType($firstLink);
if (in_array($relationType, [Entity::HAS_MANY, Entity::MANY_MANY])) {
$collection = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $firstLink)
->sth()
->find();
$ids = [];
$entityType = null;
foreach ($collection as $targetEntity) {
$entityType ??= $targetEntity->getEntityType();
$itemIds = $this->get($targetEntity, "link:$link")->getIds();
$ids = array_merge($ids, $itemIds);
}
return new RecipientIds($entityType, array_unique($ids));
}
$targetEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $firstLink)
->findOne();
if (!$targetEntity) {
return new RecipientIds();
}
}
if ($link === 'followers') {
if (!class_exists("Espo\\Tools\\Stream\\Service")) {
/** @noinspection PhpUndefinedMethodInspection */
return new RecipientIds(
User::ENTITY_TYPE,
/** @phpstan-ignore-next-line */
$this->serviceFactory->create('Stream')->getEntityFolowerIdList($targetEntity)
);
}
/** @var Service $streamService */
$streamService = $this->injectableFactory->create("Espo\\Tools\\Stream\\Service");
return new RecipientIds(
User::ENTITY_TYPE,
$streamService->getEntityFollowerIdList($targetEntity)
);
}
if (
$targetEntity->hasRelation($link) &&
(
$targetEntity->getRelationType($link) === Entity::HAS_MANY ||
$targetEntity->getRelationType($link) === Entity::MANY_MANY
)
) {
$collection = $this->entityManager
->getRDBRepository($targetEntity->getEntityType())
->getRelation($targetEntity, $link)
->select(['id'])
->sth()
->find();
$ids = [];
$entityType = null;
foreach ($collection as $e) {
$ids[] = $e->getId();
$entityType ??= $e->getEntityType();
}
return new RecipientIds($entityType, $ids);
}
if (!$targetEntity instanceof CoreEntity) {
throw new RuntimeException();
}
$fieldEntity = $this->fieldValueHelper->getValue($targetEntity, $link, true);
if ($fieldEntity instanceof Entity) {
return new RecipientIds($fieldEntity->getEntityType(), [$fieldEntity->getId()], true);
}
return new RecipientIds();
}
}

View File

@@ -0,0 +1,68 @@
<?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\Workflow\Core;
use Espo\Core\ORM\Repository\Option\SaveContext;
class SaveContextHelper
{
/**
* @param array<string, mixed> $options
* @return ?SaveContext
*/
public static function createDerived(array $options)
{
if (!class_exists("Espo\\Core\\ORM\\Repository\\Option\\SaveContext")) {
return null;
}
$newSaveContext = null;
$saveContext = $options[SaveContext::NAME] ?? null;
if (
$saveContext instanceof SaveContext &&
/** @phpstan-ignore-next-line function.alreadyNarrowedType */
method_exists($saveContext, 'getActionId')
) {
$newSaveContext = new SaveContext($saveContext->getActionId());
}
return $newSaveContext;
}
/**
* @param array<string, mixed> $options
* @return ?SaveContext
*/
public static function obtainFromRawOptions(array $options)
{
if (!class_exists("Espo\\Core\\ORM\\Repository\\Option\\SaveContext")) {
return null;
}
$saveContext = $options[SaveContext::NAME] ?? null;
if (!$saveContext instanceof SaveContext) {
return null;
}
return $saveContext;
}
}

View File

@@ -0,0 +1,56 @@
<?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\Workflow\Core;
use Espo\Core\Utils\Crypt;
use Espo\Entities\AppSecret;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
class SecretProvider
{
public function __construct(
private Crypt $crypt,
private EntityManager $entityManager,
) {}
public function get(string $name): ?string
{
if (!$this->entityManager->hasRepository('AppSecret')) {
return null;
}
$secret = $this->entityManager
->getRDBRepositoryByClass(AppSecret::class)
->where(
Condition::equal(
Expression::binary(Expression::column('name')),
$name
)
)
->findOne();
if (!$secret) {
return null;
}
return $this->crypt->decrypt($secret->getValue());
}
}

View File

@@ -0,0 +1,133 @@
<?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\Workflow\Core;
use Espo\Core\Exceptions\Error;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use stdClass;
class TargetProvider
{
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
private User $user
) {}
/**
* @return iterable<Entity>
*/
public function get(Entity $entity, ?string $target, ?stdClass $createdEntitiesData = null): iterable
{
if (!$target || $target === 'targetEntity') {
if (!$entity->hasId()) {
return [];
}
$targetEntity = $this->entityManager->getEntityById($entity->getEntityType(), $entity->getId());
return self::wrapEntityIntoArray($targetEntity);
}
if (str_starts_with($target, 'created:')) {
return self::wrapEntityIntoArray(
$this->getCreated($target, $createdEntitiesData)
);
}
if (str_starts_with($target, 'link:')) {
$path = explode('.', substr($target, 5));
$pointerEntity = $entity;
foreach ($path as $i => $link) {
$type = $this->metadata->get(['entityDefs', $pointerEntity->getEntityType(), 'links', $link, 'type']);
if (!$type) {
throw new Error("Workflow action: Bad target $target. Not existing link.");
}
$isLast = $i === count($path) - 1;
$relation = $this->entityManager
->getRDBRepository($pointerEntity->getEntityType())
->getRelation($pointerEntity, $link);
if ($isLast) {
return $relation->sth()->find();
}
$pointerEntity = $this->entityManager
->getRDBRepository($pointerEntity->getEntityType())
->getRelation($pointerEntity, $link)
->findOne();
if (!$pointerEntity instanceof Entity) {
return [];
}
}
return [];
}
if ($target == 'currentUser') {
return [$this->user];
}
return [];
}
public function getCreated(string $target, ?stdClass $createdEntitiesData): ?Entity
{
$alias = str_starts_with($target, 'created:') ? substr($target, 8) : $target;
if (!$createdEntitiesData) {
return null;
}
if (!property_exists($createdEntitiesData, $alias)) {
return null;
}
$id = $createdEntitiesData->$alias->entityId ?? null;
$entityType = $createdEntitiesData->$alias->entityType ?? null;
if (!$id || !$entityType) {
return null;
}
return $this->entityManager->getEntityById($entityType, $id);
}
/**
* @param Entity|null $entity
* @return Entity[]
*/
private static function wrapEntityIntoArray(?Entity $entity): array
{
if (!$entity) {
return [];
}
return [$entity];
}
}

View File

@@ -0,0 +1,125 @@
<?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\Workflow\Jobs;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Modules\Advanced\Entities\Workflow;
use Espo\Modules\Advanced\Tools\Report\ListType\RunParams as ListRunParams;
use Espo\Modules\Advanced\Tools\Report\Service as ReportService;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
use Exception;
use RuntimeException;
class RunScheduledWorkflow implements Job
{
public function __construct(
private ReportService $reportService,
private EntityManager $entityManager,
private Service $service,
private JobSchedulerFactory $jobSchedulerFactory,
) {}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function run(Data $data): void
{
$workflowId = $data->getTargetId() ?? $data->get('workflowId');
if (!$workflowId) {
throw new RuntimeException();
}
$workflow = $this->entityManager->getRDBRepositoryByClass(Workflow::class)->getById($workflowId);
if (!$workflow) {
throw new RuntimeException("Workflow $workflowId not found.");
}
if (!$workflow->isActive()) {
return;
}
$targetReport = $this->entityManager
->getRDBRepository(Workflow::ENTITY_TYPE)
->getRelation($workflow, 'targetReport')
->findOne();
if (!$targetReport) {
throw new RuntimeException("Workflow $workflowId: Target report not found.");
}
$result = $this->reportService->runList(
id: $targetReport->getId(),
runParams: ListRunParams::create()->withReturnSthCollection(),
);
foreach ($result->getCollection() as $entity) {
try {
$this->runScheduledWorkflowForEntity(
$workflow->getId(),
$entity->getEntityType(),
$entity->getId()
);
} catch (Exception) {
// @todo Revise.
$this->jobSchedulerFactory
->create()
->setClassName(RunScheduledWorkflowForEntity::class)
->setGroup('scheduled-workflows')
->setData([
'workflowId' => $workflow->getId(),
'entityType' => $entity->getEntityType(),
'entityId' => $entity->getId(),
])
->schedule();
}
}
}
/**
* @throws FormulaError
* @throws Error
*/
private function runScheduledWorkflowForEntity(string $workflowId, string $entityType, string $id): void
{
// @todo Create jobs if a parameter is enabled.
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity) {
throw new RuntimeException("Workflow $workflowId: Entity $entityType $id not found.");
}
$this->service->triggerWorkflow($entity, $workflowId);
}
}

View File

@@ -0,0 +1,57 @@
<?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\Workflow\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
use RuntimeException;
class RunScheduledWorkflowForEntity implements Job
{
private EntityManager $entityManager;
private Service $service;
public function __construct(
EntityManager $entityManager,
Service $service
) {
$this->entityManager = $entityManager;
$this->service = $service;
}
public function run(Data $data): void
{
$data = $data->getRaw();
$entityType = $data->entityType;
$id = $data->entityId;
$workflowId = $data->workflowId;
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity) {
throw new RuntimeException("Workflow $workflowId: Entity $entityType $id not found.");
}
$this->service->triggerWorkflow($entity, $workflowId);
}
}

View File

@@ -0,0 +1,44 @@
<?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\Workflow\Jobs;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Modules\Advanced\Tools\Workflow\SendEmailService;
class SendEmail implements Job
{
private SendEmailService $service;
public function __construct(SendEmailService $service)
{
$this->service = $service;
}
/**
* @throws Error
* @throws NoSmtp
*/
public function run(Data $data): void
{
$this->service->send($data->getRaw());
}
}

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\Workflow\Jobs;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
class TriggerWorkflow implements Job
{
public function __construct(
private Service $service,
private EntityManager $entityManager
) {}
public function run(Data $data): void
{
$data = $data->getRaw();
if (
empty($data->entityId) ||
empty($data->entityType) ||
empty($data->nextWorkflowId)
) {
throw new Error("Workflow[$data->workflowId][triggerWorkflow]: Not sufficient job data.");
}
$entityId = $data->entityId;
$entityType = $data->entityType;
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (!$entity) {
throw new Error("Workflow[$data->workflowId][triggerWorkflow]: Entity not found.");
}
$values = $data->values ?? null;
if (is_object($values)) {
$values = get_object_vars($values);
foreach ($values as $attribute => $value) {
$entity->setFetched($attribute, $value);
}
}
$this->service->triggerWorkflow($entity, $data->nextWorkflowId, true);
}
}

View File

@@ -0,0 +1,90 @@
<?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\Workflow\Jobs;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Utils\Log;
use Espo\Modules\Advanced\Entities\Workflow;
use Espo\Modules\Advanced\Tools\Workflow\Core\TargetProvider;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
use Exception;
use RuntimeException;
class TriggerWorkflowMany implements Job
{
public function __construct(
private TargetProvider $targetProvider,
private EntityManager $entityManager,
private Service $service,
private Log $log
) {}
/**
* @throws Error
*/
public function run(Data $data): void
{
$workflowId = $data->get('nextWorkflowId');
$entityId = $data->get('entityId');
$entityType = $data->get('entityType');
$target = $data->get('target');
if (!is_string($target)) {
throw new RuntimeException("No target.");
}
if (!is_string($workflowId)) {
throw new RuntimeException("No nextWorkflowId.");
}
if (!is_string($entityId)) {
throw new RuntimeException("No entityId.");
}
if (!is_string($entityType)) {
throw new RuntimeException("No entityType.");
}
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (!$entity) {
return;
}
$workflow = $this->entityManager->getRDBRepositoryByClass(Workflow::class)->getById($workflowId);
if (!$workflow) {
throw new RuntimeException("No workflow $workflowId.");
}
$targetEntityList = $this->targetProvider->get($entity, $target);
foreach ($targetEntityList as $targetEntity) {
try {
$this->service->triggerWorkflow($targetEntity, $workflowId);
}
catch (Exception $e) {
$this->log->error("Trigger workflow $workflowId for entity $entityId: " . $e->getMessage());
}
}
}
}

View File

@@ -0,0 +1,661 @@
<?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\Workflow;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Sender;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Tools\EmailTemplate\Result;
use Laminas\Mail\Message;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory;
use Espo\Core\Mail\Account\PersonalAccount\AccountFactory as PersonalAccountFactory;
use Espo\Core\InjectableFactory;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Hasher;
use Espo\Core\Utils\Language;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\Entities\EmailAccount;
use Espo\Entities\EmailTemplate;
use Espo\Entities\InboundEmail;
use Espo\Entities\User;
use Espo\Modules\Advanced\Core\Workflow\Helper;
use Espo\Modules\Advanced\Entities\BpmnProcess as BpmnProcessEntity;
use Espo\Modules\Advanced\Entities\Workflow as WorkflowEntity;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\EmailTemplate\Processor as EmailTemplateProcessor;
use Espo\Tools\EmailTemplate\Data as EmailTemplateData;
use Espo\Tools\EmailTemplate\Params as EmailTemplateParams;
use RuntimeException;
use Exception;
use stdClass;
class SendEmailService
{
public function __construct(
private EntityManager $entityManager,
private ServiceContainer $recordServiceContainer,
private Config $config,
private Helper $workflowHelper,
private EmailSender $emailSender,
private Hasher $hasher,
private Language $defaultLanguage,
private EmailTemplateProcessor $emailTemplateProcessor,
private InjectableFactory $injectableFactory
) {}
/**
* Send email for a workflow.
* @return bool|string
* @throws Error
* @throws NoSmtp
* @todo Introduce SendEmailData class.
*/
public function send(stdClass $data)
{
$workflowId = $data->workflowId;
if (!$this->validateSendEmailData($data)) {
throw new Error("Workflow[$workflowId][sendEmail]: Email data is invalid.");
}
$data->doNotStore ??= false;
$data->returnEmailId ??= false;
$data->from ??= (object) [];
$data->to ??= (object) [];
$data->cc ??= null;
$data->replyTo ??= null;
$data->attachmentIds ??= [];
/**
* @var object{
* variables?: stdClass,
* optOutLink?: bool,
* attachmentIds: string[],
* entityType?: string|null,
* entityId?: string|null,
* from: stdClass,
* to: stdClass,
* cc: stdClass|null,
* replyTo: stdClass|null,
* doNotStore: bool,
* returnEmailId: bool,
* } & stdClass $data
*/
if ($workflowId) {
$workflow = $this->entityManager->getRDBRepositoryByClass(WorkflowEntity::class)->getById($workflowId);
if (!$workflow || !$workflow->isActive()) {
return false;
}
}
$entity = null;
if (!empty($data->entityType) && !empty($data->entityId)) {
$entity = $this->entityManager->getEntityById($data->entityType, $data->entityId);
}
if (!$entity) {
throw new Error("Workflow[$workflowId][sendEmail]: Target Entity is not found.");
}
$this->recordServiceContainer->get($entity->getEntityType())
->loadAdditionalFields($entity);
$fromAddress = $this->getEmailAddress($data->from);
$toAddress = $this->getEmailAddress($data->to);
$replyToAddress = !empty($data->replyTo) ? $this->getEmailAddress($data->replyTo) : null;
$ccAddress = !empty($data->cc) ? $this->getEmailAddress($data->cc) : null;
if (!$fromAddress) {
throw new Error("Workflow[$workflowId][sendEmail]: From email address is empty or could not be obtained.");
}
if (!$toAddress) {
throw new Error("Workflow[$workflowId][sendEmail]: To email address is empty.");
}
/** @var array<string, Entity> $entityHash */
$entityHash = [$data->entityType => $entity];
if (
isset($data->to->entityType) &&
isset($data->to->entityId) &&
$data->to->entityType !== $data->entityType
) {
/** @var string $toEntityType */
$toEntityType = $data->to->entityType;
$toEntity = $this->entityManager->getEntityById($toEntityType, $data->to->entityId);
if ($toEntity) {
$entityHash[$toEntityType] = $toEntity;
}
}
$fromName = null;
if (
isset($data->from->entityType) &&
isset($data->from->entityId) &&
$data->from->entityType === User::ENTITY_TYPE
) {
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($data->from->entityId);
if ($user) {
$entityHash[User::ENTITY_TYPE] = $user;
$fromName = $user->getName();
}
}
$sender = $this->emailSender->create();
$templateResult = $this->getTemplateResult(
data: $data,
entityHash: $entityHash,
toEmailAddress: $toAddress,
entity: $entity,
);
[$subject, $body] = $this->prepareSubjectBody(
templateResult: $templateResult,
data: $data,
toEmailAddress: $toAddress,
sender: $sender,
);
$emailData = [
'from' => $fromAddress,
'to' => $toAddress,
'cc' => $ccAddress,
'replyTo' => $replyToAddress,
'subject' => $subject,
'body' => $body,
'isHtml' => $templateResult->isHtml(),
'parentId' => $entity->getId(),
'parentType' => $entity->getEntityType(),
];
if ($fromName !== null) {
$emailData['fromName'] = $fromName;
}
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email->setMultiple($emailData);
$attachmentList = $this->getAttachmentList($templateResult, $data->attachmentIds);
if (!$data->doNotStore) {
// Additional attachments not added intentionally?
$email->set('attachmentsIds', $templateResult->getAttachmentIdList());
}
$smtpParams = $this->prepareSmtpParams($data, $fromAddress);
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
$sender->withAttachments($attachmentList);
if ($replyToAddress) {
$senderParams = SenderParams::create()->withReplyToAddress($replyToAddress);
$sender->withParams($senderParams);
}
try {
$sender->send($email);
} catch (Exception $e) {
$sendExceptionMessage = $e->getMessage();
throw new Error("Workflow[$workflowId][sendEmail]: $sendExceptionMessage.", 0, $e);
}
if ($data->doNotStore) {
return true;
}
$this->storeEmail($email, $data);
if ($data->returnEmailId) {
return $email->getId();
}
return true;
}
private function validateSendEmailData(stdClass $data): bool
{
if (
!isset($data->entityId) ||
!(isset($data->entityType)) ||
!isset($data->emailTemplateId) ||
!isset($data->from) ||
!isset($data->to)
) {
return false;
}
return true;
}
private function getEmailAddress(stdClass $data): ?string
{
if (isset($data->email)) {
return $data->email;
}
$entityType = $data->entityType ?? $data->entityName ?? null;
$entity = null;
if (isset($entityType) && isset($data->entityId)) {
$entity = $this->entityManager->getEntityById($entityType, $data->entityId);
}
$workflowHelper = $this->workflowHelper;
if (isset($data->type)) {
switch ($data->type) {
case 'specifiedTeams':
$userIds = $workflowHelper->getUserIdsByTeamIds($data->entityIds);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'teamUsers':
if (!$entity instanceof CoreEntity) {
return null;
}
$entity->loadLinkMultipleField('teams');
$userIds = $workflowHelper->getUserIdsByTeamIds($entity->get('teamsIds'));
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'followers':
if (!$entity) {
return null;
}
$userIds = $workflowHelper->getFollowerUserIds($entity);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'followersExcludingAssignedUser':
if (!$entity) {
return null;
}
$userIds = $workflowHelper->getFollowerUserIdsExcludingAssignedUser($entity);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'system':
return $this->config->get('outboundEmailFromAddress');
case 'specifiedUsers':
return implode('; ', $workflowHelper->getUsersEmailAddress($data->entityIds));
case 'specifiedContacts':
return implode('; ', $workflowHelper->getEmailAddressesForEntity('Contact', $data->entityIds));
}
}
if ($entity instanceof Entity && $entity->hasAttribute('emailAddress')) {
return $entity->get('emailAddress');
}
if (
isset($data->type) &&
isset($entityType) &&
isset($data->entityIds) &&
is_array($data->entityIds)
) {
return implode('; ', $workflowHelper->getEmailAddressesForEntity($entityType, $data->entityIds));
}
return null;
}
private function applyTrackingUrlsToEmailBody(string $body, string $toEmailAddress): string
{
$siteUrl = $this->config->get('siteUrl');
if (!str_contains($body, '{trackingUrl:')) {
return $body;
}
$hash = $this->hasher->hash($toEmailAddress);
preg_match_all('/\{trackingUrl:(.*?)}/', $body, $matches);
/** @phpstan-ignore-next-line */
if (!$matches || !count($matches)) {
return $body;
}
foreach ($matches[0] as $item) {
$id = explode(':', trim($item, '{}'), 2)[1] ?? null;
if (!$id) {
continue;
}
if (strpos($id, '.')) {
[$id, $uid] = explode('.', $id);
$uidHash = $this->hasher->hash($uid);
$url = "$siteUrl?entryPoint=campaignUrl&id=$id&uid=$uid&hash=$uidHash";
} else {
$url = "$siteUrl?entryPoint=campaignUrl&id=$id&emailAddress=$toEmailAddress&hash=$hash";
}
$body = str_replace($item, $url, $body);
}
return $body;
}
/**
* @throws Error
* @throws NoSmtp
*/
private function getUserSmtpParams(string $emailAddress, string $userId): ?SmtpParams
{
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user || !$user->isActive()) {
return null;
}
$emailAccount = $this->entityManager
->getRDBRepositoryByClass(EmailAccount::class)
->where([
'emailAddress' => $emailAddress,
'assignedUserId' => $userId,
'useSmtp' => true,
'status' => EmailAccount::STATUS_ACTIVE,
])
->findOne();
if (!$emailAccount) {
return null;
}
$factory = $this->injectableFactory->create(PersonalAccountFactory::class);
$params = $factory->create($emailAccount->getId())
->getSmtpParams();
if (!$params) {
return null;
}
return $params->withFromName($user->getName());
}
/**
* @throws Error
* @throws NoSmtp
*/
private function getGroupSmtpParams(string $emailAddress): ?SmtpParams
{
$inboundEmail = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpHost!=' => null,
'emailAddress' => $emailAddress,
])
->findOne();
if (!$inboundEmail) {
return null;
}
return $this->injectableFactory
->create(GroupAccountFactory::class)
->create($inboundEmail->getId())
->getSmtpParams();
}
/**
* @param Result $templateResult
* @param string[] $attachmentIds
* @return Attachment[]
*/
private function getAttachmentList(Result $templateResult, array $attachmentIds): array
{
$attachmentList = [];
foreach (array_merge($templateResult->getAttachmentIdList(), $attachmentIds) as $attachmentId) {
$attachment = $this->entityManager
->getRDBRepositoryByClass(Attachment::class)
->getById($attachmentId);
if ($attachment) {
$attachmentList[] = $attachment;
}
}
return $attachmentList;
}
private function storeEmail(Email $email, stdClass $data): void
{
$processId = $data->processId ?? null;
$emailTemplateId = $data->emailTemplateId ?? null;
$teamsIds = [];
if ($processId) {
$process = $this->entityManager
->getRDBRepositoryByClass(BpmnProcessEntity::class)
->getById($processId);
if ($process) {
$teamsIds = $process->getLinkMultipleIdList('teams');
}
} else if ($emailTemplateId) {
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($emailTemplateId);
if ($emailTemplate) {
$teamsIds = $emailTemplate->getLinkMultipleIdList('teams');
}
}
if (count($teamsIds)) {
$email->set('teamsIds', $teamsIds);
}
$this->entityManager->saveEntity($email, ['createdById' => 'system']);
}
/**
* @throws Error
* @throws NoSmtp
*/
private function prepareSmtpParams(stdClass $data, string $fromEmailAddress): ?SmtpParams
{
if (
isset($data->from->entityType) &&
$data->from->entityType === User::ENTITY_TYPE &&
isset($data->from->entityId)
) {
return $this->getUserSmtpParams($fromEmailAddress, $data->from->entityId);
}
if (isset($data->from->email)) {
return $this->getGroupSmtpParams($fromEmailAddress);
}
return null;
}
private function getEmailTemplate(stdClass $data): EmailTemplate
{
$emailTemplateId = $data->emailTemplateId ?? null;
if (!$emailTemplateId) {
throw new RuntimeException("No email template.");
}
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($emailTemplateId);
if (!$emailTemplate) {
throw new RuntimeException("Email template $emailTemplateId not found.");
}
return $emailTemplate;
}
/**
* @param array<string, Entity> $entityHash
* @return Result
*/
private function getTemplateResult(
stdClass $data,
array $entityHash,
string $toEmailAddress,
Entity $entity
): Result {
$emailTemplate = $this->getEmailTemplate($data);
$emailTemplateData = EmailTemplateData::create()
->withEntityHash($entityHash)
->withEmailAddress($toEmailAddress)
->withParentId($entity->getId())
->withParentType($entity->getEntityType());
if (
$entity->hasAttribute('parentId') &&
$entity->hasAttribute('parentType')
) {
$emailTemplateData = $emailTemplateData
->withRelatedId($entity->get('parentId'))
->withRelatedType($entity->get('parentType'));
}
return $this->emailTemplateProcessor->process(
$emailTemplate,
EmailTemplateParams::create()->withCopyAttachments(),
$emailTemplateData
);
}
private function applyOptOutLink(
string $toEmailAddress,
string $body,
Result $templateResult,
Sender $sender,
): string {
$siteUrl = $this->config->get('siteUrl');
$hash = $this->hasher->hash($toEmailAddress);
$optOutUrl = "$siteUrl?entryPoint=unsubscribe&emailAddress=$toEmailAddress&hash=$hash";
$optOutLink = "<a href=\"$optOutUrl\">" .
"{$this->defaultLanguage->translateLabel('Unsubscribe', 'labels', 'Campaign')}</a>";
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
if (stripos($body, '?entryPoint=unsubscribe') === false) {
if ($templateResult->isHtml()) {
$body .= "<br><br>" . $optOutLink;
} else {
$body .= "\n\n" . $optOutUrl;
}
}
if (method_exists($sender, 'withAddedHeader')) { /** @phpstan-ignore-line */
$sender->withAddedHeader('List-Unsubscribe', '<' . $optOutUrl . '>');
} else {
$message = new Message();
$message->getHeaders()->addHeaderLine('List-Unsubscribe', '<' . $optOutUrl . '>');
$sender->withMessage($message);
}
return $body;
}
/**
* @param Result $templateResult
* @param object{variables?: stdClass, optOutLink?: bool}&stdClass $data
* @return array{?string, ?string}
*/
private function prepareSubjectBody(
Result $templateResult,
stdClass $data,
string $toEmailAddress,
Sender $sender
): array {
$subject = $templateResult->getSubject();
$body = $templateResult->getBody();
if (isset($data->variables)) {
foreach (get_object_vars($data->variables) as $key => $value) {
if (!is_string($value) && !is_int($value) && !is_float($value)) {
continue;
}
if (is_int($value) || is_float($value)) {
$value = strval($value);
} else if (!$value) {
continue;
}
$subject = str_replace('{$$' . $key . '}', $value, $subject);
$body = str_replace('{$$' . $key . '}', $value, $body);
}
}
$body = $this->applyTrackingUrlsToEmailBody($body, $toEmailAddress);
if ($data->optOutLink ?? false) {
$body = $this->applyOptOutLink($toEmailAddress, $body, $templateResult, $sender);
}
return [$subject, $body];
}
}

View File

@@ -0,0 +1,248 @@
<?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\Workflow;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Log;
use Espo\Entities\User;
use Espo\Modules\Advanced\Controllers\WorkflowLogRecord;
use Espo\Modules\Advanced\Core\WorkflowManager;
use Espo\Modules\Advanced\Entities\Workflow;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\DynamicLogic\ConditionCheckerFactory;
use Espo\Tools\DynamicLogic\Exceptions\BadCondition;
use Espo\Tools\DynamicLogic\Item as LogicItem;
use RuntimeException;
use stdClass;
class Service
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private User $user,
private WorkflowManager $workflowManager,
private Log $log,
private InjectableFactory $injectableFactory,
private ServiceContainer $serviceContainer,
) {}
/**
* @throws Error
* @throws Forbidden
* @throws NotFound
*/
public function runManual(string $id, string $targetId): TriggerResult
{
$workflow = $this->getManualWorkflow($id);
$entity = $this->getEntityForManualWorkflow($workflow, $targetId);
$this->processManualWorkflowAccess($workflow, $entity);
$this->processCheckManualWorkflowConditions($workflow, $entity);
try {
$result = $this->triggerWorkflow($entity, $workflow->getId(), true);
} catch (FormulaError $e) {
throw new Error("Formula error.", 500, $e);
}
if (!$result) {
throw new RuntimeException("No result.");
}
return $result;
}
/**
* @throws FormulaError
* @throws Error
*/
public function triggerWorkflow(Entity $entity, string $workflowId, bool $mandatory = false): ?TriggerResult
{
/** @var ?Workflow $workflow */
$workflow = $this->entityManager->getEntityById(Workflow::ENTITY_TYPE, $workflowId);
if (!$workflow) {
throw new Error("Workflow $workflowId does not exist.");
}
if (!$workflow->isActive()) {
if (!$mandatory) {
$this->log->debug("Workflow $workflowId not triggerred as it's not active.");
return null;
}
throw new Error("Workflow $workflowId is not active.");
}
if (!$this->workflowManager->checkConditions($workflow, $entity)) {
$this->log->debug("Workflow $workflowId not triggerred as conditions are not met.");
return null;
}
$workflowLogRecord = $this->entityManager->getNewEntity(WorkflowLogRecord::ENTITY_TYPE);
$workflowLogRecord->set([
'workflowId' => $workflowId,
'targetId' => $entity->getId(),
'targetType' => $entity->getEntityType()
]);
$this->entityManager->saveEntity($workflowLogRecord);
$alertObject = new stdClass();
$variables = ['__alert' => $alertObject];
$this->workflowManager->runActions($workflow, $entity, $variables);
return $this->prepareTriggerResult($alertObject);
}
/**
* @throws Forbidden
* @throws Error
*/
private function processCheckManualWorkflowConditions(Workflow $workflow, CoreEntity $entity): void
{
$conditionGroup = $workflow->getManualDynamicLogicConditionGroup();
if (
!$conditionGroup ||
!class_exists("Espo\\Tools\\DynamicLogic\\ConditionCheckerFactory")
) {
return;
}
$conditionCheckerFactory = $this->injectableFactory->create(ConditionCheckerFactory::class);
$checker = $conditionCheckerFactory->create($entity);
try {
$item = LogicItem::fromGroupDefinition($conditionGroup);
$isTrue = $checker->check($item);
} catch (BadCondition $e) {
throw new Error($e->getMessage(), 500, $e);
}
if (!$isTrue) {
throw new Forbidden("Workflow conditions are not met.");
}
}
/**
* @throws NotFound
*/
private function getEntityForManualWorkflow(Workflow $workflow, string $targetId): CoreEntity
{
$targetEntityType = $workflow->getTargetEntityType();
$entity = $this->entityManager->getRDBRepository($targetEntityType)->getById($targetId);
if (!$entity) {
throw new NotFound();
}
$this->serviceContainer->get($targetEntityType)->loadAdditionalFields($entity);
if (!$entity instanceof CoreEntity) {
throw new RuntimeException();
}
return $entity;
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function getManualWorkflow(string $id): Workflow
{
$workflow = $this->entityManager->getRDBRepositoryByClass(Workflow::class)->getById($id);
if (!$workflow) {
throw new NotFound("Workflow $id not found.");
}
if ($workflow->getType() !== Workflow::TYPE_MANUAL) {
throw new Forbidden();
}
return $workflow;
}
/**
* @throws Forbidden
*/
private function processManualWorkflowAccess(Workflow $workflow, CoreEntity $entity): void
{
if ($this->user->isPortal()) {
throw new Forbidden();
}
$accessRequired = $workflow->getManualAccessRequired();
if ($accessRequired === Workflow::MANUAL_ACCESS_ADMIN) {
if (!$this->user->isAdmin()) {
throw new Forbidden("No admin access.");
}
} else if ($accessRequired === Workflow::MANUAL_ACCESS_READ) {
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden("No read access.");
}
} else if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden("No edit access.");
}
if (!$this->user->isAdmin()) {
$teamIdList = $workflow->getLinkMultipleIdList('manualTeams');
if (array_intersect($teamIdList, $this->user->getTeamIdList()) === []) {
throw new Forbidden("User is not from allowed team.");
}
}
}
private function prepareTriggerResult(stdClass $alertObject): TriggerResult
{
$alert = null;
if (property_exists($alertObject, 'message') && is_string($alertObject->message)) {
$alert = new Alert(
message: $alertObject->message,
type: $alertObject->type ?? null,
autoClose: $alertObject->autoClose ?? false,
);
}
return new TriggerResult(
alert: $alert,
);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Workflow;
class TriggerResult
{
public function __construct(
public ?Alert $alert = null,
) {}
}