Compare commits
3 Commits
509c4d2247
...
8851f22e4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8851f22e4e | ||
|
|
330b9376f6 | ||
|
|
4468fd7cdf |
30
migrations/Version20260323160000.php
Normal file
30
migrations/Version20260323160000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260323160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add comment_id FK on documents table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END \$\$");
|
||||
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END \$\$");
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
|
||||
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
|
||||
}
|
||||
}
|
||||
327
src/Command/CheckMissingCustomFieldValuesCommand.php
Normal file
327
src/Command/CheckMissingCustomFieldValuesCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
266
src/Command/RestorePieceCustomFieldValuesCommand.php
Normal file
266
src/Command/RestorePieceCustomFieldValuesCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
311
src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php
Normal file
311
src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Entity\Document;
|
||||
use App\Enum\DocumentType;
|
||||
use App\Repository\ProfileRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -20,6 +24,7 @@ final class CommentController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_comments_create', methods: ['POST'])]
|
||||
@@ -38,16 +43,25 @@ final class CommentController extends AbstractController
|
||||
return $this->json(['message' => 'Profil introuvable.'], 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['message' => 'Payload JSON invalide.'], 400);
|
||||
// Parse fields from JSON or form-data
|
||||
$contentType = $request->headers->get('Content-Type', '');
|
||||
$isFormData = str_contains($contentType, 'multipart/form-data') || $request->files->count() > 0 || $request->request->has('content');
|
||||
if ($isFormData) {
|
||||
$content = trim((string) $request->request->get('content', ''));
|
||||
$entityType = trim((string) $request->request->get('entityType', ''));
|
||||
$entityId = trim((string) $request->request->get('entityId', ''));
|
||||
$entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
|
||||
} else {
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['message' => 'Payload JSON invalide.'], 400);
|
||||
}
|
||||
$content = trim((string) ($payload['content'] ?? ''));
|
||||
$entityType = trim((string) ($payload['entityType'] ?? ''));
|
||||
$entityId = trim((string) ($payload['entityId'] ?? ''));
|
||||
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
|
||||
}
|
||||
|
||||
$content = trim((string) ($payload['content'] ?? ''));
|
||||
$entityType = trim((string) ($payload['entityType'] ?? ''));
|
||||
$entityId = trim((string) ($payload['entityId'] ?? ''));
|
||||
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
|
||||
|
||||
if ('' === $content) {
|
||||
return $this->json(['message' => 'Le contenu est requis.'], 400);
|
||||
}
|
||||
@@ -75,6 +89,36 @@ final class CommentController extends AbstractController
|
||||
$comment->setAuthorName($authorName);
|
||||
|
||||
$this->entityManager->persist($comment);
|
||||
|
||||
// Handle file uploads
|
||||
$files = $request->files->all('files');
|
||||
foreach ($files as $file) {
|
||||
if (!$file instanceof UploadedFile || !$file->isValid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
$documentId = 'cl'.bin2hex(random_bytes(12));
|
||||
$document->setId($documentId);
|
||||
$document->setName($file->getClientOriginalName());
|
||||
$document->setFilename($file->getClientOriginalName());
|
||||
$document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
|
||||
$document->setSize((int) $file->getSize());
|
||||
$document->setType(DocumentType::DOCUMENTATION);
|
||||
$document->setComment($comment);
|
||||
$comment->getDocuments()->add($document);
|
||||
|
||||
$extension = $this->storageService->extensionFromFilename($file->getClientOriginalName());
|
||||
$relativePath = $this->storageService->storeFromPath(
|
||||
$file->getPathname(),
|
||||
$documentId,
|
||||
$extension,
|
||||
);
|
||||
$document->setPath($relativePath);
|
||||
|
||||
$this->entityManager->persist($document);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalize($comment), 201);
|
||||
@@ -112,6 +156,76 @@ final class CommentController extends AbstractController
|
||||
return $this->json($this->normalize($comment));
|
||||
}
|
||||
|
||||
#[Route('/search/list', name: 'api_comments_list', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$qb = $this->entityManager->getRepository(Comment::class)->createQueryBuilder('c');
|
||||
|
||||
$status = $request->query->get('status');
|
||||
if ($status) {
|
||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
$entityType = $request->query->get('entityType');
|
||||
if ($entityType) {
|
||||
$qb->andWhere('c.entityType = :entityType')->setParameter('entityType', $entityType);
|
||||
}
|
||||
|
||||
$entityName = $request->query->get('entityName');
|
||||
if ($entityName) {
|
||||
$qb->andWhere('LOWER(c.entityName) LIKE LOWER(:entityName)')->setParameter('entityName', '%'.$entityName.'%');
|
||||
}
|
||||
|
||||
// Count total before pagination
|
||||
$countQb = clone $qb;
|
||||
$total = (int) $countQb->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
// Sorting
|
||||
$sortField = $request->query->get('sort', 'createdAt');
|
||||
$sortDir = strtoupper($request->query->get('direction', 'DESC'));
|
||||
$allowedSortFields = ['createdAt', 'authorName', 'status'];
|
||||
if (!in_array($sortField, $allowedSortFields, true)) {
|
||||
$sortField = 'createdAt';
|
||||
}
|
||||
if (!in_array($sortDir, ['ASC', 'DESC'], true)) {
|
||||
$sortDir = 'DESC';
|
||||
}
|
||||
$qb->orderBy('c.'.$sortField, $sortDir);
|
||||
|
||||
// Pagination
|
||||
$itemsPerPage = min((int) $request->query->get('itemsPerPage', '30'), 200);
|
||||
$page = max((int) $request->query->get('page', '1'), 1);
|
||||
$qb->setMaxResults($itemsPerPage)->setFirstResult(($page - 1) * $itemsPerPage);
|
||||
|
||||
$comments = $qb->getQuery()->getResult();
|
||||
|
||||
return $this->json([
|
||||
'items' => array_map(fn (Comment $c) => $this->normalize($c), $comments),
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/by-entity/{entityType}/{entityId}', name: 'api_comments_by_entity', methods: ['GET'])]
|
||||
public function listByEntity(string $entityType, string $entityId, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$criteria = ['entityType' => $entityType, 'entityId' => $entityId];
|
||||
|
||||
$status = $request->query->get('status');
|
||||
if ($status) {
|
||||
$criteria['status'] = $status;
|
||||
}
|
||||
|
||||
$comments = $this->entityManager->getRepository(Comment::class)
|
||||
->findBy($criteria, ['createdAt' => 'DESC'])
|
||||
;
|
||||
|
||||
return $this->json(array_map(fn (Comment $c) => $this->normalize($c), $comments));
|
||||
}
|
||||
|
||||
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
|
||||
public function unresolvedCount(): JsonResponse
|
||||
{
|
||||
@@ -126,6 +240,21 @@ final class CommentController extends AbstractController
|
||||
|
||||
private function normalize(Comment $comment): array
|
||||
{
|
||||
$documents = [];
|
||||
foreach ($comment->getDocuments() as $document) {
|
||||
$documents[] = [
|
||||
'id' => $document->getId(),
|
||||
'name' => $document->getName(),
|
||||
'filename' => $document->getFilename(),
|
||||
'mimeType' => $document->getMimeType(),
|
||||
'size' => $document->getSize(),
|
||||
'type' => $document->getType()->value,
|
||||
'fileUrl' => '/api/documents/'.$document->getId().'/file',
|
||||
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
|
||||
'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $comment->getId(),
|
||||
'content' => $comment->getContent(),
|
||||
@@ -140,6 +269,7 @@ final class CommentController extends AbstractController
|
||||
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
|
||||
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
|
||||
'documents' => $documents,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Entity\Document;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\DocumentRepository;
|
||||
@@ -11,6 +12,7 @@ use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -25,6 +27,7 @@ class DocumentQueryController extends AbstractController
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
|
||||
@@ -102,6 +105,21 @@ class DocumentQueryController extends AbstractController
|
||||
return $this->json($this->normalizeDocuments($documents));
|
||||
}
|
||||
|
||||
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
|
||||
public function listByComment(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$comment = $this->em->find(Comment::class, $id);
|
||||
if (!$comment) {
|
||||
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
|
||||
}
|
||||
|
||||
$documents = $this->documentRepository->findBy(['comment' => $comment]);
|
||||
|
||||
return $this->json($this->normalizeDocuments($documents));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Document[] $documents
|
||||
*/
|
||||
@@ -121,6 +139,7 @@ class DocumentQueryController extends AbstractController
|
||||
'composantId' => $document->getComposant()?->getId(),
|
||||
'pieceId' => $document->getPiece()?->getId(),
|
||||
'productId' => $document->getProduct()?->getId(),
|
||||
'commentId' => $document->getComment()?->getId(),
|
||||
'type' => $document->getType()->value,
|
||||
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
|
||||
|
||||
@@ -14,6 +14,8 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
@@ -79,10 +81,15 @@ class Comment
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/** @var Collection<int, Document> */
|
||||
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
|
||||
private Collection $documents;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->documents = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
@@ -204,4 +211,10 @@ class Comment
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Document> */
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Table(name: 'documents')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
||||
#[ApiResource(
|
||||
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
|
||||
@@ -108,6 +108,11 @@ class Document
|
||||
#[Groups(['document:list'])]
|
||||
private ?Site $site = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['document:list'])]
|
||||
private ?Comment $comment = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
|
||||
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
|
||||
private DocumentType $type = DocumentType::DOCUMENTATION;
|
||||
@@ -256,4 +261,16 @@ class Document
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?Comment
|
||||
{
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
public function setComment(?Comment $comment): static
|
||||
{
|
||||
$this->comment = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +192,14 @@ class SkeletonStructureService
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
// Index existing by ID and by name for matching
|
||||
$existingById = [];
|
||||
$existingByName = [];
|
||||
// Index existing by ID, name and orderIndex for matching
|
||||
$existingById = [];
|
||||
$existingByName = [];
|
||||
$existingByOrderIndex = [];
|
||||
foreach ($existingFields as $cf) {
|
||||
$existingById[$cf->getId()] = $cf;
|
||||
$existingByName[$cf->getName()] = $cf;
|
||||
$existingById[$cf->getId()] = $cf;
|
||||
$existingByName[$cf->getName()] = $cf;
|
||||
$existingByOrderIndex[$cf->getOrderIndex()][] = $cf;
|
||||
}
|
||||
|
||||
$processedIds = [];
|
||||
@@ -211,6 +213,14 @@ class SkeletonStructureService
|
||||
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
|
||||
if ($fieldId && isset($existingById[$fieldId])) {
|
||||
$existingField = $existingById[$fieldId];
|
||||
} elseif (!empty($existingByOrderIndex[$normalized['orderIndex']])) {
|
||||
foreach ($existingByOrderIndex[$normalized['orderIndex']] as $candidate) {
|
||||
if (!isset($processedIds[$candidate->getId()])) {
|
||||
$existingField = $candidate;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (isset($existingByName[$normalized['name']]) && !isset($processedIds[$existingByName[$normalized['name']]->getId()])) {
|
||||
$existingField = $existingByName[$normalized['name']];
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ final class DocumentUploadProcessor implements ProcessorInterface
|
||||
'pieceId' => 'Piece',
|
||||
'productId' => 'Product',
|
||||
'siteId' => 'Site',
|
||||
'commentId' => 'Comment',
|
||||
];
|
||||
|
||||
foreach ($relationMap as $field => $entityName) {
|
||||
|
||||
@@ -382,6 +382,8 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
string $value = 'test value',
|
||||
?Machine $machine = null,
|
||||
?Composant $composant = null,
|
||||
?Piece $piece = null,
|
||||
?Product $product = null,
|
||||
): CustomFieldValue {
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setValue($value);
|
||||
@@ -392,6 +394,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
if (null !== $composant) {
|
||||
$cfv->setComposant($composant);
|
||||
}
|
||||
if (null !== $piece) {
|
||||
$cfv->setPiece($piece);
|
||||
}
|
||||
if (null !== $product) {
|
||||
$cfv->setProduct($product);
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($cfv);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Api\Controller;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -147,4 +148,99 @@ class CommentControllerTest extends AbstractApiTestCase
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains(['count' => 2]);
|
||||
}
|
||||
|
||||
public function testCreateCommentJsonReturnsDocumentsArray(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine A');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$response = $client->request('POST', '/api/comments', [
|
||||
'json' => [
|
||||
'content' => 'No files',
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
$this->assertSame([], $data['documents']);
|
||||
}
|
||||
|
||||
public function testCreateCommentWithFile(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine A');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'test_');
|
||||
file_put_contents($tmpFile, 'test file content');
|
||||
|
||||
$uploadedFile = new UploadedFile(
|
||||
$tmpFile,
|
||||
'test-doc.pdf',
|
||||
'application/pdf',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
|
||||
$client->request('POST', '/api/comments', [
|
||||
'extra' => [
|
||||
'parameters' => [
|
||||
'content' => 'Comment with file',
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
'entityName' => 'Machine A',
|
||||
],
|
||||
'files' => [
|
||||
'files' => [$uploadedFile],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame('Comment with file', $data['content']);
|
||||
$this->assertCount(1, $data['documents']);
|
||||
$this->assertSame('test-doc.pdf', $data['documents'][0]['filename']);
|
||||
$this->assertArrayHasKey('fileUrl', $data['documents'][0]);
|
||||
$this->assertArrayHasKey('downloadUrl', $data['documents'][0]);
|
||||
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
public function testCreateCommentWithMultipleFiles(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine A');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
|
||||
$tmpFile1 = tempnam(sys_get_temp_dir(), 'test_');
|
||||
file_put_contents($tmpFile1, 'content 1');
|
||||
$tmpFile2 = tempnam(sys_get_temp_dir(), 'test_');
|
||||
file_put_contents($tmpFile2, 'content 2');
|
||||
|
||||
$file1 = new UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true);
|
||||
$file2 = new UploadedFile($tmpFile2, 'doc2.png', 'image/png', null, true);
|
||||
|
||||
$client->request('POST', '/api/comments', [
|
||||
'extra' => [
|
||||
'parameters' => [
|
||||
'content' => 'Multiple files',
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
],
|
||||
'files' => [
|
||||
'files' => [$file1, $file2],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertCount(2, $data['documents']);
|
||||
|
||||
@unlink($tmpFile1);
|
||||
@unlink($tmpFile2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Entity;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
@@ -280,4 +282,42 @@ class ModelTypeTest extends AbstractApiTestCase
|
||||
$this->assertSame($productType->getId(), $data['structure']['products'][0]['typeProductId']);
|
||||
$this->assertSame('GR', $data['structure']['products'][0]['familyCode']);
|
||||
}
|
||||
|
||||
public function testPatchPieceStructureRenameKeepsCustomFieldValuesWhenIdIsMissing(): void
|
||||
{
|
||||
$type = $this->createModelType('Arbre', 'ARB-001', ModelCategory::PIECE);
|
||||
$piece = $this->createPiece('Arbre de test', 'ARB-TEST', $type);
|
||||
$field = $this->createCustomField('diamètre', 'text', typePiece: $type, orderIndex: 0);
|
||||
$this->createCustomFieldValue($field, '35 mm', piece: $piece);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', self::iri('model_types', $type->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => [
|
||||
'structure' => [
|
||||
'customFields' => [[
|
||||
'name' => 'Diamètre',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'orderIndex' => 0,
|
||||
]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->clear();
|
||||
|
||||
$fields = $em->getRepository(CustomField::class)->findBy(['typePiece' => $type], ['orderIndex' => 'ASC']);
|
||||
$this->assertCount(1, $fields);
|
||||
$this->assertSame($field->getId(), $fields[0]->getId(), 'Renaming must reuse the existing CustomField');
|
||||
$this->assertSame('Diamètre', $fields[0]->getName());
|
||||
|
||||
$values = $em->getRepository(CustomFieldValue::class)->findBy(['piece' => $piece]);
|
||||
$this->assertCount(1, $values);
|
||||
$this->assertSame('35 mm', $values[0]->getValue(), 'Existing custom field value must be preserved');
|
||||
$this->assertSame($field->getId(), $values[0]->getCustomField()->getId());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user