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:
Matthieu
2026-03-24 08:49:53 +01:00
parent 330b9376f6
commit 8851f22e4e
3 changed files with 904 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\PieceRepository;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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 array_slice;
use function count;
use function iconv;
use function in_array;
use function is_array;
use function is_string;
use function preg_replace;
use function sprintf;
use function str_starts_with;
use function strtolower;
use function trim;
#[AsCommand(
name: 'app:check-missing-custom-field-values',
description: 'List missing or empty custom field values for pieces and composants',
)]
final class CheckMissingCustomFieldValuesCommand extends Command
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly ComposantRepository $composants,
private readonly AuditLogRepository $auditLogs,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('entity', null, InputOption::VALUE_REQUIRED, 'piece, composant or all', 'all')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Audit entries inspected per entity', '200')
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the final table', '300')
->addOption('recoverable-only', null, InputOption::VALUE_NONE, 'Show only rows recoverable from audit')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$entityScope = (string) $input->getOption('entity');
$limit = max(1, (int) $input->getOption('limit'));
$maxRows = max(1, (int) $input->getOption('max-rows'));
$recoverableOnly = (bool) $input->getOption('recoverable-only');
if (!in_array($entityScope, ['all', 'piece', 'composant'], true)) {
$io->error('Invalid --entity value. Use: all, piece, composant');
return Command::FAILURE;
}
$rows = [];
$counts = [
'piece' => 0,
'composant' => 0,
];
if ('all' === $entityScope || 'piece' === $entityScope) {
foreach ($this->pieces->findAll() as $piece) {
if (!$piece instanceof Piece) {
continue;
}
$pieceRows = $this->inspectPiece($piece, $limit, $recoverableOnly);
$counts['piece'] += count($pieceRows);
$rows = [...$rows, ...$pieceRows];
}
}
if ('all' === $entityScope || 'composant' === $entityScope) {
foreach ($this->composants->findAll() as $composant) {
if (!$composant instanceof Composant) {
continue;
}
$composantRows = $this->inspectComposant($composant, $limit, $recoverableOnly);
$counts['composant'] += count($composantRows);
$rows = [...$rows, ...$composantRows];
}
}
if ([] === $rows) {
$io->success('No missing or empty custom field values found.');
return Command::SUCCESS;
}
$displayRows = array_slice($rows, 0, $maxRows);
$io->table(
['Entity', 'ID', 'Name', 'Reference', 'Category', 'Field', 'Issue', 'Recoverable', 'Audit value'],
$displayRows,
);
if (count($rows) > $maxRows) {
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
}
$io->note(sprintf(
'Missing/empty values found: pieces=%d, composants=%d, total=%d.',
$counts['piece'],
$counts['composant'],
count($rows),
));
return Command::SUCCESS;
}
/**
* @return list<array<int, string>>
*/
private function inspectPiece(Piece $piece, int $limit, bool $recoverableOnly): array
{
$type = $piece->getTypePiece();
if (null === $type) {
return [];
}
return $this->inspectEntity(
entityType: 'piece',
entityId: (string) $piece->getId(),
entityName: $piece->getName(),
entityReference: $piece->getReference() ?? '',
typeName: $type->getName(),
definitions: $type->getPieceCustomFields(),
currentValues: $piece->getCustomFieldValues(),
limit: $limit,
recoverableOnly: $recoverableOnly,
);
}
/**
* @return list<array<int, string>>
*/
private function inspectComposant(Composant $composant, int $limit, bool $recoverableOnly): array
{
$type = $composant->getTypeComposant();
if (null === $type) {
return [];
}
return $this->inspectEntity(
entityType: 'composant',
entityId: (string) $composant->getId(),
entityName: $composant->getName(),
entityReference: $composant->getReference() ?? '',
typeName: $type->getName(),
definitions: $type->getComponentCustomFields(),
currentValues: $composant->getCustomFieldValues(),
limit: $limit,
recoverableOnly: $recoverableOnly,
);
}
/**
* @return list<array<int, string>>
*/
private function inspectEntity(
string $entityType,
string $entityId,
string $entityName,
string $entityReference,
string $typeName,
Collection $definitions,
Collection $currentValues,
int $limit,
bool $recoverableOnly,
): array {
if (0 === $definitions->count()) {
return [];
}
$currentValuesByFieldId = $this->indexCurrentValues($currentValues);
$history = $this->auditLogs->findEntityHistory($entityType, $entityId, $limit);
$historicalValues = $this->extractHistoricalValues($history);
$rows = [];
foreach ($definitions as $definition) {
if (!$definition instanceof CustomField) {
continue;
}
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
$issue = null;
if (!$currentValue instanceof CustomFieldValue) {
$issue = 'missing';
} elseif ('' === trim($currentValue->getValue())) {
$issue = 'empty';
}
if (null === $issue) {
continue;
}
$auditCandidate = $historicalValues[$this->normalizeFieldName($definition->getName())] ?? null;
if ($recoverableOnly && null === $auditCandidate) {
continue;
}
$rows[] = [
$entityType,
$entityId,
$entityName,
$entityReference,
$typeName,
$definition->getName(),
$issue,
$auditCandidate ? 'yes' : 'no',
$auditCandidate['value'] ?? '',
];
}
return $rows;
}
/**
* @param list<AuditLog> $history
*
* @return array<string, array{value: 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;
}
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
continue;
}
$candidate = $this->extractCandidateValue($change);
if (null === $candidate) {
continue;
}
$values[$normalizedName] = ['value' => $candidate];
}
}
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));
}
}

View 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));
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\AuditLog;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
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\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 array_slice;
use function count;
use function iconv;
use function in_array;
use function is_array;
use function is_string;
use function preg_replace;
use function sprintf;
use function str_starts_with;
use function strtolower;
use function trim;
#[AsCommand(
name: 'app:restore-recoverable-piece-custom-field-values',
description: 'Restore all recoverable missing or empty custom field values for pieces',
)]
final class RestoreRecoverablePieceCustomFieldValuesCommand extends Command
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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 per piece', '500')
->addOption('category', null, InputOption::VALUE_REQUIRED, 'Only process pieces whose ModelType name matches this category')
->addOption('piece-id', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to one or more piece IDs')
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the preview table', '300')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$apply = (bool) $input->getOption('apply');
$limit = max(1, (int) $input->getOption('limit'));
$maxRows = max(1, (int) $input->getOption('max-rows'));
$category = $this->normalizeOptionalString($input->getOption('category'));
$pieceIdsRaw = $input->getOption('piece-id');
$pieceIds = is_array($pieceIdsRaw) ? array_values(array_filter(array_map('strval', $pieceIdsRaw))) : [];
$rows = [];
$changesCount = 0;
$pieceCount = 0;
foreach ($this->pieces->findAll() as $piece) {
if (!$piece instanceof Piece) {
continue;
}
if ([] !== $pieceIds && !in_array((string) $piece->getId(), $pieceIds, true)) {
continue;
}
$type = $piece->getTypePiece();
if (!$type instanceof ModelType) {
continue;
}
if (null !== $category && $this->normalizeFieldName($type->getName()) !== $this->normalizeFieldName($category)) {
continue;
}
$pieceRows = $this->restorePiece($piece, $limit, $apply);
if ([] === $pieceRows) {
continue;
}
$pieceCount++;
$changesCount += count($pieceRows);
$rows = [...$rows, ...$pieceRows];
}
if ([] === $rows) {
$io->success('No recoverable piece custom field values found.');
return Command::SUCCESS;
}
$displayRows = array_slice($rows, 0, $maxRows);
$io->table(
['Piece ID', 'Name', 'Reference', 'Category', 'Field', 'Restored value', 'Audit date', 'Action'],
$displayRows,
);
if (count($rows) > $maxRows) {
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
}
if ($apply) {
$this->em->flush();
$io->success(sprintf('%d value(s) restored across %d piece(s).', $changesCount, $pieceCount));
} else {
$io->note(sprintf(
'Dry-run only. %d value(s) recoverable across %d piece(s). Re-run with --apply to persist.',
$changesCount,
$pieceCount,
));
}
return Command::SUCCESS;
}
/**
* @return list<array<int, string>>
*/
private function restorePiece(Piece $piece, int $limit, bool $apply): array
{
$type = $piece->getTypePiece();
if (!$type instanceof ModelType) {
return [];
}
$definitions = $type->getPieceCustomFields();
if (0 === $definitions->count()) {
return [];
}
$history = $this->auditLogs->findEntityHistory('piece', (string) $piece->getId(), $limit);
if ([] === $history) {
return [];
}
$historicalValues = $this->extractHistoricalValues($history);
if ([] === $historicalValues) {
return [];
}
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
$rows = [];
foreach ($definitions as $definition) {
if (!$definition instanceof CustomField) {
continue;
}
$normalizedName = $this->normalizeFieldName($definition->getName());
$candidate = $historicalValues[$normalizedName] ?? null;
if (null === $candidate) {
continue;
}
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
if (!$shouldRestore) {
continue;
}
$action = $currentValue instanceof CustomFieldValue ? 'update-empty' : 'create-missing';
$rows[] = [
(string) $piece->getId(),
$piece->getName(),
$piece->getReference() ?? '',
$type->getName(),
$definition->getName(),
$candidate['value'],
$candidate['sourceDate'],
$action,
];
if (!$apply) {
continue;
}
if (!$currentValue instanceof CustomFieldValue) {
$currentValue = new CustomFieldValue();
$currentValue->setPiece($piece);
$currentValue->setCustomField($definition);
$this->em->persist($currentValue);
}
$currentValue->setValue($candidate['value']);
}
return $rows;
}
/**
* @param list<AuditLog> $history
*
* @return array<string, array{value: string, sourceDate: 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;
}
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
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'),
];
}
}
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));
}
private function normalizeOptionalString(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}