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.
This commit is contained in:
2026-02-07 16:09:20 +01:00
parent 26db904407
commit f95246f99f
384 changed files with 6184 additions and 3643 deletions

View File

@@ -0,0 +1,80 @@
<?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\Tools\Report\Export;
use Espo\Modules\Advanced\Tools\Report\GridType\Helper;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
use RuntimeException;
use stdClass;
class CellValueHelper
{
public function __construct(
private Helper $gridHelper,
) {}
/**
* Only for Grid-2.
*/
public function getCellDisplayValueFromResult(
int $groupIndex,
string $groupValue,
string $column,
GridResult $reportResult,
): mixed {
$groupName = $reportResult->getGroupByList()[$groupIndex];
$dataMap = $reportResult->getNonSummaryData()->$groupName ?? null;
if (!$dataMap instanceof stdClass) {
throw new RuntimeException("No non-summary data for the group '$groupName'.");
}
$value = '';
if ($this->gridHelper->isColumnNumeric($column, $reportResult)) {
$value = 0;
}
if (
property_exists($dataMap, $groupValue) &&
property_exists($dataMap->$groupValue, $column)
) {
$value = $dataMap->$groupValue->$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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
<?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\Tools\Report\Export;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Language;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\CellFunction;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\CellType;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\DataCell;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\DataRow;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\DateFunction;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\RowType;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\SheetData;
use Espo\Modules\Advanced\Tools\Report\GridType\Result;
use RuntimeException;
use stdClass;
class Grid1DataBuilder
{
private const PRECISION = 2;
public function __construct(
private Language $language,
) {}
public function build(Result $reportResult): SheetData
{
$hasSubListColumns = $reportResult->getSubListColumnList() !== [];
$groupCount = count($reportResult->getGroupByList());
$groupName = $reportResult->getGroupByList()[0] ?? '__STUB__';
$rows = [];
$cells = [
new DataCell(
value: $reportResult->getGroupNameMap()[$groupName] ?? null,
type: CellType::HeadGroup,
),
];
foreach ($reportResult->getColumnList() as $column) {
$cells[] = new DataCell(
value: $reportResult->getColumnNameMap()[$column] ?? null,
type: $this->isNonSummary($reportResult, $column) ? CellType::HeadNonSummary : CellType::HeadSummary,
);
}
$rows[] = new DataRow(
cells: $cells,
type: RowType::Header,
);
foreach ($reportResult->getGrouping()[0] ?? [] as $group) {
$itemRows = $this->buildGroupRows($reportResult, $groupName, $group);
$rows = [...$rows, ...$itemRows];
}
if ($groupCount) {
$cells = [];
$cells[] = new DataCell(
value: $this->language->translateLabel('Total', 'labels', Report::ENTITY_TYPE),
type: CellType::Label,
);
foreach ($reportResult->getColumnList() as $column) {
if (
!in_array($column, $reportResult->getNumericColumnList()) ||
!in_array($column, $reportResult->getAggregatedColumnList())
) {
$cells[] = new DataCell(
value: null,
type: CellType::Empty,
);
continue;
}
$decimalPlaces = $reportResult->getColumnDecimalPlacesMap()->$column ?? null;
$fieldType = $reportResult->getColumnTypeMap()[$column] ?? null;
$function = Grid2NormalizedDataBuilder::getCellFunction($column);
if ($fieldType === FieldType::INT && $function === CellFunction::Avg) {
$decimalPlaces = self::PRECISION;
}
$value = $reportResult->getSums()->$column ?? 0;
$cells[] = new DataCell(
value: $value,
type: CellType::Total,
fieldType: $fieldType,
function: !$hasSubListColumns ? $function : null,
decimalPlaces: $decimalPlaces,
);
}
$rows[] = new DataRow(
cells: $cells,
type: RowType::Total,
);
}
$hasTotalFunctions = !$hasSubListColumns && $groupCount;
return new SheetData(
rows: $rows,
firstSummaryRowNumber: $hasTotalFunctions ? 1 : null,
lastSummaryRowNumber: $hasTotalFunctions ? count($rows) - 2 : null,
);
}
private function isNonSummary(Result $reportResult, string $column): bool
{
return in_array($column, $reportResult->getNonSummaryColumnList());
}
/**
* @return DataRow[]
*/
private function buildGroupRows(Result $reportResult, string $groupName, mixed $group): array
{
$hasSubListColumns = $reportResult->getSubListColumnList() !== [];
$rows = [];
if (!$hasSubListColumns) {
return [
$this->buildGroupTotalRow($reportResult, $groupName, $group, true, true)
];
}
$rows[] = $this->buildGroupTotalRow($reportResult, $groupName, $group, false);
foreach ($this->buildSubRows($reportResult, $group) as $row) {
$rows[] = $row;
}
$rows[] = $this->buildGroupTotalRow($reportResult, $groupName, $group, true);
return $rows;
}
private function buildGroupTotalRow(
Result $reportResult,
string $groupName,
mixed $group,
bool $onlyNumeric,
bool $full = false,
): DataRow {
$hasSubListColumns = $reportResult->getSubListColumnList() !== [];
$cells = [];
if (!$onlyNumeric || $full) {
$cells[] = new DataCell(
value: $this->prepareGroupValue($reportResult, $groupName, $group),
type: CellType::NonSummary,
dateFunction: $this->getDateFunction($groupName),
);
} else {
$cells[] = new DataCell(
value: $this->language->translateLabel('Group Total', 'labels', 'Report'),
type: CellType::Label,
);
}
foreach ($reportResult->getColumnList() as $column) {
$isNumericValue = in_array($column, $reportResult->getNumericColumnList());
if (
$hasSubListColumns && !$onlyNumeric && $isNumericValue ||
$hasSubListColumns && $onlyNumeric && !$isNumericValue ||
$isNumericValue && !in_array($column, $reportResult->getAggregatedColumnList())
) {
$cells[] = new DataCell(
value: null,
type: CellType::Empty,
);
continue;
}
if ($isNumericValue) {
$value = $reportResult->getReportData()->$group->$column ?? 0;
$cells[] = new DataCell(
value: $value,
type: CellType::Summary,
fieldType: $reportResult->getColumnTypeMap()[$column] ?? null,
decimalPlaces: $reportResult->getColumnDecimalPlacesMap()->$column ?? null,
);
} else {
$value = $reportResult->getReportData()->$group->$column ?? null;
$cells[] = new DataCell(
value: $value,
type: CellType::Summary,
fieldType: $reportResult->getColumnTypeMap()[$column] ?? null,
);
}
}
return new DataRow(
cells: $cells,
type: $full || $onlyNumeric ? RowType::DataRow : RowType::HeadDataRow,
);
}
private function prepareGroupValue(Result $reportResult, string $groupName, mixed $group): mixed
{
if ($group) {
$label = $reportResult->getGroupValueMap()[$groupName][$group] ?? $group;
} else {
$label = $this->language->translateLabel('-Empty-', 'labels', 'Report');
}
return $label;
}
private function getDateFunction(string $column): ?DateFunction
{
return Grid2NormalizedDataBuilder::getDateFunction($column);
}
/**
* @return DataRow[]
*/
private function buildSubRows(Result $reportResult, mixed $group): array
{
$rows = [];
foreach ($reportResult->getSubListData()->$group ?? [] as $item) {
if (!$item instanceof stdClass) {
throw new RuntimeException("Bad sub-list item.");
}
$rows[] = $this->buildSubRowItem($reportResult, $group, $item);
}
return $rows;
}
private function buildSubRowItem(Result $reportResult, mixed $group, stdClass $item): DataRow
{
$cells = [];
$cells[] = new DataCell(
value: null,
type: CellType::Empty,
);
foreach ($reportResult->getColumnList() as $column) {
if (!in_array($column, $reportResult->getSubListColumnList())) {
$cells[] = new DataCell(
value: null,
type: CellType::Empty,
);
continue;
}
$isNumericValue = in_array($column, $reportResult->getNumericColumnList());
if ($isNumericValue) {
$value = $item->$column ?? 0;
$cells[] = new DataCell(
value: $value,
type: CellType::NonSummary,
fieldType: $reportResult->getColumnTypeMap()[$column] ?? null,
decimalPlaces: $reportResult->getColumnDecimalPlacesMap()->$column ?? null,
isNumeric: true,
);
} else {
$value = $item->$column ?? null;
$cells[] = new DataCell(
value: $value,
type: CellType::NonSummary,
fieldType: $reportResult->getColumnTypeMap()[$column] ?? null,
isNumeric: false,
);
}
}
return new DataRow(
cells: $cells,
type: RowType::SubDataRow,
);
}
}

View File

@@ -0,0 +1,267 @@
<?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\Tools\Report\Export;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Language;
use Espo\Modules\Advanced\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\CellFunction;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\CellType;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\DataCell;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\DataRow;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\DateFunction;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\SheetData;
use Espo\Modules\Advanced\Tools\Report\Export\Xlsx\RowType;
use Espo\Modules\Advanced\Tools\Report\GridType\Result;
use Espo\Modules\Advanced\Tools\Report\GridType\Result as GridResult;
class Grid2NormalizedDataBuilder
{
private const PRECISION = 2;
public function __construct(
private Language $language,
private CellValueHelper $cellValueHelper,
) {}
public function build(Result $reportResult): SheetData
{
$groupName1 = $reportResult->getGroupByList()[0];
$groupName2 = $reportResult->getGroupByList()[1];
$rows = [];
$cells = [
new DataCell(
value: $reportResult->getGroupNameMap()[$groupName1] ?? null,
type: CellType::HeadGroup,
),
new DataCell(
value: $reportResult->getGroupNameMap()[$groupName2] ?? null,
type: CellType::HeadGroup,
),
];
foreach ($reportResult->getNonSummaryColumnList() as $column) {
$cells[] = new DataCell(
value: $reportResult->getColumnNameMap()[$column] ?? null,
type: CellType::HeadNonSummary,
);
}
foreach ($reportResult->getSummaryColumnList() as $column) {
$cells[] = new DataCell(
value: $reportResult->getColumnNameMap()[$column] ?? null,
type: CellType::HeadSummary,
);
}
$rows[] = new DataRow(
cells: $cells,
type: RowType::Header,
);
foreach ($reportResult->getGrouping()[0] ?? [] as $group) {
foreach ($reportResult->getGrouping()[1] ?? [] as $secondGroup) {
$cells = [];
$cells[] = new DataCell(
value: $this->prepareGroupValue($reportResult, $groupName1, $group),
type: CellType::NonSummary,
dateFunction: self::getDateFunction($groupName1),
);
$cells[] = new DataCell(
value: $this->prepareGroupValue($reportResult, $groupName2, $secondGroup),
type: CellType::NonSummary,
dateFunction: self::getDateFunction($groupName2),
);
foreach ($reportResult->getNonSummaryColumnList() as $column) {
$columnGroup = $reportResult->getNonSummaryColumnGroupMap()->$column ?? null;
$columnGroupValue = $columnGroup === $reportResult->getGroupByList()[0] ?
$group : $secondGroup;
$cells[] = new DataCell(
value: $this->getCellDisplayValueFromResult(
groupIndex: $columnGroup === $reportResult->getGroupByList()[0] ? 0 : 1,
groupValue: $columnGroupValue,
column: $column,
reportResult: $reportResult,
),
type: CellType::NonSummary,
fieldType: $reportResult->getColumnTypeMap()[$column] ?? null,
decimalPlaces: $reportResult->getColumnDecimalPlacesMap()->$column ?? null,
);
}
$hasNonEmpty = false;
foreach ($reportResult->getSummaryColumnList() as $column) {
$value = $reportResult->getReportData()->$group->$secondGroup->$column ?? null;
if ($value !== null) {
$hasNonEmpty = true;
}
$decimalPlaces = $reportResult->getColumnDecimalPlacesMap()->$column ?? null;
$fieldType = $reportResult->getColumnTypeMap()[$column] ?? null;
$cells[] = new DataCell(
value: $value,
type: CellType::Summary,
fieldType: $fieldType,
decimalPlaces: $decimalPlaces,
);
}
if (!$hasNonEmpty) {
continue;
}
$rows[] = new DataRow(
cells: $cells,
type: RowType::DataRow,
);
}
}
$cells = [];
$cells[] = new DataCell(
value: $this->language->translateLabel('Total', 'labels', Report::ENTITY_TYPE),
type: CellType::Label,
);
$cells[] = new DataCell(
value: null,
type: CellType::Empty,
);
foreach ($reportResult->getNonSummaryColumnList() as $ignored) {
$cells[] = new DataCell(
value: null,
type: CellType::Empty,
);
}
foreach ($reportResult->getSummaryColumnList() as $column) {
$value = $reportResult->getSums()->$column ?? 0;
$decimalPlaces = $reportResult->getColumnDecimalPlacesMap()->$column ?? null;
$fieldType = $reportResult->getColumnTypeMap()[$column] ?? null;
$function = self::getCellFunction($column);
if ($fieldType === FieldType::INT && $function === CellFunction::Avg) {
$decimalPlaces = self::PRECISION;
}
$cells[] = new DataCell(
value: $value,
type: CellType::Total,
fieldType: $fieldType,
function: $function,
decimalPlaces: $decimalPlaces,
);
}
$rows[] = new DataRow(
cells: $cells,
type: RowType::Total,
);
return new SheetData(
rows: $rows,
firstSummaryRowNumber: 1,
lastSummaryRowNumber: count($rows) - 2,
);
}
private function getCellDisplayValueFromResult(
int $groupIndex,
string $groupValue,
string $column,
GridResult $reportResult,
): mixed {
return $this->cellValueHelper->getCellDisplayValueFromResult(
groupIndex: $groupIndex,
groupValue: $groupValue,
column: $column,
reportResult: $reportResult,
);
}
private function prepareGroupValue(Result $reportResult, string $groupName, mixed $group): string
{
if (!$group) {
return $this->language->translateLabel('-Empty-', 'labels', 'Report');
}
if (isset($reportResult->getGroupValueMap()[$groupName][$group])) {
return $reportResult->getGroupValueMap()[$groupName][$group];
}
return (string) $group;
}
public static function getCellFunction(string $column): ?CellFunction
{
[$function] = explode(':', $column);
if ($function === 'COUNT') {
return CellFunction::Sum;
}
if ($function === 'SUM') {
return CellFunction::Sum;
}
if ($function === 'AVG') {
return CellFunction::Avg;
}
if ($function === 'MIN') {
return CellFunction::Min;
}
if ($function === 'MAX') {
return CellFunction::Max;
}
return null;
}
public static function getDateFunction(string $column): ?DateFunction
{
if (!str_contains($column, ':')) {
return null;
}
[$f,] = explode(':', $column);
return match ($f) {
'MONTH' => DateFunction::Month,
'YEAR' => DateFunction::Year,
'DAY' => DateFunction::Day,
default => null,
};
}
}

View File

@@ -0,0 +1,696 @@
<?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\Tools\Report\Export;
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\Field\LinkParent;
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\Entities\Report;
use Espo\Modules\Advanced\Tools\Report\GridType\Data as GridTypeData;
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\Advanced\Tools\Report\Service;
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\Exception as PhpSpreadsheetException;
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,
private PdfService $pdfService,
private Grid2NormalizedDataBuilder $grid2NormalizedDataBuilder,
private Grid1DataBuilder $grid1DataBuilder,
private CellValueHelper $cellValueHelper,
) {}
/**
* @throws Forbidden
* @throws NotFound
* @throws Error
* @throws BadRequest
*/
public function exportXlsx(string $id, ?WhereItem $where, ?User $user = null): string
{
$report = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($id);
if (!$report) {
throw new NotFound();
}
$this->checkAccess($report, $user);
$contents = $this->buildXlsxContents($id, $where, $user);
$mimeType = $this->metadata->get(['app', 'export', 'formatDefs', 'xlsx', 'mimeType']);
$fileName = $this->prepareXlsxFileName($report);
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew();
$attachment
->setName($fileName)
->setRole(Attachment::ROLE_EXPORT_FILE)
->setType($mimeType)
->setContents($contents)
->setRelated(LinkParent::createFromEntity($report));
$this->entityManager->saveEntity($attachment);
return $attachment->getId();
}
/**
* @throws Error
* @throws Forbidden
* @throws NotFound
* @throws BadRequest
*/
public function buildXlsxContents(string $id, ?WhereItem $where, ?User $user = null): string
{
$report = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($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);
}
$reportResult ??= $this->service->runGrid($id, $where, $user);
$result = [];
$sheetData = null;
if ($groupCount === 2 && $reportResult->getTableMode() !== GridTypeData::TABLE_MODE_NORMALIZED) {
foreach ($reportResult->getSummaryColumnList() as $column) {
$result[] = $this->getGrid2ResultForExport($reportResult, $column);
}
} else if ($groupCount === 2 && $reportResult->getTableMode() === GridTypeData::TABLE_MODE_NORMALIZED) {
$sheetData = $this->grid2NormalizedDataBuilder->build($reportResult);
} else if ($groupCount === 1 && $reportResult->getSubListColumnList()) {
$sheetData = $this->grid1DataBuilder->build($reportResult);
} else {
$result[] = $this->getGrid1ResultForExport($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] ?? null;
}
}
$exportParams = [
'exportName' => $report->getName(),
'columnList' => $columnList,
'columnTypes' => $columnTypes,
'chartType' => $reportResult->getChartType() ?? $report->getChartType(),
'groupByList' => $groupByList,
'columnLabels' => $columnLabels,
'reportResult' => $reportResult,
'groupLabel' => '',
'currency' => $reportResult->getCurrency(),
];
if ($groupCount) {
$group = $groupByList[$groupCount - 1];
$exportParams['groupLabel'] = $reportResult->getGroupNameMap()[$group] ??
$this->gridUtil->translateGroupName($entityType, $group);
}
$export = $this->injectableFactory->create(ExportXlsx::class);
if ($sheetData) {
try {
return $export->processWithSheedData($reportResult, $sheetData, $report);
} catch (PhpSpreadsheetException $e) {
throw new RuntimeException($e->getMessage(), previous: $e);
}
}
try {
return $export->process($entityType, $exportParams, $result);
} catch (PhpSpreadsheetException $e) {
throw new RuntimeException($e->getMessage(), previous: $e);
}
}
/**
* @throws Forbidden
* @throws NotFound
* @throws Error
* @throws BadRequest
*/
public function exportCsv(
string $id,
?WhereItem $where,
?string $column = null,
?User $user = null,
): string {
$report = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($id);
if (!$report) {
throw new NotFound();
}
$this->checkAccess($report, $user);
$contents = $this->getGridReportCsv($id, $where, $column, $user);
$mimeType = $this->metadata->get(['app', 'export', 'formatDefs', 'csv', 'mimeType']);
$fileName = $this->prepareCsvFileName($report);
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew();
$attachment
->setName($fileName)
->setRole(Attachment::ROLE_EXPORT_FILE)
->setType($mimeType)
->setContents($contents)
->setRelated(LinkParent::createFromEntity($report));
$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: $id,
where: $where,
currentColumn: $column,
user: $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,
): array {
$reportResult = $this->service->runGrid($id, $where, $user);
$depth = count($reportResult->getGroupByList());
if ($depth === 2 && $reportResult->getTableMode() === GridTypeData::TABLE_MODE_NORMALIZED) {
$sheetData = $this->grid2NormalizedDataBuilder->build($reportResult);
return $this->sheetDataToRaw($sheetData);
}
if ($depth === 1 && $reportResult->getSubListColumnList()) {
$sheetData = $this->grid1DataBuilder->build($reportResult);
return $this->sheetDataToRaw($sheetData);
}
if ($depth === 2) {
return $this->getGrid2ResultForExport($reportResult, $currentColumn);
}
if ($depth === 1 || $depth === 0) {
return $this->getGrid1ResultForExport($reportResult);
}
throw new RuntimeException();
}
public function getCellDisplayValueFromResult(
int $groupIndex,
string $groupValue,
string $column,
GridResult $reportResult,
): mixed {
return $this->cellValueHelper->getCellDisplayValueFromResult(
groupIndex: $groupIndex,
groupValue: $groupValue,
column: $column,
reportResult: $reportResult,
);
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function exportPdf(
string $id,
?WhereItem $where,
string $templateId,
?User $user = null,
): string {
$report = $this->entityManager->getRDBRepositoryByClass(Report::class)->getById($id);
$template = $this->entityManager->getRDBRepositoryByClass(Template::class)->getById($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,
];
$contents = $this->pdfService
->generate(
Report::ENTITY_TYPE,
$report->getId(),
$template->getId(),
null,
Data::create()->withAdditionalTemplateData((object) $additionalData)
)
->getString();
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew();
$attachment
->setRole(Attachment::ROLE_EXPORT_FILE)
->setType('application/pdf')
->setContents($contents)
->setRelated(LinkParent::createFromEntity($report));
$this->entityManager->saveEntity($attachment);
return $attachment->getId();
}
/**
* @return array<int, mixed>[]
*/
private function getGrid2ResultForExport(GridResult $reportResult, ?string $currentColumn): array
{
$result = [];
$reportData = $reportResult->getReportData();
$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;
}
return $result;
}
/**
* @return array<int, mixed>[]
*/
private function getGrid1ResultForExport(GridResult $reportResult): array
{
$result = [];
$depth = count($reportResult->getGroupByList());
$reportData = $reportResult->getReportData();
$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;
}
private function prepareXlsxFileName(Report $report): string
{
$name = preg_replace("/([^\w\s\d\-_~,;:\[\]().])/u", '_', $report->getName()) . ' ' . date('Y-m-d');
$fileExtension = $this->metadata->get(['app', 'export', 'formatDefs', 'xlsx', 'fileExtension']);
return $name . '.' . $fileExtension;
}
private function prepareCsvFileName(Report $report): string
{
$name = preg_replace("/([^\w\s\d\-_~,;:\[\]().])/u", '_', $report->getName()) . ' ' . date('Y-m-d');
$fileExtension = $this->metadata->get(['app', 'export', 'formatDefs', 'csv', 'fileExtension']);
return $name . '.' . $fileExtension;
}
/**
* @throws Forbidden
*/
private function checkAccess(Report $report, ?User $user): void
{
if ($user && !$this->aclManager->checkEntityRead($user, $report)) {
throw new Forbidden();
}
}
/**
* @return array<int, array<int, mixed>>
*/
private function sheetDataToRaw(Xlsx\SheetData $sheetData): array
{
$rows = [];
foreach ($sheetData->rows as $row) {
$cells = [];
foreach ($row->cells as $cell) {
$cells[] = $cell->value;
}
$rows[] = $cells;
}
return $rows;
}
}

View File

@@ -0,0 +1,27 @@
<?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\Tools\Report\Export\Xlsx;
enum CellFunction
{
case Sum;
case Avg;
case Min;
case Max;
}

View File

@@ -0,0 +1,32 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2026 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Export\Xlsx;
enum CellType
{
case Empty;
case Label;
case HeadGroup;
case HeadNonSummary;
case HeadSummary;
case Summary;
case NonSummary;
case Total;
case RowTotal;
}

View File

@@ -0,0 +1,32 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2026 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Export\Xlsx;
readonly class DataCell
{
public function __construct(
public mixed $value,
public CellType $type,
public ?string $fieldType = null,
public ?CellFunction $function = null,
public ?DateFunction $dateFunction = null,
public ?int $decimalPlaces = null,
public ?bool $isNumeric = null,
) {}
}

View File

@@ -0,0 +1,30 @@
<?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\Tools\Report\Export\Xlsx;
readonly class DataRow
{
/**
* @param DataCell[] $cells
*/
public function __construct(
public array $cells,
public RowType $type,
) {}
}

View File

@@ -0,0 +1,26 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2026 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Export\Xlsx;
enum DateFunction
{
case Month;
case Year;
case Day;
}

View File

@@ -0,0 +1,28 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2026 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Report\Export\Xlsx;
enum RowType
{
case Header;
case DataRow;
case HeadDataRow;
case SubDataRow;
case Total;
}

View File

@@ -0,0 +1,31 @@
<?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\Tools\Report\Export\Xlsx;
readonly class SheetData
{
/**
* @param DataRow[] $rows
*/
public function __construct(
public array $rows,
public ?int $firstSummaryRowNumber = null,
public ?int $lastSummaryRowNumber = null,
) {}
}