mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-20 18:31:33 +00:00
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / docker (push) Waiting to run
Docker Image Build (FrankenPHP) / docker (push) Waiting to run
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
* feat: add supplier SPN lookup for BOM import Add automatic part linking via supplier part numbers (SPNs) in the BOM importer. When a Part-DB ID is not provided, the importer now searches for existing parts by matching supplier SPNs from the CSV with orderdetail records in the database. This allows automatic part linking when KiCad schematic BOMs contain supplier information like LCSC SPN, Mouser SPN, etc., improving the import workflow for users who track parts by supplier part numbers. * add tests for BOM import with supplier SPN handling
780 lines
30 KiB
PHP
780 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
*
|
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
namespace App\Services\ImportExportSystem;
|
|
|
|
use App\Entity\Parts\Part;
|
|
use App\Entity\ProjectSystem\Project;
|
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
|
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;
|
|
|
|
/**
|
|
* @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
|
|
) {
|
|
}
|
|
|
|
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
|
|
{
|
|
$resolver->setRequired('type');
|
|
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
|
|
|
|
// 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.
|
|
* @return ProjectBOMEntry[]
|
|
*/
|
|
public function fileToBOMEntries(File $file, array $options): array
|
|
{
|
|
return $this->stringToBOMEntries($file->getContent(), $options);
|
|
}
|
|
|
|
/**
|
|
* 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[] An array of imported entries
|
|
*/
|
|
public function stringToBOMEntries(string $data, array $options): array
|
|
{
|
|
$resolver = new OptionsResolver();
|
|
$resolver = $this->configureOptions($resolver);
|
|
$options = $resolver->resolve($options);
|
|
|
|
return match ($options['type']) {
|
|
'kicad_pcbnew' => $this->parseKiCADPCB($data),
|
|
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
|
|
default => throw new InvalidArgumentException('Invalid import type!'),
|
|
};
|
|
}
|
|
|
|
private function parseKiCADPCB(string $data): array
|
|
{
|
|
$csv = Reader::fromString($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 = new ProjectBOMEntry();
|
|
$bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
|
|
$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::fromString($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::fromString($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);
|
|
}
|
|
|
|
/**
|
|
* 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::fromString($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
|
|
|
|
// Fetch suppliers once for efficiency
|
|
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
|
$supplierSPNKeys = [];
|
|
$suppliersByName = []; // Map supplier names to supplier objects
|
|
foreach ($suppliers as $supplier) {
|
|
$supplierName = $supplier->getName();
|
|
$supplierSPNKeys[] = $supplierName . ' SPN';
|
|
$suppliersByName[$supplierName] = $supplier;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Try to link existing part based on supplier part number if no Part-DB ID is given
|
|
if ($part === null) {
|
|
// Check all available supplier SPN fields
|
|
foreach ($suppliersByName as $supplierName => $supplier) {
|
|
$supplier_spn = null;
|
|
|
|
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
|
|
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
|
|
}
|
|
|
|
if ($supplier_spn !== null) {
|
|
// Query for orderdetails with matching supplier and SPN
|
|
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
|
|
->findOneBy([
|
|
'supplier' => $supplier,
|
|
'supplierpartnr' => $supplier_spn,
|
|
]);
|
|
|
|
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
|
|
$part = $orderdetail->getPart();
|
|
$name = $part->getName(); // Update name with actual part name
|
|
|
|
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
|
|
'supplier' => $supplierName,
|
|
'supplier_spn' => $supplier_spn,
|
|
'part_id' => $part->getID(),
|
|
'part_name' => $part->getName(),
|
|
]);
|
|
|
|
break; // Stop searching once a match is found
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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'];
|
|
}
|
|
|
|
// Add supplier part numbers dynamically
|
|
foreach ($supplierSPNKeys as $spnKey) {
|
|
if (isset($mapped_entry[$spnKey]) && !empty($mapped_entry[$spnKey])) {
|
|
$comment_parts[] = $spnKey . ': ' . $mapped_entry[$spnKey];
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|