some big beautfiul update
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Core\Currency;
|
||||
|
||||
use DivisionByZeroError;
|
||||
|
||||
class CalculatorUtil
|
||||
{
|
||||
private const SCALE = 14;
|
||||
|
||||
/**
|
||||
* @param numeric-string $arg1
|
||||
* @param numeric-string $arg2
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function add(string $arg1, string $arg2): string
|
||||
{
|
||||
if (!function_exists('bcadd')) {
|
||||
return (string) (
|
||||
(float) $arg1 + (float) $arg2
|
||||
);
|
||||
}
|
||||
|
||||
return bcadd(
|
||||
$arg1,
|
||||
$arg2,
|
||||
self::SCALE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param numeric-string $arg1
|
||||
* @param numeric-string $arg2
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function subtract(string $arg1, string $arg2): string
|
||||
{
|
||||
if (!function_exists('bcsub')) {
|
||||
return (string) (
|
||||
(float) $arg1 - (float) $arg2
|
||||
);
|
||||
}
|
||||
|
||||
return bcsub(
|
||||
$arg1,
|
||||
$arg2,
|
||||
self::SCALE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param numeric-string $arg1
|
||||
* @param numeric-string $arg2
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function multiply(string $arg1, string $arg2): string
|
||||
{
|
||||
if (!function_exists('bcmul')) {
|
||||
return (string) (
|
||||
(float) $arg1 * (float) $arg2
|
||||
);
|
||||
}
|
||||
|
||||
return bcmul(
|
||||
$arg1,
|
||||
$arg2,
|
||||
self::SCALE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param numeric-string $arg1
|
||||
* @param numeric-string $arg2
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function divide(string $arg1, string $arg2): string
|
||||
{
|
||||
if (!function_exists('bcdiv')) {
|
||||
return (string) (
|
||||
(float) $arg1 / (float) $arg2
|
||||
);
|
||||
}
|
||||
|
||||
$result = bcdiv(
|
||||
$arg1,
|
||||
$arg2,
|
||||
self::SCALE
|
||||
);
|
||||
|
||||
if ($result === null) { /** @phpstan-ignore-line */
|
||||
throw new DivisionByZeroError();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param numeric-string $arg
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function round(string $arg, int $precision = 0): string
|
||||
{
|
||||
if (!function_exists('bcadd')) {
|
||||
return (string) round((float) $arg, $precision);
|
||||
}
|
||||
|
||||
$addition = '0.' . str_repeat('0', $precision) . '5';
|
||||
|
||||
if ($arg[0] === '-') {
|
||||
$addition = '-' . $addition;
|
||||
}
|
||||
|
||||
assert(is_numeric($addition));
|
||||
|
||||
return bcadd(
|
||||
$arg,
|
||||
$addition,
|
||||
$precision
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param numeric-string $arg1
|
||||
* @param numeric-string $arg2
|
||||
*/
|
||||
public static function compare(string $arg1, string $arg2): int
|
||||
{
|
||||
if (!function_exists('bccomp')) {
|
||||
return (float) $arg1 <=> (float) $arg2;
|
||||
}
|
||||
|
||||
return bccomp(
|
||||
$arg1,
|
||||
$arg2,
|
||||
self::SCALE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Core\Field;
|
||||
|
||||
use Espo\Core\Currency\CalculatorUtil;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* A currency value object. Immutable.
|
||||
*/
|
||||
class Currency
|
||||
{
|
||||
/** @var numeric-string */
|
||||
private string $amount;
|
||||
private string $code;
|
||||
|
||||
/**
|
||||
* @param numeric-string|float|int $amount An amount.
|
||||
* @param string $code A currency code.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct($amount, string $code)
|
||||
{
|
||||
if (!is_string($amount) && !is_float($amount) && !is_int($amount)) {
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
if (strlen($code) !== 3) {
|
||||
throw new InvalidArgumentException("Bad currency code.");
|
||||
}
|
||||
|
||||
if (is_float($amount) || is_int($amount)) {
|
||||
$amount = (string) $amount;
|
||||
}
|
||||
|
||||
$this->amount = $amount;
|
||||
$this->code = $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an amount as string.
|
||||
*
|
||||
* @return numeric-string
|
||||
*/
|
||||
public function getAmountAsString(): string
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an amount.
|
||||
*/
|
||||
public function getAmount(): float
|
||||
{
|
||||
return (float) $this->amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a currency code.
|
||||
*/
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a currency value.
|
||||
*
|
||||
* @throws InvalidArgumentException If currency codes are different.
|
||||
*/
|
||||
public function add(self $value): self
|
||||
{
|
||||
if ($this->getCode() !== $value->getCode()) {
|
||||
throw new InvalidArgumentException("Can't add a currency value with a different code.");
|
||||
}
|
||||
|
||||
$amount = CalculatorUtil::add(
|
||||
$this->getAmountAsString(),
|
||||
$value->getAmountAsString()
|
||||
);
|
||||
|
||||
return new self($amount, $this->getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract a currency value.
|
||||
*
|
||||
* @throws InvalidArgumentException If currency codes are different.
|
||||
*/
|
||||
public function subtract(self $value): self
|
||||
{
|
||||
if ($this->getCode() !== $value->getCode()) {
|
||||
throw new InvalidArgumentException("Can't subtract a currency value with a different code.");
|
||||
}
|
||||
|
||||
$amount = CalculatorUtil::subtract(
|
||||
$this->getAmountAsString(),
|
||||
$value->getAmountAsString()
|
||||
);
|
||||
|
||||
return new self($amount, $this->getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply by a multiplier.
|
||||
*
|
||||
* @param float|int|numeric-string $multiplier
|
||||
*/
|
||||
public function multiply(float|int|string $multiplier): self
|
||||
{
|
||||
$amount = CalculatorUtil::multiply(
|
||||
$this->getAmountAsString(),
|
||||
(string) $multiplier
|
||||
);
|
||||
|
||||
return new self($amount, $this->getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide by a divider.
|
||||
*
|
||||
* @param float|int|numeric-string $divider
|
||||
*/
|
||||
public function divide(float|int|string $divider): self
|
||||
{
|
||||
$amount = CalculatorUtil::divide(
|
||||
$this->getAmountAsString(),
|
||||
(string) $divider
|
||||
);
|
||||
|
||||
return new self($amount, $this->getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Round with a precision.
|
||||
*/
|
||||
public function round(int $precision = 0): self
|
||||
{
|
||||
$amount = CalculatorUtil::round($this->getAmountAsString(), $precision);
|
||||
|
||||
return new self($amount, $this->getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another currency value. Returns:
|
||||
* - `1` if greater than the value;
|
||||
* - `0` if equal to the value;
|
||||
* - `-1` if less than the value.
|
||||
*
|
||||
* @throws InvalidArgumentException If currency codes are different.
|
||||
*/
|
||||
public function compare(self $value): int
|
||||
{
|
||||
if ($this->getCode() !== $value->getCode()) {
|
||||
throw new InvalidArgumentException("Can't compare currencies with different codes.");
|
||||
}
|
||||
|
||||
return CalculatorUtil::compare(
|
||||
$this->getAmountAsString(),
|
||||
$value->getAmountAsString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the value is negative.
|
||||
*/
|
||||
public function isNegative(): bool
|
||||
{
|
||||
return $this->compare(self::create(0.0, $this->code)) === -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from an amount and code.
|
||||
*
|
||||
* @param numeric-string|float|int $amount An amount.
|
||||
* @param string $code A currency code.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function create($amount, string $code): self
|
||||
{
|
||||
return new self($amount, $code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Core\FieldProcessing\LinkMultiple;
|
||||
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\FieldProcessing\Loader as LoaderInterface;
|
||||
use Espo\Core\FieldProcessing\Loader\Params;
|
||||
|
||||
use Espo\ORM\Defs as OrmDefs;
|
||||
|
||||
/**
|
||||
* @implements LoaderInterface<Entity>
|
||||
*/
|
||||
class ListLoader implements LoaderInterface
|
||||
{
|
||||
/** @var array<string, string[]> */
|
||||
private array $fieldListCacheMap = [];
|
||||
|
||||
public function __construct(private OrmDefs $ormDefs)
|
||||
{}
|
||||
|
||||
public function process(Entity $entity, Params $params): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
$select = $params->getSelect() ?? [];
|
||||
|
||||
if (count($select) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->getFieldList($entityType) as $field) {
|
||||
if (
|
||||
!in_array($field . 'Ids', $select) &&
|
||||
!in_array($field . 'Names', $select)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entity->has($field . 'Ids') && $entity->has($field . 'Names')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity->loadLinkMultipleField($field);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getFieldList(string $entityType): array
|
||||
{
|
||||
if (array_key_exists($entityType, $this->fieldListCacheMap)) {
|
||||
return $this->fieldListCacheMap[$entityType];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
|
||||
$entityDefs = $this->ormDefs->getEntity($entityType);
|
||||
|
||||
foreach ($entityDefs->getFieldList() as $fieldDefs) {
|
||||
if (
|
||||
$fieldDefs->getType() !== FieldType::LINK_MULTIPLE &&
|
||||
$fieldDefs->getType() !== FieldType::ATTACHMENT_MULTIPLE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fieldDefs->getParam('noLoad')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fieldDefs->isNotStorable()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $fieldDefs->getName();
|
||||
|
||||
if (!$entityDefs->hasRelation($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = $name;
|
||||
}
|
||||
|
||||
$this->fieldListCacheMap[$entityType] = $list;
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Core\Hook\Hook;
|
||||
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Repository\Option\RelateOptions;
|
||||
|
||||
/**
|
||||
* An afterRelate hook.
|
||||
*
|
||||
* @template TEntity of Entity = Entity
|
||||
* @template TRelatedEntity of Entity = Entity
|
||||
*/
|
||||
interface AfterRelate
|
||||
{
|
||||
/**
|
||||
* Processed after an entity is related with another entity. Called from within a repository.
|
||||
*
|
||||
* @param TEntity $entity An entity.
|
||||
* @param string $relationName A relation name.
|
||||
* @param TRelatedEntity $relatedEntity An entity is being related.
|
||||
* @param array<string, mixed> $columnData Middle table role values.
|
||||
* @param RelateOptions $options Options.
|
||||
*/
|
||||
public function afterRelate(
|
||||
Entity $entity,
|
||||
string $relationName,
|
||||
Entity $relatedEntity,
|
||||
array $columnData,
|
||||
RelateOptions $options
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Core\Hook\Hook;
|
||||
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Repository\Option\UnrelateOptions;
|
||||
|
||||
/**
|
||||
* An afterUnrelate hook.
|
||||
*
|
||||
* @template TEntity of Entity = Entity
|
||||
* @template TRelatedEntity of Entity = Entity
|
||||
*/
|
||||
interface AfterUnrelate
|
||||
{
|
||||
/**
|
||||
* Processed after an entity is unrelated from another entity. Called from within a repository.
|
||||
*
|
||||
* @param TEntity $entity An entity.
|
||||
* @param string $relationName A relation name.
|
||||
* @param TRelatedEntity $relatedEntity An entity is being unrelated.
|
||||
* @param UnrelateOptions $options Options.
|
||||
*/
|
||||
public function afterUnrelate(
|
||||
Entity $entity,
|
||||
string $relationName,
|
||||
Entity $relatedEntity,
|
||||
UnrelateOptions $options
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Core\Utils\Database\Orm\FieldConverters;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Espo\Core\Currency\ConfigDataProvider;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
|
||||
use Espo\Core\Utils\Database\Orm\FieldConverter;
|
||||
use Espo\ORM\Defs\FieldDefs;
|
||||
use Espo\ORM\Defs\Params\AttributeParam;
|
||||
use Espo\ORM\Defs\Params\FieldParam;
|
||||
use Espo\ORM\Query\Part\Expression as Expr;
|
||||
use Espo\ORM\Type\AttributeType;
|
||||
|
||||
class Currency implements FieldConverter
|
||||
{
|
||||
private const DEFAULT_PRECISION = 13;
|
||||
private const DEFAULT_SCALE = 4;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private ConfigDataProvider $configDataProvider
|
||||
) {}
|
||||
|
||||
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
|
||||
{
|
||||
$name = $fieldDefs->getName();
|
||||
|
||||
$amountDefs = AttributeDefs::create($name)
|
||||
->withType(AttributeType::FLOAT)
|
||||
->withParamsMerged([
|
||||
'attributeRole' => 'value',
|
||||
'fieldType' => FieldType::CURRENCY,
|
||||
]);
|
||||
|
||||
$currencyDefs = AttributeDefs::create($name . 'Currency')
|
||||
->withType(AttributeType::VARCHAR)
|
||||
->withParamsMerged([
|
||||
'attributeRole' => 'currency',
|
||||
'fieldType' => FieldType::CURRENCY,
|
||||
]);
|
||||
|
||||
$convertedDefs = null;
|
||||
|
||||
if ($fieldDefs->getParam(FieldParam::DECIMAL)) {
|
||||
$dbType = $fieldDefs->getParam(FieldParam::DB_TYPE) ?? Types::DECIMAL;
|
||||
$precision = $fieldDefs->getParam(FieldParam::PRECISION) ?? self::DEFAULT_PRECISION;
|
||||
$scale = $fieldDefs->getParam(FieldParam::SCALE) ?? self::DEFAULT_SCALE;
|
||||
|
||||
$amountDefs = $amountDefs
|
||||
->withType(AttributeType::VARCHAR)
|
||||
->withDbType($dbType)
|
||||
->withParam(AttributeParam::PRECISION, $precision)
|
||||
->withParam(AttributeParam::SCALE, $scale);
|
||||
|
||||
$defaultValue = $fieldDefs->getParam(AttributeParam::DEFAULT);
|
||||
|
||||
if (is_int($defaultValue) || is_float($defaultValue)) {
|
||||
$defaultValue = number_format($defaultValue, $scale, '.', '');
|
||||
|
||||
$amountDefs = $amountDefs->withParam(AttributeParam::DEFAULT, $defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fieldDefs->isNotStorable()) {
|
||||
$amountDefs = $amountDefs->withNotStorable();
|
||||
$currencyDefs = $currencyDefs->withNotStorable();
|
||||
}
|
||||
|
||||
if (!$fieldDefs->isNotStorable()) {
|
||||
[$amountDefs, $convertedDefs] = $this->config->get('currencyNoJoinMode') ?
|
||||
$this->applyNoJoinMode($fieldDefs, $amountDefs) :
|
||||
$this->applyJoinMode($fieldDefs, $amountDefs, $entityType);
|
||||
}
|
||||
|
||||
$entityDefs = EntityDefs::create()
|
||||
->withAttribute($amountDefs)
|
||||
->withAttribute($currencyDefs);
|
||||
|
||||
if ($convertedDefs) {
|
||||
$entityDefs = $entityDefs->withAttribute($convertedDefs);
|
||||
}
|
||||
|
||||
return $entityDefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{AttributeDefs, AttributeDefs}
|
||||
*/
|
||||
private function applyNoJoinMode(FieldDefs $fieldDefs, AttributeDefs $amountDefs): array
|
||||
{
|
||||
$name = $fieldDefs->getName();
|
||||
|
||||
$currencyAttribute = $name . 'Currency';
|
||||
|
||||
$defaultCurrency = $this->configDataProvider->getDefaultCurrency();
|
||||
$baseCurrency = $this->configDataProvider->getBaseCurrency();
|
||||
$rates = $this->configDataProvider->getCurrencyRates()->toAssoc();
|
||||
|
||||
if ($defaultCurrency !== $baseCurrency) {
|
||||
$rates = $this->exchangeRates($baseCurrency, $defaultCurrency, $rates);
|
||||
}
|
||||
|
||||
$expr = Expr::multiply(
|
||||
Expr::column($name),
|
||||
Expr::if(
|
||||
Expr::equal(Expr::column($currencyAttribute), $defaultCurrency),
|
||||
1.0,
|
||||
$this->buildExpression($currencyAttribute, $rates)
|
||||
)
|
||||
)->getValue();
|
||||
|
||||
$exprForeign = Expr::multiply(
|
||||
Expr::column("ALIAS.{$name}"),
|
||||
Expr::if(
|
||||
Expr::equal(Expr::column("ALIAS.{$name}Currency"), $defaultCurrency),
|
||||
1.0,
|
||||
$this->buildExpression("ALIAS.{$name}Currency", $rates)
|
||||
)
|
||||
)->getValue();
|
||||
|
||||
$exprForeign = str_replace('ALIAS', '{alias}', $exprForeign);
|
||||
|
||||
$convertedDefs = AttributeDefs::create($name . 'Converted')
|
||||
->withType(AttributeType::FLOAT)
|
||||
->withParamsMerged([
|
||||
'select' => [
|
||||
'select' => $expr,
|
||||
],
|
||||
'selectForeign' => [
|
||||
'select' => $exprForeign,
|
||||
],
|
||||
'where' => [
|
||||
"=" => [
|
||||
'whereClause' => [
|
||||
$expr . '=' => '{value}',
|
||||
],
|
||||
],
|
||||
">" => [
|
||||
'whereClause' => [
|
||||
$expr . '>' => '{value}',
|
||||
],
|
||||
],
|
||||
"<" => [
|
||||
'whereClause' => [
|
||||
$expr . '<' => '{value}',
|
||||
],
|
||||
],
|
||||
">=" => [
|
||||
'whereClause' => [
|
||||
$expr . '>=' => '{value}',
|
||||
],
|
||||
],
|
||||
"<=" => [
|
||||
'whereClause' => [
|
||||
$expr . '<=' => '{value}',
|
||||
],
|
||||
],
|
||||
"<>" => [
|
||||
'whereClause' => [
|
||||
$expr . '!=' => '{value}',
|
||||
],
|
||||
],
|
||||
"IS NULL" => [
|
||||
'whereClause' => [
|
||||
$expr . '=' => null,
|
||||
],
|
||||
],
|
||||
"IS NOT NULL" => [
|
||||
'whereClause' => [
|
||||
$expr . '!=' => null,
|
||||
],
|
||||
],
|
||||
],
|
||||
AttributeParam::NOT_STORABLE => true,
|
||||
'order' => [
|
||||
'order' => [
|
||||
[$expr, '{direction}'],
|
||||
],
|
||||
],
|
||||
'attributeRole' => 'valueConverted',
|
||||
'fieldType' => FieldType::CURRENCY,
|
||||
]);
|
||||
|
||||
return [$amountDefs, $convertedDefs];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, float> $currencyRates
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function exchangeRates(string $baseCurrency, string $defaultCurrency, array $currencyRates): array
|
||||
{
|
||||
$precision = 5;
|
||||
$defaultCurrencyRate = round(1 / $currencyRates[$defaultCurrency], $precision);
|
||||
|
||||
$exchangedRates = [];
|
||||
$exchangedRates[$baseCurrency] = $defaultCurrencyRate;
|
||||
|
||||
unset($currencyRates[$baseCurrency], $currencyRates[$defaultCurrency]);
|
||||
|
||||
foreach ($currencyRates as $currencyName => $rate) {
|
||||
$exchangedRates[$currencyName] = round($rate * $defaultCurrencyRate, $precision);
|
||||
}
|
||||
|
||||
return $exchangedRates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, float> $rates
|
||||
*/
|
||||
private function buildExpression(string $currencyAttribute, array $rates): Expr|float
|
||||
{
|
||||
if ($rates === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$currency = array_key_first($rates);
|
||||
$value = $rates[$currency];
|
||||
unset($rates[$currency]);
|
||||
|
||||
return Expr::if(
|
||||
Expr::equal(Expr::column($currencyAttribute), $currency),
|
||||
$value,
|
||||
$this->buildExpression($currencyAttribute, $rates)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{AttributeDefs, AttributeDefs}
|
||||
*/
|
||||
private function applyJoinMode(FieldDefs $fieldDefs, AttributeDefs $amountDefs, string $entityType): array
|
||||
{
|
||||
$name = $fieldDefs->getName();
|
||||
|
||||
$alias = $name . 'CurrencyRecordRate';
|
||||
$leftJoins = [
|
||||
[
|
||||
'Currency',
|
||||
$alias,
|
||||
[$alias . '.id:' => $name . 'Currency'],
|
||||
]
|
||||
];
|
||||
$foreignCurrencyAlias = "{$alias}{$entityType}{alias}Foreign";
|
||||
$mulExpression = "MUL:({$name}, {$alias}.rate)";
|
||||
|
||||
$amountDefs = $amountDefs->withParamsMerged([
|
||||
'order' => [
|
||||
'order' => [
|
||||
[$mulExpression, '{direction}'],
|
||||
],
|
||||
'leftJoins' => $leftJoins,
|
||||
'additionalSelect' => ["{$alias}.rate"],
|
||||
]
|
||||
]);
|
||||
|
||||
$convertedDefs = AttributeDefs::create($name . 'Converted')
|
||||
->withType(AttributeType::FLOAT)
|
||||
->withParamsMerged([
|
||||
'select' => [
|
||||
'select' => $mulExpression,
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
'selectForeign' => [
|
||||
'select' => "MUL:({alias}.{$name}, {$foreignCurrencyAlias}.rate)",
|
||||
'leftJoins' => [
|
||||
[
|
||||
'Currency',
|
||||
$foreignCurrencyAlias,
|
||||
[$foreignCurrencyAlias . '.id:' => "{alias}.{$name}Currency"]
|
||||
]
|
||||
],
|
||||
],
|
||||
'where' => [
|
||||
"=" => [
|
||||
'whereClause' => [$mulExpression . '=' => '{value}'],
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
">" => [
|
||||
'whereClause' => [$mulExpression . '>' => '{value}'],
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
"<" => [
|
||||
'whereClause' => [$mulExpression . '<' => '{value}'],
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
">=" => [
|
||||
'whereClause' => [$mulExpression . '>=' => '{value}'],
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
"<=" => [
|
||||
'whereClause' => [$mulExpression . '<=' => '{value}'],
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
"<>" => [
|
||||
'whereClause' => [$mulExpression . '!=' => '{value}'],
|
||||
'leftJoins' => $leftJoins,
|
||||
],
|
||||
"IS NULL" => [
|
||||
'whereClause' => [$name . '=' => null],
|
||||
],
|
||||
"IS NOT NULL" => [
|
||||
'whereClause' => [$name . '!=' => null],
|
||||
],
|
||||
],
|
||||
AttributeParam::NOT_STORABLE => true,
|
||||
'order' => [
|
||||
'order' => [
|
||||
[$mulExpression, '{direction}'],
|
||||
],
|
||||
'leftJoins' => $leftJoins,
|
||||
'additionalSelect' => ["{$alias}.rate"],
|
||||
],
|
||||
'attributeRole' => 'valueConverted',
|
||||
'fieldType' => FieldType::CURRENCY,
|
||||
]);
|
||||
|
||||
return [$amountDefs, $convertedDefs];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Entities;
|
||||
|
||||
use Espo\Core\Field\Date;
|
||||
use Espo\Core\Field\LinkMultiple;
|
||||
use Espo\Core\ORM\Entity;
|
||||
use Espo\Tools\WorkingTime\Calendar\Time;
|
||||
use Espo\Tools\WorkingTime\Calendar\TimeRange;
|
||||
use RuntimeException;
|
||||
|
||||
class WorkingTimeRange extends Entity
|
||||
{
|
||||
public const ENTITY_TYPE = 'WorkingTimeRange';
|
||||
|
||||
public const TYPE_NON_WORKING = 'Non-working';
|
||||
public const TYPE_WORKING = 'Working';
|
||||
|
||||
/**
|
||||
* @return (self::TYPE_NON_WORKING|self::TYPE_WORKING)
|
||||
*/
|
||||
public function getType(): string
|
||||
{
|
||||
$type = $this->get('type');
|
||||
|
||||
if (!$type) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
public function getDateStart(): Date
|
||||
{
|
||||
/** @var ?Date $value */
|
||||
$value = $this->getValueObject('dateStart');
|
||||
|
||||
if (!$value) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function getDateEnd(): Date
|
||||
{
|
||||
/** @var ?Date $value */
|
||||
$value = $this->getValueObject('dateEnd');
|
||||
|
||||
if (!$value) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?TimeRange[]
|
||||
*/
|
||||
public function getTimeRanges(): ?array
|
||||
{
|
||||
$ranges = self::convertRanges($this->get('timeRanges') ?? []);
|
||||
|
||||
if ($ranges === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{string, string}[] $ranges
|
||||
* @return TimeRange[]
|
||||
*/
|
||||
private static function convertRanges(array $ranges): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
foreach ($ranges as $range) {
|
||||
$list[] = new TimeRange(
|
||||
self::convertTime($range[0]),
|
||||
self::convertTime($range[1])
|
||||
);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private static function convertTime(string $time): Time
|
||||
{
|
||||
/** @var int<0, 23> $h */
|
||||
$h = (int) explode(':', $time)[0];
|
||||
/** @var int<0, 59> $m */
|
||||
$m = (int) explode(':', $time)[1];
|
||||
|
||||
return new Time($h, $m);
|
||||
}
|
||||
|
||||
public function getUsers(): LinkMultiple
|
||||
{
|
||||
/** @var LinkMultiple */
|
||||
return $this->getValueObject('users');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\EntryPoints;
|
||||
|
||||
use Espo\Repositories\Attachment as AttachmentRepository;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\EntryPoint\EntryPoint;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Exceptions\NotFoundSilent;
|
||||
use Espo\Core\FileStorage\Manager as FileStorageManager;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\File\Manager as FileManager;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Attachment;
|
||||
|
||||
use GdImage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class Image implements EntryPoint
|
||||
{
|
||||
/** @var ?string[] */
|
||||
protected $allowedRelatedTypeList = null;
|
||||
/** @var ?string[] */
|
||||
protected $allowedFieldList = null;
|
||||
|
||||
public function __construct(
|
||||
private FileStorageManager $fileStorageManager,
|
||||
private FileManager $fileManager,
|
||||
protected Acl $acl,
|
||||
protected EntityManager $entityManager,
|
||||
protected Config $config,
|
||||
protected Metadata $metadata
|
||||
) {}
|
||||
|
||||
public function run(Request $request, Response $response): void
|
||||
{
|
||||
$id = $request->getQueryParam('id');
|
||||
$size = $request->getQueryParam('size') ?? null;
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest("No id.");
|
||||
}
|
||||
|
||||
$this->show($response, $id, $size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws NotFoundSilent
|
||||
* @throws NotFound
|
||||
* @throws ForbiddenSilent
|
||||
*/
|
||||
protected function show(
|
||||
Response $response,
|
||||
string $id,
|
||||
?string $size,
|
||||
bool $disableAccessCheck = false,
|
||||
bool $noCacheHeaders = false,
|
||||
): void {
|
||||
|
||||
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($id);
|
||||
|
||||
if (!$attachment) {
|
||||
throw new NotFoundSilent("Attachment not found.");
|
||||
}
|
||||
|
||||
if (!$disableAccessCheck && !$this->acl->checkEntity($attachment)) {
|
||||
throw new ForbiddenSilent("No access to attachment.");
|
||||
}
|
||||
|
||||
$fileType = $attachment->getType();
|
||||
|
||||
if (!in_array($fileType, $this->getAllowedFileTypeList())) {
|
||||
throw new ForbiddenSilent("Not allowed file type '$fileType'.");
|
||||
}
|
||||
|
||||
if ($this->allowedRelatedTypeList) {
|
||||
if (!in_array($attachment->getRelatedType(), $this->allowedRelatedTypeList)) {
|
||||
throw new NotFoundSilent("Not allowed related type.");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->allowedFieldList) {
|
||||
if (!in_array($attachment->getTargetField(), $this->allowedFieldList)) {
|
||||
throw new NotFoundSilent("Not allowed field.");
|
||||
}
|
||||
}
|
||||
|
||||
$fileSize = 0;
|
||||
$fileName = $attachment->getName();
|
||||
|
||||
$toResize = $size && in_array($fileType, $this->getResizableFileTypeList());
|
||||
|
||||
if ($toResize) {
|
||||
$contents = $this->getThumbContents($attachment, $size);
|
||||
|
||||
if ($contents) {
|
||||
$fileName = $size . '-' . $attachment->getName();
|
||||
$fileSize = strlen($contents);
|
||||
|
||||
$response->writeBody($contents);
|
||||
} else {
|
||||
$toResize = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$toResize) {
|
||||
$stream = $this->fileStorageManager->getStream($attachment);
|
||||
$fileSize = $stream->getSize() ?? $this->fileStorageManager->getSize($attachment);
|
||||
|
||||
$response->setBody($stream);
|
||||
}
|
||||
|
||||
if ($fileType) {
|
||||
$response->setHeader('Content-Type', $fileType);
|
||||
}
|
||||
|
||||
$response
|
||||
->setHeader('Content-Disposition', 'inline;filename="' . $fileName . '"')
|
||||
->setHeader('Content-Length', (string) $fileSize)
|
||||
->setHeader('Content-Security-Policy', "default-src 'self'");
|
||||
|
||||
if (!$noCacheHeaders) {
|
||||
$response->setHeader('Cache-Control', 'private, max-age=864000, immutable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getThumbContents(Attachment $attachment, string $size): ?string
|
||||
{
|
||||
if (!array_key_exists($size, $this->getSizes())) {
|
||||
throw new Error("Bad size.");
|
||||
}
|
||||
|
||||
$useCache = !$this->config->get('thumbImageCacheDisabled', false);
|
||||
|
||||
$sourceId = $attachment->getSourceId();
|
||||
|
||||
$cacheFilePath = "data/upload/thumbs/{$sourceId}_$size";
|
||||
|
||||
if ($useCache && $this->fileManager->isFile($cacheFilePath)) {
|
||||
return $this->fileManager->getContents($cacheFilePath);
|
||||
}
|
||||
|
||||
$filePath = $this->getAttachmentRepository()->getFilePath($attachment);
|
||||
|
||||
if (!$this->fileManager->isFile($filePath)) {
|
||||
throw new NotFound("File not found.");
|
||||
}
|
||||
|
||||
$fileType = $attachment->getType() ?? '';
|
||||
|
||||
$targetImage = $this->createThumbImage($filePath, $fileType, $size);
|
||||
|
||||
if (!$targetImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ob_start();
|
||||
|
||||
switch ($fileType) {
|
||||
case 'image/jpeg':
|
||||
imagejpeg($targetImage);
|
||||
|
||||
break;
|
||||
|
||||
case 'image/png':
|
||||
imagepng($targetImage);
|
||||
|
||||
break;
|
||||
|
||||
case 'image/gif':
|
||||
imagegif($targetImage);
|
||||
|
||||
break;
|
||||
|
||||
case 'image/webp':
|
||||
imagewebp($targetImage);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$contents = ob_get_contents() ?: '';
|
||||
|
||||
ob_end_clean();
|
||||
|
||||
imagedestroy($targetImage);
|
||||
|
||||
if ($useCache) {
|
||||
$this->fileManager->putContents($cacheFilePath, $contents);
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
private function createThumbImage(string $filePath, string $fileType, string $size): ?GdImage
|
||||
{
|
||||
if (!is_array(getimagesize($filePath))) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
[$originalWidth, $originalHeight] = getimagesize($filePath);
|
||||
|
||||
[$width, $height] = $this->getSizes()[$size];
|
||||
|
||||
if ($originalWidth <= $width && $originalHeight <= $height) {
|
||||
$targetWidth = $originalWidth;
|
||||
$targetHeight = $originalHeight;
|
||||
} else {
|
||||
if ($originalWidth > $originalHeight) {
|
||||
$targetWidth = $width;
|
||||
$targetHeight = (int) ($originalHeight / ($originalWidth / $width));
|
||||
|
||||
if ($targetHeight > $height) {
|
||||
$targetHeight = $height;
|
||||
$targetWidth = (int) ($originalWidth / ($originalHeight / $height));
|
||||
}
|
||||
} else {
|
||||
$targetHeight = $height;
|
||||
$targetWidth = (int) ($originalWidth / ($originalHeight / $height));
|
||||
|
||||
if ($targetWidth > $width) {
|
||||
$targetWidth = $width;
|
||||
$targetHeight = (int) ($originalHeight / ($originalWidth / $width));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetWidth < 1 || $targetHeight < 1) {
|
||||
throw new RuntimeException("No width or height.");
|
||||
}
|
||||
|
||||
$targetImage = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
|
||||
if ($targetImage === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch ($fileType) {
|
||||
case 'image/jpeg':
|
||||
$sourceImage = imagecreatefromjpeg($filePath);
|
||||
|
||||
if ($sourceImage === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->resample(
|
||||
$targetImage,
|
||||
$sourceImage,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$originalWidth,
|
||||
$originalHeight
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'image/png':
|
||||
$sourceImage = imagecreatefrompng($filePath);
|
||||
|
||||
if ($sourceImage === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
imagealphablending($targetImage, false);
|
||||
imagesavealpha($targetImage, true);
|
||||
|
||||
$transparent = imagecolorallocatealpha($targetImage, 255, 255, 255, 127);
|
||||
|
||||
if ($transparent !== false) {
|
||||
imagefilledrectangle($targetImage, 0, 0, $targetWidth, $targetHeight, $transparent);
|
||||
}
|
||||
|
||||
$this->resample(
|
||||
$targetImage,
|
||||
$sourceImage,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$originalWidth,
|
||||
$originalHeight
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'image/gif':
|
||||
$sourceImage = imagecreatefromgif($filePath);
|
||||
|
||||
if ($sourceImage === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->resample(
|
||||
$targetImage,
|
||||
$sourceImage,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$originalWidth,
|
||||
$originalHeight
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'image/webp':
|
||||
try {
|
||||
$sourceImage = imagecreatefromwebp($filePath);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($sourceImage === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->resample(
|
||||
$targetImage,
|
||||
$sourceImage,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$originalWidth,
|
||||
$originalHeight
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (in_array($fileType, $this->getFixOrientationFileTypeList())) {
|
||||
$targetImage = $this->fixOrientation($targetImage, $filePath);
|
||||
}
|
||||
|
||||
return $targetImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $filePath
|
||||
* @return ?int
|
||||
*/
|
||||
private function getOrientation(string $filePath)
|
||||
{
|
||||
if (!function_exists('exif_read_data')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$data = exif_read_data($filePath) ?: [];
|
||||
|
||||
return $data['Orientation'] ?? null;
|
||||
}
|
||||
|
||||
private function fixOrientation(GdImage $targetImage, string $filePath): GdImage
|
||||
{
|
||||
$orientation = $this->getOrientation($filePath);
|
||||
|
||||
if ($orientation) {
|
||||
$angle = [0, 0, 0, 180, 0, 0, -90, 0, 90][$orientation] ?? 0;
|
||||
|
||||
$targetImage = imagerotate($targetImage, $angle, 0) ?: $targetImage;
|
||||
}
|
||||
|
||||
return $targetImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getAllowedFileTypeList(): array
|
||||
{
|
||||
return $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getResizableFileTypeList(): array
|
||||
{
|
||||
return $this->metadata->get(['app', 'image', 'resizableFileTypeList']) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getFixOrientationFileTypeList(): array
|
||||
{
|
||||
return $this->metadata->get(['app', 'image', 'fixOrientationFileTypeList']) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{int, int}>
|
||||
*/
|
||||
protected function getSizes(): array
|
||||
{
|
||||
return $this->metadata->get(['app', 'image', 'sizes']) ?? [];
|
||||
}
|
||||
|
||||
private function getAttachmentRepository(): AttachmentRepository
|
||||
{
|
||||
/** @var AttachmentRepository */
|
||||
return $this->entityManager->getRepository(Attachment::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
private function resample(
|
||||
GdImage $targetImage,
|
||||
GdImage $sourceImage,
|
||||
int $targetWidth,
|
||||
int $targetHeight,
|
||||
int $originalWidth,
|
||||
int $originalHeight
|
||||
): void {
|
||||
|
||||
imagecopyresampled(
|
||||
$targetImage,
|
||||
$sourceImage,
|
||||
0, 0, 0, 0,
|
||||
$targetWidth, $targetHeight, $originalWidth, $originalHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create ApiUser": "Create API User"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
{"name": "type"},
|
||||
{"name": "name"}
|
||||
],
|
||||
[
|
||||
{"name": "dateStart"},
|
||||
{"name": "dateEnd"}
|
||||
],
|
||||
[
|
||||
{"name": "timeRanges"},
|
||||
false
|
||||
],
|
||||
[
|
||||
{"name": "calendars"},
|
||||
{"name": "users"}
|
||||
],
|
||||
[
|
||||
{"name": "description"}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"rows": [
|
||||
[
|
||||
{"name": "type"},
|
||||
{"name": "name"}
|
||||
],
|
||||
[
|
||||
{"name": "dateStart"},
|
||||
{"name": "dateEnd"}
|
||||
],
|
||||
[
|
||||
{"name": "timeRanges"},
|
||||
false
|
||||
],
|
||||
[
|
||||
{"name": "calendars"},
|
||||
{"name": "users"}
|
||||
],
|
||||
[
|
||||
{"name": "description"}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"platforms": {
|
||||
"Mysql": {
|
||||
"queryComposerClassName": "Espo\\ORM\\QueryComposer\\MysqlQueryComposer",
|
||||
"pdoFactoryClassName": "Espo\\ORM\\PDO\\MysqlPDOFactory",
|
||||
"functionConverterClassNameMap": {
|
||||
"ABS": "Espo\\Core\\ORM\\QueryComposer\\Part\\FunctionConverters\\Abs"
|
||||
}
|
||||
},
|
||||
"Postgresql": {
|
||||
"queryComposerClassName": "Espo\\ORM\\QueryComposer\\PostgresqlQueryComposer",
|
||||
"pdoFactoryClassName": "Espo\\ORM\\PDO\\PostgresqlPDOFactory",
|
||||
"functionConverterClassNameMap": {
|
||||
"ABS": "Espo\\Core\\ORM\\QueryComposer\\Part\\FunctionConverters\\Abs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"controller": "controllers/api-user",
|
||||
"views": {
|
||||
"detail": "views/user/detail",
|
||||
"list": "views/api-user/list"
|
||||
},
|
||||
"recordViews": {
|
||||
"list": "views/user/record/list",
|
||||
"detail": "views/user/record/detail",
|
||||
"edit":"views/user/record/edit",
|
||||
"detailSmall":"views/user/record/detail-quick",
|
||||
"editSmall":"views/user/record/edit-quick"
|
||||
},
|
||||
"defaultSidePanelFieldLists": {
|
||||
"detail": [
|
||||
"avatar",
|
||||
"createdAt",
|
||||
"lastAccess"
|
||||
],
|
||||
"detailSmall": [
|
||||
"avatar",
|
||||
"createdAt"
|
||||
],
|
||||
"edit": [
|
||||
"avatar"
|
||||
],
|
||||
"editSmall": [
|
||||
"avatar"
|
||||
]
|
||||
},
|
||||
"filterList": [
|
||||
],
|
||||
"boolFilterList": []
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"fields": {
|
||||
"record": {
|
||||
"type": "link",
|
||||
"required": true,
|
||||
"readOnlyAfterCreate": true,
|
||||
"validatorClassNameList": [
|
||||
"Espo\\Classes\\FieldValidators\\CurrencyRecordRate\\Record\\NonBase"
|
||||
]
|
||||
},
|
||||
"baseCode": {
|
||||
"type": "varchar",
|
||||
"readOnly": true,
|
||||
"maxLength": 3
|
||||
},
|
||||
"date": {
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"readOnlyAfterCreate": true,
|
||||
"default": "javascript: return this.dateTime.getToday();"
|
||||
},
|
||||
"rate": {
|
||||
"type": "decimal",
|
||||
"decimalPlaces": 6,
|
||||
"min": 0.0001,
|
||||
"precision": 15,
|
||||
"scale": 8,
|
||||
"required": true,
|
||||
"audited": true,
|
||||
"view": "views/currency-record-rate/fields/rate"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/user",
|
||||
"fieldManagerParamList": []
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/user",
|
||||
"fieldManagerParamList": []
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"record": {
|
||||
"type": "belongsTo",
|
||||
"entity": "CurrencyRecord",
|
||||
"foreignName": "code"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"recordIdBaseCodeDate": {
|
||||
"type": "unique",
|
||||
"columns": [
|
||||
"recordId",
|
||||
"baseCode",
|
||||
"date",
|
||||
"deleteId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"deleteId": true,
|
||||
"collection": {
|
||||
"orderBy": "date",
|
||||
"order": "desc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"required": true,
|
||||
"pattern": "$noBadCharacters"
|
||||
},
|
||||
"body": {
|
||||
"type": "wysiwyg",
|
||||
"view": "views/template/fields/body"
|
||||
},
|
||||
"header": {
|
||||
"type": "wysiwyg",
|
||||
"view": "views/template/fields/body"
|
||||
},
|
||||
"footer": {
|
||||
"type": "wysiwyg",
|
||||
"view": "views/template/fields/body",
|
||||
"tooltip": true
|
||||
},
|
||||
"entityType": {
|
||||
"type": "enum",
|
||||
"required": true,
|
||||
"translation": "Global.scopeNames",
|
||||
"view": "views/template/fields/entity-type"
|
||||
},
|
||||
"status": {
|
||||
"type": "enum",
|
||||
"options": [
|
||||
"Active",
|
||||
"Inactive"
|
||||
],
|
||||
"default": "Active",
|
||||
"style": {
|
||||
"Inactive": "info"
|
||||
},
|
||||
"maxLength": 8
|
||||
},
|
||||
"leftMargin": {
|
||||
"type": "float",
|
||||
"default": 10
|
||||
},
|
||||
"rightMargin": {
|
||||
"type": "float",
|
||||
"default": 10
|
||||
},
|
||||
"topMargin": {
|
||||
"type": "float",
|
||||
"default": 10
|
||||
},
|
||||
"bottomMargin": {
|
||||
"type": "float",
|
||||
"default": 20
|
||||
},
|
||||
"printFooter": {
|
||||
"type": "bool",
|
||||
"inlineEditDisabled": true
|
||||
},
|
||||
"printHeader": {
|
||||
"type": "bool",
|
||||
"inlineEditDisabled": true
|
||||
},
|
||||
"footerPosition": {
|
||||
"type": "float",
|
||||
"default": 10
|
||||
},
|
||||
"headerPosition": {
|
||||
"type": "float",
|
||||
"default": 0
|
||||
},
|
||||
"style": {
|
||||
"type": "text",
|
||||
"view": "views/template/fields/style"
|
||||
},
|
||||
"teams": {
|
||||
"type": "linkMultiple"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"variables": {
|
||||
"type": "base",
|
||||
"notStorable": true,
|
||||
"tooltip": true
|
||||
},
|
||||
"pageOrientation": {
|
||||
"type": "enum",
|
||||
"options": ["Portrait", "Landscape"],
|
||||
"default": "Portrait"
|
||||
},
|
||||
"pageFormat": {
|
||||
"type": "enum",
|
||||
"options": ["A3", "A4", "A5", "A6", "A7", "Custom"],
|
||||
"default": "A4"
|
||||
},
|
||||
"pageWidth": {
|
||||
"type": "float",
|
||||
"min": 1
|
||||
},
|
||||
"pageHeight": {
|
||||
"type": "float",
|
||||
"min": 1
|
||||
},
|
||||
"fontFace": {
|
||||
"type": "enum",
|
||||
"view": "views/template/fields/font-face"
|
||||
},
|
||||
"title": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"filename": {
|
||||
"type": "varchar",
|
||||
"maxLength": 150,
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"teams": {
|
||||
"type": "hasMany",
|
||||
"entity": "Team",
|
||||
"relationName": "entityTeam"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "name",
|
||||
"order": "asc"
|
||||
},
|
||||
"optimisticConcurrencyControl": true
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"fields": {
|
||||
"timeRanges": {
|
||||
"type": "jsonArray",
|
||||
"default": null,
|
||||
"view": "views/working-time-calendar/fields/time-ranges"
|
||||
},
|
||||
"dateStart": {
|
||||
"type": "date",
|
||||
"required": true
|
||||
},
|
||||
"dateEnd": {
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"view": "views/working-time-range/fields/date-end",
|
||||
"after": "dateStart",
|
||||
"afterOrEqual": true
|
||||
},
|
||||
"type": {
|
||||
"type": "enum",
|
||||
"options": [
|
||||
"Non-working",
|
||||
"Working"
|
||||
],
|
||||
"default": "Non-working",
|
||||
"index": true,
|
||||
"maxLength": 11
|
||||
},
|
||||
"name": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"calendars": {
|
||||
"type": "linkMultiple",
|
||||
"tooltip": true
|
||||
},
|
||||
"users": {
|
||||
"type": "linkMultiple",
|
||||
"view": "views/working-time-range/fields/users",
|
||||
"tooltip": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"calendars": {
|
||||
"type": "hasMany",
|
||||
"foreign": "ranges",
|
||||
"entity": "WorkingTimeCalendar"
|
||||
},
|
||||
"users": {
|
||||
"type": "hasMany",
|
||||
"foreign": "workingTimeRanges",
|
||||
"entity": "User"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "dateStart",
|
||||
"order": "desc"
|
||||
},
|
||||
"indexes": {
|
||||
"typeRange": {
|
||||
"columns": ["type", "dateStart", "dateEnd"]
|
||||
},
|
||||
"type": {
|
||||
"columns": ["type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"fields": {
|
||||
"timeRanges": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "equals",
|
||||
"attribute": "type",
|
||||
"value": "Working"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "or",
|
||||
"value": [
|
||||
{
|
||||
"type": "isNotEmpty",
|
||||
"attribute": "id"
|
||||
},
|
||||
{
|
||||
"type": "isNotEmpty",
|
||||
"attribute": "usersIds"
|
||||
},
|
||||
{
|
||||
"type": "isEmpty",
|
||||
"attribute": "calendarsIds"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Tools\EntityManager;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Tools\EntityManager\Hook\CreateHook;
|
||||
use Espo\Tools\EntityManager\Hook\DeleteHook;
|
||||
use Espo\Core\DataManager;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Config\ConfigWriter;
|
||||
use Espo\Core\Utils\File\Manager as FileManager;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Tools\EntityManager\Hook\UpdateHook;
|
||||
use Espo\Tools\LinkManager\LinkManager;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Administration > Entity Manager.
|
||||
*/
|
||||
class EntityManager
|
||||
{
|
||||
private const DEFAULT_PARAM_LOCATION = 'scopes';
|
||||
|
||||
/** @var string[] */
|
||||
private const ALLOWED_PARAM_LOCATIONS = [
|
||||
'scopes',
|
||||
'entityDefs',
|
||||
'clientDefs',
|
||||
'recordDefs',
|
||||
'aclDefs',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private Language $language,
|
||||
private Language $baseLanguage,
|
||||
private FileManager $fileManager,
|
||||
private Config $config,
|
||||
private ConfigWriter $configWriter,
|
||||
private DataManager $dataManager,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private NameUtil $nameUtil,
|
||||
private LinkManager $linkManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return string An actual name.
|
||||
* @throws BadRequest
|
||||
* @throws Error
|
||||
* @throws Conflict
|
||||
*/
|
||||
public function create(string $name, string $type, array $params = [], ?CreateParams $createParams = null): string
|
||||
{
|
||||
$createParams ??= new CreateParams();
|
||||
|
||||
$name = ucfirst($name);
|
||||
$name = trim($name);
|
||||
|
||||
if (empty($name) || empty($type)) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if (!in_array($type, $this->metadata->get(['app', 'entityTemplateList'], []))) {
|
||||
throw new Error("Type '$type' does not exist.");
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $templateDefs */
|
||||
$templateDefs = $this->metadata->get(['app', 'entityTemplates', $type], []);
|
||||
|
||||
if (!empty($templateDefs['isNotCreatable']) && !$createParams->forceCreate()) {
|
||||
throw new Error("Type '$type' is not creatable.");
|
||||
}
|
||||
|
||||
$name = $this->nameUtil->addCustomPrefix($name, true);
|
||||
|
||||
if ($this->nameUtil->nameIsBad($name)) {
|
||||
throw new Error("Entity name should contain only letters and numbers, " .
|
||||
"start with an upper case letter.");
|
||||
}
|
||||
|
||||
if ($this->nameUtil->nameIsTooShort($name)) {
|
||||
throw new Error("Entity name should not shorter than " . NameUtil::MIN_ENTITY_NAME_LENGTH . ".");
|
||||
}
|
||||
|
||||
if ($this->nameUtil->nameIsTooLong($name)) {
|
||||
throw Error::createWithBody(
|
||||
"Entity type name should not be longer than " . NameUtil::MAX_ENTITY_NAME_LENGTH . ".",
|
||||
Error\Body::create()
|
||||
->withMessageTranslation('nameIsTooLong', 'EntityManager')
|
||||
->encode()
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->nameUtil->nameIsUsed($name)) {
|
||||
throw Conflict::createWithBody(
|
||||
"Name '$name' is already used.",
|
||||
Error\Body::create()
|
||||
->withMessageTranslation('nameIsAlreadyUsed', 'EntityManager', [
|
||||
'name' => $name,
|
||||
])
|
||||
->encode()
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->nameUtil->nameIsNotAllowed($name)) {
|
||||
throw Conflict::createWithBody(
|
||||
"Entity type name '$name' is not allowed.",
|
||||
Error\Body::create()
|
||||
->withMessageTranslation('nameIsNotAllowed', 'EntityManager', [
|
||||
'name' => $name,
|
||||
])
|
||||
->encode()
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedName = Util::normalizeClassName($name);
|
||||
|
||||
$templateNamespace = "\Espo\Core\Templates";
|
||||
|
||||
$templatePath = "application/Espo/Core/Templates";
|
||||
|
||||
if (!empty($templateDefs['module'])) {
|
||||
$templateModuleName = $templateDefs['module'];
|
||||
|
||||
$normalizedTemplateModuleName = Util::normalizeClassName($templateModuleName);
|
||||
|
||||
$templateNamespace = "\Espo\Modules\\$normalizedTemplateModuleName\Core\Templates";
|
||||
$templatePath = "custom/Espo/Modules/$normalizedTemplateModuleName/Core/Templates";
|
||||
}
|
||||
|
||||
$contents = "<" . "?" . "php\n\n".
|
||||
"namespace Espo\Custom\Controllers;\n\n".
|
||||
"class $normalizedName extends $templateNamespace\Controllers\\$type\n".
|
||||
"{\n".
|
||||
"}\n";
|
||||
|
||||
$this->fileManager->putContents("custom/Espo/Custom/Controllers/$normalizedName.php", $contents);
|
||||
|
||||
$stream = false;
|
||||
|
||||
if (!empty($params['stream'])) {
|
||||
$stream = $params['stream'];
|
||||
}
|
||||
|
||||
$disabled = false;
|
||||
|
||||
if (!empty($params['disabled'])) {
|
||||
$disabled = $params['disabled'];
|
||||
}
|
||||
|
||||
$labelSingular = $name;
|
||||
|
||||
if (!empty($params['labelSingular'])) {
|
||||
$labelSingular = $params['labelSingular'];
|
||||
}
|
||||
|
||||
$labelPlural = $name;
|
||||
|
||||
if (!empty($params['labelPlural'])) {
|
||||
$labelPlural = $params['labelPlural'];
|
||||
}
|
||||
|
||||
$languageList = $this->metadata->get(['app', 'language', 'list'], []);
|
||||
|
||||
foreach ($languageList as $language) {
|
||||
$filePath = $templatePath . '/i18n/' . $language . '/' . $type . '.json';
|
||||
|
||||
if (!$this->fileManager->exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$languageContents = $this->fileManager->getContents($filePath);
|
||||
$languageContents = $this->replace($languageContents, $name, $createParams->getReplaceData());
|
||||
$languageContents = str_replace('{entityTypeTranslated}', $labelSingular, $languageContents);
|
||||
|
||||
$destinationFilePath = 'custom/Espo/Custom/Resources/i18n/' . $language . '/' . $name . '.json';
|
||||
|
||||
$this->fileManager->putContents($destinationFilePath, $languageContents);
|
||||
}
|
||||
|
||||
$filePath = $templatePath . "/Metadata/$type/scopes.json";
|
||||
|
||||
$scopesDataContents = $this->fileManager->getContents($filePath);
|
||||
$scopesDataContents = $this->replace($scopesDataContents, $name, $createParams->getReplaceData());
|
||||
|
||||
$scopesData = Json::decode($scopesDataContents, true);
|
||||
|
||||
$scopesData['stream'] = $stream;
|
||||
$scopesData['disabled'] = $disabled;
|
||||
$scopesData['type'] = $type;
|
||||
$scopesData['module'] = 'Custom';
|
||||
$scopesData['object'] = true;
|
||||
$scopesData['isCustom'] = true;
|
||||
|
||||
if (!empty($templateDefs['isNotRemovable']) || !empty($params['isNotRemovable'])) {
|
||||
$scopesData['isNotRemovable'] = true;
|
||||
}
|
||||
|
||||
if (!empty($params['kanbanStatusIgnoreList'])) {
|
||||
$scopesData['kanbanStatusIgnoreList'] = $params['kanbanStatusIgnoreList'];
|
||||
}
|
||||
|
||||
$this->metadata->set('scopes', $name, $scopesData);
|
||||
|
||||
$filePath = $templatePath . "/Metadata/$type/entityDefs.json";
|
||||
|
||||
$entityDefsDataContents = $this->fileManager->getContents($filePath);
|
||||
$entityDefsDataContents = $this->replace($entityDefsDataContents, $name, $createParams->getReplaceData());
|
||||
|
||||
$entityDefsData = Json::decode($entityDefsDataContents, true);
|
||||
|
||||
$this->metadata->set('entityDefs', $name, $entityDefsData);
|
||||
|
||||
$filePath = $templatePath . "/Metadata/$type/clientDefs.json";
|
||||
|
||||
$clientDefsContents = $this->fileManager->getContents($filePath);
|
||||
$clientDefsContents = $this->replace($clientDefsContents, $name, $createParams->getReplaceData());
|
||||
|
||||
$clientDefsData = Json::decode($clientDefsContents, true);
|
||||
|
||||
if (array_key_exists('color', $params)) {
|
||||
$clientDefsData['color'] = $params['color'];
|
||||
}
|
||||
|
||||
if (array_key_exists('iconClass', $params)) {
|
||||
$clientDefsData['iconClass'] = $params['iconClass'];
|
||||
}
|
||||
|
||||
if (!empty($params['kanbanViewMode'])) {
|
||||
$clientDefsData['kanbanViewMode'] = true;
|
||||
}
|
||||
|
||||
$this->metadata->set('clientDefs', $name, $clientDefsData);
|
||||
|
||||
$this->processMetadataCreateSelectDefs($templatePath, $name, $type);
|
||||
$this->processMetadataCreateRecordDefs($templatePath, $name, $type);
|
||||
|
||||
$this->baseLanguage->set('Global', 'scopeNames', $name, $labelSingular);
|
||||
$this->baseLanguage->set('Global', 'scopeNamesPlural', $name, $labelPlural);
|
||||
|
||||
$this->metadata->save();
|
||||
$this->baseLanguage->save();
|
||||
|
||||
$layoutsPath = $templatePath . "/Layouts/$type";
|
||||
|
||||
if ($this->fileManager->isDir($layoutsPath)) {
|
||||
$this->fileManager->copy($layoutsPath, 'custom/Espo/Custom/Resources/layouts/' . $name);
|
||||
}
|
||||
|
||||
$entityTypeParams = new Params($name, $type, $params);
|
||||
|
||||
$this->processCreateHook($entityTypeParams);
|
||||
|
||||
$tabList = $this->config->get('tabList', []);
|
||||
|
||||
if (!in_array($name, $tabList)) {
|
||||
$tabList[] = $name;
|
||||
|
||||
$this->configWriter->set('tabList', $tabList);
|
||||
$this->configWriter->save();
|
||||
}
|
||||
|
||||
$this->dataManager->rebuild();
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $data
|
||||
*/
|
||||
private function replace(
|
||||
string $contents,
|
||||
string $name,
|
||||
array $data
|
||||
): string {
|
||||
|
||||
$contents = str_replace('{entityType}', $name, $contents);
|
||||
$contents = str_replace('{entityTypeLowerFirst}', lcfirst($name), $contents);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$contents = str_replace('{' . $key . '}', $value, $contents);
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
private function processMetadataCreateSelectDefs(string $templatePath, string $name, string $type): void
|
||||
{
|
||||
$path = $templatePath . "/Metadata/$type/selectDefs.json";
|
||||
|
||||
if (!$this->fileManager->isFile($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contents = $this->fileManager->getContents($path);
|
||||
|
||||
$data = Json::decode($contents, true);
|
||||
|
||||
$this->metadata->set('selectDefs', $name, $data);
|
||||
}
|
||||
|
||||
private function processMetadataCreateRecordDefs(string $templatePath, string $name, string $type): void
|
||||
{
|
||||
$path = $templatePath . "/Metadata/$type/recordDefs.json";
|
||||
|
||||
if (!$this->fileManager->isFile($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contents = $this->fileManager->getContents($path);
|
||||
|
||||
$data = Json::decode($contents, true);
|
||||
|
||||
$this->metadata->set('recordDefs', $name, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* stream?: bool,
|
||||
* disabled?: bool,
|
||||
* statusField?: ?string,
|
||||
* labelSingular?: ?string,
|
||||
* labelPlural?: ?string,
|
||||
* sortBy?: ?string,
|
||||
* sortDirection?: ?string,
|
||||
* textFilterFields?: ?string[],
|
||||
* fullTextSearch?: bool,
|
||||
* countDisabled?: bool,
|
||||
* kanbanStatusIgnoreList?: ?string[],
|
||||
* kanbanViewMode?: bool,
|
||||
* color?: ?string,
|
||||
* iconClass?: ?string,
|
||||
* optimisticConcurrencyControl?: bool,
|
||||
* }|array<string, mixed> $params
|
||||
* @throws Error
|
||||
*/
|
||||
public function update(string $name, array $params): void
|
||||
{
|
||||
if (!$this->metadata->get('scopes.' . $name)) {
|
||||
throw new Error("Entity `$name` does not exist.");
|
||||
}
|
||||
|
||||
if (!$this->isScopeCustomizable($name)) {
|
||||
throw new Error("Entity type $name is not customizable.");
|
||||
}
|
||||
|
||||
$isCustom = $this->metadata->get(['scopes', $name, 'isCustom']);
|
||||
$type = $this->metadata->get(['scopes', $name, 'type']);
|
||||
|
||||
if ($this->metadata->get(['scopes', $name, 'statusFieldLocked'])) {
|
||||
unset($params['statusField']);
|
||||
}
|
||||
|
||||
$initialData = [
|
||||
'optimisticConcurrencyControl' =>
|
||||
$this->metadata->get(['entityDefs', $name, 'optimisticConcurrencyControl']) ?? false,
|
||||
'fullTextSearch' =>
|
||||
$this->metadata->get(['entityDefs', $name, 'collection', 'fullTextSearch']) ?? false,
|
||||
];
|
||||
|
||||
$entityTypeParams = new Params($name, $type, array_merge($this->getCurrentParams($name), $params));
|
||||
$previousEntityTypeParams = new Params($name, $type, $this->getCurrentParams($name));
|
||||
|
||||
if (array_key_exists('stream', $params)) {
|
||||
$this->metadata->set('scopes', $name, ['stream' => (bool) $params['stream']]);
|
||||
}
|
||||
|
||||
if (array_key_exists('disabled', $params)) {
|
||||
$this->metadata->set('scopes', $name, ['disabled' => (bool) $params['disabled']]);
|
||||
}
|
||||
|
||||
if (array_key_exists('statusField', $params)) {
|
||||
$this->metadata->set('scopes', $name, ['statusField' => $params['statusField']]);
|
||||
|
||||
if (!$params['statusField'] && $this->metadata->get("clientDefs.$name.kanbanViewMode")) {
|
||||
$params['kanbanViewMode'] = false;
|
||||
$params['kanbanStatusIgnoreList'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($params['sortBy'])) {
|
||||
$this->metadata->set('entityDefs', $name, [
|
||||
'collection' => ['orderBy' => $params['sortBy']],
|
||||
]);
|
||||
|
||||
if (isset($params['sortDirection'])) {
|
||||
$this->metadata->set('entityDefs', $name, [
|
||||
'collection' => ['order' => $params['sortDirection']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($params['textFilterFields'])) {
|
||||
$this->metadata->set('entityDefs', $name, [
|
||||
'collection' => ['textFilterFields' => $params['textFilterFields']]
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($params['fullTextSearch'])) {
|
||||
$this->metadata->set('entityDefs', $name, [
|
||||
'collection' => ['fullTextSearch' => (bool) $params['fullTextSearch']],
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($params['countDisabled'])) {
|
||||
$this->metadata->set('entityDefs', $name, [
|
||||
'collection' => ['countDisabled' => (bool) $params['countDisabled']],
|
||||
]);
|
||||
}
|
||||
|
||||
if (array_key_exists('kanbanStatusIgnoreList', $params)) {
|
||||
$itemValue = $params['kanbanStatusIgnoreList'] ?: null;
|
||||
|
||||
$this->metadata->set('scopes', $name, ['kanbanStatusIgnoreList' => $itemValue]);
|
||||
}
|
||||
|
||||
if (array_key_exists('kanbanViewMode', $params)) {
|
||||
$this->metadata->set('clientDefs', $name, ['kanbanViewMode' => $params['kanbanViewMode']]);
|
||||
}
|
||||
|
||||
if (array_key_exists('color', $params)) {
|
||||
$this->metadata->set('clientDefs', $name, ['color' => $params['color']]);
|
||||
}
|
||||
|
||||
if (array_key_exists('iconClass', $params)) {
|
||||
$this->metadata->set('clientDefs', $name, ['iconClass' => $params['iconClass']]);
|
||||
}
|
||||
|
||||
$this->setAdditionalParamsInMetadata($name, $params);
|
||||
|
||||
if (!empty($params['labelSingular'])) {
|
||||
$labelSingular = $params['labelSingular'];
|
||||
$labelCreate = $this->language->translateLabel('Create') . ' ' . $labelSingular;
|
||||
|
||||
$this->language->set('Global', 'scopeNames', $name, $labelSingular);
|
||||
$this->language->set($name, 'labels', 'Create ' . $name, $labelCreate);
|
||||
|
||||
if ($isCustom) {
|
||||
$this->baseLanguage->set('Global', 'scopeNames', $name, $labelSingular);
|
||||
$this->baseLanguage->set($name, 'labels', 'Create ' . $name, $labelCreate);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($params['labelPlural'])) {
|
||||
$labelPlural = $params['labelPlural'];
|
||||
$this->language->set('Global', 'scopeNamesPlural', $name, $labelPlural);
|
||||
|
||||
if ($isCustom) {
|
||||
$this->baseLanguage->set('Global', 'scopeNamesPlural', $name, $labelPlural);
|
||||
}
|
||||
}
|
||||
|
||||
$this->metadata->save();
|
||||
$this->language->save();
|
||||
|
||||
if ($isCustom) {
|
||||
if ($this->isLanguageNotBase()) {
|
||||
$this->baseLanguage->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->processUpdateHook($entityTypeParams, $previousEntityTypeParams);
|
||||
|
||||
$this->dataManager->clearCache();
|
||||
|
||||
if (
|
||||
!$initialData['optimisticConcurrencyControl'] &&
|
||||
!empty($params['optimisticConcurrencyControl']) &&
|
||||
(
|
||||
empty($params['fullTextSearch']) || $initialData['fullTextSearch']
|
||||
)
|
||||
) {
|
||||
$this->dataManager->rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function delete(string $name, ?DeleteParams $deleteParams = null): void
|
||||
{
|
||||
$deleteParams ??= new DeleteParams();
|
||||
|
||||
if (!$this->isCustom($name)) {
|
||||
throw new Forbidden;
|
||||
}
|
||||
|
||||
if (!$this->isScopeCustomizable($name)) {
|
||||
throw new Error("Entity type $name is not customizable.");
|
||||
}
|
||||
|
||||
$normalizedName = Util::normalizeClassName($name);
|
||||
|
||||
$type = $this->metadata->get(['scopes', $name, 'type']);
|
||||
$isNotRemovable = $this->metadata->get(['scopes', $name, 'isNotRemovable']);
|
||||
/** @var array<string, mixed> $templateDefs */
|
||||
$templateDefs = $this->metadata->get(['app', 'entityTemplates', $type], []);
|
||||
|
||||
if (
|
||||
(!empty($templateDefs['isNotRemovable']) || $isNotRemovable) &&
|
||||
!$deleteParams->forceRemove()
|
||||
) {
|
||||
throw new Error("Type '$type' is not removable.");
|
||||
}
|
||||
|
||||
$entityTypeParams = new Params($name, $type, $this->getCurrentParams($name));
|
||||
|
||||
$this->metadata->delete('entityDefs', $name);
|
||||
$this->metadata->delete('clientDefs', $name);
|
||||
$this->metadata->delete('recordDefs', $name);
|
||||
$this->metadata->delete('selectDefs', $name);
|
||||
$this->metadata->delete('entityAcl', $name);
|
||||
$this->metadata->delete('scopes', $name);
|
||||
|
||||
foreach ($this->metadata->get(['entityDefs', $name, 'links'], []) as $link => $item) {
|
||||
try {
|
||||
$this->linkManager->delete(['entity' => $name, 'link' => $link]);
|
||||
} catch (Exception) {}
|
||||
}
|
||||
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Resources/metadata/entityDefs/$name.json");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Resources/metadata/clientDefs/$name.json");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Resources/metadata/recordDefs/$name.json");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Resources/metadata/selectDefs/$name.json");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Resources/metadata/scopes/$name.json");
|
||||
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Entities/$normalizedName.php");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Services/$normalizedName.php");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Controllers/$normalizedName.php");
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/Repositories/$normalizedName.php");
|
||||
|
||||
if (file_exists("custom/Espo/Custom/SelectManagers/$normalizedName.php")) {
|
||||
$this->fileManager->removeFile("custom/Espo/Custom/SelectManagers/$normalizedName.php");
|
||||
}
|
||||
|
||||
$this->fileManager->removeInDir("custom/Espo/Custom/Resources/layouts/$normalizedName");
|
||||
$this->fileManager->removeDir("custom/Espo/Custom/Resources/layouts/$normalizedName");
|
||||
|
||||
$languageList = $this->metadata->get(['app', 'language', 'list'], []);
|
||||
|
||||
foreach ($languageList as $language) {
|
||||
$filePath = 'custom/Espo/Custom/Resources/i18n/' . $language . '/' . $normalizedName . '.json';
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->fileManager->removeFile($filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->language->delete('Global', 'scopeNames', $name);
|
||||
$this->language->delete('Global', 'scopeNamesPlural', $name);
|
||||
|
||||
$this->baseLanguage->delete('Global', 'scopeNames', $name);
|
||||
$this->baseLanguage->delete('Global', 'scopeNamesPlural', $name);
|
||||
} catch (Exception) {}
|
||||
|
||||
$this->metadata->save();
|
||||
$this->language->save();
|
||||
|
||||
if ($this->isLanguageNotBase()) {
|
||||
$this->baseLanguage->save();
|
||||
}
|
||||
|
||||
if ($type) {
|
||||
$this->processDeleteHook($entityTypeParams);
|
||||
}
|
||||
|
||||
$this->deleteEntityTypeFromConfigParams($name);
|
||||
|
||||
$this->dataManager->clearCache();
|
||||
}
|
||||
|
||||
private function deleteEntityTypeFromConfigParams(string $entityType): void
|
||||
{
|
||||
$paramList = $this->metadata->get(['app', 'config', 'entityTypeListParamList']) ?? [];
|
||||
|
||||
foreach ($paramList as $param) {
|
||||
$this->deleteEntityTypeFromConfigParam($entityType, $param);
|
||||
}
|
||||
|
||||
$this->configWriter->save();
|
||||
}
|
||||
|
||||
private function deleteEntityTypeFromConfigParam(string $entityType, string $param): void
|
||||
{
|
||||
$list = $this->config->get($param) ?? [];
|
||||
|
||||
if (($key = array_search($entityType, $list)) !== false) {
|
||||
unset($list[$key]);
|
||||
|
||||
$list = array_values($list);
|
||||
}
|
||||
|
||||
$this->configWriter->set($param, $list);
|
||||
}
|
||||
|
||||
private function isCustom(string $name): bool
|
||||
{
|
||||
return (bool) $this->metadata->get('scopes.' . $name . '.isCustom');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $data
|
||||
* @throws Error
|
||||
*/
|
||||
public function setFormulaData(string $scope, array $data): void
|
||||
{
|
||||
if (!$this->isScopeCustomizableFormula($scope)) {
|
||||
throw new Error("Entity type $scope is not customizable.");
|
||||
}
|
||||
|
||||
$this->metadata->set('formula', $scope, $data);
|
||||
$this->metadata->save();
|
||||
|
||||
$this->dataManager->clearCache();
|
||||
}
|
||||
|
||||
private function processUpdateHook(Params $params, Params $previousParams): void
|
||||
{
|
||||
/** @var class-string<UpdateHook>[] $classNameList */
|
||||
$classNameList = $this->metadata->get(['app', 'entityManager', 'updateHookClassNameList']) ?? [];
|
||||
|
||||
foreach ($classNameList as $className) {
|
||||
$hook = $this->injectableFactory->create($className);
|
||||
|
||||
$hook->process($params, $previousParams);
|
||||
}
|
||||
}
|
||||
|
||||
private function processDeleteHook(Params $params): void
|
||||
{
|
||||
/** @var class-string<DeleteHook>[] $classNameList */
|
||||
$classNameList = $this->metadata->get(['app', 'entityManager', 'deleteHookClassNameList']) ?? [];
|
||||
|
||||
foreach ($classNameList as $className) {
|
||||
$hook = $this->injectableFactory->create($className);
|
||||
|
||||
$hook->process($params);
|
||||
}
|
||||
}
|
||||
|
||||
private function processCreateHook(Params $params): void
|
||||
{
|
||||
/** @var class-string<CreateHook>[] $classNameList */
|
||||
$classNameList = $this->metadata->get(['app', 'entityManager', 'createHookClassNameList']) ?? [];
|
||||
|
||||
foreach ($classNameList as $className) {
|
||||
$hook = $this->injectableFactory->create($className);
|
||||
|
||||
$hook->process($params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function resetToDefaults(string $name): void
|
||||
{
|
||||
if ($this->isCustom($name)) {
|
||||
throw new Error("Can't reset to defaults custom entity type '$name.'");
|
||||
}
|
||||
|
||||
$type = $this->metadata->get(['scopes', $name, 'type']);
|
||||
|
||||
$previousEntityTypeParams = new Params($name, $type, $this->getCurrentParams($name));
|
||||
|
||||
$this->metadata->delete('scopes', $name, [
|
||||
'disabled',
|
||||
'stream',
|
||||
'statusField',
|
||||
'kanbanStatusIgnoreList',
|
||||
]);
|
||||
|
||||
$this->metadata->delete('clientDefs', $name, [
|
||||
'iconClass',
|
||||
'statusField',
|
||||
'kanbanViewMode',
|
||||
'color',
|
||||
]);
|
||||
|
||||
$this->metadata->delete('entityDefs', $name, [
|
||||
'collection.sortBy',
|
||||
'collection.asc',
|
||||
'collection.orderBy',
|
||||
'collection.order',
|
||||
'collection.textFilterFields',
|
||||
'collection.fullTextSearch',
|
||||
]);
|
||||
|
||||
foreach ($this->getAdditionalParamLocationMap($name) as $it) {
|
||||
['location' => $location, 'param' => $actualParam] = $it;
|
||||
|
||||
$this->metadata->delete($location, $name, [$actualParam]);
|
||||
}
|
||||
|
||||
$this->metadata->save();
|
||||
|
||||
$this->language->delete('Global', 'scopeNames', $name);
|
||||
$this->language->delete('Global', 'scopeNamesPlural', $name);
|
||||
$this->language->save();
|
||||
|
||||
$entityTypeParams = new Params($name, $type, $this->getCurrentParams($name));
|
||||
|
||||
$this->processUpdateHook($entityTypeParams, $previousEntityTypeParams);
|
||||
|
||||
$this->dataManager->clearCache();
|
||||
|
||||
if (
|
||||
!$previousEntityTypeParams->get('optimisticConcurrencyControl') &&
|
||||
$entityTypeParams->get('optimisticConcurrencyControl')
|
||||
) {
|
||||
$this->dataManager->rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function setAdditionalParamsInMetadata(string $entityType, array $data): void
|
||||
{
|
||||
foreach ($this->getAdditionalParamLocationMap($entityType) as $param => $it) {
|
||||
['location' => $location, 'param' => $actualParam] = $it;
|
||||
|
||||
if (!array_key_exists($param, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $data[$param];
|
||||
|
||||
$this->metadata->setParam($location, $entityType, $actualParam, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getCurrentParams(string $entityType): array
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($this->getAdditionalParamLocationMap($entityType) as $param => $item) {
|
||||
['location' => $location, 'param' => $actualParam] = $item;
|
||||
|
||||
$data[$param] = $this->metadata->get([$location, $entityType, $actualParam]);
|
||||
}
|
||||
|
||||
$data['statusField'] = $this->metadata->get(['scopes', $entityType, 'statusField']);
|
||||
$data['kanbanViewMode'] = $this->metadata->get(['scopes', $entityType, 'kanbanViewMode']);
|
||||
$data['disabled'] = $this->metadata->get(['scopes', $entityType, 'disabled']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{location: string, param: string}>
|
||||
*/
|
||||
private function getAdditionalParamLocationMap(string $entityType): array
|
||||
{
|
||||
$templateType = $this->metadata->get(['scopes', $entityType, 'type']);
|
||||
|
||||
$map1 = $this->metadata->get(['app', 'entityManagerParams', 'Global']) ?? [];
|
||||
$map2 = $this->metadata->get(['app', 'entityManagerParams', '@' . ($templateType ?? '_')]) ?? [];
|
||||
$map3 = $this->metadata->get(['app', 'entityManagerParams', $entityType]) ?? [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> $params */
|
||||
$params = [...$map1, ...$map2, ...$map3];
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($params as $param => $defs) {
|
||||
$location = $defs['location'] ?? self::DEFAULT_PARAM_LOCATION;
|
||||
$actualParam = $defs['param'] ?? $param;
|
||||
|
||||
if (!in_array($location, self::ALLOWED_PARAM_LOCATIONS)) {
|
||||
throw new RuntimeException("Param location `$location` is not supported.");
|
||||
}
|
||||
|
||||
$result[$param] = [
|
||||
'location' => $location,
|
||||
'param' => $actualParam,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function isLanguageNotBase(): bool
|
||||
{
|
||||
return $this->language->getLanguage() !== $this->baseLanguage->getLanguage();
|
||||
}
|
||||
|
||||
public function resetFormulaToDefault(string $scope, string $type): void
|
||||
{
|
||||
$this->metadata->delete('formula', $scope, $type);
|
||||
$this->metadata->save();
|
||||
}
|
||||
|
||||
private function isScopeCustomizable(string $scope): bool
|
||||
{
|
||||
if (!$this->metadata->get("scopes.$scope.customizable")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->metadata->get("scopes.$scope.entityManager.edit") === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isScopeCustomizableFormula(string $scope): bool
|
||||
{
|
||||
if (!$this->metadata->get("scopes.$scope.customizable")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->metadata->get("scopes.$scope.entityManager.formula") === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Tools\LeadCapture;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Utils\Util;
|
||||
use Espo\Entities\InboundEmail;
|
||||
use Espo\Entities\LeadCapture as LeadCaptureEntity;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
use stdClass;
|
||||
|
||||
class Service
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private ServiceContainer $recordServiceContainer,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
public function isApiKeyValid(string $apiKey): bool
|
||||
{
|
||||
$leadCapture = $this->entityManager
|
||||
->getRDBRepositoryByClass(LeadCaptureEntity::class)
|
||||
->where([
|
||||
'apiKey' => $apiKey,
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if ($leadCapture) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ForbiddenSilent
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function generateNewApiKeyForEntity(string $id): LeadCaptureEntity
|
||||
{
|
||||
$service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class);
|
||||
|
||||
$entity = $service->getEntity($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$entity->setApiKey($this->generateApiKey());
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
$service->prepareEntityForOutput($entity);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ForbiddenSilent
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function generateNewFormIdForEntity(string $id): LeadCaptureEntity
|
||||
{
|
||||
$service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class);
|
||||
|
||||
$entity = $service->getEntity($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$entity->setFormId($this->generateFormId());
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
$service->prepareEntityForOutput($entity);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
public function generateApiKey(): string
|
||||
{
|
||||
return Util::generateApiKey();
|
||||
}
|
||||
|
||||
public function generateFormId(): string
|
||||
{
|
||||
return Util::generateId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass[]
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function getSmtpAccountDataList(): array
|
||||
{
|
||||
if (!$this->user->isAdmin()) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$dataList = [];
|
||||
|
||||
$inboundEmailList = $this->entityManager
|
||||
->getRDBRepositoryByClass(InboundEmail::class)
|
||||
->where([
|
||||
'useSmtp' => true,
|
||||
'status' => InboundEmail::STATUS_ACTIVE,
|
||||
['emailAddress!=' => ''],
|
||||
['emailAddress!=' => null],
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($inboundEmailList as $inboundEmail) {
|
||||
$item = (object) [];
|
||||
|
||||
$key = 'inboundEmail:' . $inboundEmail->getId();
|
||||
|
||||
$item->key = $key;
|
||||
$item->emailAddress = $inboundEmail->getEmailAddress();
|
||||
$item->fromName = $inboundEmail->getFromName();
|
||||
|
||||
$dataList[] = $item;
|
||||
}
|
||||
|
||||
return $dataList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Tools\Notification;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Record\Collection as RecordCollection;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\Notification;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityCollection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\ORM\Query\Part\Expression as Expr;
|
||||
use Espo\ORM\Query\Part\WhereItem;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
use Espo\Tools\Stream\NoteAccessControl;
|
||||
|
||||
class RecordService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl,
|
||||
private Metadata $metadata,
|
||||
private NoteAccessControl $noteAccessControl,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get notifications for a user.
|
||||
*
|
||||
* @return RecordCollection<Notification>
|
||||
* @throws Error
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function get(User $user, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
$queryBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(Notification::ENTITY_TYPE)
|
||||
->withSearchParams($searchParams)
|
||||
->buildQueryBuilder()
|
||||
->where([Notification::ATTR_USER_ID => $user->getId()])
|
||||
->order(Notification::ATTR_NUMBER, SearchParams::ORDER_DESC);
|
||||
|
||||
if ($this->isGroupingEnabled()) {
|
||||
$queryBuilder->where($this->getActionIdWhere($user->getId()));
|
||||
}
|
||||
|
||||
$offset = $searchParams->getOffset();
|
||||
$limit = $searchParams->getMaxSize();
|
||||
|
||||
if ($limit) {
|
||||
$queryBuilder->limit($offset, $limit + 1);
|
||||
}
|
||||
|
||||
$ignoreScopeList = $this->getIgnoreScopeList();
|
||||
|
||||
if ($ignoreScopeList !== []) {
|
||||
$queryBuilder->where([
|
||||
'OR' => [
|
||||
'relatedParentType' => null,
|
||||
'relatedParentType!=' => $ignoreScopeList,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$query = $queryBuilder->build();
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Notification::class)
|
||||
->clone($query)
|
||||
->find();
|
||||
|
||||
if (!$collection instanceof EntityCollection) {
|
||||
throw new Error("Collection is not instance of EntityCollection.");
|
||||
}
|
||||
|
||||
$collection = $this->prepareCollection($collection, $user);
|
||||
|
||||
$groupedCountMap = $this->getGroupedCountMap($collection, $user->getId());
|
||||
|
||||
$ids = [];
|
||||
$actionIds = [];
|
||||
|
||||
foreach ($collection as $i => $entity) {
|
||||
if ($i === $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$ids[] = $entity->getId();
|
||||
|
||||
$groupedCount = null;
|
||||
|
||||
if ($entity->getActionId() && $this->isGroupingEnabled()) {
|
||||
$actionIds[] = $entity->getActionId();
|
||||
|
||||
$groupedCount = $groupedCountMap[$entity->getActionId()] ?? 0;
|
||||
}
|
||||
|
||||
$entity->setGroupedCount($groupedCount);
|
||||
}
|
||||
|
||||
$collection = new EntityCollection([...$collection], Notification::ENTITY_TYPE);
|
||||
|
||||
$this->markAsRead($user, $ids, $actionIds);
|
||||
|
||||
return RecordCollection::createNoCount($collection, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<Notification> $collection
|
||||
* @return EntityCollection<Notification>
|
||||
*/
|
||||
public function prepareCollection(Collection $collection, User $user): EntityCollection
|
||||
{
|
||||
if (!$collection instanceof EntityCollection) {
|
||||
$collection = new EntityCollection([...$collection], Notification::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
$limit = count($collection);
|
||||
|
||||
foreach ($collection as $i => $entity) {
|
||||
if ($i === $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$this->prepareListItem(
|
||||
entity: $entity,
|
||||
index: $i,
|
||||
collection: $collection,
|
||||
count: $limit,
|
||||
user: $user,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var EntityCollection<Notification> */
|
||||
return new EntityCollection([...$collection], Notification::ENTITY_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @param string[] $actionIds
|
||||
*/
|
||||
private function markAsRead(User $user, array $ids, array $actionIds): void
|
||||
{
|
||||
if ($ids === [] && $actionIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->update()
|
||||
->in(Notification::ENTITY_TYPE)
|
||||
->set([Notification::ATTR_READ => true])
|
||||
->where([Notification::ATTR_USER_ID => $user->getId()])
|
||||
->where(
|
||||
Cond::or(
|
||||
Cond::in(Expr::column(Attribute::ID), $ids),
|
||||
Cond::in(Expr::column(Notification::ATTR_ACTION_ID), $actionIds),
|
||||
)
|
||||
)
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EntityCollection<Notification> $collection
|
||||
*/
|
||||
private function prepareListItem(
|
||||
Notification $entity,
|
||||
int $index,
|
||||
EntityCollection $collection,
|
||||
?int &$count,
|
||||
User $user
|
||||
): void {
|
||||
|
||||
$this->prepareSetFields($entity);
|
||||
|
||||
$noteId = $this->getNoteId($entity);
|
||||
|
||||
if (!$noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!in_array($entity->getType(), [
|
||||
Notification::TYPE_NOTE,
|
||||
Notification::TYPE_MENTION_IN_POST,
|
||||
Notification::TYPE_USER_REACTION,
|
||||
])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$note = $this->entityManager->getRDBRepositoryByClass(Note::class)->getById($noteId);
|
||||
|
||||
if (!$note) {
|
||||
unset($collection[$index]);
|
||||
|
||||
if ($count !== null) {
|
||||
$count--;
|
||||
}
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->noteAccessControl->apply($note, $user);
|
||||
$this->loadNoteFields($note, $entity);
|
||||
|
||||
$entity->set('noteData', $note->getValueMap());
|
||||
}
|
||||
|
||||
public function getNotReadCount(string $userId): int
|
||||
{
|
||||
$whereClause = [
|
||||
Notification::ATTR_USER_ID => $userId,
|
||||
Notification::ATTR_READ => false,
|
||||
];
|
||||
|
||||
$ignoreScopeList = $this->getIgnoreScopeList();
|
||||
|
||||
if (count($ignoreScopeList)) {
|
||||
$whereClause[] = [
|
||||
'OR' => [
|
||||
'relatedParentType' => null,
|
||||
'relatedParentType!=' => $ignoreScopeList,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$builder = $this->entityManager
|
||||
->getRDBRepositoryByClass(Notification::class)
|
||||
->where($whereClause);
|
||||
|
||||
if ($this->isGroupingEnabled()) {
|
||||
$builder->where($this->getActionIdWhere($userId));
|
||||
}
|
||||
|
||||
return $builder->count();
|
||||
}
|
||||
|
||||
public function markAllRead(string $userId): bool
|
||||
{
|
||||
$update = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->update()
|
||||
->in(Notification::ENTITY_TYPE)
|
||||
->set(['read' => true])
|
||||
->where([
|
||||
'userId' => $userId,
|
||||
'read' => false,
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($update);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getIgnoreScopeList(): array
|
||||
{
|
||||
$ignoreScopeList = [];
|
||||
|
||||
$scopes = $this->metadata->get('scopes', []);
|
||||
|
||||
foreach ($scopes as $scope => $item) {
|
||||
if (empty($item['entity'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($item['object'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->acl->checkScope($scope)) {
|
||||
$ignoreScopeList[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return $ignoreScopeList;
|
||||
}
|
||||
|
||||
private function getNoteId(Notification $entity): ?string
|
||||
{
|
||||
$noteId = null;
|
||||
|
||||
$data = $entity->getData();
|
||||
|
||||
if ($data) {
|
||||
$noteId = $data->noteId ?? null;
|
||||
}
|
||||
|
||||
if ($entity->getRelated()?->getEntityType() === Note::ENTITY_TYPE) {
|
||||
$noteId = $entity->getRelated()->getId();
|
||||
}
|
||||
|
||||
return $noteId;
|
||||
}
|
||||
|
||||
private function loadNoteFields(Note $note, Notification $notification): void
|
||||
{
|
||||
$parentId = $note->getParentId();
|
||||
$parentType = $note->getParentType();
|
||||
|
||||
if ($parentId && $parentType) {
|
||||
if ($notification->getType() !== Notification::TYPE_USER_REACTION) {
|
||||
$parent = $this->entityManager->getEntityById($parentType, $parentId);
|
||||
|
||||
if ($parent) {
|
||||
$note->set('parentName', $parent->get(Field::NAME));
|
||||
}
|
||||
}
|
||||
} else if (!$note->isGlobal()) {
|
||||
$targetType = $note->getTargetType();
|
||||
|
||||
if (!$targetType || $targetType === Note::TARGET_USERS) {
|
||||
$note->loadLinkMultipleField('users');
|
||||
}
|
||||
|
||||
if ($targetType !== Note::TARGET_USERS) {
|
||||
if (!$targetType || $targetType === Note::TARGET_TEAMS) {
|
||||
$note->loadLinkMultipleField(Field::TEAMS);
|
||||
} else if ($targetType === Note::TARGET_PORTALS) {
|
||||
$note->loadLinkMultipleField('portals');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relatedId = $note->getRelatedId();
|
||||
$relatedType = $note->getRelatedType();
|
||||
|
||||
if ($relatedId && $relatedType && $notification->getType() !== Notification::TYPE_USER_REACTION) {
|
||||
$related = $this->entityManager->getEntityById($relatedType, $relatedId);
|
||||
|
||||
if ($related) {
|
||||
$note->set('relatedName', $related->get(Field::NAME));
|
||||
}
|
||||
}
|
||||
|
||||
if ($notification->getType() !== Notification::TYPE_USER_REACTION) {
|
||||
$note->loadLinkMultipleField('attachments');
|
||||
}
|
||||
}
|
||||
|
||||
private function getActionIdWhere(string $userId): WhereItem
|
||||
{
|
||||
return Cond::or(
|
||||
Expr::isNull(Expr::column('actionId')),
|
||||
Cond::and(
|
||||
Expr::isNotNull(Expr::column('actionId')),
|
||||
Cond::not(
|
||||
Cond::exists(
|
||||
SelectBuilder::create()
|
||||
->from(Notification::ENTITY_TYPE, 'sub')
|
||||
->select('id')
|
||||
->where(
|
||||
Cond::equal(
|
||||
Expr::column('sub.actionId'),
|
||||
Expr::column('notification.actionId')
|
||||
)
|
||||
)
|
||||
->where(
|
||||
Cond::less(
|
||||
Expr::column('sub.number'),
|
||||
Expr::column('notification.number')
|
||||
)
|
||||
)
|
||||
->where([Notification::ATTR_USER_ID => $userId])
|
||||
->build()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EntityCollection<Notification> $collection
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getGroupedCountMap(EntityCollection $collection, string $userId): array
|
||||
{
|
||||
if (!$this->isGroupingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupedCountMap = [];
|
||||
|
||||
$actionIds = [];
|
||||
|
||||
foreach ($collection as $note) {
|
||||
if ($note->getActionId()) {
|
||||
$actionIds[] = $note->getActionId();
|
||||
}
|
||||
}
|
||||
|
||||
$countsQuery = SelectBuilder::create()
|
||||
->from(Notification::ENTITY_TYPE)
|
||||
->select(Expr::count(Expr::column(Attribute::ID)), 'count')
|
||||
->select(Expr::column(Notification::ATTR_ACTION_ID))
|
||||
->where([
|
||||
Notification::ATTR_ACTION_ID => $actionIds,
|
||||
Notification::ATTR_USER_ID => $userId,
|
||||
])
|
||||
->group(Expr::column(Notification::ATTR_ACTION_ID))
|
||||
->build();
|
||||
|
||||
$rows = $this->entityManager->getQueryExecutor()->execute($countsQuery)->fetchAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$actionId = $row[Notification::ATTR_ACTION_ID] ?? null;
|
||||
|
||||
if (!is_string($actionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupedCountMap[$actionId] = $row['count'] ?? 0;
|
||||
}
|
||||
|
||||
return $groupedCountMap;
|
||||
}
|
||||
|
||||
private function isGroupingEnabled(): bool
|
||||
{
|
||||
// @todo Param in preferences?
|
||||
return (bool) ($this->config->get('notificationGrouping') ?? true);
|
||||
}
|
||||
|
||||
private function prepareSetFields(Notification $entity): void
|
||||
{
|
||||
if ($entity->getRelated() && $entity->getData()?->relatedName) {
|
||||
$entity->set('relatedName', $entity->getData()->relatedName);
|
||||
}
|
||||
|
||||
if ($entity->getCreatedBy() && $entity->getData()?->createdByName) {
|
||||
$entity->set('createdByName', $entity->getData()->createdByName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 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\Tools\Pdf\Dompdf;
|
||||
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Tools\Pdf\Params;
|
||||
use Espo\Tools\Pdf\Template;
|
||||
|
||||
class DompdfInitializer
|
||||
{
|
||||
private string $defaultFontFace = 'DejaVu Sans';
|
||||
|
||||
private const PT = 2.83465;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
public function initialize(Template $template, Params $params): Dompdf
|
||||
{
|
||||
$options = new Options();
|
||||
|
||||
$options->setIsPdfAEnabled($params->isPdfA());
|
||||
$options->setDefaultFont($this->getFontFace($template));
|
||||
|
||||
$pdf = new Dompdf($options);
|
||||
|
||||
if ($params->isPdfA()) {
|
||||
$this->mapFonts($pdf);
|
||||
}
|
||||
|
||||
$size = $template->getPageFormat() === Template::PAGE_FORMAT_CUSTOM ?
|
||||
[0.0, 0.0, $template->getPageWidth() * self::PT, $template->getPageHeight() * self::PT] :
|
||||
$template->getPageFormat();
|
||||
|
||||
$orientation = $template->getPageOrientation() === Template::PAGE_ORIENTATION_PORTRAIT ?
|
||||
'portrait' :
|
||||
'landscape';
|
||||
|
||||
$pdf->setPaper($size, $orientation);
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function getFontFace(Template $template): string
|
||||
{
|
||||
return
|
||||
$template->getFontFace() ??
|
||||
$this->config->get('pdfFontFace') ??
|
||||
$this->defaultFontFace;
|
||||
}
|
||||
|
||||
private function mapFonts(Dompdf $pdf): void
|
||||
{
|
||||
// Fonts are included in PDF/A. Map standard fonts to open source analogues.
|
||||
$fontMetrics = $pdf->getFontMetrics();
|
||||
|
||||
$fontMetrics->setFontFamily('courier', $fontMetrics->getFamily('DejaVu Sans Mono'));
|
||||
$fontMetrics->setFontFamily('fixed', $fontMetrics->getFamily('DejaVu Sans Mono'));
|
||||
$fontMetrics->setFontFamily('helvetica', $fontMetrics->getFamily('DejaVu Sans'));
|
||||
$fontMetrics->setFontFamily('monospace', $fontMetrics->getFamily('DejaVu Sans Mono'));
|
||||
$fontMetrics->setFontFamily('sans-serif', $fontMetrics->getFamily('DejaVu Sans'));
|
||||
$fontMetrics->setFontFamily('serif', $fontMetrics->getFamily('DejaVu Serif'));
|
||||
$fontMetrics->setFontFamily('times', $fontMetrics->getFamily('DejaVu Serif'));
|
||||
$fontMetrics->setFontFamily('times-roman', $fontMetrics->getFamily('DejaVu Serif'));
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
210
data/.backup/upgrades/6997089ba410536c8/files/client/lib/espo.js
Normal file
210
data/.backup/upgrades/6997089ba410536c8/files/client/lib/espo.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,9 +50,10 @@ return [
|
||||
'text' => 'Vermieterhelden',
|
||||
'id' => '737249'
|
||||
],
|
||||
1 => 'CVmhErstgespraech',
|
||||
2 => 'CMietobjekt',
|
||||
3 => (object) [
|
||||
1 => 'CPuls',
|
||||
2 => 'CVmhErstgespraech',
|
||||
3 => 'CMietobjekt',
|
||||
4 => (object) [
|
||||
'type' => 'group',
|
||||
'text' => 'Beteiligte',
|
||||
'iconClass' => NULL,
|
||||
@@ -64,71 +65,70 @@ return [
|
||||
2 => 'CBankverbindungen'
|
||||
]
|
||||
],
|
||||
4 => 'CVmhMietverhltnis',
|
||||
5 => 'CKuendigung',
|
||||
6 => 'CVmhRumungsklage',
|
||||
7 => 'CMietinkasso',
|
||||
8 => 'CDokumente',
|
||||
9 => (object) [
|
||||
5 => 'CVmhMietverhltnis',
|
||||
6 => 'CKuendigung',
|
||||
7 => 'CVmhRumungsklage',
|
||||
8 => 'CMietinkasso',
|
||||
9 => 'CDokumente',
|
||||
10 => (object) [
|
||||
'type' => 'divider',
|
||||
'id' => '342567',
|
||||
'text' => '$CRM'
|
||||
],
|
||||
10 => 'Contact',
|
||||
11 => (object) [
|
||||
11 => 'Contact',
|
||||
12 => (object) [
|
||||
'type' => 'divider',
|
||||
'text' => '$Activities',
|
||||
'id' => '219419'
|
||||
],
|
||||
12 => 'Email',
|
||||
13 => 'Call',
|
||||
14 => 'Task',
|
||||
15 => 'Calendar',
|
||||
16 => (object) [
|
||||
13 => 'Email',
|
||||
14 => 'Call',
|
||||
15 => 'Task',
|
||||
16 => 'Calendar',
|
||||
17 => (object) [
|
||||
'type' => 'divider',
|
||||
'id' => '655187',
|
||||
'text' => '$Support'
|
||||
],
|
||||
17 => 'Case',
|
||||
18 => 'KnowledgeBaseArticle',
|
||||
19 => (object) [
|
||||
18 => 'Case',
|
||||
19 => 'KnowledgeBaseArticle',
|
||||
20 => (object) [
|
||||
'type' => 'divider',
|
||||
'text' => NULL,
|
||||
'id' => '137994'
|
||||
],
|
||||
20 => '_delimiter_',
|
||||
21 => (object) [
|
||||
21 => '_delimiter_',
|
||||
22 => (object) [
|
||||
'type' => 'divider',
|
||||
'text' => '$Marketing',
|
||||
'id' => '463280'
|
||||
],
|
||||
22 => 'Campaign',
|
||||
23 => 'TargetList',
|
||||
24 => (object) [
|
||||
23 => 'Campaign',
|
||||
24 => 'TargetList',
|
||||
25 => (object) [
|
||||
'type' => 'divider',
|
||||
'text' => '$Business',
|
||||
'id' => '518202'
|
||||
],
|
||||
25 => (object) [
|
||||
26 => (object) [
|
||||
'type' => 'divider',
|
||||
'text' => '$Organization',
|
||||
'id' => '566592'
|
||||
],
|
||||
26 => 'User',
|
||||
27 => (object) [
|
||||
27 => 'User',
|
||||
28 => (object) [
|
||||
'type' => 'divider',
|
||||
'text' => NULL,
|
||||
'id' => '898671'
|
||||
],
|
||||
28 => 'Team',
|
||||
29 => 'WorkingTimeCalendar',
|
||||
30 => 'EmailTemplate',
|
||||
31 => 'Template',
|
||||
32 => 'Import',
|
||||
33 => 'GlobalStream',
|
||||
34 => 'Report',
|
||||
35 => 'CCallQueues',
|
||||
36 => 'CPuls'
|
||||
29 => 'Team',
|
||||
30 => 'WorkingTimeCalendar',
|
||||
31 => 'EmailTemplate',
|
||||
32 => 'Template',
|
||||
33 => 'Import',
|
||||
34 => 'GlobalStream',
|
||||
35 => 'Report',
|
||||
36 => 'CCallQueues'
|
||||
],
|
||||
'quickCreateList' => [
|
||||
0 => 'Account',
|
||||
@@ -358,7 +358,7 @@ return [
|
||||
0 => 'youtube.com',
|
||||
1 => 'google.com'
|
||||
],
|
||||
'microtime' => 1770974863.576723,
|
||||
'microtime' => 1772471137.489776,
|
||||
'siteUrl' => 'https://crm.bitbylaw.com',
|
||||
'fullTextSearchMinLength' => 4,
|
||||
'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
return [
|
||||
'cacheTimestamp' => 1770974863,
|
||||
'microtimeState' => 1770974863.697017,
|
||||
'cacheTimestamp' => 1772471137,
|
||||
'microtimeState' => 1772471137.62098,
|
||||
'currencyRates' => [
|
||||
'EUR' => 1.0
|
||||
],
|
||||
'appTimestamp' => 1770476925,
|
||||
'version' => '9.3.0',
|
||||
'latestVersion' => '9.3.0',
|
||||
'appTimestamp' => 1771505822,
|
||||
'version' => '9.3.1',
|
||||
'latestVersion' => '9.3.1',
|
||||
'latestExtensionVersions' => [
|
||||
'Advanced Pack' => '3.12.0'
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user