feat: cron controlled cli command to sync xml data with remote

This commit is contained in:
Björn Fromme
2026-03-16 12:00:56 +01:00
parent ef191c55be
commit 2fe9185a6f
11 changed files with 352 additions and 9 deletions
+6
View File
@@ -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_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
###< league/oauth2-server-bundle ### ###< 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 ###
+1
View File
@@ -36,6 +36,7 @@
"symfony/html-sanitizer": "6.4.*", "symfony/html-sanitizer": "6.4.*",
"symfony/http-client": "6.4.*", "symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*", "symfony/intl": "6.4.*",
"symfony/lock": "6.4.*",
"symfony/mailer": "6.4.*", "symfony/mailer": "6.4.*",
"symfony/mime": "6.4.*", "symfony/mime": "6.4.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
Generated
+84 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "cdf22f45562d34493b3d37f46bea70bc", "content-hash": "c898aa38c821e66646b38af7f5335200",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -6465,6 +6465,89 @@
], ],
"time": "2025-11-24T13:57:00+00:00" "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", "name": "symfony/mailer",
"version": "v6.4.27", "version": "v6.4.27",
+7 -7
View File
@@ -4,13 +4,13 @@ flysystem:
adapter: 'local' adapter: 'local'
options: options:
directory: '%env(resolve:XML_EXPORT_PATH)%' directory: '%env(resolve:XML_EXPORT_PATH)%'
# xml_export.storage: xml_source.storage:
# adapter: 'sftp' adapter: 'sftp'
# options: options:
# host: '%env(SFTP_XML_EXPORT_HOST)%' host: '%env(SFTP_XML_EXPORT_HOST)%'
# port: '%env(int:SFTP_XML_EXPORT_PORT)%' port: '%env(int:SFTP_XML_EXPORT_PORT)%'
# username: '%env(SFTP_XML_EXPORT_USER)%' username: '%env(SFTP_XML_EXPORT_USER)%'
# password: '%env(SFTP_XML_EXPORT_PASSWORD)%' password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
xml_dump.storage: xml_dump.storage:
adapter: 'local' adapter: 'local'
options: options:
+2
View File
@@ -0,0 +1,2 @@
framework:
lock: '%env(LOCK_DSN)%'
+8
View File
@@ -12,3 +12,11 @@ zenstruck_schedule:
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
frequency: '0 1 * * *' frequency: '0 1 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging" 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'
+1
View File
@@ -34,6 +34,7 @@ services:
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind: bind:
$xmlExport: '@xml_export.storage' $xmlExport: '@xml_export.storage'
$xmlSource: '@xml_source.storage'
$xmlDump: '@xml_dump.storage' $xmlDump: '@xml_dump.storage'
$jsonExport: '@json_export.storage' $jsonExport: '@json_export.storage'
$travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%' $travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%'
+48
View File
@@ -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;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand( #[AsCommand(
name: 'bpn:replay', name: 'app:bpn:replay',
description: 'Replay a BPN API request from an XML file', description: 'Replay a BPN API request from an XML file',
)] )]
class BpnReplayCommand extends Command class BpnReplayCommand extends Command
+182
View File
@@ -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);
}
}
}
+12
View File
@@ -166,6 +166,18 @@
"src/Kernel.php" "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": { "symfony/mailer": {
"version": "6.4", "version": "6.4",
"recipe": { "recipe": {