feat: cron controlled cli command to sync xml data with remote
This commit is contained in:
@@ -71,3 +71,9 @@ OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
|
||||
OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
|
||||
###< league/oauth2-server-bundle ###
|
||||
|
||||
###> symfony/lock ###
|
||||
# Choose one of the stores below
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
LOCK_DSN=flock
|
||||
###< symfony/lock ###
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"symfony/html-sanitizer": "6.4.*",
|
||||
"symfony/http-client": "6.4.*",
|
||||
"symfony/intl": "6.4.*",
|
||||
"symfony/lock": "6.4.*",
|
||||
"symfony/mailer": "6.4.*",
|
||||
"symfony/mime": "6.4.*",
|
||||
"symfony/monolog-bundle": "^3.0",
|
||||
|
||||
Generated
+84
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cdf22f45562d34493b3d37f46bea70bc",
|
||||
"content-hash": "c898aa38c821e66646b38af7f5335200",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -6465,6 +6465,89 @@
|
||||
],
|
||||
"time": "2025-11-24T13:57:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/lock",
|
||||
"version": "v6.4.26",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/lock.git",
|
||||
"reference": "c8b4a3f3ba5a969d5eb9eb69870d2648c9c82a97"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/lock/zipball/c8b4a3f3ba5a969d5eb9eb69870d2648c9c82a97",
|
||||
"reference": "c8b4a3f3ba5a969d5eb9eb69870d2648c9c82a97",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/dbal": "<2.13",
|
||||
"symfony/cache": "<6.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^2.13|^3|^4",
|
||||
"predis/predis": "^1.1|^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Lock\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jérémy Derussé",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"cas",
|
||||
"flock",
|
||||
"locking",
|
||||
"mutex",
|
||||
"redlock",
|
||||
"semaphore"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/lock/tree/v6.4.26"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-11T09:57:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
"version": "v6.4.27",
|
||||
|
||||
@@ -4,13 +4,13 @@ flysystem:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
||||
# xml_export.storage:
|
||||
# adapter: 'sftp'
|
||||
# options:
|
||||
# host: '%env(SFTP_XML_EXPORT_HOST)%'
|
||||
# port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
||||
# username: '%env(SFTP_XML_EXPORT_USER)%'
|
||||
# password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
||||
xml_source.storage:
|
||||
adapter: 'sftp'
|
||||
options:
|
||||
host: '%env(SFTP_XML_EXPORT_HOST)%'
|
||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
||||
xml_dump.storage:
|
||||
adapter: 'local'
|
||||
options:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
framework:
|
||||
lock: '%env(LOCK_DSN)%'
|
||||
@@ -12,3 +12,11 @@ zenstruck_schedule:
|
||||
- task: app:cleanup:xml-dumps
|
||||
frequency: '0 1 * * *'
|
||||
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: '*/15 8-19 * * *'
|
||||
description: 'Sync BusPro XML data'
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: '0 20-23,0-7 * * *'
|
||||
description: 'Sync BusPro XML data'
|
||||
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
bind:
|
||||
$xmlExport: '@xml_export.storage'
|
||||
$xmlSource: '@xml_source.storage'
|
||||
$xmlDump: '@xml_dump.storage'
|
||||
$jsonExport: '@json_export.storage'
|
||||
$travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
final readonly class XmlExportInfo
|
||||
{
|
||||
private const INFO_FILENAME = 'uebertragung.info';
|
||||
private const TIMESTAMP_FORMAT = 'd.m.Y H:i:s';
|
||||
|
||||
public function __construct(
|
||||
public \DateTimeImmutable $lastTransfer,
|
||||
public int $fileCount,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getFilename(): string
|
||||
{
|
||||
return self::INFO_FILENAME;
|
||||
}
|
||||
|
||||
public static function fromString(string $content): self
|
||||
{
|
||||
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
|
||||
$lines = array_filter(array_map('trim', explode("\n", $content)));
|
||||
|
||||
if (count($lines) < 3) {
|
||||
throw new \InvalidArgumentException('Invalid uebertragung.info format: expected at least 3 lines');
|
||||
}
|
||||
|
||||
$timestamp = \DateTimeImmutable::createFromFormat(self::TIMESTAMP_FORMAT, $lines[0]);
|
||||
if (false === $timestamp) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid timestamp format in uebertragung.info: expected "%s", got "%s"', self::TIMESTAMP_FORMAT, $lines[0]));
|
||||
}
|
||||
|
||||
if (1 !== preg_match('/(\d+)/', $lines[2], $matches)) {
|
||||
throw new \InvalidArgumentException(sprintf('Could not extract file count from: "%s"', $lines[2]));
|
||||
}
|
||||
|
||||
return new self($timestamp, (int) $matches[1]);
|
||||
}
|
||||
|
||||
public function isNewerThan(self $other): bool
|
||||
{
|
||||
return $this->lastTransfer > $other->lastTransfer;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'bpn:replay',
|
||||
name: 'app:bpn:replay',
|
||||
description: 'Replay a BPN API request from an XML file',
|
||||
)]
|
||||
class BpnReplayCommand extends Command
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\Model\XmlExportInfo;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Command\LockableTrait;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:bpn:xml-sync',
|
||||
description: 'Synchronizes XML export files from remote SFTP source to local storage'
|
||||
)]
|
||||
class BpnXmlSyncCommand extends Command
|
||||
{
|
||||
use LockableTrait;
|
||||
|
||||
private const CACHE_KEYS_TO_INVALIDATE = [
|
||||
'bpn_travels_mapping',
|
||||
'bpn_hotels',
|
||||
'bpn_pickups',
|
||||
'bpn_insurances',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly FilesystemOperator $xmlSource,
|
||||
private readonly FilesystemOperator $xmlExport,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force sync even if local data is up to date')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Check for updates without downloading');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if (false === $this->lock()) {
|
||||
$io->warning('Sync is already running in another process');
|
||||
$this->logger->info('XML sync skipped: another instance is running');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$force = $input->getOption('force');
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$remoteInfo = $this->readRemoteInfo();
|
||||
} catch (FilesystemException|\InvalidArgumentException $e) {
|
||||
$io->error(sprintf('Failed to read remote info: %s', $e->getMessage()));
|
||||
$this->logger->error('XML sync failed: could not read remote info', ['exception' => $e]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->text(sprintf(
|
||||
'Remote: %s (%d files)',
|
||||
$remoteInfo->lastTransfer->format('d.m.Y H:i:s'),
|
||||
$remoteInfo->fileCount
|
||||
));
|
||||
|
||||
$localInfo = $this->readLocalInfo();
|
||||
if (null !== $localInfo) {
|
||||
$io->text(sprintf(
|
||||
'Local: %s (%d files)',
|
||||
$localInfo->lastTransfer->format('d.m.Y H:i:s'),
|
||||
$localInfo->fileCount
|
||||
));
|
||||
} else {
|
||||
$io->text('Local: no data');
|
||||
}
|
||||
|
||||
$needsSync = $force || null === $localInfo || $remoteInfo->isNewerThan($localInfo);
|
||||
|
||||
if (false === $needsSync) {
|
||||
$io->success('Local data is up to date, no sync required');
|
||||
$this->logger->info('XML sync skipped: local data is up to date');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Sync required (dry-run mode, no files downloaded)');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section('Syncing files');
|
||||
|
||||
try {
|
||||
$syncedCount = $this->syncFiles($io);
|
||||
} catch (FilesystemException $e) {
|
||||
$io->error(sprintf('Sync failed: %s', $e->getMessage()));
|
||||
$this->logger->error('XML sync failed during file transfer', ['exception' => $e]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->invalidateCaches();
|
||||
$io->text(sprintf('Invalidated %d cache keys', count(self::CACHE_KEYS_TO_INVALIDATE)));
|
||||
|
||||
$io->success(sprintf('Synced %d files', $syncedCount));
|
||||
$this->logger->info('XML sync completed', [
|
||||
'files_synced' => $syncedCount,
|
||||
'remote_timestamp' => $remoteInfo->lastTransfer->format('c'),
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function readRemoteInfo(): XmlExportInfo
|
||||
{
|
||||
$content = $this->xmlSource->read(XmlExportInfo::getFilename());
|
||||
|
||||
return XmlExportInfo::fromString($content);
|
||||
}
|
||||
|
||||
private function readLocalInfo(): ?XmlExportInfo
|
||||
{
|
||||
try {
|
||||
if (false === $this->xmlExport->fileExists(XmlExportInfo::getFilename())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $this->xmlExport->read(XmlExportInfo::getFilename());
|
||||
|
||||
return XmlExportInfo::fromString($content);
|
||||
} catch (FilesystemException|\InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FilesystemException
|
||||
*/
|
||||
private function syncFiles(SymfonyStyle $io): int
|
||||
{
|
||||
$files = $this->xmlSource
|
||||
->listContents('.')
|
||||
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
|
||||
->map(fn (StorageAttributes $attributes) => $attributes->path())
|
||||
->toArray();
|
||||
|
||||
$io->progressStart(count($files));
|
||||
|
||||
foreach ($files as $path) {
|
||||
$content = $this->xmlSource->read($path);
|
||||
$this->xmlExport->write($path, $content);
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
return count($files);
|
||||
}
|
||||
|
||||
private function invalidateCaches(): void
|
||||
{
|
||||
foreach (self::CACHE_KEYS_TO_INVALIDATE as $key) {
|
||||
$this->cache->delete($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,18 @@
|
||||
"src/Kernel.php"
|
||||
]
|
||||
},
|
||||
"symfony/lock": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.2",
|
||||
"ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/lock.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/mailer": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
|
||||
Reference in New Issue
Block a user