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