Files
espocrm/custom/Espo/Modules/Advanced/Business/Report/EmailBuilder.php
bsiggel f95246f99f updated advanced pack to 3.12.0:
Reports:

    Non-aggregated columns in Grid report export.
    Normalized table mode for 2-dimensional Grid reports.
    Ability to create internal reports via the UI.
    Ability to show/hide and resize columns in the list report result view.
2026-02-07 16:09:20 +01:00

1559 lines
51 KiB
PHP

<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2026 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Business\Report;
use Espo\Core\Exceptions\Error;
use Espo\Core\Htmlizer\TemplateRendererFactory;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\EmailSender;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Log;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\Export\GridExportService;
use Espo\Modules\Advanced\Tools\Report\GridType\Data;
use Espo\Modules\Advanced\Tools\Report\GridType\Helper;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use Espo\ORM\EntityManager;
use Espo\Entities\Attachment;
use Espo\Entities\Preferences;
use Espo\Core\Utils\DateTime;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\TemplateFileManager;
use Exception;
use RuntimeException;
use stdClass;
class EmailBuilder
{
private DateTime $dateTime;
private Preferences $preferences;
public function __construct(
private Metadata $metadata,
private EntityManager $entityManager,
private Config $config,
private Language $language,
private TemplateFileManager $templateFileManager,
private FileStorageManager $fileStorageManager,
private Helper $gridHelper,
private GridExportService $gridExportService,
private InjectableFactory $injectableFactory,
private EmailSender $emailSender,
private TemplateRendererFactory $templateRendererFactory,
private Log $log,
) {}
private function initForUserById(string $userId): void
{
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
throw new RuntimeException('Report Sending Builder: No User with id = ' . $userId);
}
/** @var ?Preferences $preferences */
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
$this->preferences = $preferences;
$this->language = $this->injectableFactory->createWith(Language::class, [
'language' => $this->getPreference('language'),
'useCache' => $this->config->get('useCache'),
]);
$this->dateTime = new DateTime(
$this->getPreference('dateFormat'),
$this->getPreference('timeFormat'),
$this->getPreference('timeZone')
);
}
/**
* @return mixed
*/
private function getPreference(string $attribute)
{
if (
$this->preferences->get($attribute) !== null &&
$this->preferences->get($attribute) !== ''
) {
$this->preferences->get($attribute);
}
return $this->config->get($attribute);
}
/**
* @param stdClass $data
* @param GridResult|array<int, mixed> $reportResult
* @param bool $isLocal Images will be included.
* @throws Error
*/
public function buildEmailData($data, $reportResult, Report $report, bool $isLocal = false): void
{
if (!is_object($data) || !isset($data->userId)) {
throw new RuntimeException('Report Sending Builder: Not enough data for sending email.');
}
$this->initForUserById($data->userId);
$type = $report->getType();
switch ($type) {
case Report::TYPE_GRID:
case Report::TYPE_JOINT_GRID:
assert($reportResult instanceof GridResult);
$this->buildEmailGridData($data, $reportResult, $report);
break;
case Report::TYPE_LIST:
assert(is_array($reportResult));
$this->buildEmailListData($data, $reportResult, $report, $isLocal);
break;
}
}
/**
* @param stdClass $data
* @throws Error
*/
private function buildEmailGridData($data, GridResult $reportResult, Report $report): void
{
$depth = count($reportResult->getGroupByList());
if ($depth === 2) {
if ($reportResult->getTableMode() === Data::TABLE_MODE_NORMALIZED) {
$this->buildEmailGrid2NormalizedData($data, $reportResult, $report);
return;
}
$this->buildEmailGrid2Data($data, $reportResult, $report);
return;
}
$this->buildEmailGrid1Data($data, $reportResult, $report);
}
/**
* @param stdClass $data
* @param array<int, mixed> $reportResult
* @throws Error
*/
private function buildEmailListData($data, array $reportResult, Report $report, bool $isLocal): void
{
$entityType = $report->getTargetEntityType();
$columns = $report->getColumns();
$columnsData = get_object_vars($report->getColumnsData());
$fields = $this->metadata->get(['entityDefs', $entityType, 'fields']);
$columnAttributes = [];
foreach ($columns as $column) {
$columnData = $columnsData[$column] ?? null;
$attrs = [];
if (is_object($columnData)) {
if (isset($columnData->width)) {
$attrs['width'] = $columnData->width . '%';
}
if (isset($columnData->align)) {
$attrs['align'] = $columnData->align;
}
}
$columnAttributes[$column] = $attrs;
}
$columnTitles = [];
foreach ($columns as $column) {
$field = $column;
$scope = $entityType;
$isForeign = false;
if (str_contains($column, '.')) {
$isForeign = true;
[$link, $field] = explode('.', $column);
$scope = $this->metadata->get(['entityDefs', $entityType, 'links', $link, 'entity']);
$fields[$column] = $this->metadata->get(['entityDefs', $scope, 'fields', $field]);
$fields[$column]['scope'] = $scope;
$fields[$column]['isForeign'] = true;
}
$label = $this->language->translateLabel($field, 'fields', $scope);
if ($isForeign) {
$label = $this->language->translateLabel($link ?? '', 'links', $entityType) . ' . ' . $label;
}
$columnTitles[] = [
'label' => $label,
'attrs' => $columnAttributes[$column],
'value' => $label,
'isBold' => true,
];
}
$rows = [];
foreach ($reportResult as $recordKey => $record) {
foreach ($columns as $columnKey => $column) {
$type = (isset($fields[$column])) ? $fields[$column]['type'] : '';
$columnInRecord = str_replace('.', '_', $column);
$value = $record[$columnInRecord] ?? null;
$value = is_scalar($value) ? (string) $record[$columnInRecord] : '';
switch ($type) {
case FieldType::DATE:
if (!empty($value)) {
$value = $this->dateTime->convertSystemDate($value);
}
break;
case FieldType::DATETIME:
case FieldType::DATETIME_OPTIONAL:
if (!empty($value)) {
$value = $this->dateTime->convertSystemDateTime($value);
}
break;
case FieldType::LINK:
case FieldType::LINK_PARENT:
case FieldType::LINK_ONE:
if (!empty($record[$columnInRecord . 'Name'])) {
$value = $record[$columnInRecord . 'Name'];
}
break;
case FieldType::LINK_MULTIPLE:
case FieldType::ATTACHMENT_MULTIPLE:
if (!empty($record[$columnInRecord . 'Names'])) {
$value = implode(', ', array_values( (array) $record[$columnInRecord . 'Names']));
}
break;
case 'jsonArray':
break;
case FieldType::BOOL:
$value = ($value) ? '1' : '0';
break;
case FieldType::ENUM:
if (isset($fields[$column]['isForeign']) && $fields[$column]['isForeign']) {
[, $field] = explode('.', $column);
$value = $this->language->translateOption($value, $field, $fields[$column]['scope']);
}
else {
$value = $this->language->translateOption($value, $column, $entityType);
}
break;
case FieldType::INT:
$value = $this->formatInt($value);
break;
case FieldType::FLOAT:
case 'decimal':
$isCurrency = isset($fields[$column]['view']) &&
// @todo Refactor.
strpos($fields[$column]['view'], 'currency-converted');
$decimalPlaces = $fields[$column]['decimalPlaces'] ?? null;
$value = ($isCurrency) ?
$this->formatCurrency($value, null, $decimalPlaces) :
$this->formatFloat($value, $decimalPlaces);
break;
case FieldType::CURRENCY:
$decimalPlaces = $fields[$column]['decimalPlaces'] ?? null;
$value = $this->formatCurrency($value, $record[$columnInRecord . 'Currency'], $decimalPlaces);
break;
case FieldType::CURRENCY_CONVERTED:
$decimalPlaces = $fields[$column]['decimalPlaces'] ?? null;
$value = $this->formatCurrency($value, null, $decimalPlaces);
break;
case FieldType::ADDRESS:
$value = '';
if (!empty($record[$columnInRecord . 'Street'])) {
$value = $record[$columnInRecord.'Street'];
}
if (
!empty($record[$columnInRecord.'City']) ||
!empty($record[$columnInRecord.'State']) ||
!empty($record[$columnInRecord.'PostalCode'])
) {
if ($value) {
$value .= " ";
}
if (!empty($record[$columnInRecord.'City'])) {
$value .= $record[$columnInRecord.'City'];
if (
!empty($record[$columnInRecord.'State']) ||
!empty($record[$columnInRecord.'PostalCode'])
) {
$value .= ', ';
}
}
if (!empty($record[$columnInRecord.'State'])) {
$value .= $record[$columnInRecord.'State'];
if (!empty($record[$columnInRecord.'PostalCode'])) {
$value .= ' ';
}
}
if (!empty($record[$columnInRecord.'PostalCode'])) {
$value .= $record[$columnInRecord.'PostalCode'];
}
}
if (!empty($record[$columnInRecord.'Country'])) {
if ($value) {
$value .= " ";
}
$value .= $record[$columnInRecord.'Country'];
}
break;
case FieldType::ARRAY:
case FieldType::MULTI_ENUM:
case FieldType::CHECKLIST:
$value = $record[$columnInRecord] ?? [];
if (is_array($value)) {
$value = implode(", ", $value);
} else {
$value = '';
}
break;
case FieldType::IMAGE:
if (!$isLocal) {
break;
}
$attachmentId = $record[$columnInRecord . 'Id'] ?? null;
if ($attachmentId) {
/** @var ?Attachment $attachment */
$attachment = $this->entityManager
->getEntityById(Attachment::ENTITY_TYPE, $attachmentId);
if ($attachment) {
$filePath = $this->fileStorageManager->getLocalFilePath($attachment);
$value = "<img src=\"$filePath\" alt=\"image\">";
}
}
break;
}
$rows[$recordKey][$columnKey] = [
'value' => $value,
'attrs' => $columnAttributes[$column],
];
}
}
$bodyData = [
'columnList' => $columnTitles,
'rowList' => $rows,
'noDataLabel' => $this->language->translateLabel('No Data'),
];
try {
$subject = $this->renderReport($report, 'reportSendingList', 'subject');
$body = $this->renderReport($report, 'reportSendingList', 'body', $bodyData);
}
catch (Exception $e) {
$this->log->error($e->getMessage());
throw Error::createWithBody(
'emailTemplateParsingError',
Error\Body::create()
->withMessageTranslation('emailTemplateParsingError', 'Report',
['template' => 'reportSendingList'])
->encode()
);
}
$data->emailSubject = $subject;
$data->emailBody = $body;
$data->tableData = array_merge([$columnTitles], $rows);
}
/**
* @param stdClass $data
* @throws Error
*/
private function buildEmailGrid1Data($data, GridResult $reportResult, Report $report): void
{
$reportData = $reportResult->getReportData();
/** @var array<string, int> $columnDecimalPlacesMap */
$columnDecimalPlacesMap = get_object_vars($reportResult->getColumnDecimalPlacesMap());
$groupCount = count($reportResult->getGroupByList());
if ($groupCount) {
$groupName = $reportResult->getGroupByList()[0];
} else {
$groupName = '__STUB__';
}
$rows = [];
$row = [];
$row[] = ['value' => ''];
$columnTypes = [];
$hasSubListColumns = count($reportResult->getSubListColumnList()) > 0;
foreach ($reportResult->getColumnList() as $column) {
$allowedTypeList = [
'int',
'float',
'decimal',
'currency',
'currencyConverted',
];
$columnType = null;
$function = null;
if (strpos($column, ':')) {
[$function, $field] = explode(':', $column);
} else {
$field = $column;
}
$fieldEntityType = $report->getTargetEntityType();
$columnType = $reportResult->getColumnTypeMap()[$column] ?? null;
if (strpos($column, '.')) {
[$link, $field] = explode('.', $column);
$fieldEntityType = $this->metadata->get(['entityDefs', $fieldEntityType, 'links', $link, 'entity']);
}
if (!$columnType) {
if ($function === 'COUNT') {
$columnType = 'int';
} else {
$columnType = $this->metadata->get(['entityDefs', $fieldEntityType, 'fields', $field, 'type']);
}
}
$columnTypes[$column] = (in_array($columnType, $allowedTypeList)) ? $columnType : 'float';
$label = $column;
if (isset($reportResult->getColumnNameMap()[$column])) {
$label = $reportResult->getColumnNameMap()[$column];
}
$row[] = [
'value' => $label,
'isBold' => true,
];
}
$rows[] = $row;
foreach ($reportResult->getGrouping()[0] ?? [] as $gr) {
$rows[] = $this->buildEmailGrid1GroupingRow(
gr: $gr,
groupName: $groupName,
reportData: $reportData,
reportResult: $reportResult,
columnTypes: $columnTypes,
columnDecimalPlacesMap: $columnDecimalPlacesMap,
);
if ($hasSubListColumns) {
$rows = array_merge(
$rows,
$this->buildEmailGrid1SubListRowList(
gr: $gr,
reportResult: $reportResult,
columnTypes: $columnTypes,
columnDecimalPlacesMap: $columnDecimalPlacesMap,
)
);
$rows[] = $this->buildEmailGrid1GroupingRow(
gr: $gr,
groupName: $groupName,
reportData: $reportData,
reportResult: $reportResult,
columnTypes: $columnTypes,
columnDecimalPlacesMap: $columnDecimalPlacesMap,
onlyNumeric: true,
);
}
}
if ($groupCount) {
$row = [];
$totalLabel = $this->language->translateLabel('Total', 'labels', 'Report');
$row[] = [
'value' => $totalLabel,
'isBold' => true,
];
foreach ($reportResult->getColumnList() as $column) {
if (
in_array($column, $reportResult->getNumericColumnList()) &&
in_array($column, $reportResult->getAggregatedColumnList())
) {
$sum = 0;
if (isset($reportResult->getSums()->$column)) {
$sum = $reportResult->getSums()->$column;
switch ($columnTypes[$column]) {
case 'int':
$sum = $this->formatInt($sum);
break;
case 'float':
case 'decimal':
$decimalPlaces = $columnDecimalPlacesMap[$column] ?? null;
$sum = $this->formatFloat($sum, $decimalPlaces);
break;
case 'currency':
case 'currencyConverted':
$decimalPlaces = $columnDecimalPlacesMap[$column] ?? null;
$sum = $this->formatCurrency($sum, $reportResult->getCurrency(), $decimalPlaces);
break;
}
}
} else {
$sum = '';
}
$row[] = [
'value' => $sum,
'isBold' => true,
'attrs' => ['align' => 'right'],
];
}
$rows[] = $row;
} else {
foreach ($rows as &$row) {
unset($row[0]);
$row = array_values($row);
}
}
$this->prepareGrid1Result($rows, $report, $data);
}
/**
* @param array<string, string> $columnTypes
* @param array<string, int> $columnDecimalPlacesMap
* @return (array<string, mixed>)[]
*/
private function buildEmailGrid1GroupingRow(
string $gr,
string $groupName,
object $reportData,
GridResult $reportResult,
array $columnTypes,
array $columnDecimalPlacesMap,
bool $onlyNumeric = false,
): array {
$row = [];
$hasSubListColumns = count($reportResult->getSubListColumnList()) > 0;
$label = $gr;
if (empty($label)) {
$label = $this->language->translateLabel('-Empty-', 'labels', 'Report');
} else if (isset($reportResult->getGroupValueMap()[$groupName][$gr])) {
$label = $reportResult->getGroupValueMap()[$groupName][$gr];
}
if (strpos($groupName , ':')) {
[$function,] = explode(':', $groupName);
$label = $this->handleDateGroupValue($function, $label);
}
if ($hasSubListColumns && $onlyNumeric) {
$label = $this->language->translateLabel('Group Total', 'labels', 'Report');
}
$row[] = [
'value' => $label,
'isBold' => $hasSubListColumns && !$onlyNumeric,
];
foreach ($reportResult->getColumnList() as $column) {
$isNumericValue = in_array($column, $reportResult->getNumericColumnList());
if ($hasSubListColumns && !$onlyNumeric && $isNumericValue) {
$row[] = [];
continue;
}
if ($hasSubListColumns && $onlyNumeric && !$isNumericValue) {
$row[] = [];
continue;
}
if (
$isNumericValue &&
!in_array($column, $reportResult->getAggregatedColumnList())
) {
$row[] = [];
continue;
}
if ($isNumericValue) {
$value = $this->formatColumnValue(
value: $reportData->$gr->$column ?? 0,
type: $columnTypes[$column],
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
$align = 'right';
} else {
$value = $reportData->$gr->$column ?? null;
if ($value !== null) {
$value = $reportResult->getCellValueMaps()->$column->$value ?? $value;
}
$align = 'left';
}
$row[] = [
'value' => $value,
'attrs' => [
'align' => $align,
],
];
}
return $row;
}
private function formatColumnValue(
mixed $value,
?string $type,
?int $decimalPlaces = null,
?string $currency = null,
): string {
if ((!$type || $type === 'int') && $decimalPlaces !== null) {
$type = 'float';
}
return match ($type) {
'int' => $this->formatInt($value),
'float' => $this->formatFloat($value, $decimalPlaces),
'currency', 'currencyConverted' => $this->formatCurrency($value, $currency, $decimalPlaces),
default => (string) $value,
};
}
/**
* @param array<string, string> $columnTypes
* @param array<string, int> $columnDecimalPlacesMap
* @return array{value: mixed, isBold?: bool, attrs?: array<string, mixed>}[][]
*/
private function buildEmailGrid1SubListRowList(
string $gr,
GridResult $reportResult,
array $columnTypes,
array $columnDecimalPlacesMap,
): array {
$itemList = $reportResult->getSubListData()->$gr;
$rowList = [];
foreach ($itemList as $item) {
$rowList[] = $this->buildEmailGrid1SubListRow(
item: $item,
reportResult: $reportResult,
columnTypes: $columnTypes,
columnDecimalPlacesMap: $columnDecimalPlacesMap,
);
}
return $rowList;
}
/**
* @param array<string, string> $columnTypes
* @param array<string, int> $columnDecimalPlacesMap
* @return array{value: mixed, isBold?: bool, attrs?: array<string, mixed>}[]
*/
private function buildEmailGrid1SubListRow(
object $item,
GridResult $reportResult,
array $columnTypes,
array $columnDecimalPlacesMap
): array {
$row = [];
$row[] = [
'value' => '',
];
foreach ($reportResult->getColumnList() as $column) {
if (!in_array($column, $reportResult->getSubListColumnList())) {
$row[] = [
'value' => '',
];
continue;
}
$isNumericValue = in_array($column, $reportResult->getNumericColumnList());
if ($isNumericValue) {
$value = $this->formatColumnValue(
value: $item->$column ?? 0,
type: $columnTypes[$column],
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null
);
} else {
$value = $item->$column ?? '';
}
$row[] = [
'value' => $value,
'attrs' => ['align' => $isNumericValue ? 'right' : 'left'],
];
}
return $row;
}
/**
* @param stdClass $data
* @throws Error
*/
private function buildEmailGrid2NormalizedData($data, GridResult $reportResult, Report $report): void
{
$groupName1 = $reportResult->getGroupByList()[0];
$groupName2 = $reportResult->getGroupByList()[1];
/** @var array<string, int> $columnDecimalPlacesMap */
$columnDecimalPlacesMap = get_object_vars($reportResult->getColumnDecimalPlacesMap());
$rows = [];
$row = [
[
'value' => $reportResult->getGroupNameMap()[$groupName1] ?? null,
'isBold' => true,
],
[
'value' => $reportResult->getGroupNameMap()[$groupName2] ?? null,
'isBold' => true,
],
];
foreach ($reportResult->getNonSummaryColumnList() as $column) {
$row[] = [
'value' => $reportResult->getColumnNameMap()[$column] ?? null,
'isBold' => true,
];
}
foreach ($reportResult->getSummaryColumnList() as $column) {
$row[] = [
'value' => $reportResult->getColumnNameMap()[$column] ?? null,
'isBold' => true,
];
}
$rows[] = $row;
foreach ($reportResult->getGrouping()[0] ?? [] as $group) {
foreach ($reportResult->getGrouping()[1] ?? [] as $secondGroup) {
$row = [];
$row[] = [
'value' => $this->prepareGroupLabel($reportResult, $groupName1, $group),
];
$row[] = [
'value' => $this->prepareGroupLabel($reportResult, $groupName2, $secondGroup),
];
foreach ($reportResult->getNonSummaryColumnList() as $column) {
$columnGroup = $reportResult->getNonSummaryColumnGroupMap()->$column ?? null;
$columnGroupValue = $columnGroup === $reportResult->getGroupByList()[0] ?
$group : $secondGroup;
$value = $this->gridExportService->getCellDisplayValueFromResult(
groupIndex: $columnGroup === $reportResult->getGroupByList()[0] ? 0 : 1,
groupValue: $columnGroupValue,
column: $column,
reportResult: $reportResult,
);
[$value, $align] = $this->prepareColumnDisplayData(
reportResult: $reportResult,
column: $column,
value: $value,
);
$row[] = [
'value' => $value,
'attrs' => ['align' => $align],
];
}
$hasNonEmpty = false;
foreach ($reportResult->getSummaryColumnList() as $column) {
$value = $reportResult->getReportData()->$group->$secondGroup->$column ?? null;
if ($value !== null) {
$hasNonEmpty = true;
}
$value = $this->formatColumnValue(
value: $value,
type: $this->getNumericColumnType($column, $report),
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
$row[] = [
'value' => $value,
'attrs' => ['align' => 'right'],
];
}
if (!$hasNonEmpty) {
continue;
}
$rows[] = $row;
}
}
$row = [];
$row[] = [
'value' => $this->language->translateLabel('Total', 'labels', 'Report'),
'isBold' => true,
];
$row[] = [];
foreach ($reportResult->getNonSummaryColumnList() as $ignored) {
$row[] = [];
}
foreach ($reportResult->getSummaryColumnList() as $column) {
$sum = $reportResult->getSums()->$column ?? 0;
$value = $this->formatColumnValue(
value: $sum ,
type: $this->getNumericColumnType($column, $report),
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
$row[] = [
'value' => $value,
'isBold' => true,
'attrs' => ['align' => 'right'],
];
}
$rows[] = $row;
$this->prepareGrid1Result($rows, $report, $data);
}
/**
* @param stdClass $data
* @throws Error
*/
private function buildEmailGrid2Data($data, GridResult $reportResult, Report $report): void
{
$reportData = $reportResult->getReportData();
/** @var array<string, int> $columnDecimalPlacesMap */
$columnDecimalPlacesMap = get_object_vars($reportResult->getColumnDecimalPlacesMap());
$specificColumn = $data->specificColumn ?? null;
$grids = [];
foreach ($reportResult->getSummaryColumnList() as $column) {
$groupName1 = $reportResult->getGroupByList()[0];
$groupName2 = $reportResult->getGroupByList()[1];
if ($specificColumn && $specificColumn !== $column) {
continue;
}
$group1NonSummaryColumnList = $reportResult->getGroup1NonSummaryColumnList() ?? [];
$group2NonSummaryColumnList = $reportResult->getGroup2NonSummaryColumnList() ?? [];
$columnType = $this->getNumericColumnType($column, $report);
$columnTypes[$column] = $columnType;
$grid = [];
$row = [];
$row[] = [];
foreach ($group2NonSummaryColumnList as $c) {
$text = $reportResult->getColumnNameMap()[$c];
$row[] = ['value' => $text];
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$label = $this->prepareGroupLabel($reportResult, $groupName1, $gr1);
$row[] = ['value' => $label];
}
if ($reportResult->getGroup2Sums() !== null) {
$row[] = [
'value' => $this->language->translateLabel('Total', 'labels', 'Report'),
'isBold' => true,
];
}
$grid[] = $row;
foreach ($group1NonSummaryColumnList as $c) {
$row = [];
$text = $reportResult->getColumnNameMap()[$c];
$row[] = ['value' => $text];
foreach ($group2NonSummaryColumnList as $ignored3) {
$row[] = [];
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$value = $this->gridExportService->getCellDisplayValueFromResult(
groupIndex: 0,
groupValue: $gr1,
column: $c,
reportResult: $reportResult,
);
[$value, $align] = $this->prepareColumnDisplayData(
reportResult: $reportResult,
column: $c,
value: $value,
);
$row[] = [
'value' => $value,
'attrs' => ['align' => $align],
];
}
$row[] = [];
$grid[] = $row;
}
foreach ($reportResult->getGrouping()[1] ?? [] as $gr2) {
$row = [];
$label = $gr2;
if (empty($label)) {
$label = $this->language->translateLabel('-Empty-', 'labels', 'Report');
} else if (isset($reportResult->getGroupValueMap()[$groupName2][$gr2])) {
$label = $reportResult->getGroupValueMap()[$groupName2][$gr2];
}
if (strpos($groupName2 , ':')) {
[$function, $field] = explode(':', $groupName2);
$label = $this->handleDateGroupValue($function, $label);
}
$row[] = [
'value' => $label,
'isBold' => true,
];
foreach ($group2NonSummaryColumnList as $c) {
$value = $this->gridExportService->getCellDisplayValueFromResult(
groupIndex: 1,
groupValue: $gr2,
column: $c,
reportResult: $reportResult,
);
[$value, $align] = $this->prepareColumnDisplayData(
reportResult: $reportResult,
column: $c,
value: $value,
);
$row[] = [
'value' => $value,
'attrs' => ['align' => $align],
];
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$value = 0;
if (isset($reportData->$gr1->$gr2->$column)) {
$value = $reportData->$gr1->$gr2->$column;
$value = $this->formatColumnValue(
value: $value,
type: $columnTypes[$column],
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
}
$row[] = [
'value' => $value,
'attrs' => ['align' => 'right'],
];
}
if ($reportResult->getGroup2Sums() !== null) {
$value = $reportResult->getGroup2Sums()->$gr2->$column;
$value = $this->formatColumnValue(
value: $value ?? 0,
type: $columnTypes[$column],
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
$row[] = [
'value' => $value,
'attrs' => ['align' => 'right'],
'isBold' => true,
];
}
$grid[] = $row;
}
$row = [];
$row[] = [
'value' => $this->language->translateLabel('Total', 'labels', 'Report'),
'isBold' => true,
];
foreach ($group2NonSummaryColumnList as $ignored) {
$row[] = ['value' => ''];
}
foreach ($reportResult->getGrouping()[0] ?? [] as $gr1) {
$sum = 0;
if (
!empty($reportResult->getGroup1Sums()->$gr1) &&
!empty($reportResult->getGroup1Sums()->$gr1->$column)
) {
$sum = $reportResult->getGroup1Sums()->$gr1->$column;
$sum = $this->formatColumnValue(
value: $sum ,
type: $columnTypes[$column],
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
}
$row[] = [
'value' => $sum,
'isBold' => true,
'attrs' => ['align' => 'right'],
];
}
if ($reportResult->getGroup2Sums() !== null) {
$value = $reportResult->getSums()->$column;
$value = $this->formatColumnValue(
value: $value ?? 0,
type: $columnTypes[$column],
decimalPlaces: $columnDecimalPlacesMap[$column] ?? null,
currency: $reportResult->getCurrency(),
);
$row[] = [
'value' => $value,
'attrs' => ['align' => 'right'],
'isBold' => true,
];
}
$grid[] = $row;
$rows = $grid;
$grids[] = [
'rowList' => $rows,
'header' => $reportResult->getColumnNameMap()[$column],
];
}
$bodyData = ['gridList' => $grids];
try {
$subject = $this->renderReport($report, 'reportSendingGrid2', 'subject');
$body = $this->renderReport($report, 'reportSendingGrid2', 'body', $bodyData);
} catch (Exception $e) {
$this->log->error($e->getMessage());
throw Error::createWithBody(
'emailTemplateParsingError',
Error\Body::create()
->withMessageTranslation('emailTemplateParsingError', 'Report',
['template' => 'reportSendingGrid2'])
->encode()
);
}
$data->emailSubject = $subject;
$data->emailBody = $body;
if (count($grids)) {
$data->tableData = $grids[0]['rowList'];
} else {
$data->tableData = [];
}
}
public function sendEmail(
string $userId,
string $emailSubject,
string $emailBody,
?string $attachmentId,
): void {
if (!$emailSubject || !$emailBody) {
throw new RuntimeException('Not enough data for sending report email.');
}
$user = $this->entityManager->getEntity('User', $userId);
if (!$user) {
throw new RuntimeException('Report Sending Builder[sendEmail]: No user with id ' . $userId);
}
$emailAddress = $user->get('emailAddress');
if (!$emailAddress) {
throw new RuntimeException('Report Sending Builder[sendEmail]: User has no email address');
}
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$email->set([
'to' => $emailAddress,
'subject' => $emailSubject,
'body' => $emailBody,
'isHtml' => true,
]);
$attachmentList = [];
if ($attachmentId) {
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $attachmentId);
if ($attachment) {
$attachmentList[] = $attachment;
}
}
try {
$this->emailSender
->create()
->withAttachments($attachmentList)
->send($email);
}
catch (Exception $e) {
if (isset($attachment)) {
$this->entityManager->removeEntity($attachment);
}
throw new RuntimeException("Report Email Sending:" . $e->getMessage());
}
if (isset($attachment)) {
$this->entityManager->removeEntity($attachment);
}
}
/**
* @param string|numeric $value
*/
private function formatCurrency($value, ?string $currency = null, ?int $decimalPlaces = null): string
{
if ($value === "") {
return $value;
}
$userThousandSeparator = $this->getPreference('thousandSeparator');
$userDecimalMark = $this->getPreference('decimalMark');
// @todo Revise.
$currencyFormat = (int) $this->config->get('currencyFormat');
if (!$currency) {
// @todo Support currency defined in the report result.
$currency = $this->config->get('defaultCurrency');
}
if ($currencyFormat) {
$pad = $decimalPlaces ?? ((int) $this->config->get('currencyDecimalPlaces'));
$value = number_format(floatval($value), $pad, $userDecimalMark, $userThousandSeparator);
} else {
$value = $this->formatFloat($value, $decimalPlaces);
}
switch ($currencyFormat) {
case 1:
$value = $value . ' ' . $currency;
break;
case 2:
$currencySign = $this->metadata->get(['app', 'currency', 'symbolMap', $currency]);
$value = $currencySign . $value;
break;
case 3:
$currencySign = $this->metadata->get(['app', 'currency', 'symbolMap', $currency]);
$value = $value . ' ' . $currencySign;
break;
}
return $value;
}
/**
* @param string|int $value
*/
private function formatInt($value): string
{
if ($value === '') {
return $value;
}
$userThousandSeparator = $this->getPreference('thousandSeparator');
$userDecimalMark = $this->getPreference('decimalMark');
return number_format(floatval($value), 0, $userDecimalMark, $userThousandSeparator);
}
/**
* @param string|float|int $value
*/
private function formatFloat($value, ?int $decimalPlaces = null): string
{
if ($value === '') {
return $value;
}
if ($value === 0 || $value === 0.0) {
return '0';
}
$userThousandSeparator = $this->getPreference('thousandSeparator');
$userDecimalMark = $this->getPreference('decimalMark');
$valueString = number_format(floatval($value), $decimalPlaces ?? 8, $userDecimalMark, $userThousandSeparator);
if ($decimalPlaces !== null) {
return $valueString;
}
return rtrim(rtrim($valueString, '0'), $userDecimalMark);
}
/**
* @param array<string, mixed> $data
*/
private function renderReport(
Report $entity,
string $templateName,
string $type,
array $data = []
): string {
$template = $this->templateFileManager->getTemplate($templateName, $type, null, 'Advanced');
$template = str_replace(["\n", "\r"], '', $template);
return $this->templateRendererFactory
->create()
->setTemplate($template)
->setEntity($entity)
->setSkipInlineAttachmentHandling()
->setData($data)
->render();
}
private function handleDateGroupValue(string $function, string $value): string
{
if ($function === 'MONTH') {
[$year, $month] = explode('-', $value);
$monthNamesShort = $this->language->get('Global.lists.monthNamesShort');
$monthLabel = $monthNamesShort[intval($month) - 1];
return $monthLabel . ' ' . $year;
}
if ($function === 'DAY') {
return $this->dateTime->convertSystemDate($value);
}
if ($function === 'QUARTER') {
return $value;
}
if ($function === 'YEAR_FISCAL') {
return $value . '-' . ((string) ((int) $value + 1));
}
return $value;
}
/**
* @param array<string, mixed>[][] $rows
* @throws Error
*/
private function prepareGrid1Result(array $rows, Report $report, stdClass $data): void
{
$bodyData = [
'rowList' => $rows,
];
try {
$subject = $this->renderReport($report, 'reportSendingGrid1', 'subject');
$body = $this->renderReport($report, 'reportSendingGrid1', 'body', $bodyData);
} catch (Exception $e) {
$this->log->error($e->getMessage());
throw Error::createWithBody(
'emailTemplateParsingError',
Error\Body::create()
->withMessageTranslation('emailTemplateParsingError', 'Report',
['template' => 'reportSendingGrid1'])
->encode()
);
}
$data->emailSubject = $subject;
$data->emailBody = $body;
$data->tableData = $rows;
}
/**
* @param GridResult $reportResult
*/
private function prepareGroupLabel(GridResult $reportResult, string $groupName, string $group): string
{
$label = $group;
if (empty($label)) {
$label = $this->language->translateLabel('-Empty-', 'labels', 'Report');
} else if (!empty($reportResult->getGroupValueMap()[$groupName][$group])) {
$label = $reportResult->getGroupValueMap()[$groupName][$group];
}
if (strpos($groupName, ':')) {
[$function,] = explode(':', $groupName);
$label = $this->handleDateGroupValue($function, $label);
}
return $label;
}
/**
* @param GridResult $reportResult
* @return array{string, string}
*/
private function prepareColumnDisplayData(
GridResult $reportResult,
string $column,
mixed $value,
): array {
$cDat = $this->gridHelper->getDataFromColumnName(
entityType: $reportResult->getEntityType(),
column: $column,
result: $reportResult,
);
/** @var array<string, int> $columnDecimalPlacesMap */
$columnDecimalPlacesMap = get_object_vars($reportResult->getColumnDecimalPlacesMap());
$align = 'left';
switch ($cDat->fieldType) {
case 'int':
$value = $this->formatInt($value);
$align = 'right';
break;
case 'float':
case 'decimal':
$decimalPlace = $columnDecimalPlacesMap[$column] ?? null;
$value = $this->formatFloat($value, $decimalPlace);
$align = 'right';
break;
case 'currency':
case 'currencyConverted':
$decimalPlace = $columnDecimalPlacesMap[$column] ?? null;
$value = $this->formatCurrency($value, null, $decimalPlace);
$align = 'right';
break;
}
return [$value, $align];
}
private function getNumericColumnType(string $column, Report $report): string
{
$columnType = 'int';
if (strpos($column, ':')) {
[$f, $field] = explode(':', $column);
if ($f !== 'COUNT') {
$columnType = $this->metadata
->get(['entityDefs', $report->getTargetEntityType(), 'fields', $field, 'type']);
}
}
if ($columnType === 'float') {
// @todo Refactor.
$view = $this->metadata
->get(['entityDefs', $report->getTargetEntityType(), 'fields', $field ?? $column, 'view']);
if ($view && strpos($view, 'currency-converted')) {
$columnType = 'currencyConverted';
}
}
$allowedTypeList = [
'int',
'float',
'decimal',
'currency',
'currencyConverted',
];
if (!in_array($columnType, $allowedTypeList)) {
$columnType = 'int';
}
return $columnType;
}
}