Skip to content

Commit 9c3d59c

Browse files
committed
Implement functionality to import schematic csv (or any other csv for that matter), with ability to map input columns to output columns with input validation and error handling
1 parent 2a0bea8 commit 9c3d59c

File tree

7 files changed

+2184
-61
lines changed

7 files changed

+2184
-61
lines changed

makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ dev-db-migrate:
9797

9898
dev-cache-clear:
9999
@echo "🗑️ Clearing development cache..."
100-
rm -rf var/cache/dev
100+
php -d memory_limit=1G bin/console cache:clear --env dev -n
101101
@echo "✅ Development cache cleared"
102102

103103
dev-warmup:

src/Controller/ProjectController.php

Lines changed: 286 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Doctrine\ORM\EntityManagerInterface;
3636
use League\Csv\SyntaxError;
3737
use Omines\DataTablesBundle\DataTableFactory;
38+
use Psr\Log\LoggerInterface;
3839
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
3940
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
4041
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -100,9 +101,14 @@ public function build(Project $project, Request $request, ProjectBuildHelper $bu
100101
$this->addFlash('success', 'project.build.flash.success');
101102

102103
return $this->redirect(
103-
$request->get('_redirect',
104-
$this->generateUrl('project_info', ['id' => $project->getID()]
105-
)));
104+
$request->get(
105+
'_redirect',
106+
$this->generateUrl(
107+
'project_info',
108+
['id' => $project->getID()]
109+
)
110+
)
111+
);
106112
}
107113

108114
$this->addFlash('error', 'project.build.flash.invalid_input');
@@ -118,9 +124,13 @@ public function build(Project $project, Request $request, ProjectBuildHelper $bu
118124
}
119125

120126
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
121-
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
122-
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
123-
{
127+
public function importBOM(
128+
Request $request,
129+
EntityManagerInterface $entityManager,
130+
Project $project,
131+
BOMImporter $BOMImporter,
132+
ValidatorInterface $validator
133+
): Response {
124134
$this->denyAccessUnlessGranted('edit', $project);
125135

126136
$builder = $this->createFormBuilder();
@@ -136,6 +146,7 @@ public function importBOM(Request $request, EntityManagerInterface $entityManage
136146
'required' => true,
137147
'choices' => [
138148
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
149+
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
139150
]
140151
]);
141152
$builder->add('clear_existing_bom', CheckboxType::class, [
@@ -159,25 +170,40 @@ public function importBOM(Request $request, EntityManagerInterface $entityManage
159170
$entityManager->flush();
160171
}
161172

173+
$import_type = $form->get('type')->getData();
174+
162175
try {
176+
// For schematic imports, redirect to field mapping step
177+
if ($import_type === 'kicad_schematic') {
178+
// Store file content and options in session for field mapping step
179+
$file_content = $form->get('file')->getData()->getContent();
180+
$clear_existing = $form->get('clear_existing_bom')->getData();
181+
182+
$request->getSession()->set('bom_import_data', $file_content);
183+
$request->getSession()->set('bom_import_clear', $clear_existing);
184+
185+
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
186+
}
187+
188+
// For PCB imports, proceed directly
163189
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
164-
'type' => $form->get('type')->getData(),
190+
'type' => $import_type,
165191
]);
166192

167-
//Validate the project entries
193+
// Validate the project entries
168194
$errors = $validator->validateProperty($project, 'bom_entries');
169195

170-
//If no validation errors occured, save the changes and redirect to edit page
171-
if (count ($errors) === 0) {
196+
// If no validation errors occurred, save the changes and redirect to edit page
197+
if (count($errors) === 0) {
172198
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
173199
$entityManager->flush();
174200
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
175201
}
176202

177-
//When we get here, there were validation errors
203+
// When we get here, there were validation errors
178204
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
179205

180-
} catch (\UnexpectedValueException|SyntaxError $e) {
206+
} catch (\UnexpectedValueException | SyntaxError $e) {
181207
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
182208
}
183209
}
@@ -189,11 +215,257 @@ public function importBOM(Request $request, EntityManagerInterface $entityManage
189215
]);
190216
}
191217

218+
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
219+
public function importBOMMapFields(
220+
Request $request,
221+
EntityManagerInterface $entityManager,
222+
Project $project,
223+
BOMImporter $BOMImporter,
224+
ValidatorInterface $validator,
225+
LoggerInterface $logger
226+
): Response {
227+
$this->denyAccessUnlessGranted('edit', $project);
228+
229+
// Get stored data from session
230+
$file_content = $request->getSession()->get('bom_import_data');
231+
$clear_existing = $request->getSession()->get('bom_import_clear', false);
232+
233+
234+
if (!$file_content) {
235+
$this->addFlash('error', 'project.bom_import.flash.session_expired');
236+
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
237+
}
238+
239+
// Detect fields and get suggestions
240+
$detected_fields = $BOMImporter->detectFields($file_content);
241+
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
242+
243+
// Create mapping of original field names to sanitized field names for template
244+
$field_name_mapping = [];
245+
foreach ($detected_fields as $field) {
246+
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
247+
$field_name_mapping[$field] = $sanitized_field;
248+
}
249+
250+
// Create form for field mapping
251+
$builder = $this->createFormBuilder();
252+
253+
// Add delimiter selection
254+
$builder->add('delimiter', ChoiceType::class, [
255+
'label' => 'project.bom_import.delimiter',
256+
'required' => true,
257+
'data' => ',',
258+
'choices' => [
259+
'project.bom_import.delimiter.comma' => ',',
260+
'project.bom_import.delimiter.semicolon' => ';',
261+
'project.bom_import.delimiter.tab' => "\t",
262+
]
263+
]);
264+
265+
// Get dynamic field mapping targets from BOMImporter
266+
$available_targets = $BOMImporter->getAvailableFieldTargets();
267+
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
268+
269+
foreach ($available_targets as $target_key => $target_info) {
270+
$target_fields[$target_info['label']] = $target_key;
271+
}
272+
273+
foreach ($detected_fields as $field) {
274+
// Sanitize field name for form use - replace invalid characters with underscores
275+
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
276+
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
277+
'label' => $field,
278+
'required' => false,
279+
'choices' => $target_fields,
280+
'data' => $suggested_mapping[$field] ?? '',
281+
]);
282+
}
283+
284+
$builder->add('submit', SubmitType::class, [
285+
'label' => 'project.bom_import.preview',
286+
]);
287+
288+
$form = $builder->getForm();
289+
$form->handleRequest($request);
290+
291+
if ($form->isSubmitted() && $form->isValid()) {
292+
// Build field mapping array with priority support
293+
$field_mapping = [];
294+
$field_priorities = [];
295+
$delimiter = $form->get('delimiter')->getData();
296+
297+
foreach ($detected_fields as $field) {
298+
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
299+
$target = $form->get('mapping_' . $sanitized_field)->getData();
300+
if (!empty($target)) {
301+
$field_mapping[$field] = $target;
302+
303+
// Get priority from request (default to 10)
304+
$priority = $request->request->get('priority_' . $sanitized_field, 10);
305+
$field_priorities[$field] = (int) $priority;
306+
}
307+
}
308+
309+
// Validate field mapping
310+
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
311+
312+
if (!$validation['is_valid']) {
313+
foreach ($validation['errors'] as $error) {
314+
$this->addFlash('error', $error);
315+
}
316+
foreach ($validation['warnings'] as $warning) {
317+
$this->addFlash('warning', $warning);
318+
}
319+
320+
return $this->render('projects/import_bom_map_fields.html.twig', [
321+
'project' => $project,
322+
'form' => $form->createView(),
323+
'detected_fields' => $detected_fields,
324+
'suggested_mapping' => $suggested_mapping,
325+
'field_name_mapping' => $field_name_mapping,
326+
]);
327+
}
328+
329+
// Show warnings but continue
330+
foreach ($validation['warnings'] as $warning) {
331+
$this->addFlash('warning', $warning);
332+
}
333+
334+
try {
335+
// Re-detect fields with chosen delimiter
336+
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
337+
338+
// Clear existing BOM entries if requested
339+
if ($clear_existing) {
340+
$existing_count = $project->getBomEntries()->count();
341+
$logger->info('Clearing existing BOM entries', [
342+
'existing_count' => $existing_count,
343+
'project_id' => $project->getID(),
344+
]);
345+
$project->getBomEntries()->clear();
346+
$entityManager->flush();
347+
$logger->info('Existing BOM entries cleared');
348+
} else {
349+
$existing_count = $project->getBomEntries()->count();
350+
$logger->info('Keeping existing BOM entries', [
351+
'existing_count' => $existing_count,
352+
'project_id' => $project->getID(),
353+
]);
354+
}
355+
356+
// Validate data before importing
357+
$validation_result = $BOMImporter->validateBOMData($file_content, [
358+
'type' => 'kicad_schematic',
359+
'field_mapping' => $field_mapping,
360+
'field_priorities' => $field_priorities,
361+
'delimiter' => $delimiter,
362+
]);
363+
364+
// Log validation results
365+
$logger->info('BOM import validation completed', [
366+
'total_entries' => $validation_result['total_entries'],
367+
'valid_entries' => $validation_result['valid_entries'],
368+
'invalid_entries' => $validation_result['invalid_entries'],
369+
'error_count' => count($validation_result['errors']),
370+
'warning_count' => count($validation_result['warnings']),
371+
]);
372+
373+
// Show validation warnings to user
374+
foreach ($validation_result['warnings'] as $warning) {
375+
$this->addFlash('warning', $warning);
376+
}
377+
378+
// If there are validation errors, show them and stop
379+
if (!empty($validation_result['errors'])) {
380+
foreach ($validation_result['errors'] as $error) {
381+
$this->addFlash('error', $error);
382+
}
383+
384+
return $this->render('projects/import_bom_map_fields.html.twig', [
385+
'project' => $project,
386+
'form' => $form->createView(),
387+
'detected_fields' => $detected_fields,
388+
'suggested_mapping' => $suggested_mapping,
389+
'field_name_mapping' => $field_name_mapping,
390+
'validation_result' => $validation_result,
391+
]);
392+
}
393+
394+
// Import with field mapping and priorities (validation already passed)
395+
$entries = $BOMImporter->stringToBOMEntries($file_content, [
396+
'type' => 'kicad_schematic',
397+
'field_mapping' => $field_mapping,
398+
'field_priorities' => $field_priorities,
399+
'delimiter' => $delimiter,
400+
]);
401+
402+
// Log entry details for debugging
403+
$logger->info('BOM entries created', [
404+
'total_entries' => count($entries),
405+
]);
406+
407+
foreach ($entries as $index => $entry) {
408+
$logger->debug("BOM entry {$index}", [
409+
'name' => $entry->getName(),
410+
'mountnames' => $entry->getMountnames(),
411+
'quantity' => $entry->getQuantity(),
412+
'comment' => $entry->getComment(),
413+
'part_id' => $entry->getPart()?->getID(),
414+
]);
415+
}
416+
417+
// Assign entries to project
418+
$logger->info('Adding BOM entries to project', [
419+
'entries_count' => count($entries),
420+
'project_id' => $project->getID(),
421+
]);
422+
423+
foreach ($entries as $index => $entry) {
424+
$logger->debug("Adding BOM entry {$index} to project", [
425+
'name' => $entry->getName(),
426+
'part_id' => $entry->getPart()?->getID(),
427+
'quantity' => $entry->getQuantity(),
428+
]);
429+
$project->addBomEntry($entry);
430+
}
431+
432+
// Validate the project entries (includes collection constraints)
433+
$errors = $validator->validateProperty($project, 'bom_entries');
434+
435+
// If no validation errors occurred, save and redirect
436+
if (count($errors) === 0) {
437+
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
438+
$entityManager->flush();
439+
440+
// Clear session data
441+
$request->getSession()->remove('bom_import_data');
442+
$request->getSession()->remove('bom_import_clear');
443+
444+
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
445+
}
446+
447+
// When we get here, there were validation errors
448+
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
449+
450+
} catch (\UnexpectedValueException | SyntaxError $e) {
451+
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
452+
}
453+
}
454+
455+
return $this->render('projects/import_bom_map_fields.html.twig', [
456+
'project' => $project,
457+
'form' => $form,
458+
'detected_fields' => $detected_fields,
459+
'suggested_mapping' => $suggested_mapping,
460+
'field_name_mapping' => $field_name_mapping,
461+
]);
462+
}
463+
192464
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
193465
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
194466
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
195467
{
196-
if($project instanceof Project) {
468+
if ($project instanceof Project) {
197469
$this->denyAccessUnlessGranted('edit', $project);
198470
} else {
199471
$this->denyAccessUnlessGranted('@projects.edit');
@@ -240,7 +512,7 @@ public function addPart(Request $request, EntityManagerInterface $entityManager,
240512

241513
$data = $form->getData();
242514
$bom_entries = $data['bom_entries'];
243-
foreach ($bom_entries as $bom_entry){
515+
foreach ($bom_entries as $bom_entry) {
244516
$target_project->addBOMEntry($bom_entry);
245517
}
246518

0 commit comments

Comments
 (0)