. */ namespace App\Services\ImportExportSystem; use App\Entity\AssemblySystem\Assembly; use App\Entity\AssemblySystem\AssemblyBOMEntry; use App\Entity\Parts\Category; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Repository\DBElementRepository; use App\Repository\PartRepository; use App\Repository\Parts\CategoryRepository; use App\Repository\Parts\ManufacturerRepository; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use League\Csv\Reader; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\OptionsResolver\OptionsResolver; use RuntimeException; use UnexpectedValueException; /** * @see \App\Tests\Services\ImportExportSystem\BOMImporterTest */ class BOMImporter { private const MAP_KICAD_PCB_FIELDS = [ 0 => 'Id', 1 => 'Designator', 2 => 'Package', 3 => 'Quantity', 4 => 'Designation', 5 => 'Supplier and ref', ]; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, private readonly BOMValidationService $validationService, private readonly PartRepository $partRepository, private readonly ManufacturerRepository $manufacturerRepository, private readonly CategoryRepository $categoryRepository, private readonly DBElementRepository $assemblyBOMEntryRepository ) { } protected function configureOptions(OptionsResolver $resolver): OptionsResolver { $resolver->setRequired('type'); $resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic', 'json']); // For flexible schematic import with field mapping $resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']); $resolver->setDefault('delimiter', ','); $resolver->setDefault('field_priorities', []); $resolver->setAllowedTypes('field_mapping', 'array'); $resolver->setAllowedTypes('field_priorities', 'array'); $resolver->setAllowedTypes('delimiter', 'string'); return $resolver; } /** * Converts the given file into an array of BOM entries using the given options and save them into the given project. * The changes are not saved into the database yet. * @return ProjectBOMEntry[] */ public function importFileIntoProject(File $file, Project $project, array $options): array { $bom_entries = $this->fileToBOMEntries($file, $options); //Assign the bom_entries to the project foreach ($bom_entries as $bom_entry) { $project->addBomEntry($bom_entry); } return $bom_entries; } /** * Converts the given file into an array of BOM entries using the given options and save them into the given assembly. * The changes are not saved into the database yet. * @return AssemblyBOMEntry[] */ public function importFileIntoAssembly(File $file, Assembly $assembly, array $options): array { $bomEntries = $this->fileToBOMEntries($file, $options, AssemblyBOMEntry::class); //Assign the bom_entries to the assembly foreach ($bomEntries as $bom_entry) { $assembly->addBomEntry($bom_entry); } return $bomEntries; } /** * Converts the given file into an array of BOM entries using the given options. * @return ProjectBOMEntry[]|AssemblyBOMEntry[] */ public function fileToBOMEntries(File $file, array $options, string $objectType = ProjectBOMEntry::class): array { return $this->stringToBOMEntries($file->getContent(), $options, $objectType); } /** * Validate BOM data before importing * @return array Validation result with errors, warnings, and info */ public function validateBOMData(string $data, array $options): array { $resolver = new OptionsResolver(); $resolver = $this->configureOptions($resolver); $options = $resolver->resolve($options); return match ($options['type']) { 'kicad_pcbnew' => $this->validateKiCADPCB($data), 'kicad_schematic' => $this->validateKiCADSchematicData($data, $options), default => throw new InvalidArgumentException('Invalid import type!'), }; } /** * Import string data into an array of BOM entries, which are not yet assigned to a project. * @param string $data The data to import * @param array $options An array of options * @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries */ public function stringToBOMEntries(string $data, array $options, string $objectType = ProjectBOMEntry::class): array { $resolver = new OptionsResolver(); $resolver = $this->configureOptions($resolver); $options = $resolver->resolve($options); return match ($options['type']) { 'kicad_pcbnew' => $this->parseKiCADPCB($data, $objectType), 'kicad_schematic' => $this->parseKiCADSchematic($data, $options), default => throw new InvalidArgumentException('Invalid import type!'), }; } private function parseKiCADPCB(string $data, string $objectType = ProjectBOMEntry::class): array { $csv = Reader::createFromString($data); $csv->setDelimiter(';'); $csv->setHeaderOffset(0); $bom_entries = []; foreach ($csv->getRecords() as $offset => $entry) { //Translate the german field names to english $entry = $this->normalizeColumnNames($entry); //Ensure that the entry has all required fields if (!isset($entry['Designator'])) { throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!'); } if (!isset($entry['Package'])) { throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!'); } if (!isset($entry['Designation'])) { throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!'); } if (!isset($entry['Quantity'])) { throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!'); } $bom_entry = $objectType === ProjectBOMEntry::class ? new ProjectBOMEntry() : new AssemblyBOMEntry(); if ($objectType === ProjectBOMEntry::class) { $bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')'); } else { $bom_entry->setName($entry['Designation']); } $bom_entry->setMountnames($entry['Designator'] ?? ''); $bom_entry->setComment($entry['Supplier and ref'] ?? ''); $bom_entry->setQuantity((float) ($entry['Quantity'] ?? 1)); $bom_entries[] = $bom_entry; } return $bom_entries; } /** * Validate KiCad PCB data */ private function validateKiCADPCB(string $data): array { $csv = Reader::createFromString($data); $csv->setDelimiter(';'); $csv->setHeaderOffset(0); $mapped_entries = []; foreach ($csv->getRecords() as $offset => $entry) { // Translate the german field names to english $entry = $this->normalizeColumnNames($entry); $mapped_entries[] = $entry; } return $this->validationService->validateBOMEntries($mapped_entries); } /** * Validate KiCad schematic data */ private function validateKiCADSchematicData(string $data, array $options): array { $delimiter = $options['delimiter'] ?? ','; $field_mapping = $options['field_mapping'] ?? []; $field_priorities = $options['field_priorities'] ?? []; // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); $csv = Reader::createFromString($data); $csv->setDelimiter($delimiter); $csv->setHeaderOffset(0); // Handle quoted fields properly $csv->setEscape('\\'); $csv->setEnclosure('"'); $mapped_entries = []; foreach ($csv->getRecords() as $offset => $entry) { // Apply field mapping to translate column names $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities); // Extract footprint package name if it contains library prefix if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) { $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1]; } $mapped_entries[] = $mapped_entry; } return $this->validationService->validateBOMEntries($mapped_entries, $options); } private function parseJson(string $data, array $options = [], string $objectType = ProjectBOMEntry::class): array { $result = []; $data = json_decode($data, true); foreach ($data as $entry) { // Check quantity if (!isset($entry['quantity'])) { throw new UnexpectedValueException('quantity missing'); } if (!is_float($entry['quantity']) || $entry['quantity'] <= 0) { throw new UnexpectedValueException('quantity expected as float greater than 0.0'); } // Check name if (isset($entry['name']) && !is_string($entry['name'])) { throw new UnexpectedValueException('name of part list entry expected as string'); } // Check if part is assigned with relevant information if (isset($entry['part'])) { if (!is_array($entry['part'])) { throw new UnexpectedValueException('The property "part" should be an array'); } $partIdValid = isset($entry['part']['id']) && is_int($entry['part']['id']) && $entry['part']['id'] > 0; $partNameValid = isset($entry['part']['name']) && is_string($entry['part']['name']) && trim($entry['part']['name']) !== ''; $partMpnrValid = isset($entry['part']['mpnr']) && is_string($entry['part']['mpnr']) && trim($entry['part']['mpnr']) !== ''; $partIpnValid = isset($entry['part']['ipn']) && is_string($entry['part']['ipn']) && trim($entry['part']['ipn']) !== ''; if (!$partIdValid && !$partNameValid && !$partMpnrValid && !$partIpnValid) { throw new UnexpectedValueException( 'The property "part" must have either assigned: "id" as integer greater than 0, "name", "mpnr", or "ipn" as non-empty string' ); } $part = $partIdValid ? $this->partRepository->findOneBy(['id' => $entry['part']['id']]) : null; $part = $part ?? ($partMpnrValid ? $this->partRepository->findOneBy(['manufacturer_product_number' => trim($entry['part']['mpnr'])]) : null); $part = $part ?? ($partIpnValid ? $this->partRepository->findOneBy(['ipn' => trim($entry['part']['ipn'])]) : null); $part = $part ?? ($partNameValid ? $this->partRepository->findOneBy(['name' => trim($entry['part']['name'])]) : null); if ($part === null) { $part = new Part(); $part->setName($entry['part']['name']); } if ($partNameValid && $part->getName() !== trim($entry['part']['name'])) { throw new RuntimeException(sprintf('Part name does not match exact the given name. Given for import: %s, found part: %s', $entry['part']['name'], $part->getName())); } if ($partIpnValid && $part->getManufacturerProductNumber() !== trim($entry['part']['mpnr'])) { throw new RuntimeException(sprintf('Part mpnr does not match exact the given mpnr. Given for import: %s, found part: %s', $entry['part']['mpnr'], $part->getManufacturerProductNumber())); } if ($partIpnValid && $part->getIpn() !== trim($entry['part']['ipn'])) { throw new RuntimeException(sprintf('Part ipn does not match exact the given ipn. Given for import: %s, found part: %s', $entry['part']['ipn'], $part->getIpn())); } // Part: Description check if (isset($entry['part']['description']) && !is_null($entry['part']['description'])) { if (!is_string($entry['part']['description']) || trim($entry['part']['description']) === '') { throw new UnexpectedValueException('The property path "part.description" must be a non-empty string if not null'); } } $partDescription = $entry['part']['description'] ?? ''; // Part: Manufacturer check $manufacturerIdValid = false; $manufacturerNameValid = false; if (array_key_exists('manufacturer', $entry['part'])) { if (!is_array($entry['part']['manufacturer'])) { throw new UnexpectedValueException('The property path "part.manufacturer" must be an array'); } $manufacturerIdValid = isset($entry['part']['manufacturer']['id']) && is_int($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] > 0; $manufacturerNameValid = isset($entry['part']['manufacturer']['name']) && is_string($entry['part']['manufacturer']['name']) && trim($entry['part']['manufacturer']['name']) !== ''; // Stellen sicher, dass mindestens eine Bedingung für manufacturer erfüllt sein muss if (!$manufacturerIdValid && !$manufacturerNameValid) { throw new UnexpectedValueException( 'The property "manufacturer" must have either assigned: "id" as integer greater than 0, or "name" as non-empty string' ); } } $manufacturer = $manufacturerIdValid ? $this->manufacturerRepository->findOneBy(['id' => $entry['part']['manufacturer']['id']]) : null; $manufacturer = $manufacturer ?? ($manufacturerNameValid ? $this->manufacturerRepository->findOneBy(['name' => trim($entry['part']['manufacturer']['name'])]) : null); if ($manufacturer === null) { throw new RuntimeException( 'Manufacturer not found' ); } if ($manufacturerNameValid && $manufacturer->getName() !== trim($entry['part']['manufacturer']['name'])) { throw new RuntimeException(sprintf('Manufacturer name does not match exact the given name. Given for import: %s, found manufacturer: %s', $entry['manufacturer']['name'], $manufacturer->getName())); } // Part: Category check $categoryIdValid = false; $categoryNameValid = false; if (array_key_exists('category', $entry['part'])) { if (!is_array($entry['part']['category'])) { throw new UnexpectedValueException('part.category must be an array'); } $categoryIdValid = isset($entry['part']['category']['id']) && is_int($entry['part']['category']['id']) && $entry['part']['category']['id'] > 0; $categoryNameValid = isset($entry['part']['category']['name']) && is_string($entry['part']['category']['name']) && trim($entry['part']['category']['name']) !== ''; if (!$categoryIdValid && !$categoryNameValid) { throw new UnexpectedValueException( 'The property "category" must have either assigned: "id" as integer greater than 0, or "name" as non-empty string' ); } } $category = $categoryIdValid ? $this->categoryRepository->findOneBy(['id' => $entry['part']['category']['id']]) : null; $category = $category ?? ($categoryNameValid ? $this->categoryRepository->findOneBy(['name' => trim($entry['part']['category']['name'])]) : null); if ($category === null) { throw new RuntimeException( 'Category not found' ); } if ($categoryNameValid && $category->getName() !== trim($entry['part']['category']['name'])) { throw new RuntimeException(sprintf('Category name does not match exact the given name. Given for import: %s, found category: %s', $entry['category']['name'], $category->getName())); } $part->setDescription($partDescription); $part->setManufacturer($manufacturer); $part->setCategory($category); if ($partMpnrValid) { $part->setManufacturerProductNumber($entry['part']['mpnr'] ?? ''); } if ($partIpnValid) { $part->setIpn($entry['part']['ipn'] ?? ''); } if ($objectType === AssemblyBOMEntry::class) { $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['part' => $part]); if ($bomEntry === null) { $name = isset($entry['name']) && $entry['name'] !== null ? trim($entry['name']) : ''; $bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['name' => $name]); if ($bomEntry === null) { $bomEntry = new AssemblyBOMEntry(); } } } else { $bomEntry = new ProjectBOMEntry(); } $bomEntry->setQuantity($entry['quantity']); $bomEntry->setName($entry['name'] ?? ''); $bomEntry->setPart($part); } $result[] = $bomEntry; } return $result; } /** * This function uses the order of the fields in the CSV files to make them locale independent. * @param array $entry * @return array */ private function normalizeColumnNames(array $entry): array { $out = []; //Map the entry order to the correct column names foreach (array_values($entry) as $index => $field) { if ($index > 5) { break; } //@phpstan-ignore-next-line We want to keep this check just to be safe when something changes $new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new UnexpectedValueException('Invalid field index!'); $out[$new_index] = $field; } return $out; } /** * Parse KiCad schematic BOM with flexible field mapping */ private function parseKiCADSchematic(string $data, array $options = []): array { $delimiter = $options['delimiter'] ?? ','; $field_mapping = $options['field_mapping'] ?? []; $field_priorities = $options['field_priorities'] ?? []; // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); $csv = Reader::createFromString($data); $csv->setDelimiter($delimiter); $csv->setHeaderOffset(0); // Handle quoted fields properly $csv->setEscape('\\'); $csv->setEnclosure('"'); $bom_entries = []; $entries_by_key = []; // Track entries by name+part combination $mapped_entries = []; // Collect all mapped entries for validation foreach ($csv->getRecords() as $offset => $entry) { // Apply field mapping to translate column names $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities); // Extract footprint package name if it contains library prefix if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) { $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1]; } $mapped_entries[] = $mapped_entry; } // Validate all entries before processing $validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options); // Log validation results $this->logger->info('BOM import validation completed', [ 'total_entries' => $validation_result['total_entries'], 'valid_entries' => $validation_result['valid_entries'], 'invalid_entries' => $validation_result['invalid_entries'], 'error_count' => count($validation_result['errors']), 'warning_count' => count($validation_result['warnings']), ]); // If there are validation errors, throw an exception with detailed messages if (!empty($validation_result['errors'])) { $error_message = $this->validationService->getErrorMessage($validation_result); throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message); } // Process validated entries foreach ($mapped_entries as $offset => $mapped_entry) { // Set name - prefer MPN, fall back to Value, then default format $mpn = trim($mapped_entry['MPN'] ?? ''); $designation = trim($mapped_entry['Designation'] ?? ''); $value = trim($mapped_entry['Value'] ?? ''); // Use the first non-empty value, or 'Unknown Component' if all are empty $name = ''; if (!empty($mpn)) { $name = $mpn; } elseif (!empty($designation)) { $name = $designation; } elseif (!empty($value)) { $name = $value; } else { $name = 'Unknown Component'; } if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) { $name .= ' (' . trim($mapped_entry['Package']) . ')'; } // Set mountnames and quantity // The Designator field contains comma-separated mount names for all instances $designator = trim($mapped_entry['Designator']); $quantity = (float) $mapped_entry['Quantity']; // Get mountnames array (validation already ensured they match quantity) $mountnames_array = array_map('trim', explode(',', $designator)); // Try to link existing Part-DB part if ID is provided $part = null; if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) { $partDbId = (int) $mapped_entry['Part-DB ID']; $existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId); if ($existingPart) { $part = $existingPart; // Update name with actual part name $name = $existingPart->getName(); } } // Create unique key for this entry (name + part ID) $entry_key = $name . '|' . ($part ? $part->getID() : 'null'); // Check if we already have an entry with the same name and part if (isset($entries_by_key[$entry_key])) { // Merge with existing entry $existing_entry = $entries_by_key[$entry_key]; // Combine mountnames $existing_mountnames = $existing_entry->getMountnames(); $combined_mountnames = $existing_mountnames . ',' . $designator; $existing_entry->setMountnames($combined_mountnames); // Add quantities $existing_quantity = $existing_entry->getQuantity(); $existing_entry->setQuantity($existing_quantity + $quantity); $this->logger->info('Merged duplicate BOM entry', [ 'name' => $name, 'part_id' => $part ? $part->getID() : null, 'original_quantity' => $existing_quantity, 'added_quantity' => $quantity, 'new_quantity' => $existing_quantity + $quantity, 'original_mountnames' => $existing_mountnames, 'added_mountnames' => $designator, ]); continue; // Skip creating new entry } // Create new BOM entry $bom_entry = new ProjectBOMEntry(); $bom_entry->setName($name); $bom_entry->setMountnames($designator); $bom_entry->setQuantity($quantity); if ($part) { $bom_entry->setPart($part); } // Set comment with additional info $comment_parts = []; if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) { $comment_parts[] = 'Value: ' . $mapped_entry['Value']; } if (isset($mapped_entry['MPN'])) { $comment_parts[] = 'MPN: ' . $mapped_entry['MPN']; } if (isset($mapped_entry['Manufacturer'])) { $comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer']; } if (isset($mapped_entry['LCSC'])) { $comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC']; } if (isset($mapped_entry['Supplier and ref'])) { $comment_parts[] = $mapped_entry['Supplier and ref']; } if ($part) { $comment_parts[] = "Part-DB ID: " . $part->getID(); } elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) { $comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)"; } $bom_entry->setComment(implode(', ', $comment_parts)); $bom_entries[] = $bom_entry; $entries_by_key[$entry_key] = $bom_entry; } return $bom_entries; } /** * Get all available field mapping targets with descriptions */ public function getAvailableFieldTargets(): array { $targets = [ 'Designator' => [ 'label' => 'Designator', 'description' => 'Component reference designators (e.g., R1, C2, U3)', 'required' => true, 'multiple' => false, ], 'Quantity' => [ 'label' => 'Quantity', 'description' => 'Number of components', 'required' => true, 'multiple' => false, ], 'Designation' => [ 'label' => 'Designation', 'description' => 'Component designation/part number', 'required' => false, 'multiple' => true, ], 'Value' => [ 'label' => 'Value', 'description' => 'Component value (e.g., 10k, 100nF)', 'required' => false, 'multiple' => true, ], 'Package' => [ 'label' => 'Package', 'description' => 'Component package/footprint', 'required' => false, 'multiple' => true, ], 'MPN' => [ 'label' => 'MPN', 'description' => 'Manufacturer Part Number', 'required' => false, 'multiple' => true, ], 'Manufacturer' => [ 'label' => 'Manufacturer', 'description' => 'Component manufacturer name', 'required' => false, 'multiple' => true, ], 'Part-DB ID' => [ 'label' => 'Part-DB ID', 'description' => 'Existing Part-DB part ID for linking', 'required' => false, 'multiple' => false, ], 'Comment' => [ 'label' => 'Comment', 'description' => 'Additional component information', 'required' => false, 'multiple' => true, ], ]; // Add dynamic supplier fields based on available suppliers in the database $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); foreach ($suppliers as $supplier) { $supplierName = $supplier->getName(); $targets[$supplierName . ' SPN'] = [ 'label' => $supplierName . ' SPN', 'description' => "Supplier part number for {$supplierName}", 'required' => false, 'multiple' => true, 'supplier_id' => $supplier->getID(), ]; } return $targets; } /** * Get suggested field mappings based on common field names */ public function getSuggestedFieldMapping(array $detected_fields): array { $suggestions = []; $field_patterns = [ 'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'], 'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'], 'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'], 'Value' => ['value', 'val', 'component_value'], 'Designation' => ['designation', 'part_number', 'partnumber', 'part'], 'Package' => ['footprint', 'package', 'housing', 'fp'], 'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'], 'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'], 'Comment' => ['comment', 'comments', 'note', 'notes', 'description'], ]; // Add supplier-specific patterns $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); foreach ($suppliers as $supplier) { $supplierName = $supplier->getName(); $supplierLower = strtolower($supplierName); // Create patterns for each supplier $field_patterns[$supplierName . ' SPN'] = [ $supplierLower, $supplierLower . '#', $supplierLower . '_part', $supplierLower . '_number', $supplierLower . 'pn', $supplierLower . '_spn', $supplierLower . ' spn', // Common abbreviations $supplierLower === 'mouser' ? 'mouser' : null, $supplierLower === 'digikey' ? 'dk' : null, $supplierLower === 'farnell' ? 'farnell' : null, $supplierLower === 'rs' ? 'rs' : null, $supplierLower === 'lcsc' ? 'lcsc' : null, ]; // Remove null values $field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null); } foreach ($detected_fields as $field) { $field_lower = strtolower(trim($field)); foreach ($field_patterns as $target => $patterns) { foreach ($patterns as $pattern) { if (str_contains($field_lower, $pattern)) { $suggestions[$field] = $target; break 2; // Break both loops } } } } return $suggestions; } /** * Validate field mapping configuration */ public function validateFieldMapping(array $field_mapping, array $detected_fields): array { $errors = []; $warnings = []; $available_targets = $this->getAvailableFieldTargets(); // Check for required fields $mapped_targets = array_values($field_mapping); $required_fields = ['Designator', 'Quantity']; foreach ($required_fields as $required) { if (!in_array($required, $mapped_targets, true)) { $errors[] = "Required field '{$required}' is not mapped from any CSV column."; } } // Check for invalid target fields foreach ($field_mapping as $csv_field => $target) { if (!empty($target) && !isset($available_targets[$target])) { $errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'."; } } // Check for unmapped fields (warnings) $unmapped_fields = array_diff($detected_fields, array_keys($field_mapping)); if (!empty($unmapped_fields)) { $warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields); } return [ 'errors' => $errors, 'warnings' => $warnings, 'is_valid' => empty($errors), ]; } /** * Apply field mapping with support for multiple fields and priority */ private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array { $mapped = []; $field_groups = []; // Group fields by target with priority information foreach ($field_mapping as $csv_field => $target) { if (!empty($target)) { if (!isset($field_groups[$target])) { $field_groups[$target] = []; } $priority = $field_priorities[$csv_field] ?? 10; $field_groups[$target][] = [ 'field' => $csv_field, 'priority' => $priority, 'value' => $entry[$csv_field] ?? '' ]; } } // Process each target field foreach ($field_groups as $target => $field_data) { // Sort by priority (lower number = higher priority) usort($field_data, function ($a, $b) { return $a['priority'] <=> $b['priority']; }); $values = []; $non_empty_values = []; // Collect all non-empty values for this target foreach ($field_data as $data) { $value = trim($data['value']); if (!empty($value)) { $non_empty_values[] = $value; } $values[] = $value; } // Use the first non-empty value (highest priority) if (!empty($non_empty_values)) { $mapped[$target] = $non_empty_values[0]; // If multiple non-empty values exist, add alternatives to comment if (count($non_empty_values) > 1) { $mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1); } } } return $mapped; } /** * Detect available fields in CSV data for field mapping UI */ public function detectFields(string $data, ?string $delimiter = null): array { if ($delimiter === null) { // Detect delimiter by counting occurrences in the first row (header) $delimiters = [',', ';', "\t"]; $lines = explode("\n", $data, 2); $header_line = $lines[0] ?? ''; $delimiter_counts = []; foreach ($delimiters as $delim) { $delimiter_counts[$delim] = substr_count($header_line, $delim); } // Choose the delimiter with the highest count, default to comma if all are zero $max_count = max($delimiter_counts); $delimiter = array_search($max_count, $delimiter_counts, true); if ($max_count === 0 || $delimiter === false) { $delimiter = ','; } } // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); // Get first line only for header detection $lines = explode("\n", $data); $header_line = trim($lines[0] ?? ''); // Simple manual parsing for header detection // This handles quoted CSV fields better than the library for detection $fields = []; $current_field = ''; $in_quotes = false; $quote_char = '"'; for ($i = 0; $i < strlen($header_line); $i++) { $char = $header_line[$i]; if ($char === $quote_char && !$in_quotes) { $in_quotes = true; } elseif ($char === $quote_char && $in_quotes) { // Check for escaped quote (double quote) if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) { $current_field .= $quote_char; $i++; // Skip next quote } else { $in_quotes = false; } } elseif ($char === $delimiter && !$in_quotes) { $fields[] = trim($current_field); $current_field = ''; } else { $current_field .= $char; } } // Add the last field if ($current_field !== '') { $fields[] = trim($current_field); } // Clean up headers - remove quotes and trim whitespace $headers = array_map(function ($header) { return trim($header, '"\''); }, $fields); return array_values($headers); } }