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