Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
124
custom/Espo/Modules/Advanced/Tools/Report/ExportService.php
Normal file
124
custom/Espo/Modules/Advanced/Tools/Report/ExportService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
custom/Espo/Modules/Advanced/Tools/Report/FormulaChecker.php
Normal file
100
custom/Espo/Modules/Advanced/Tools/Report/FormulaChecker.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
634
custom/Espo/Modules/Advanced/Tools/Report/GridExportService.php
Normal file
634
custom/Espo/Modules/Advanced/Tools/Report/GridExportService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
216
custom/Espo/Modules/Advanced/Tools/Report/GridType/Data.php
Normal file
216
custom/Espo/Modules/Advanced/Tools/Report/GridType/Data.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
367
custom/Espo/Modules/Advanced/Tools/Report/GridType/Helper.php
Normal file
367
custom/Espo/Modules/Advanced/Tools/Report/GridType/Helper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
689
custom/Espo/Modules/Advanced/Tools/Report/GridType/Result.php
Normal file
689
custom/Espo/Modules/Advanced/Tools/Report/GridType/Result.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
1476
custom/Espo/Modules/Advanced/Tools/Report/GridType/ResultHelper.php
Normal file
1476
custom/Espo/Modules/Advanced/Tools/Report/GridType/ResultHelper.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
204
custom/Espo/Modules/Advanced/Tools/Report/GridType/Util.php
Normal file
204
custom/Espo/Modules/Advanced/Tools/Report/GridType/Util.php
Normal 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;
|
||||
}
|
||||
}
|
||||
159
custom/Espo/Modules/Advanced/Tools/Report/Jobs/Send.php
Normal file
159
custom/Espo/Modules/Advanced/Tools/Report/Jobs/Send.php
Normal 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);
|
||||
}
|
||||
}
|
||||
569
custom/Espo/Modules/Advanced/Tools/Report/JointGridExecutor.php
Normal file
569
custom/Espo/Modules/Advanced/Tools/Report/JointGridExecutor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
214
custom/Espo/Modules/Advanced/Tools/Report/ListExportService.php
Normal file
214
custom/Espo/Modules/Advanced/Tools/Report/ListExportService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
84
custom/Espo/Modules/Advanced/Tools/Report/ListType/Data.php
Normal file
84
custom/Espo/Modules/Advanced/Tools/Report/ListType/Data.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
107
custom/Espo/Modules/Advanced/Tools/Report/ListType/RunParams.php
Normal file
107
custom/Espo/Modules/Advanced/Tools/Report/ListType/RunParams.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
504
custom/Espo/Modules/Advanced/Tools/Report/ReportHelper.php
Normal file
504
custom/Espo/Modules/Advanced/Tools/Report/ReportHelper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
889
custom/Espo/Modules/Advanced/Tools/Report/SelectHelper.php
Normal file
889
custom/Espo/Modules/Advanced/Tools/Report/SelectHelper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
435
custom/Espo/Modules/Advanced/Tools/Report/SendingService.php
Normal file
435
custom/Espo/Modules/Advanced/Tools/Report/SendingService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
1058
custom/Espo/Modules/Advanced/Tools/Report/Service.php
Normal file
1058
custom/Espo/Modules/Advanced/Tools/Report/Service.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
235
custom/Espo/Modules/Advanced/Tools/ReportFilter/Service.php
Normal file
235
custom/Espo/Modules/Advanced/Tools/ReportFilter/Service.php
Normal 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;
|
||||
}
|
||||
}
|
||||
719
custom/Espo/Modules/Advanced/Tools/ReportPanel/Service.php
Normal file
719
custom/Espo/Modules/Advanced/Tools/ReportPanel/Service.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
28
custom/Espo/Modules/Advanced/Tools/Workflow/Alert.php
Normal file
28
custom/Espo/Modules/Advanced/Tools/Workflow/Alert.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
661
custom/Espo/Modules/Advanced/Tools/Workflow/SendEmailService.php
Normal file
661
custom/Espo/Modules/Advanced/Tools/Workflow/SendEmailService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
248
custom/Espo/Modules/Advanced/Tools/Workflow/Service.php
Normal file
248
custom/Espo/Modules/Advanced/Tools/Workflow/Service.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user