diff --git a/composer.json b/composer.json index b515cc4a..c823221d 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "gregwar/captcha-bundle": "^2.1.0", "hslavich/oneloginsaml-bundle": "^2.10", "jbtronics/2fa-webauthn": "^1.0.0", + "league/csv": "^9.8.0", "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", "nelexa/zip": "^4.0", diff --git a/composer.lock b/composer.lock index 45e59d11..b31f28a4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "73b35aff40231c2fe1ebf72c1d098689", + "content-hash": "43882c51b2efa31f08c345a94661916c", "packages": [ { "name": "beberlei/assert", @@ -2746,6 +2746,90 @@ ], "time": "2023-01-02T13:28:00+00:00" }, + { + "name": "league/csv", + "version": "9.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "9d2e0265c5d90f5dd601bc65ff717e05cec19b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/9d2e0265c5d90f5dd601bc65ff717e05cec19b47", + "reference": "9d2e0265c5d90f5dd601bc65ff717e05cec19b47", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "friendsofphp/php-cs-fixer": "^v3.4.0", + "phpstan/phpstan": "^1.3.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpstan/phpstan-strict-rules": "^1.1.0", + "phpunit/phpunit": "^9.5.11" + }, + "suggest": { + "ext-dom": "Required to use the XMLConverter and or the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Csv\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "CSV data manipulation made easy in PHP", + "homepage": "https://csv.thephpleague.com", + "keywords": [ + "convert", + "csv", + "export", + "filter", + "import", + "read", + "transform", + "write" + ], + "support": { + "docs": "https://csv.thephpleague.com", + "issues": "https://github.com/thephpleague/csv/issues", + "rss": "https://github.com/thephpleague/csv/releases.atom", + "source": "https://github.com/thephpleague/csv" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2022-01-04T00:13:07+00:00" + }, { "name": "league/html-to-markdown", "version": "5.1.0", diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 625e4ae1..c1c77f9b 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -28,17 +28,24 @@ use App\Form\ProjectSystem\ProjectBOMEntryCollectionType; use App\Form\ProjectSystem\ProjectBuildType; use App\Form\Type\StructuralEntityType; use App\Helpers\Projects\ProjectBuildRequest; +use App\Services\ImportExportSystem\BOMImporter; use App\Services\ProjectSystem\ProjectBuildHelper; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use function Symfony\Component\Translation\t; /** * @Route("/project") @@ -119,6 +126,80 @@ class ProjectController extends AbstractController ]); } + /** + * @Route("/{id}/import_bom", name="project_import_bom", requirements={"id"="\d+"}) + */ + public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project, + BOMImporter $BOMImporter, ValidatorInterface $validator): Response + { + $this->denyAccessUnlessGranted('edit', $project); + + $builder = $this->createFormBuilder(); + $builder->add('file', FileType::class, [ + 'label' => 'import.file', + 'required' => true, + 'attr' => [ + 'accept' => '.csv' + ] + ]); + $builder->add('type', ChoiceType::class, [ + 'label' => 'project.bom_import.type', + 'required' => true, + 'choices' => [ + 'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew', + ] + ]); + $builder->add('clear_existing_bom', CheckboxType::class, [ + 'label' => 'project.bom_import.clear_existing_bom', + 'required' => false, + 'data' => false, + 'help' => 'project.bom_import.clear_existing_bom.help', + ]); + $builder->add('submit', SubmitType::class, [ + 'label' => 'import.btn', + ]); + + $form = $builder->getForm(); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + + //Clear existing BOM entries if requested + if ($form->get('clear_existing_bom')->getData()) { + $project->getBomEntries()->clear(); + $entityManager->flush($project); + } + + try { + $entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [ + 'type' => $form->get('type')->getData(), + ]); + + //Validate the project entries + $errors = $validator->validateProperty($project, 'bom_entries'); + + //If no validation errors occured, save the changes and redirect to edit page + if (count ($errors) === 0) { + $this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)])); + $entityManager->flush(); + return $this->redirectToRoute('project_edit', ['id' => $project->getID()]); + } + + if (count ($errors) > 0) { + $this->addFlash('error', t('project.bom_import.flash.invalid_entries')); + } + } catch (\UnexpectedValueException $e) { + $this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()])); + } + } + + return $this->renderForm('projects/import_bom.html.twig', [ + 'project' => $project, + 'form' => $form, + 'errors' => $errors ?? null, + ]); + } + /** * @Route("/add_parts", name="project_add_parts_no_id") * @Route("/{id}/add_parts", name="project_add_parts", requirements={"id"="\d+"}) diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php index e7ef436f..7fefb1fe 100644 --- a/src/Entity/ProjectSystem/ProjectBOMEntry.php +++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php @@ -110,7 +110,8 @@ class ProjectBOMEntry extends AbstractDBElement public function __construct() { - $this->price = BigDecimal::zero()->toScale(5); + //$this->price = BigDecimal::zero()->toScale(5); + $this->price = null; } /** diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php new file mode 100644 index 00000000..b846e4f8 --- /dev/null +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -0,0 +1,147 @@ +. + */ + +namespace App\Services\ImportExportSystem; + +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use InvalidArgumentException; +use League\Csv\Reader; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +class BOMImporter +{ + + private const MAP_KICAD_PCB_FIELDS = [ + 'ID' => 'Id', + 'Bezeichner' => 'Designator', + 'Footprint' => 'Package', + 'Stückzahl' => 'Quantity', + 'Bezeichnung' => 'Designation', + 'Anbieter und Referenz' => 'Supplier and ref', + ]; + + public function __construct() + { + } + + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + $resolver->setRequired('type'); + $resolver->setAllowedValues('type', ['kicad_pcbnew']); + + 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. + * @param File $file + * @param array $options + * @param Project $project + * @param array $errors + * @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. + * @param File $file + * @param array $options + * @return ProjectBOMEntry[] + */ + public function fileToBOMEntries(File $file, array $options): array + { + return $this->stringToBOMEntries($file->getContent(), $options); + } + + /** + * 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); + + switch ($options['type']) { + case 'kicad_pcbnew': + return $this->parseKiCADPCB($data, $options); + default: + throw new InvalidArgumentException('Invalid import type!'); + } + } + + private function parseKiCADPCB(string $data, array $options = []): 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 = array_combine(array_map(function ($key) { + return self::MAP_KICAD_PCB_FIELDS[$key] ?? $key; + }, array_keys($entry)), $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; + } +} \ No newline at end of file diff --git a/templates/projects/import_bom.html.twig b/templates/projects/import_bom.html.twig new file mode 100644 index 00000000..2c552687 --- /dev/null +++ b/templates/projects/import_bom.html.twig @@ -0,0 +1,31 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}project.add_parts_to_project{% endtrans %}{% endblock %} + +{% block before_card %} + {% if errors %} +