From 8851f22e4ed18f1c5e42596db8895be671938bd3 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 24 Mar 2026 08:49:53 +0100 Subject: [PATCH] feat(ops) : add custom field audit and restore commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../CheckMissingCustomFieldValuesCommand.php | 327 ++++++++++++++++++ .../RestorePieceCustomFieldValuesCommand.php | 266 ++++++++++++++ ...coverablePieceCustomFieldValuesCommand.php | 311 +++++++++++++++++ 3 files changed, 904 insertions(+) create mode 100644 src/Command/CheckMissingCustomFieldValuesCommand.php create mode 100644 src/Command/RestorePieceCustomFieldValuesCommand.php create mode 100644 src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php diff --git a/src/Command/CheckMissingCustomFieldValuesCommand.php b/src/Command/CheckMissingCustomFieldValuesCommand.php new file mode 100644 index 0000000..feaddbb --- /dev/null +++ b/src/Command/CheckMissingCustomFieldValuesCommand.php @@ -0,0 +1,327 @@ +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> + */ + 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> + */ + 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> + */ + 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 $history + * + * @return array + */ + 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 + */ + 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)); + } +} diff --git a/src/Command/RestorePieceCustomFieldValuesCommand.php b/src/Command/RestorePieceCustomFieldValuesCommand.php new file mode 100644 index 0000000..54503b3 --- /dev/null +++ b/src/Command/RestorePieceCustomFieldValuesCommand.php @@ -0,0 +1,266 @@ +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 $history + * + * @return array + */ + 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 + */ + 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)); + } +} diff --git a/src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php b/src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php new file mode 100644 index 0000000..c40d405 --- /dev/null +++ b/src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php @@ -0,0 +1,311 @@ +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> + */ + 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 $history + * + * @return array + */ + 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 + */ + 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; + } +}