decimal field type

This commit is contained in:
Yuri Kuznetsov
2025-10-30 12:03:49 +02:00
parent f75ed7e279
commit e1a5ed86e7
13 changed files with 946 additions and 0 deletions
@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldSanitizers;
use Espo\Core\FieldSanitize\Sanitizer;
use Espo\Core\FieldSanitize\Sanitizer\Data;
class Decimal implements Sanitizer
{
public function sanitize(Data $data, string $field): void
{
if (!$data->has($field)) {
return;
}
$value = $data->get($field);
if (is_string($value) || $value === null) {
return;
}
if (is_float($value) || is_int($value)) {
$value = (string) $value;
}
$data->set($field, $value);
}
}
@@ -0,0 +1,103 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldValidators;
use Espo\Core\Currency\CalculatorUtil;
use Espo\ORM\Entity;
use stdClass;
/**
* @noinspection PhpUnused
*/
class DecimalType
{
public function __construct()
{}
public function checkRequired(Entity $entity, string $field): bool
{
return $this->isNotEmpty($entity, $field);
}
/**
* @param mixed $validationValue
* @noinspection PhpUnused
*/
public function checkMax(Entity $entity, string $field, $validationValue): bool
{
if (!$this->isNotEmpty($entity, $field)) {
return true;
}
return CalculatorUtil::compare($entity->get($field), $validationValue) <= 0;
}
/**
* @param mixed $validationValue
* @noinspection PhpUnused
*/
public function checkMin(Entity $entity, string $field, $validationValue): bool
{
if (!$this->isNotEmpty($entity, $field)) {
return true;
}
return CalculatorUtil::compare($entity->get($field), $validationValue) >= 0;
}
/** @noinspection PhpUnused */
public function rawCheckValid(stdClass $data, string $field): bool
{
$value = $data->$field ?? null;
if ($value === null) {
return true;
}
if (!is_string($value)) {
return false;
}
if ($value === '') {
return false;
}
if (preg_match('/^-?[0-9]+\.?[0-9]*$/', $value)) {
return true;
}
return false;
}
protected function isNotEmpty(Entity $entity, string $field): bool
{
return $entity->has($field) && $entity->get($field) !== null;
}
}
@@ -64,4 +64,9 @@ class FieldType
public const JSON_ARRAY = 'jsonArray';
public const JSON_OBJECT = 'jsonObject';
public const PASSWORD = 'password';
/**
* @since 9.3.0
*/
public const DECIMAL = 'decimal';
}
@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Utils\Database\Orm\FieldConverters;
use Doctrine\DBAL\Types\Types;
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\Type\AttributeType;
class Decimal implements FieldConverter
{
private const DEFAULT_PRECISION = 13;
private const DEFAULT_SCALE = 4;
public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs
{
$name = $fieldDefs->getName();
$dbType = $fieldDefs->getParam(FieldParam::DB_TYPE) ?? Types::DECIMAL;
$precision = $fieldDefs->getParam(FieldParam::PRECISION) ?? self::DEFAULT_PRECISION;
$scale = $fieldDefs->getParam(FieldParam::SCALE) ?? self::DEFAULT_SCALE;
$defs = AttributeDefs::create($name)
->withType(AttributeType::VARCHAR)
->withDbType($dbType)
->withParam(AttributeParam::PRECISION, $precision)
->withParam(AttributeParam::SCALE, $scale);
return EntityDefs::create()
->withAttribute($defs);
}
}
@@ -124,6 +124,7 @@
"personName": "Person Name",
"autoincrement": "Auto-increment",
"bool": "Boolean",
"decimal": "Decimal",
"currency": "Currency",
"currencyConverted": "Currency (Converted)",
"date": "Date",
@@ -137,6 +137,7 @@
},
"fieldInfo": {
"varchar": "A single-line text.",
"decimal": "A decimal number with fixed-point precision.",
"enum": "Selectbox, only one value can be selected.",
"text": "A multiline text with markdown support.",
"date": "Date w/o time.",
@@ -179,6 +179,17 @@
"lessThanOrEquals"
]
},
"decimal": {
"view": "views/admin/dynamic-logic/conditions/field-types/base",
"typeList": [
"isEmpty",
"isNotEmpty",
"greaterThan",
"lessThan",
"greaterThanOrEquals",
"lessThanOrEquals"
]
},
"currency": {
"view": "views/admin/dynamic-logic/conditions/field-types/base",
"typeList": [
@@ -0,0 +1,60 @@
{
"params": [
{
"name": "required",
"type": "bool",
"default": false
},
{
"name": "default",
"type": "decimal"
},
{
"name": "min",
"type": "decimal"
},
{
"name": "max",
"type": "decimal"
},
{
"name": "decimalPlaces",
"type": "int"
},
{
"name": "audited",
"type": "bool"
},
{
"name": "readOnly",
"type": "bool"
},
{
"name": "readOnlyAfterCreate",
"type": "bool"
},
{
"name": "precision",
"type": "int",
"hidden": true
},
{
"name": "scale",
"type": "int",
"hidden": true
}
],
"filter": true,
"validationList": [
"required",
"min",
"max"
],
"mandatoryValidationList": [
"valid"
],
"sanitizerClassNameList": [
"Espo\\Classes\\FieldSanitizers\\Decimal"
],
"converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\FieldConverters\\Decimal"
}
@@ -29,6 +29,7 @@
namespace Espo\Tools\DynamicLogic;
use Espo\Core\Currency\CalculatorUtil;
use Espo\Core\Field\Date;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\DateTime\SystemClock;
@@ -214,18 +215,34 @@ class ConditionChecker
}
if ($type === Type::GreaterThan) {
if (is_string($setValue) || is_string($value)) {
return $this->compare($setValue, $value) > 0;
}
return $setValue > $value;
}
if ($type === Type::LessThan) {
if (is_string($setValue) || is_string($value)) {
return $this->compare($setValue, $value) < 0;
}
return $setValue < $value;
}
if ($type === Type::GreaterThanOrEquals) {
if (is_string($setValue) || is_string($value)) {
return $this->compare($setValue, $value) >= 0;
}
return $setValue >= $value;
}
if ($type === Type::LessThanOrEquals) {
if (is_string($setValue) || is_string($value)) {
return $this->compare($setValue, $value) <= 0;
}
return $setValue <= $value;
}
@@ -346,4 +363,20 @@ class ConditionChecker
return Date::fromDateTime($dateTime);
}
/**
* @throws BadCondition
*/
private function compare(mixed $arg1, mixed $arg2): int
{
$arg1 = (string) $arg1;
$arg2 = (string) $arg2;
if (!is_numeric($arg1) || !is_numeric($arg2)) {
throw new BadCondition();
}
return CalculatorUtil::compare($arg1, $arg2);
}
}
+271
View File
@@ -0,0 +1,271 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
/** @module views/fields/decimal */
import IntFieldView from 'views/fields/int';
/**
* A decimal field.
*
* @extends IntFieldView<module:views/fields/decimal~params>
*/
class DecimalFieldView extends IntFieldView {
/**
* @typedef {Object} module:views/fields/decimal~options
* @property {
* module:views/fields/decimal~params &
* module:views/fields/base~params &
* Record
* } [params] Parameters.
*/
/**
* @typedef {Object} module:views/fields/decimal~params
* @property {string} [min] A max value.
* @property {string} [max] A max value.
* @property {boolean} [required] Required.
* @property {boolean} [disableFormatting] Disable formatting.
* @property {number|null} [decimalPlaces] A number of decimal places.
*/
/**
* @param {
* module:views/fields/float~options &
* module:views/fields/base~options
* } options Options.
*/
constructor(options) {
super(options);
}
type = 'decimal'
editTemplate = 'fields/float/edit'
decimalMark = '.'
decimalPlacesRawValue = 10
/**
* @inheritDoc
* @type {Array<(function (): boolean)|string>}
*/
validations = [
'required',
'range',
]
/**
* @private
* @type {number|null}
*/
decimalPlaces
setup() {
super.setup();
if (this.getPreferences().has('decimalMark')) {
this.decimalMark = this.getPreferences().get('decimalMark');
} else if (this.getConfig().has('decimalMark')) {
this.decimalMark = this.getConfig().get('decimalMark');
}
if (!this.decimalMark) {
this.decimalMark = '.';
}
if (this.decimalMark === this.thousandSeparator) {
this.thousandSeparator = '';
}
this.decimalPlaces = this.params.decimalPlaces ?? null;
}
/**
* @inheritDoc
*/
setupAutoNumericOptions() {
// noinspection JSValidateTypes
this.autoNumericOptions = {
digitGroupSeparator: this.thousandSeparator || '',
decimalCharacter: this.decimalMark,
modifyValueOnWheel: false,
selectOnFocus: false,
decimalPlaces: this.decimalPlaces,
decimalPlacesRawValue: this.decimalPlacesRawValue,
allowDecimalPadding: true,
showWarnings: false,
formulaMode: true,
};
if (this.decimalPlaces === null) {
this.autoNumericOptions.decimalPlaces = this.decimalPlacesRawValue;
this.autoNumericOptions.decimalPlacesRawValue = this.decimalPlacesRawValue;
this.autoNumericOptions.allowDecimalPadding = false;
}
}
getValueForDisplay() {
const value = isNaN(this.model.get(this.name)) ? null : this.model.get(this.name);
return this.formatNumber(value);
}
formatNumber(value) {
if (this.disableFormatting) {
return value;
}
return this.formatNumberDetail(value);
}
/**
*
* @param {string|null} value
* @return {string}
*/
formatNumberDetail(value) {
if (value === null) {
return '';
}
const decimalPlaces = this.decimalPlaces;
const parts = value.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
if (decimalPlaces === 0) {
return parts[0];
}
if (decimalPlaces) {
let decimalPartLength = 0;
if (parts.length > 1) {
parts[1] = parts[1].replace(/0+$/, '');
decimalPartLength = parts[1].length;
} else {
parts[1] = '';
}
if (decimalPlaces && decimalPartLength < decimalPlaces) {
const limit = decimalPlaces - decimalPartLength;
for (let i = 0; i < limit; i++) {
parts[1] += '0';
}
}
}
return parts.join(this.decimalMark);
}
/**
* @param {string|null} value
* @return {string|null}
*/
parse(value) {
value = (value !== '') ? value : null;
if (value === null) {
return null;
}
value = value
.split(this.thousandSeparator)
.join('')
.split(this.decimalMark)
.join('.');
return value;
}
/**
* @return {Record}
*/
fetch() {
const rawValue = this.mainInputElement?.value ?? null;
const value = this.parse(rawValue);
return {[this.name]: value};
}
// noinspection JSUnusedGlobalSymbols
validateRange() {
const value = this.model.get(this.name);
if (value === null) {
return false;
}
const minValue = this.params.min;
const maxValue = this.params.max;
if (minValue !== null && maxValue !== null) {
if (Number(value) < Number(minValue) || Number(value) > Number(maxValue)) {
const msg = this.translate('fieldShouldBeBetween', 'messages')
.replace('{field}', this.getLabelText())
.replace('{min}', minValue)
.replace('{max}', maxValue);
this.showValidationMessage(msg);
return true;
}
} else {
if (minValue !== null) {
if (Number(value) < Number(minValue)) {
const msg = this.translate('fieldShouldBeGreater', 'messages')
.replace('{field}', this.getLabelText())
.replace('{value}', minValue);
this.showValidationMessage(msg);
return true;
}
} else if (maxValue !== null) {
if (Number(value) > Number(maxValue)) {
const msg = this.translate('fieldShouldBeLess', 'messages')
.replace('{field}', this.getLabelText())
.replace('{value}', maxValue);
this.showValidationMessage(msg);
return true;
}
}
}
return false;
}
}
export default DecimalFieldView;
+9
View File
@@ -600,6 +600,15 @@
]
}
}
},
{
"properties": {
"type": {
"anyOf": [
{"const": "decimal"}
]
}
}
}
]
},
@@ -32,6 +32,7 @@ namespace tests\integration\Espo\Record;
use Espo\Core\Application;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\FieldValidation\Type;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\CreateParams;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Record\UpdateParams;
@@ -575,4 +576,145 @@ class FieldValidationTest extends BaseTestCase
$this->assertFalse($isThrown);
}
/**
* @noinspection PhpUnhandledExceptionInspection
*/
public function testDecimal(): void
{
$metadata = $this->getMetadata();
$metadata->set('entityDefs', 'Account', [
'fields' => [
'test' => [
'type' => FieldType::DECIMAL,
'min' => '-1.0',
'max' => '100.0',
]
],
]);
$metadata->save();
$this->getDataManager()->rebuild();
$this->reCreateApplication();
$service = $this->getContainer()
->getByClass(ServiceContainer::class)
->getByClass(Account::class);
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-0',
'test' => null,
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertFalse($isThrown);
/** @noinspection PhpConditionAlreadyCheckedInspection */
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-1',
'test' => '10.0',
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertFalse($isThrown);
/** @noinspection PhpConditionAlreadyCheckedInspection */
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-2',
'test' => '-1.0',
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertFalse($isThrown);
/** @noinspection PhpConditionAlreadyCheckedInspection */
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-3',
'test' => '100.0',
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertFalse($isThrown);
/** @noinspection PhpConditionAlreadyCheckedInspection */
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-4',
'test' => '-10.0',
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertTrue($isThrown);
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-5',
'test' => '100.1',
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertTrue($isThrown);
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-6',
'test' => 'abc',
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertTrue($isThrown);
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-7',
'test' => ['test'],
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertTrue($isThrown);
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-8',
'test' => 10.0,
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertFalse($isThrown);
/** @noinspection PhpConditionAlreadyCheckedInspection */
$isThrown = false;
try {
$service->create((object) [
'name' => 'Test-9',
'test' => 10,
], CreateParams::create());
} catch (BadRequest) {
$isThrown = true;
}
$this->assertFalse($isThrown);
}
}
@@ -1295,4 +1295,196 @@ class CheckerTest extends TestCase
$this->assertTrue($checker->check($item));
}
/**
* @noinspection PhpUnhandledExceptionInspection
*/
public function testDecimalGreaterThan(): void
{
$map = [
'value' => '1.0',
];
$checker = new ConditionChecker($this->initEntity($map));
$this->assertTrue(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'greaterThan',
'attribute' => 'value',
'value' => '0.0',
]
])
)
);
$this->assertFalse(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'greaterThan',
'attribute' => 'value',
'value' => '1.0',
]
])
)
);
$this->assertFalse(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'greaterThan',
'attribute' => 'value',
'value' => '2.0',
]
])
)
);
}
/**
* @noinspection PhpUnhandledExceptionInspection
*/
public function testDecimalLessThan(): void
{
$map = [
'value' => '1.0',
];
$checker = new ConditionChecker($this->initEntity($map));
$this->assertTrue(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'lessThan',
'attribute' => 'value',
'value' => '2.0',
]
])
)
);
$this->assertFalse(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'lessThan',
'attribute' => 'value',
'value' => '1.0',
]
])
)
);
$this->assertFalse(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'lessThan',
'attribute' => 'value',
'value' => '0.0',
]
])
)
);
}
/**
* @noinspection PhpUnhandledExceptionInspection
*/
public function testDecimalGreaterThanOrEquals(): void
{
$map = [
'value' => '1.0',
];
$checker = new ConditionChecker($this->initEntity($map));
$this->assertTrue(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'greaterThanOrEquals',
'attribute' => 'value',
'value' => '0.0',
]
])
)
);
$this->assertTrue(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'greaterThanOrEquals',
'attribute' => 'value',
'value' => '1.0',
]
])
)
);
$this->assertFalse(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'greaterThanOrEquals',
'attribute' => 'value',
'value' => '2.0',
]
])
)
);
}
/**
* @noinspection PhpUnhandledExceptionInspection
*/
public function testDecimalLessThanOrEquals(): void
{
$map = [
'value' => '1',
];
$checker = new ConditionChecker($this->initEntity($map));
$this->assertTrue(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'lessThanOrEquals',
'attribute' => 'value',
'value' => '2.0',
]
])
)
);
$this->assertTrue(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'lessThanOrEquals',
'attribute' => 'value',
'value' => '1.0',
]
])
)
);
$this->assertFalse(
$checker->check(
Item::fromGroupDefinition([
(object) [
'type' => 'lessThanOrEquals',
'attribute' => 'value',
'value' => '0.0',
]
])
)
);
}
}