diff --git a/application/Espo/Classes/Jobs/SyncCurrencyRates.php b/application/Espo/Classes/Jobs/SyncCurrencyRates.php index 646bcc0242..bfbd68bb4f 100644 --- a/application/Espo/Classes/Jobs/SyncCurrencyRates.php +++ b/application/Espo/Classes/Jobs/SyncCurrencyRates.php @@ -30,7 +30,7 @@ namespace Espo\Classes\Jobs; use Espo\Core\Job\JobDataLess; -use Espo\Tools\Currency\RecordManager; +use Espo\Tools\Currency\SyncManager; /** * @noinspection PhpUnused @@ -38,11 +38,11 @@ use Espo\Tools\Currency\RecordManager; class SyncCurrencyRates implements JobDataLess { public function __construct( - private RecordManager $recordManager, + private SyncManager $syncManager, ) {} public function run(): void { - $this->recordManager->syncToConfig(); + $this->syncManager->syncToConfig(); } } diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php index b8c7003b4a..b59bc6e473 100644 --- a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php @@ -36,7 +36,7 @@ use Espo\Core\WebSocket\Submission; use Espo\Entities\CurrencyRecordRate; use Espo\ORM\Entity; use Espo\Tools\Currency\Exceptions\NotEnabled; -use Espo\Tools\Currency\RecordManager; +use Espo\Tools\Currency\SyncManager; /** * @implements DeleteHook @@ -44,7 +44,7 @@ use Espo\Tools\Currency\RecordManager; class AfterDelete implements DeleteHook { public function __construct( - private RecordManager $recordManager, + private SyncManager $syncManager, private Submission $submission, ) {} @@ -53,7 +53,7 @@ class AfterDelete implements DeleteHook $code = $entity->getRecord()->getCode(); try { - $this->recordManager->syncCodeToConfig($code); + $this->syncManager->syncCodeToConfig($code); } catch (NotEnabled $e) { throw new Conflict($e->getMessage(), previous: $e); } diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php index f2b49ee778..e8fdcaa264 100644 --- a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php @@ -35,7 +35,7 @@ use Espo\Core\WebSocket\Submission; use Espo\Entities\CurrencyRecordRate; use Espo\ORM\Entity; use Espo\Tools\Currency\Exceptions\NotEnabled; -use Espo\Tools\Currency\RecordManager; +use Espo\Tools\Currency\SyncManager; /** * @implements SaveHook @@ -43,7 +43,7 @@ use Espo\Tools\Currency\RecordManager; class AfterSave implements SaveHook { public function __construct( - private RecordManager $recordManager, + private SyncManager $syncManager, private Submission $submission, ) {} @@ -52,7 +52,7 @@ class AfterSave implements SaveHook $code = $entity->getRecord()->getCode(); try { - $this->recordManager->syncCodeToConfig($code); + $this->syncManager->syncCodeToConfig($code); } catch (NotEnabled $e) { throw new Conflict($e->getMessage(), previous: $e); } diff --git a/application/Espo/Core/Currency/ConfigDataProvider.php b/application/Espo/Core/Currency/ConfigDataProvider.php index d356b60c24..f21062301e 100644 --- a/application/Espo/Core/Currency/ConfigDataProvider.php +++ b/application/Espo/Core/Currency/ConfigDataProvider.php @@ -34,8 +34,10 @@ use RuntimeException; class ConfigDataProvider { - public function __construct(private Config $config) - {} + public function __construct( + private Config $config, + private InternalRatesProvider $internalRatesProvider, + ) {} /** * Get decimal places. @@ -66,7 +68,7 @@ class ConfigDataProvider /** * Get a list of available currencies. * - * @return array + * @return string[] */ public function getCurrencyList(): array { @@ -86,10 +88,10 @@ class ConfigDataProvider */ public function getCurrencyRate(string $currencyCode): float { - $rates = $this->config->get('currencyRates') ?? []; + $rates = $this->internalRatesProvider->get($this->getBaseCurrency()); if (!$this->hasCurrency($currencyCode)) { - throw new RuntimeException("Can't get currency rate of '{$currencyCode}' currency."); + throw new RuntimeException("Can't get currency rate of '$currencyCode' currency."); } return $rates[$currencyCode] ?? 1.0; @@ -100,7 +102,7 @@ class ConfigDataProvider */ public function getCurrencyRates(): Rates { - $rates = $this->config->get('currencyRates') ?? []; + $rates = $this->internalRatesProvider->get($this->getBaseCurrency()); $rates[$this->getBaseCurrency()] = 1.0; diff --git a/application/Espo/Core/Currency/InternalRateEntryProvider.php b/application/Espo/Core/Currency/InternalRateEntryProvider.php new file mode 100644 index 0000000000..bcaf6d221b --- /dev/null +++ b/application/Espo/Core/Currency/InternalRateEntryProvider.php @@ -0,0 +1,91 @@ +. + * + * 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 Espo\Core\Field\Date; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\CurrencyRecord; +use Espo\Entities\CurrencyRecordRate; +use Espo\ORM\Query\Part\Order; +use Traversable; + +/** + * @internal + */ +class InternalRateEntryProvider +{ + public function __construct( + private EntityManagerProxy $entityManager, + ) {} + + /** + * @return CurrencyRecordRate[] + */ + public function getRateEntries(Date $date, string $base): array + { + $rates = []; + + foreach ($this->getActiveCurrencyRecords() as $record) { + $rate = $this->getRateEntryForRecord($record, $date, $base); + + if ($rate) { + $rates[] = $rate; + } + } + + return $rates; + } + + /** + * @return Traversable + */ + private function getActiveCurrencyRecords(): Traversable + { + return $this->entityManager + ->getRDBRepositoryByClass(CurrencyRecord::class) + ->where([ + CurrencyRecord::FIELD_STATUS => CurrencyRecord::STATUS_ACTIVE, + ]) + ->find(); + } + + public function getRateEntryForRecord(CurrencyRecord $record, Date $date, string $base): ?CurrencyRecordRate + { + return $this->entityManager + ->getRDBRepositoryByClass(CurrencyRecordRate::class) + ->where([ + CurrencyRecordRate::ATTR_RECORD_ID => $record->getId(), + CurrencyRecordRate::FIELD_BASE_CODE => $base, + CurrencyRecordRate::FIELD_DATE . '<=' => $date->toString(), + ]) + ->order(CurrencyRecordRate::FIELD_DATE, Order::DESC) + ->findOne(); + } +} diff --git a/application/Espo/Core/Currency/InternalRatesProvider.php b/application/Espo/Core/Currency/InternalRatesProvider.php new file mode 100644 index 0000000000..375071f323 --- /dev/null +++ b/application/Espo/Core/Currency/InternalRatesProvider.php @@ -0,0 +1,132 @@ +. + * + * 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 Espo\Core\Field\Date; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\DateTime; +use LogicException; +use stdClass; + +/** + * @internal + */ +class InternalRatesProvider +{ + private string $cacheKey = 'currencyRates'; + + /** @var (stdClass&object{date: string, rates: stdClass})|null */ + private ?stdClass $data = null; + + public function __construct( + private DataCache $dataCache, + private SystemConfig $systemConfig, + private DateTime $dateTime, + private InternalRateEntryProvider $rateEntryProvider, + ) {} + + /** + * @return array + */ + public function get(string $base): array + { + $this->data ??= $this->getCachedData(); + + $today = $this->dateTime->getToday(); + + if (!$this->data || $this->data->date !== $today->toString()) { + $this->data = $this->buildData($today, $base); + + $this->storeData(); + } + + if ($this->data === null) { + throw new LogicException(); + } + + return get_object_vars($this->data->rates); + } + + /** + * @return (stdClass&object{date: string, rates: stdClass})|null + */ + private function getCachedData(): ?stdClass + { + if (!$this->systemConfig->useCache()) { + return null; + } + + $cached = $this->dataCache->tryGet($this->cacheKey); + + if (!$cached instanceof stdClass) { + return null; + } + + if (!isset($cached->date) || !isset($cached->rates)) { + $this->dataCache->clear($this->cacheKey); + + return null; + } + + /** @var stdClass&object{date: string, rates: stdClass} */ + return $cached; + } + + private function storeData(): void + { + if (!$this->systemConfig->useCache()) { + return; + } + + if (!$this->data instanceof stdClass) { + throw new LogicException(); + } + + $this->dataCache->store($this->cacheKey, $this->data); + } + + /** + * @return stdClass&object{date: string, rates: stdClass} + */ + private function buildData(Date $today, string $base): stdClass + { + $rates = []; + + foreach ($this->rateEntryProvider->getRateEntries($today, $base) as $rate) { + $rates[$rate->getRecord()->getCode()] = (float) $rate->getRate(); + } + + return (object) [ + 'date' => $today->toString(), + 'rates' => (object) $rates, + ]; + } +} diff --git a/application/Espo/Core/Rebuild/Actions/SyncCurrency.php b/application/Espo/Core/Rebuild/Actions/SyncCurrency.php index f148c5c1af..e61db54f7b 100644 --- a/application/Espo/Core/Rebuild/Actions/SyncCurrency.php +++ b/application/Espo/Core/Rebuild/Actions/SyncCurrency.php @@ -30,7 +30,7 @@ namespace Espo\Core\Rebuild\Actions; use Espo\Core\Rebuild\RebuildAction; -use Espo\Tools\Currency\RecordManager; +use Espo\Tools\Currency\SyncManager; /** * @noinspection PhpUnused @@ -38,11 +38,11 @@ use Espo\Tools\Currency\RecordManager; class SyncCurrency implements RebuildAction { public function __construct( - private RecordManager $recordManager, + private SyncManager $syncManager, ) {} public function process(): void { - $this->recordManager->sync(); + $this->syncManager->sync(); } } diff --git a/application/Espo/Core/Utils/DataCache.php b/application/Espo/Core/Utils/DataCache.php index cf03a92945..378704cae3 100644 --- a/application/Espo/Core/Utils/DataCache.php +++ b/application/Espo/Core/Utils/DataCache.php @@ -29,6 +29,7 @@ namespace Espo\Core\Utils; +use Espo\Core\Utils\File\Exceptions\FileError; use Espo\Core\Utils\File\Manager as FileManager; use InvalidArgumentException; @@ -56,6 +57,7 @@ class DataCache * Get a stored value. * * @return array|stdClass + * @throws FileError */ public function get(string $key) { @@ -64,6 +66,27 @@ class DataCache return $this->fileManager->getPhpSafeContents($cacheFile); } + /** + * Try to get a stored value. Returns null if does not exist. + * + * @return array|stdClass|null + * @since 9.3.0 + */ + public function tryGet(string $key) + { + if (!$this->has($key)) { + return null; + } + + $cacheFile = $this->getCacheFile($key); + + try { + return $this->fileManager->getPhpSafeContents($cacheFile); + } catch (FileError) { + return null; + } + } + /** * Store in cache. * diff --git a/application/Espo/Tools/App/SettingsService.php b/application/Espo/Tools/App/SettingsService.php index cdc1124c58..4960641803 100644 --- a/application/Espo/Tools/App/SettingsService.php +++ b/application/Espo/Tools/App/SettingsService.php @@ -51,7 +51,7 @@ use Espo\Core\Utils\Config\ConfigWriter; use Espo\Core\Utils\Config\Access; use Espo\Entities\Portal; use Espo\Repositories\Portal as PortalRepository; -use Espo\Tools\Currency\RecordManager as CurrencyRecordManager; +use Espo\Tools\Currency\SyncManager as CurrencySyncManager; use stdClass; class SettingsService @@ -72,7 +72,7 @@ class SettingsService private Config\SystemConfig $systemConfig, private EmailConfigDataProvider $emailConfigDataProvider, private Acl\Cache\Clearer $aclCacheClearer, - private CurrencyRecordManager $currencyRecordManager, + private CurrencySyncManager $currencySyncManager, private CurrencyDatabasePopulator $currencyDatabasePopulator, ) {} @@ -244,7 +244,7 @@ class SettingsService } if (isset($data->baseCurrency) || isset($data->currencyList) || isset($data->defaultCurrency)) { - $this->currencyRecordManager->sync(); + $this->currencySyncManager->sync(); $this->currencyDatabasePopulator->process(); } } diff --git a/application/Espo/Tools/Currency/RateEntryProvider.php b/application/Espo/Tools/Currency/RateEntryProvider.php index 2f314b73b1..d710275b18 100644 --- a/application/Espo/Tools/Currency/RateEntryProvider.php +++ b/application/Espo/Tools/Currency/RateEntryProvider.php @@ -30,6 +30,7 @@ namespace Espo\Tools\Currency; use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\Currency\InternalRateEntryProvider; use Espo\Core\Field\Date; use Espo\Core\Utils\DateTime; use Espo\Entities\CurrencyRecord; @@ -52,6 +53,7 @@ class RateEntryProvider private ConfigDataProvider $configDataProvider, private EntityManager $entityManager, private DateTime $dateTime, + private InternalRateEntryProvider $internalRateEntryProvider, ) { $this->map = new WeakMap(); } @@ -89,22 +91,22 @@ class RateEntryProvider return $entry; } + private function getRateEntryForRecord(CurrencyRecord $record, ?Date $date = null): ?CurrencyRecordRate + { + $date ??= $this->dateTime->getToday(); + $base = $this->configDataProvider->getBaseCurrency(); + + return $this->internalRateEntryProvider->getRateEntryForRecord($record, $date, $base); + } + /** * Get rate against the base currency by a record. * * @return ?numeric-string */ - public function getRateForRecord(CurrencyRecord $record): ?string + public function getRateForRecord(CurrencyRecord $record, ?Date $date = null): ?string { - $rateEntry = $this->entityManager - ->getRDBRepositoryByClass(CurrencyRecordRate::class) - ->where([ - CurrencyRecordRate::ATTR_RECORD_ID => $record->getId(), - CurrencyRecordRate::FIELD_BASE_CODE => $this->configDataProvider->getBaseCurrency(), - CurrencyRecordRate::FIELD_DATE . '<=' => $this->dateTime->getToday()->toString(), - ]) - ->order(CurrencyRecordRate::FIELD_DATE, Order::DESC) - ->findOne(); + $rateEntry = $this->getRateEntryForRecord($record, $date); return $rateEntry?->getRate(); } diff --git a/application/Espo/Tools/Currency/RateService.php b/application/Espo/Tools/Currency/RateService.php index e971c73762..2e9e58d76e 100644 --- a/application/Espo/Tools/Currency/RateService.php +++ b/application/Espo/Tools/Currency/RateService.php @@ -48,7 +48,7 @@ class RateService private Acl $acl, private DatabasePopulator $databasePopulator, private ConfigDataProvider $configDataProvider, - private RecordManager $recordManager, + private SyncManager $syncManager, private RateEntryProvider $rateEntryProvider, private DateTime $dateTime, private EntityManager $entityManager, @@ -93,7 +93,7 @@ class RateService $this->writeOne($code, $value); } - $this->recordManager->syncToConfig(); + $this->syncManager->syncToConfig(); $this->databasePopulator->process(); } diff --git a/application/Espo/Tools/Currency/RecordManager.php b/application/Espo/Tools/Currency/SyncManager.php similarity index 98% rename from application/Espo/Tools/Currency/RecordManager.php rename to application/Espo/Tools/Currency/SyncManager.php index 08cfdcc3fe..85bf3492b6 100644 --- a/application/Espo/Tools/Currency/RecordManager.php +++ b/application/Espo/Tools/Currency/SyncManager.php @@ -37,7 +37,11 @@ use Espo\ORM\Query\UpdateBuilder; use Espo\Tools\Currency\Exceptions\NotEnabled; use Traversable; -class RecordManager +/** + * @since 9.3.0 + * @internal + */ +class SyncManager { public function __construct( private ConfigDataProvider $configDataProvider, diff --git a/tests/integration/Espo/Currency/CurrencyTest.php b/tests/integration/Espo/Currency/CurrencyTest.php index 7f3839457e..e9b1aedaa5 100644 --- a/tests/integration/Espo/Currency/CurrencyTest.php +++ b/tests/integration/Espo/Currency/CurrencyTest.php @@ -37,7 +37,7 @@ use Espo\Core\Currency\Rates; use Espo\Core\Field\Currency; use Espo\Core\InjectableFactory; use Espo\Core\Utils\Config\ConfigWriter; -use Espo\Tools\Currency\RecordManager; +use Espo\Tools\Currency\SyncManager; use tests\integration\Core\BaseTestCase; class CurrencyTest extends BaseTestCase @@ -60,7 +60,7 @@ class CurrencyTest extends BaseTestCase $configWriter->save(); - $this->getInjectableFactory()->create(RecordManager::class)->sync(); + $this->getInjectableFactory()->create(SyncManager::class)->sync(); $service = $factory->create(RateService::class); diff --git a/tests/unit/Espo/Core/Field/Currency/CurrencyConfigDataProviderTest.php b/tests/unit/Espo/Core/Field/Currency/CurrencyConfigDataProviderTest.php index 5b742190af..847faba72c 100644 --- a/tests/unit/Espo/Core/Field/Currency/CurrencyConfigDataProviderTest.php +++ b/tests/unit/Espo/Core/Field/Currency/CurrencyConfigDataProviderTest.php @@ -30,6 +30,7 @@ namespace tests\unit\Espo\Core\Field\Currency; use Espo\Core\Currency\ConfigDataProvider as CurrencyConfigDataProvider; +use Espo\Core\Currency\InternalRatesProvider; use Espo\Core\Utils\Config; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -38,12 +39,17 @@ class CurrencyConfigDataProviderTest extends TestCase { private $config; private $provider; + private $ratesProvider; protected function setUp() : void { $this->config = $this->createMock(Config::class); + $this->ratesProvider = $this->createMock(InternalRatesProvider::class); - $this->provider = new CurrencyConfigDataProvider($this->config); + $this->provider = new CurrencyConfigDataProvider( + $this->config, + $this->ratesProvider, + ); } public function testDefaultCurrency() @@ -100,6 +106,13 @@ class CurrencyConfigDataProviderTest extends TestCase public function testCurrencyRate1() { + $this->ratesProvider + ->expects($this->any()) + ->method('get') + ->willReturn([ + 'EUR' => 1.2, + ]); + $invokedCount = $this->exactly(2); $this->config @@ -107,11 +120,9 @@ class CurrencyConfigDataProviderTest extends TestCase ->method('get') ->willReturnCallback(function ($param) use ($invokedCount) { if ($invokedCount->numberOfInvocations() === 1) { - $this->assertEquals('currencyRates', $param); + $this->assertEquals('baseCurrency', $param); - return [ - 'EUR' => 1.2, - ]; + return 'USD'; } if ($invokedCount->numberOfInvocations() === 2) { @@ -130,6 +141,13 @@ class CurrencyConfigDataProviderTest extends TestCase public function testCurrencyRate2() { + $this->ratesProvider + ->expects($this->any()) + ->method('get') + ->willReturn([ + 'EUR' => 1.2, + ]); + $invokedCount = $this->exactly(2); $this->config @@ -137,11 +155,9 @@ class CurrencyConfigDataProviderTest extends TestCase ->method('get') ->willReturnCallback(function ($param) use ($invokedCount) { if ($invokedCount->numberOfInvocations() === 1) { - $this->assertEquals('currencyRates', $param); + $this->assertEquals('baseCurrency', $param); - return [ - 'EUR' => 1.2, - ]; + return 'USD'; } if ($invokedCount->numberOfInvocations() === 2) {