diff --git a/assets/controllers/project_bom_export_controller.js b/assets/controllers/project_bom_export_controller.js new file mode 100644 index 000000000..0668d327c --- /dev/null +++ b/assets/controllers/project_bom_export_controller.js @@ -0,0 +1,176 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + url: String, + }; + + async export(event) { + event.preventDefault(); + + const tableElement = this.element + .closest('#bom-tab-pane') + ?.querySelector('table'); + + if (!tableElement) { + throw new Error('Could not find the project BOM table.'); + } + + if ( + typeof window.jQuery === 'undefined' + || !window.jQuery.fn.DataTable.isDataTable(tableElement) + ) { + throw new Error('The project BOM DataTable is not initialized.'); + } + + const dataTable = window.jQuery(tableElement).DataTable(); + const ajaxParameters = dataTable.ajax.params(); + + const parameters = this.toSearchParameters(ajaxParameters); + + /* + * Export the currently visible columns in their current display order. + * Exclude the picture column because it has no useful CSV value. + */ + dataTable + .columns() + .every(function () { + /* + * column.visible() returns DataTables' configured visibility. + * + * Responsive may hide a column visually and move it into a child + * row, but it does not change this configured visibility state. + */ + if (!this.visible()) { + return; + } + + const index = this.index(); + const settings = dataTable.settings()[0]; + const columnSettings = settings.aoColumns[index]; + const name = columnSettings.data; + + /* + * The picture column is not useful in CSV. + */ + if (!name || name === 'picture') { + return; + } + + const heading = this.header().textContent.trim(); + + parameters.append('exportColumns[]', name); + parameters.append( + 'exportLabels[]', + heading || name + ); + }); + + await this.downloadExport(parameters); + } + + async downloadExport(parameters) { + const response = await fetch(this.urlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: parameters.toString(), + credentials: 'same-origin', + }); + + if (!response.ok) { + const errorBody = await response.text(); + + console.error('BOM CSV export failed:', { + status: response.status, + statusText: response.statusText, + body: errorBody, + }); + + throw new Error( + `BOM CSV export failed with HTTP ${response.status}` + ); + } + + const blob = await response.blob(); + const filename = this.getFilename(response) + ?? 'project_bom.csv'; + + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = downloadUrl; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(downloadUrl); + } + + getFilename(response) { + const disposition = response.headers.get('Content-Disposition'); + + if (!disposition) { + return null; + } + + /* + * Prefer RFC 5987 filename*=UTF-8''... when present. + */ + const encodedMatch = disposition.match( + /filename\*=UTF-8''([^;]+)/i + ); + + if (encodedMatch) { + return decodeURIComponent(encodedMatch[1]); + } + + const filenameMatch = disposition.match( + /filename="?([^";]+)"?/i + ); + + return filenameMatch ? filenameMatch[1] : null; + } + + /** + * Convert DataTables' nested AJAX object into query-string parameters. + */ + toSearchParameters(object) { + const parameters = new URLSearchParams(); + + const append = (key, value) => { + if (Array.isArray(value)) { + value.forEach((item, index) => { + append(`${key}[${index}]`, item); + }); + + return; + } + + if (value !== null && typeof value === 'object') { + Object.entries(value).forEach(([childKey, childValue]) => { + const fullKey = key + ? `${key}[${childKey}]` + : childKey; + + append(fullKey, childValue); + }); + + return; + } + + parameters.append(key, String(value ?? '')); + }; + + Object.entries(object).forEach(([key, value]) => { + append(key, value); + }); + + return parameters; + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 531deb3f4..de16eb6e1 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -46,6 +46,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; +use App\Helpers\FilenameSanatizer; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\Serializer\SerializerInterface; +use App\Services\ImportExportSystem\ProjectBomExporter; use function Symfony\Component\Translation\t; @@ -128,6 +132,148 @@ public function build(Project $project, Request $request, ProjectBuildHelper $bu ]); } + #[Route( + path: '/{id}/bom/export', + name: 'project_bom_export', + requirements: ['id' => '\d+'], + methods: ['POST'] + )] + public function exportBOM( + Project $project, + Request $request, + ProjectBomExporter $projectBomExporter, + SerializerInterface $serializer, + ): Response { + $this->denyAccessUnlessGranted('read', $project); + + /* + * First run the normal BOM DataTable callback. This applies exactly the + * same project restriction, search criteria and active column ordering + * as the displayed table. + * + * We only use its hidden ID column. Rendered cell contents are discarded. + */ + $table = $this->dataTableFactory->createFromType( + ProjectBomEntriesDataTable::class, + ['project' => $project], + ); + + $request->request->set('_dt', $table->getName()); + /* + * Export every row matching the current search/filter. + * Ignore the page currently displayed in the browser. + */ + $request->request->set('start', 0); + $request->request->set('length', -1); + + $table->handleRequest($request); + + if (!$table->isCallback()) { + throw new \RuntimeException( + 'The BOM export request was not recognised as a DataTable callback.' + ); + } + + $tableResponse = $table->getResponse(); + + /** @var array{ + * data?: list> + * } $payload + */ + $payload = json_decode( + $tableResponse->getContent() ?: '{}', + true, + 512, + JSON_THROW_ON_ERROR, + ); + + /* + * The DataTable already contains a hidden ID column. Collect those IDs in + * their returned order. That order is the currently selected table order. + */ + $orderedIds = []; + + foreach ($payload['data'] ?? [] as $tableRow) { + $id = filter_var( + $tableRow['id'] ?? null, + FILTER_VALIDATE_INT, + ); + + if ($id !== false) { + $orderedIds[] = $id; + } + } + + $columns = array_values( + array_filter( + $request->request->all('exportColumns'), + static fn(mixed $column): bool => is_string($column), + ) + ); + + $labels = array_values( + array_filter( + $request->request->all('exportLabels'), + static fn(mixed $label): bool => is_string($label), + ) + ); + + if ($columns === []) { + throw new \InvalidArgumentException( + 'No columns were specified for BOM export.' + ); + } + + /* + * Reload raw entities from Doctrine. The exporter verifies that every + * entry belongs to this project and restores the DataTable's ID order. + */ + $entries = $projectBomExporter->getOrderedEntries( + $project, + $orderedIds, + ); + + $rows = []; + + foreach ($entries as $entry) { + $rows[] = $projectBomExporter->createRow( + $entry, + $columns, + $labels, + ); + } + + $csv = $serializer->serialize($rows, 'csv', [ + 'as_collection' => true, + 'csv_delimiter' => ';', + ]); + + $filename = FilenameSanatizer::sanitizeFilename( + sprintf( + 'project_%s_bom.csv', + $project->getName(), + ) + ); + + $response = new Response($csv); + + $response->headers->set( + 'Content-Type', + 'text/csv; charset=UTF-8', + ); + + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $filename, + 'project_bom.csv', + ) + ); + + return $response; + } + #[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])] public function importBOM( Request $request, diff --git a/src/Services/ImportExportSystem/ProjectBomExporter.php b/src/Services/ImportExportSystem/ProjectBomExporter.php new file mode 100644 index 000000000..1c44ee874 --- /dev/null +++ b/src/Services/ImportExportSystem/ProjectBomExporter.php @@ -0,0 +1,320 @@ + $orderedIds + * + * @return list + */ + public function getOrderedEntries( + Project $project, + array $orderedIds, + ): array { + if ($orderedIds === []) { + return []; + } + + $entries = $this->entityManager + ->createQueryBuilder() + ->select('bom_entry') + ->addSelect('part') + ->addSelect('category') + ->addSelect('footprint') + ->addSelect('manufacturer') + ->addSelect('partLots') + ->addSelect('storageLocation') + ->from(ProjectBOMEntry::class, 'bom_entry') + ->leftJoin('bom_entry.part', 'part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.footprint', 'footprint') + ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storageLocation') + ->where('bom_entry.project = :project') + ->andWhere('bom_entry.id IN (:ids)') + ->setParameter('project', $project) + ->setParameter('ids', $orderedIds) + ->getQuery() + ->getResult(); + + $entriesById = []; + + foreach ($entries as $entry) { + if (!$entry instanceof ProjectBOMEntry) { + continue; + } + + $entriesById[$entry->getID()] = $entry; + } + + $orderedEntries = []; + + foreach ($orderedIds as $id) { + if (isset($entriesById[$id])) { + $orderedEntries[] = $entriesById[$id]; + } + } + + return $orderedEntries; + } + + /** + * Generate a CSV row from raw entity values. + * + * @param list $columns + * @param list $labels + * + * @return array + */ + public function createRow( + ProjectBOMEntry $entry, + array $columns, + array $labels, + ): array { + $row = []; + + foreach ($columns as $index => $column) { + $label = $labels[$index] ?? $column; + + $row[$label] = $this->getColumnValue($entry, $column); + } + + return $row; + } + + private function getColumnValue( + ProjectBOMEntry $entry, + string $column, + ): string|int|float|null { + $part = $entry->getPart(); + + return match ($column) { + 'id' => $entry->getID(), + + 'quantity' => $this->formatQuantity($entry), + + 'partId' => $part?->getID(), + + 'name' => $this->formatName($entry), + + 'ipn' => $part?->getIpn() ?? '', + + 'description' => $part instanceof Part + ? $part->getDescription() + : $entry->getComment(), + + 'category' => $part?->getCategory()?->getName() ?? '', + + 'footprint' => $part?->getFootprint()?->getName() ?? '', + + 'manufacturer' => $part?->getManufacturer()?->getName() ?? '', + + 'manufacturing_status' => $this->formatManufacturingStatus( + $part + ), + + 'mountnames' => $this->formatMountNames($entry), + + 'instockAmount' => $this->formatInStockAmount($part), + + 'storelocation' => $this->formatStorageLocations($part), + + 'price' => $this->formatUnitPrice($entry), + + 'ext_price' => $this->formatExtendedPrice($entry), + + 'addedDate' => $entry->getAddedDate()?->format(DATE_ATOM) ?? '', + + 'lastModified' => $entry->getLastModified()?->format(DATE_ATOM) + ?? '', + + default => '', + }; + } + + private function formatQuantity(ProjectBOMEntry $entry): float|string + { + $part = $entry->getPart(); + + if (!$part instanceof Part) { + return round($entry->getQuantity()); + } + + return $this->amountFormatter->format( + $entry->getQuantity(), + $part->getPartUnit(), + ); + } + + private function formatName(ProjectBOMEntry $entry): string + { + $part = $entry->getPart(); + + if (!$part instanceof Part) { + return trim((string) $entry->getName()); + } + + $partName = trim($part->getName()); + $entryName = trim((string) $entry->getName()); + + $values = [$partName]; + + /* + * The DataTable displays the optional BOM entry name beneath the linked + * part's name. Preserve that information in the CSV, but avoid exporting + * duplicate names. + */ + if ( + $entryName !== '' + && $entryName !== $partName + ) { + $values[] = $entryName; + } + + return implode(' ', $values); + } + + private function formatManufacturingStatus(?Part $part): string + { + $status = $part?->getManufacturingStatus(); + + if ($status === null) { + return ''; + } + + return $this->translator->trans( + $status->toTranslationKey() + ); + } + + private function formatMountNames(ProjectBOMEntry $entry): string + { + $mountNames = array_map( + static fn(string $value): string => trim($value), + explode(',', $entry->getMountnames()), + ); + + $mountNames = array_filter( + $mountNames, + static fn(string $value): bool => $value !== '', + ); + + return implode(', ', $mountNames); + } + + private function formatInStockAmount(?Part $part): string + { + if (!$part instanceof Part) { + return ''; + } + + $amount = $this->amountFormatter->format( + $part->getAmountSum(), + $part->getPartUnit(), + ); + + if ($part->isAmountUnknown()) { + $amount = $part->getAmountSum() === 0.0 + ? '?' + : '≥'.$amount; + } + + $expiredAmount = $part->getExpiredAmountSum(); + + if ($expiredAmount > 0) { + $amount .= sprintf( + ' (+%s expired)', + $this->amountFormatter->format( + $expiredAmount, + $part->getPartUnit(), + ), + ); + } + + return $amount; + } + + private function formatStorageLocations(?Part $part): string + { + if (!$part instanceof Part) { + return ''; + } + + $locations = []; + + foreach ($part->getPartLots() as $partLot) { + $storageLocation = $partLot->getStorageLocation(); + + if (!$storageLocation instanceof StorageLocation) { + continue; + } + + $locations[$storageLocation->getID()] = + $storageLocation->getName(); + } + + return implode(', ', array_values($locations)); + } + + private function formatUnitPrice(ProjectBOMEntry $entry): string + { + $price = $this->projectBuildHelper->getEntryUnitPrice($entry); + + return $this->moneyFormatter->format( + $price->toScale(2, RoundingMode::Up)->toFloat(), + null, + 2, + true, + ); + } + + private function formatExtendedPrice( + ProjectBOMEntry $entry, + ): string { + $price = $this->projectBuildHelper->getEntryUnitPrice($entry); + + $extendedPrice = $price->multipliedBy( + BigDecimal::fromFloatShortest($entry->getQuantity()) + ); + + return $this->moneyFormatter->format( + $extendedPrice + ->toScale(2, RoundingMode::Up) + ->toFloat(), + null, + 2, + true, + ); + } +} diff --git a/templates/projects/info/_bom.html.twig b/templates/projects/info/_bom.html.twig index 2a9cfd004..f3b52f59a 100644 --- a/templates/projects/info/_bom.html.twig +++ b/templates/projects/info/_bom.html.twig @@ -1,22 +1,32 @@ {% import "components/datatables.macro.html.twig" as datatables %} - + + - -{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'projects') }} \ No newline at end of file +{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'projects') }} diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php new file mode 100644 index 000000000..938cd71e8 --- /dev/null +++ b/tests/Controller/ProjectControllerTest.php @@ -0,0 +1,558 @@ + + */ + private array $entitiesToRemove = []; + + protected function setUp(): void + { + $this->client = static::createClient(); + + $this->entityManager = self::getContainer()->get( + EntityManagerInterface::class + ); + + $user = $this->entityManager + ->getRepository(User::class) + ->findOneBy(['name' => 'admin']); + + $this->assertInstanceOf( + User::class, + $user, + 'The admin fixture user was not found.' + ); + + $this->client->loginUser($user); + $this->client->catchExceptions(false); + } + + protected function tearDown(): void + { + foreach (array_reverse($this->entitiesToRemove) as $entity) { + if ($this->entityManager->contains($entity)) { + $this->entityManager->remove($entity); + } + } + + $this->entityManager->flush(); + $this->entitiesToRemove = []; + + parent::tearDown(); + } + + public function testExportBOMAsCSV(): void + { + $project = $this->createProject('Controller CSV Export Test'); + + $this->createBOMEntry( + $project, + '10k resistor', + 'R1,R2', + 2.0, + 'Generic resistor' + ); + + $this->createBOMEntry( + $project, + '100nF capacitor', + 'C1', + 1.0, + 'Ceramic capacitor' + ); + + $this->entityManager->flush(); + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + $this->getDataTableRequest([ + 'quantity', + 'name', + 'description', + 'mountnames', + ], [ + 'BOM Qty.', + 'Name', + 'Description', + 'Mount names', + ]) + ); + + $response = $this->client->getResponse(); + + $this->assertTrue( + $response->isSuccessful(), + sprintf( + 'The CSV export request failed with HTTP %d.', + $response->getStatusCode() + ) + ); + + $this->assertSame( + 'text/csv; charset=UTF-8', + $response->headers->get('Content-Type') + ); + + $contentDisposition = (string) $response->headers->get( + 'Content-Disposition' + ); + + $this->assertStringContainsString( + 'attachment', + $contentDisposition + ); + + $this->assertStringContainsString( + '.csv', + $contentDisposition + ); + + $content = $response->getContent(); + + $this->assertNotFalse($content); + + /* + * Parse the generated CSV so the test verifies the exported data rather + * than depending on the exact quoting produced by Symfony's CSV encoder. + */ + $stream = fopen('php://memory', 'r+'); + + $this->assertIsResource($stream); + + fwrite($stream, $content); + rewind($stream); + + $header = fgetcsv( + $stream, + separator: ';', + enclosure: '"', + escape: '' + ); + + fclose($stream); + + $this->assertSame( + [ + 'BOM Qty.', + 'Name', + 'Description', + 'Mount names', + ], + $header + ); + + $this->assertStringContainsString('10k resistor', $content); + $this->assertStringContainsString('Generic resistor', $content); + $this->assertStringContainsString('R1, R2', $content); + $this->assertStringContainsString('100nF capacitor', $content); + $this->assertStringContainsString('Ceramic capacitor', $content); + } + + public function testExportBOMIgnoresPagination(): void + { + $project = $this->createProject( + 'Controller Unpaginated CSV Export Test' + ); + + for ($index = 1; $index <= 11; ++$index) { + $this->createBOMEntry( + $project, + sprintf('Pagination test part %02d', $index), + sprintf('R%d', $index), + 1.0, + '' + ); + } + + $this->entityManager->flush(); + + $request = $this->getDataTableRequest( + ['name'], + ['Name'] + ); + + /* + * Simulate the browser currently displaying the second page with + * ten rows per page. The controller must override these values. + */ + $request['start'] = 10; + $request['length'] = 10; + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + $request + ); + + $response = $this->client->getResponse(); + + $this->assertTrue( + $response->isSuccessful(), + sprintf( + 'The CSV export request failed with HTTP %d.', + $response->getStatusCode() + ) + ); + + $content = $response->getContent(); + + $this->assertNotFalse($content); + + $rows = preg_split('/\R/', trim($content)); + + $this->assertIsArray($rows); + + $rows = array_values(array_filter( + $rows, + static fn(string $row): bool => $row !== '' + )); + + /* + * One heading row plus all eleven BOM entries. If pagination were + * applied, only one data row from the second page would be present. + */ + $this->assertCount(12, $rows); + + $this->assertStringContainsString( + 'Pagination test part 01', + $content + ); + + $this->assertStringContainsString( + 'Pagination test part 11', + $content + ); + } + + public function testExportBOMPreservesCurrentSortOrder(): void + { + $project = $this->createProject( + 'Controller Sorted CSV Export Test' + ); + + $this->createBOMEntry( + $project, + 'Lower quantity component', + 'U1', + 1.0, + '' + ); + + $this->createBOMEntry( + $project, + 'Higher quantity component', + 'U2', + 5.0, + '' + ); + + $this->entityManager->flush(); + + $request = $this->getDataTableRequest( + [ + 'quantity', + 'name', + ], + [ + 'BOM Qty.', + 'Name', + ] + ); + + /* + * Quantity is column index 2 in ProjectBomEntriesDataTable. + */ + $request['order'] = [ + [ + 'column' => 2, + 'dir' => 'desc', + 'name' => '', + ], + ]; + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + $request + ); + + $response = $this->client->getResponse(); + + $this->assertTrue( + $response->isSuccessful(), + sprintf( + 'The CSV export request failed with HTTP %d.', + $response->getStatusCode() + ) + ); + + $content = $response->getContent(); + + $this->assertNotFalse($content); + + $higherPosition = strpos( + $content, + 'Higher quantity component' + ); + + $lowerPosition = strpos( + $content, + 'Lower quantity component' + ); + + $this->assertNotFalse($higherPosition); + $this->assertNotFalse($lowerPosition); + + $this->assertLessThan( + $lowerPosition, + $higherPosition, + 'The CSV did not preserve descending quantity order.' + ); + } + + public function testExportBOMRequiresExportColumns(): void + { + $project = $this->createProject( + 'Controller Missing Columns CSV Export Test' + ); + + $this->createBOMEntry( + $project, + 'Missing columns test entry', + 'U1', + 1.0, + '' + ); + + $this->entityManager->flush(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'No columns were specified for BOM export.' + ); + + $this->client->request( + 'POST', + sprintf( + '/en/project/%d/bom/export', + $project->getID() + ), + [ + '_dt' => 'dt', + 'draw' => 1, + 'start' => 0, + 'length' => 10, + 'search' => [ + 'value' => '', + 'regex' => false, + ], + ] + ); + } + + private function createProject(string $name): Project + { + $project = new Project(); + $project->setName($name); + + $this->entityManager->persist($project); + $this->entitiesToRemove[] = $project; + + return $project; + } + + private function createBOMEntry( + Project $project, + string $name, + string $mountnames, + float $quantity, + string $comment, + ): ProjectBOMEntry { + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setName($name); + $entry->setMountnames($mountnames); + $entry->setQuantity($quantity); + $entry->setComment($comment); + + $this->entityManager->persist($entry); + $this->entitiesToRemove[] = $entry; + + return $entry; + } + + /** + * Build the DataTables state sent by the browser. + * + * The table callback uses the configured server-side column definitions, + * but order indexes refer to the complete ProjectBomEntriesDataTable. + * + * @param list $exportColumns + * @param list $exportLabels + * + * @return array + */ + private function getDataTableRequest( + array $exportColumns, + array $exportLabels, + ): array { + return [ + '_dt' => 'dt', + 'draw' => 1, + 'start' => 0, + 'length' => 10, + 'columns' => [ + $this->getColumnRequest( + 'picture', + false, + false + ), + $this->getColumnRequest( + 'id', + true, + true + ), + $this->getColumnRequest( + 'quantity', + true, + true + ), + $this->getColumnRequest( + 'partId', + false, + true + ), + $this->getColumnRequest( + 'name', + true, + true + ), + $this->getColumnRequest( + 'ipn', + false, + true + ), + $this->getColumnRequest( + 'description', + false, + false + ), + $this->getColumnRequest( + 'category', + true, + true + ), + $this->getColumnRequest( + 'footprint', + true, + true + ), + $this->getColumnRequest( + 'manufacturer', + true, + true + ), + $this->getColumnRequest( + 'manufacturing_status', + false, + true + ), + $this->getColumnRequest( + 'mountnames', + true, + true + ), + $this->getColumnRequest( + 'instockAmount', + false, + false + ), + $this->getColumnRequest( + 'storelocation', + false, + true + ), + $this->getColumnRequest( + 'price', + true, + true + ), + $this->getColumnRequest( + 'ext_price', + false, + false + ), + $this->getColumnRequest( + 'addedDate', + true, + true + ), + $this->getColumnRequest( + 'lastModified', + true, + true + ), + ], + 'order' => [ + [ + 'column' => 4, + 'dir' => 'asc', + 'name' => '', + ], + ], + 'search' => [ + 'value' => '', + 'regex' => false, + ], + 'exportColumns' => $exportColumns, + 'exportLabels' => $exportLabels, + ]; + } + + /** + * @return array + */ + private function getColumnRequest( + string $data, + bool $searchable, + bool $orderable, + ): array { + return [ + 'data' => $data, + 'name' => '', + 'searchable' => $searchable, + 'orderable' => $orderable, + 'search' => [ + 'value' => '', + 'regex' => false, + ], + ]; + } +} diff --git a/tests/Services/ImportExportSystem/ProjectBomExporterTest.php b/tests/Services/ImportExportSystem/ProjectBomExporterTest.php new file mode 100644 index 000000000..2a64ffaae --- /dev/null +++ b/tests/Services/ImportExportSystem/ProjectBomExporterTest.php @@ -0,0 +1,377 @@ + + */ + private array $entitiesToRemove = []; + + protected function setUp(): void + { + self::bootKernel(); + + $this->service = self::getContainer()->get(ProjectBomExporter::class); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + foreach (array_reverse($this->entitiesToRemove) as $entity) { + if ($this->entityManager->contains($entity)) { + $this->entityManager->remove($entity); + } + } + + $this->entityManager->flush(); + $this->entitiesToRemove = []; + + parent::tearDown(); + } + + public function testGetOrderedEntriesWithEmptyIDList(): void + { + $project = new Project(); + $project->setName('Empty BOM export test'); + + $this->assertSame( + [], + $this->service->getOrderedEntries($project, []) + ); + } + + public function testGetOrderedEntriesPreservesRequestedOrder(): void + { + $project = $this->createProject('Ordered BOM export test'); + + $first = $this->createBOMEntry( + $project, + 'First export entry', + 'R1', + 1.0, + 'First comment' + ); + + $second = $this->createBOMEntry( + $project, + 'Second export entry', + 'R2', + 2.0, + 'Second comment' + ); + + $this->entityManager->flush(); + + $result = $this->service->getOrderedEntries( + $project, + [ + $second->getID(), + $first->getID(), + ] + ); + + $this->assertCount(2, $result); + $this->assertSame($second->getID(), $result[0]->getID()); + $this->assertSame($first->getID(), $result[1]->getID()); + } + + public function testGetOrderedEntriesIgnoresEntriesFromAnotherProject(): void + { + $firstProject = $this->createProject('First BOM export project'); + $secondProject = $this->createProject('Second BOM export project'); + + $firstEntry = $this->createBOMEntry( + $firstProject, + 'First project entry', + 'R1', + 1.0, + '' + ); + + $secondEntry = $this->createBOMEntry( + $secondProject, + 'Second project entry', + 'R2', + 1.0, + '' + ); + + $this->entityManager->flush(); + + $result = $this->service->getOrderedEntries( + $firstProject, + [ + $secondEntry->getID(), + $firstEntry->getID(), + ] + ); + + $this->assertCount(1, $result); + $this->assertSame($firstEntry->getID(), $result[0]->getID()); + } + + public function testCreateRowForUnlinkedBOMEntry(): void + { + $entry = new ProjectBOMEntry(); + $entry->setName('10k resistor'); + $entry->setComment('Generic resistor used for testing'); + $entry->setMountnames('R1,R2, R3,,'); + $entry->setQuantity(3.0); + + $row = $this->service->createRow( + $entry, + [ + 'quantity', + 'partId', + 'name', + 'ipn', + 'description', + 'category', + 'footprint', + 'manufacturer', + 'manufacturing_status', + 'mountnames', + 'instockAmount', + 'storelocation', + 'addedDate', + 'lastModified', + 'unknown_column', + ], + [ + 'BOM Qty.', + 'Part ID', + 'Name', + 'IPN', + 'Description', + 'Category', + 'Footprint', + 'Manufacturer', + 'Manufacturing status', + 'Mount names', + 'In stock', + 'Storage locations', + 'Created at', + 'Last modified', + ] + ); + + $this->assertSame(3.0, $row['BOM Qty.']); + $this->assertNull($row['Part ID']); + $this->assertSame('10k resistor', $row['Name']); + $this->assertSame('', $row['IPN']); + $this->assertSame( + 'Generic resistor used for testing', + $row['Description'] + ); + $this->assertSame('', $row['Category']); + $this->assertSame('', $row['Footprint']); + $this->assertSame('', $row['Manufacturer']); + $this->assertSame('', $row['Manufacturing status']); + $this->assertSame('R1, R2, R3', $row['Mount names']); + $this->assertSame('', $row['In stock']); + $this->assertSame('', $row['Storage locations']); + $this->assertSame('', $row['Created at']); + $this->assertSame('', $row['Last modified']); + + /* + * No corresponding label was supplied for this column, so the + * exporter falls back to the column name. + */ + $this->assertArrayHasKey('unknown_column', $row); + $this->assertSame('', $row['unknown_column']); + } + + public function testCreateRowForLinkedPart(): void + { + $category = $this->getDefaultCategory(); + + $part = new Part(); + $part->setName('RC0402FR-071ML'); + $part->setDescription('10k ohm resistor'); + $part->setIpn('TEST-IPN-001'); + $part->setCategory($category); + + $project = $this->createProject('Linked part BOM export test'); + + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setPart($part); + $entry->setName('10k ohm resistor'); + $entry->setComment('BOM entry comment'); + $entry->setMountnames('R1,R2'); + $entry->setQuantity(2.0); + + $this->entityManager->persist($part); + $this->entityManager->persist($entry); + + $this->entitiesToRemove[] = $entry; + $this->entitiesToRemove[] = $part; + + $this->entityManager->flush(); + + $row = $this->service->createRow( + $entry, + [ + 'id', + 'quantity', + 'partId', + 'name', + 'ipn', + 'description', + 'category', + 'footprint', + 'manufacturer', + 'manufacturing_status', + 'mountnames', + 'instockAmount', + 'storelocation', + 'price', + 'ext_price', + 'addedDate', + 'lastModified', + ], + [ + 'BOM ID', + 'BOM Qty.', + 'Part ID', + 'Name', + 'IPN', + 'Description', + 'Category', + 'Footprint', + 'Manufacturer', + 'Manufacturing status', + 'Mount names', + 'In stock', + 'Storage locations', + 'Price', + 'Extended Price', + 'Created at', + 'Last modified', + ] + ); + + $this->assertSame($entry->getID(), $row['BOM ID']); + $this->assertSame($part->getID(), $row['Part ID']); + $this->assertSame( + 'RC0402FR-071ML 10k ohm resistor', + $row['Name'] + ); + $this->assertSame('TEST-IPN-001', $row['IPN']); + $this->assertSame('10k ohm resistor', $row['Description']); + $this->assertSame($category->getName(), $row['Category']); + $this->assertSame('', $row['Footprint']); + $this->assertSame('', $row['Manufacturer']); + $this->assertSame('', $row['Manufacturing status']); + $this->assertSame('R1, R2', $row['Mount names']); + $this->assertSame('', $row['Storage locations']); + + /* + * The exact formatting depends on the configured part unit and + * currency, so verify that the formatter produced scalar strings. + */ + $this->assertIsString($row['BOM Qty.']); + $this->assertIsString($row['In stock']); + $this->assertIsString($row['Price']); + $this->assertIsString($row['Extended Price']); + + $this->assertNotSame('', $row['Created at']); + $this->assertNotSame('', $row['Last modified']); + } + + public function testCreateRowUsesColumnNameWhenLabelIsMissing(): void + { + $entry = new ProjectBOMEntry(); + $entry->setName('Label fallback test'); + $entry->setComment(''); + $entry->setMountnames('U1'); + $entry->setQuantity(1.0); + + $row = $this->service->createRow( + $entry, + [ + 'name', + 'mountnames', + ], + [ + 'Component', + ] + ); + + $this->assertSame('Label fallback test', $row['Component']); + $this->assertSame('U1', $row['mountnames']); + } + + private function createProject(string $name): Project + { + $project = new Project(); + $project->setName($name); + + $this->entityManager->persist($project); + $this->entitiesToRemove[] = $project; + + return $project; + } + + private function createBOMEntry( + Project $project, + string $name, + string $mountnames, + float $quantity, + string $comment, + ): ProjectBOMEntry { + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setName($name); + $entry->setMountnames($mountnames); + $entry->setQuantity($quantity); + $entry->setComment($comment); + + $this->entityManager->persist($entry); + $this->entitiesToRemove[] = $entry; + + return $entry; + } + + private function getDefaultCategory(): Category + { + $category = $this->entityManager + ->getRepository(Category::class) + ->findOneBy([]); + + if ($category instanceof Category) { + return $category; + } + + $category = new Category(); + $category->setName('BOM Export Test Category'); + + $this->entityManager->persist($category); + $this->entitiesToRemove[] = $category; + $this->entityManager->flush(); + + return $category; + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 360218689..8e7f6c6a0 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -7248,6 +7248,12 @@ Element 1 -> Element 1.2 Add BOM entries + + + project.bom.export_csv + Export as CSV + + project.info.info.label