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