feat(ops) : add custom field audit and restore commands
- app:check-missing-custom-field-values — diagnostic des valeurs perdues - app:restore-piece-custom-field-values — restauration générale - app:restore-recoverable-piece-custom-field-values — restauration ciblée depuis l'audit log (dry-run par défaut, --apply pour exécuter) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
266
src/Command/RestorePieceCustomFieldValuesCommand.php
Normal file
266
src/Command/RestorePieceCustomFieldValuesCommand.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
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 function array_key_exists;
|
||||
use function count;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function ksort;
|
||||
use function preg_replace;
|
||||
use function sprintf;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:restore-piece-custom-field-values',
|
||||
description: 'Restore missing or empty piece custom field values from audit history',
|
||||
)]
|
||||
final class RestorePieceCustomFieldValuesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('pieceId', InputArgument::REQUIRED, 'Piece ID to restore')
|
||||
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect', '500')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$pieceId = (string) $input->getArgument('pieceId');
|
||||
$apply = (bool) $input->getOption('apply');
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
|
||||
$piece = $this->pieces->find($pieceId);
|
||||
if (!$piece instanceof Piece) {
|
||||
$io->error(sprintf('Piece not found: %s', $pieceId));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$type = $piece->getTypePiece();
|
||||
if (null === $type) {
|
||||
$io->error('This piece has no category (typePiece).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$definitions = $type->getPieceCustomFields();
|
||||
if (0 === $definitions->count()) {
|
||||
$io->warning('This piece category has no current custom field definitions.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$history = $this->auditLogs->findEntityHistory('piece', $pieceId, $limit);
|
||||
if ([] === $history) {
|
||||
$io->warning('No audit history found for this piece.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$historicalValues = $this->extractHistoricalValues($history);
|
||||
if ([] === $historicalValues) {
|
||||
$io->warning('No historical custom field values were found in audit logs.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
|
||||
$plannedRows = [];
|
||||
$changesCount = 0;
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if (!$definition instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedName = $this->normalizeFieldName($definition->getName());
|
||||
if ('' === $normalizedName || !isset($historicalValues[$normalizedName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
|
||||
if (!$shouldRestore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $historicalValues[$normalizedName];
|
||||
$plannedRows[] = [
|
||||
$definition->getName(),
|
||||
$candidate['value'],
|
||||
$candidate['sourceDate'],
|
||||
$currentValue ? 'update-empty' : 'create-missing',
|
||||
];
|
||||
$changesCount++;
|
||||
|
||||
if (!$apply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$currentValue instanceof CustomFieldValue) {
|
||||
$currentValue = new CustomFieldValue();
|
||||
$currentValue->setPiece($piece);
|
||||
$currentValue->setCustomField($definition);
|
||||
$this->em->persist($currentValue);
|
||||
}
|
||||
|
||||
$currentValue->setValue($candidate['value']);
|
||||
}
|
||||
|
||||
if (0 === $changesCount) {
|
||||
$io->success('No missing or empty custom field values needed restoration.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($apply) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Field', 'Restored value', 'Audit date', 'Action'],
|
||||
$plannedRows,
|
||||
);
|
||||
|
||||
if ($apply) {
|
||||
$io->success(sprintf('%d custom field value(s) restored.', $changesCount));
|
||||
} else {
|
||||
$io->note(sprintf(
|
||||
'Dry-run only. Re-run with --apply to persist %d restoration(s).',
|
||||
$changesCount,
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AuditLog> $history
|
||||
*
|
||||
* @return array<string, array{value: string, sourceDate: string, sourceField: string}>
|
||||
*/
|
||||
private function extractHistoricalValues(array $history): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($history as $log) {
|
||||
$diff = $log->getDiff();
|
||||
if (!is_array($diff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($diff as $field => $change) {
|
||||
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawName = trim(substr($field, \strlen('customField:')));
|
||||
$normalizedName = $this->normalizeFieldName($rawName);
|
||||
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->extractCandidateValue($change);
|
||||
if (null === $candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$normalizedName] = [
|
||||
'value' => $candidate,
|
||||
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'sourceField'=> $rawName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from?: mixed, to?: mixed} $change
|
||||
*/
|
||||
private function extractCandidateValue(array $change): ?string
|
||||
{
|
||||
$to = $change['to'] ?? null;
|
||||
if (is_string($to) && '' !== trim($to)) {
|
||||
return $to;
|
||||
}
|
||||
|
||||
$from = $change['from'] ?? null;
|
||||
if (is_string($from) && '' !== trim($from)) {
|
||||
return $from;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CustomFieldValue>
|
||||
*/
|
||||
private function indexCurrentValues(Collection $customFieldValues): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($customFieldValues as $customFieldValue) {
|
||||
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function normalizeFieldName(string $name): string
|
||||
{
|
||||
$normalized = trim($name);
|
||||
if ('' === $normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||
if (false !== $transliterated) {
|
||||
$normalized = $transliterated;
|
||||
}
|
||||
|
||||
$normalized = strtolower($normalized);
|
||||
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user