Initial commit
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
class ByLeadSource
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
$options = $this->metadata->get('entityDefs.Lead.fields.source.options', []);
|
||||
|
||||
try {
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
} catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
$whereClause = [
|
||||
['stage!=' => $this->util->getLostStageList()],
|
||||
['leadSource!=' => ''],
|
||||
['leadSource!=' => null],
|
||||
];
|
||||
|
||||
if ($from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($from && !$to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->select([
|
||||
'leadSource',
|
||||
['SUM:amountWeightedConverted', 'amount'],
|
||||
])
|
||||
->order(
|
||||
Order::createByPositionInList(
|
||||
Expression::column('leadSource'),
|
||||
$options
|
||||
)
|
||||
)
|
||||
->group('leadSource')
|
||||
->where($whereClause);
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$leadSource = $row['leadSource'];
|
||||
|
||||
$result[$leadSource] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
return (object) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use stdClass;
|
||||
|
||||
class ByStage
|
||||
{
|
||||
private Acl $acl;
|
||||
private Config $config;
|
||||
private Metadata $metadata;
|
||||
private EntityManager $entityManager;
|
||||
private SelectBuilderFactory $selectBuilderFactory;
|
||||
private Util $util;
|
||||
|
||||
public function __construct(
|
||||
Acl $acl,
|
||||
Config $config,
|
||||
Metadata $metadata,
|
||||
EntityManager $entityManager,
|
||||
SelectBuilderFactory $selectBuilderFactory,
|
||||
Util $util
|
||||
) {
|
||||
$this->acl = $acl;
|
||||
$this->config = $config;
|
||||
$this->metadata = $metadata;
|
||||
$this->entityManager = $entityManager;
|
||||
$this->selectBuilderFactory = $selectBuilderFactory;
|
||||
$this->util = $util;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
$options = $this->metadata->get('entityDefs.Opportunity.fields.stage.options') ?? [];
|
||||
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
|
||||
$whereClause = [
|
||||
['stage!=' => $this->util->getLostStageList()],
|
||||
['stage!=' => $this->util->getWonStageList()],
|
||||
];
|
||||
|
||||
if ($from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($from && !$to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
$queryBuilder
|
||||
->select([
|
||||
'stage',
|
||||
['SUM:amountConverted', 'amount'],
|
||||
])
|
||||
->order(
|
||||
Order::createByPositionInList(
|
||||
Expression::column('stage'),
|
||||
$options
|
||||
)
|
||||
)
|
||||
->group('stage')
|
||||
->where($whereClause);
|
||||
|
||||
$stageIgnoreList = array_merge(
|
||||
$this->util->getLostStageList(),
|
||||
$this->util->getWonStageList()
|
||||
);
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$stage = $row['stage'];
|
||||
|
||||
if (in_array($stage, $stageIgnoreList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$stage] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
foreach ($options as $stage) {
|
||||
if (in_array($stage, $stageIgnoreList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($stage, $result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$stage] = 0.0;
|
||||
}
|
||||
|
||||
return (object) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use DateInterval;
|
||||
use Espo\Core\Field\Date;
|
||||
use InvalidArgumentException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* Immutable.
|
||||
*/
|
||||
class DateRange
|
||||
{
|
||||
public const TYPE_BETWEEN = 'between';
|
||||
public const TYPE_EVER = 'ever';
|
||||
public const TYPE_CURRENT_YEAR = 'currentYear';
|
||||
public const TYPE_CURRENT_QUARTER = 'currentQuarter';
|
||||
public const TYPE_CURRENT_MONTH = 'currentMonth';
|
||||
public const TYPE_CURRENT_FISCAL_YEAR = 'currentFiscalYear';
|
||||
public const TYPE_CURRENT_FISCAL_QUARTER = 'currentFiscalQuarter';
|
||||
|
||||
private string $type;
|
||||
private ?Date $from;
|
||||
private ?Date $to;
|
||||
private int $fiscalYearShift;
|
||||
|
||||
public function __construct(
|
||||
string $type,
|
||||
?Date $from = null,
|
||||
?Date $to = null,
|
||||
int $fiscalYearShift = 0
|
||||
) {
|
||||
if ($type === self::TYPE_BETWEEN && (!$from || !$to)) {
|
||||
throw new InvalidArgumentException("Missing range dates.");
|
||||
}
|
||||
|
||||
$this->type = $type;
|
||||
$this->from = $from;
|
||||
$this->to = $to;
|
||||
$this->fiscalYearShift = $fiscalYearShift;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getFrom(): ?Date
|
||||
{
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
public function getTo(): ?Date
|
||||
{
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
public function withFiscalYearShift(int $fiscalYearShift): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->fiscalYearShift = $fiscalYearShift;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{?Date, ?Date}
|
||||
*/
|
||||
public function getRange(): array
|
||||
{
|
||||
if ($this->type === self::TYPE_EVER) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
if ($this->type === self::TYPE_BETWEEN) {
|
||||
return [$this->from, $this->to];
|
||||
}
|
||||
|
||||
$fiscalYearShift = $this->fiscalYearShift;
|
||||
|
||||
switch ($this->type) {
|
||||
case self::TYPE_CURRENT_YEAR:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of January this year');
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addYears(1)
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_QUARTER:
|
||||
$dt = Date::createToday();
|
||||
|
||||
$quarter = (int) ceil($dt->getMonth() / 3);
|
||||
|
||||
$dt = $dt
|
||||
->modify('first day of January this year')
|
||||
->addMonths(($quarter - 1) * 3);
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addMonths(3),
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_MONTH:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of this month');
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addMonths(1),
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_FISCAL_YEAR:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of January this year')
|
||||
->modify('+' . $fiscalYearShift . ' months');
|
||||
|
||||
if (Date::createToday()->getMonth() < $fiscalYearShift + 1) {
|
||||
$dt = $dt->addYears(-1);
|
||||
}
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->addYears(1)
|
||||
];
|
||||
|
||||
case self::TYPE_CURRENT_FISCAL_QUARTER:
|
||||
$dt = Date::createToday()
|
||||
->modify('first day of January this year')
|
||||
->addMonths($fiscalYearShift);
|
||||
|
||||
$month = Date::createToday()->getMonth();
|
||||
|
||||
$quarterShift = (int) floor(($month - $fiscalYearShift - 1) / 3);
|
||||
|
||||
if ($quarterShift) {
|
||||
$dt = $dt->addMonths($quarterShift * 3);
|
||||
}
|
||||
|
||||
return [
|
||||
$dt,
|
||||
$dt->add(new DateInterval('P3M'))
|
||||
];
|
||||
}
|
||||
|
||||
throw new UnexpectedValueException("Not supported range type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use DateTime;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use stdClass;
|
||||
|
||||
class SalesByMonth
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
if (!$from || !$to) {
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
|
||||
$whereClause = [
|
||||
'stage' => $this->util->getWonStageList(),
|
||||
];
|
||||
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
|
||||
$queryBuilder
|
||||
->select([
|
||||
['MONTH:closeDate', 'month'],
|
||||
['SUM:amountConverted', 'amount'],
|
||||
])
|
||||
->order('MONTH:closeDate')
|
||||
->group('MONTH:closeDate')
|
||||
->where($whereClause);
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$result = [];
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$month = $row['month'];
|
||||
|
||||
$result[$month] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
$dt = $from;
|
||||
$dtTo = $to;
|
||||
|
||||
if ($dtTo->getDay() > 1) {
|
||||
$dtTo = $dtTo
|
||||
->addDays(1 - $dtTo->getDay()) // First day of month.
|
||||
->addMonths(1);
|
||||
} else {
|
||||
$dtTo = $dtTo->addDays(1 - $dtTo->getDay());
|
||||
}
|
||||
|
||||
while ($dt->toTimestamp() < $dtTo->toTimestamp()) {
|
||||
$month = $dt->toDateTime()->format('Y-m');
|
||||
|
||||
if (!array_key_exists($month, $result)) {
|
||||
$result[$month] = 0;
|
||||
}
|
||||
|
||||
$dt = $dt->addMonths(1);
|
||||
}
|
||||
|
||||
$keyList = array_keys($result);
|
||||
|
||||
sort($keyList);
|
||||
|
||||
$today = new DateTime();
|
||||
$endPosition = count($keyList);
|
||||
|
||||
for ($i = count($keyList) - 1; $i >= 0; $i--) {
|
||||
$key = $keyList[$i];
|
||||
|
||||
try {
|
||||
$dt = new DateTime($key . '-01');
|
||||
} catch (Exception $e) {
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
if ($dt->getTimestamp() < $today->getTimestamp()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (empty($result[$key])) {
|
||||
$endPosition = $i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$keyListSliced = array_slice($keyList, 0, $endPosition);
|
||||
|
||||
return (object) [
|
||||
'keyList' => $keyListSliced,
|
||||
'dataMap' => $result,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use stdClass;
|
||||
|
||||
class SalesPipeline
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Util $util
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function run(DateRange $range, bool $useLastStage = false, ?string $teamId = null): stdClass
|
||||
{
|
||||
$range = $range->withFiscalYearShift(
|
||||
$this->config->get('fiscalYearShift') ?? 0
|
||||
);
|
||||
|
||||
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
|
||||
throw new Forbidden("No access to 'amount' field.");
|
||||
}
|
||||
|
||||
[$from, $to] = $range->getRange();
|
||||
|
||||
$lostStageList = $this->util->getLostStageList();
|
||||
|
||||
$options = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Opportunity::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder();
|
||||
|
||||
$stageField = 'stage';
|
||||
|
||||
if ($useLastStage) {
|
||||
$stageField = 'lastStage';
|
||||
}
|
||||
|
||||
$whereClause = [
|
||||
[$stageField . '!=' => $lostStageList],
|
||||
[$stageField . '!=' => null],
|
||||
];
|
||||
|
||||
if ($from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($from && !$to) {
|
||||
$whereClause[] = [
|
||||
'closeDate>=' => $from->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$from && $to) {
|
||||
$whereClause[] = [
|
||||
'closeDate<' => $to->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($teamId) {
|
||||
$whereClause[] = [
|
||||
'teamsFilter.id' => $teamId,
|
||||
];
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->select([
|
||||
$stageField,
|
||||
['SUM:amountConverted', 'amount'],
|
||||
])
|
||||
->order(
|
||||
Order::createByPositionInList(
|
||||
Expression::column($stageField),
|
||||
$options
|
||||
)
|
||||
)
|
||||
->group($stageField)
|
||||
->where($whereClause);
|
||||
|
||||
if ($teamId) {
|
||||
$queryBuilder->join(Field::TEAMS, 'teamsFilter');
|
||||
}
|
||||
|
||||
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
|
||||
|
||||
$sth = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($queryBuilder->build());
|
||||
|
||||
$rowList = $sth->fetchAll() ?: [];
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($rowList as $row) {
|
||||
$stage = $row[$stageField];
|
||||
|
||||
$data[$stage] = floatval($row['amount']);
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
foreach ($stageList as $stage) {
|
||||
if (in_array($stage, $lostStageList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($stage, $lostStageList) && !isset($data[$stage])) {
|
||||
$data[$stage] = 0.0;
|
||||
}
|
||||
|
||||
$dataList[] = [
|
||||
'stage' => $stage,
|
||||
'value' => $data[$stage],
|
||||
];
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'dataList' => $dataList,
|
||||
];
|
||||
}
|
||||
}
|
||||
120
application/Espo/Modules/Crm/Tools/Opportunity/Report/Util.php
Normal file
120
application/Espo/Modules/Crm/Tools/Opportunity/Report/Util.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity\Report;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Opportunity as OpportunityEntity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
class Util
|
||||
{
|
||||
private Metadata $metadata;
|
||||
private EntityManager $entityManager;
|
||||
|
||||
public function __construct(
|
||||
Metadata $metadata,
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
$this->metadata = $metadata;
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grouping-by with distinct will give wrong results. Need to use sub-query.
|
||||
*
|
||||
* @param array<string|int, mixed> $whereClause
|
||||
*/
|
||||
public function handleDistinctReportQueryBuilder(SelectBuilder $queryBuilder, array $whereClause): void
|
||||
{
|
||||
if (!$queryBuilder->build()->isDistinct()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subQuery = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->select()
|
||||
->from(OpportunityEntity::ENTITY_TYPE)
|
||||
->select(Attribute::ID)
|
||||
->where($whereClause)
|
||||
->build();
|
||||
|
||||
$queryBuilder->where([
|
||||
'id=s' => $subQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getLostStageList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$probabilityMap = $this->metadata
|
||||
->get(['entityDefs', OpportunityEntity::ENTITY_TYPE, 'fields', 'stage', 'probabilityMap']) ?? [];
|
||||
|
||||
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
foreach ($stageList as $stage) {
|
||||
$value = $probabilityMap[$stage] ?? 0;
|
||||
|
||||
if (!$value) {
|
||||
$list[] = $stage;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWonStageList(): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$probabilityMap = $this->metadata
|
||||
->get(['entityDefs', OpportunityEntity::ENTITY_TYPE, 'fields', 'stage', 'probabilityMap']) ?? [];
|
||||
|
||||
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
|
||||
|
||||
foreach ($stageList as $stage) {
|
||||
$value = $probabilityMap[$stage] ?? 0;
|
||||
|
||||
if ($value == 100) {
|
||||
$list[] = $stage;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
218
application/Espo/Modules/Crm/Tools/Opportunity/Service.php
Normal file
218
application/Espo/Modules/Crm/Tools/Opportunity/Service.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2025 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Modules\Crm\Tools\Opportunity;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Field\EmailAddress;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Email\EmailAddressEntityPair;
|
||||
use RuntimeException;
|
||||
|
||||
class Service
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private ServiceContainer $serviceContainer,
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private SelectBuilderFactory $selectBuilderFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return EmailAddressEntityPair[]
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function getEmailAddressList(string $id): array
|
||||
{
|
||||
/** @var Opportunity $entity */
|
||||
$entity = $this->serviceContainer
|
||||
->get(Opportunity::ENTITY_TYPE)
|
||||
->getEntity($id);
|
||||
|
||||
$list = [];
|
||||
|
||||
if (
|
||||
$this->acl->checkField(Opportunity::ENTITY_TYPE, 'contacts') &&
|
||||
$this->acl->checkScope(Contact::ENTITY_TYPE)
|
||||
) {
|
||||
foreach ($this->getContactEmailAddressList($entity) as $item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$list === [] &&
|
||||
$this->acl->checkField(Opportunity::ENTITY_TYPE, 'account') &&
|
||||
$this->acl->checkScope(Account::ENTITY_TYPE)
|
||||
) {
|
||||
$item = $this->getAccountEmailAddress($entity, $list);
|
||||
|
||||
if ($item) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EmailAddressEntityPair[] $dataList
|
||||
*/
|
||||
private function getAccountEmailAddress(Opportunity $entity, array $dataList): ?EmailAddressEntityPair
|
||||
{
|
||||
$accountLink = $entity->getAccount();
|
||||
|
||||
if (!$accountLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ?Account $account */
|
||||
$account = $this->entityManager->getEntityById(Account::ENTITY_TYPE, $accountLink->getId());
|
||||
|
||||
if (!$account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emailAddress = $account->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($account)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($dataList as $item) {
|
||||
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $account);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EmailAddressEntityPair[]
|
||||
*/
|
||||
private function getContactEmailAddressList(Opportunity $entity): array
|
||||
{
|
||||
$contactsLinkMultiple = $entity->getContacts();
|
||||
|
||||
$contactIdList = $contactsLinkMultiple->getIdList();
|
||||
|
||||
if (!count($contactIdList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$this->acl->checkField(Contact::ENTITY_TYPE, 'emailAddress')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
$emailAddressList = [];
|
||||
|
||||
try {
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Contact::ENTITY_TYPE)
|
||||
->withStrictAccessControl()
|
||||
->buildQueryBuilder()
|
||||
->select([
|
||||
'id',
|
||||
'emailAddress',
|
||||
'name',
|
||||
])
|
||||
->where([
|
||||
'id' => $contactIdList,
|
||||
])
|
||||
->build();
|
||||
} catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
/** @var Collection<Contact> $contactCollection */
|
||||
$contactCollection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Contact::class)
|
||||
->clone($query)
|
||||
->find();
|
||||
|
||||
foreach ($contactCollection as $contact) {
|
||||
$emailAddress = $contact->getEmailAddress();
|
||||
|
||||
if (!$emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($emailAddress, $emailAddressList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailAddressList[] = $emailAddress;
|
||||
|
||||
$dataList[] = new EmailAddressEntityPair(EmailAddress::create($emailAddress), $contact);
|
||||
}
|
||||
|
||||
$contact = $entity->getContact();
|
||||
|
||||
if (!$contact) {
|
||||
return $dataList;
|
||||
}
|
||||
|
||||
usort(
|
||||
$dataList,
|
||||
function (
|
||||
EmailAddressEntityPair $o1,
|
||||
EmailAddressEntityPair $o2
|
||||
) use ($contact) {
|
||||
if ($o1->getEntity()->getId() === $contact->getId()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($o2->getEntity()->getId() === $contact->getId()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
return $dataList;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user