Add CSV export for Project BOM tables (#1442)
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
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

* add Export AS CSV button to the Project BOM Entry tab

* added Export as CSV translation

* add service file for BOM exporter

* added controller for handling exporting project BOM data as CSV file

* added tests for Export as CSV controller and exporter code

* remove hierarchical paths from being exported in CSV to match datatable format

* Fixed message id

* Use stimulus functions to refer to stimulus controller

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
swdee 2026-07-28 07:40:51 +12:00 committed by GitHub
parent af2eae75d4
commit be03d1f503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1610 additions and 17 deletions

View file

@ -46,6 +46,10 @@ use Symfony\Component\HttpFoundation\Request;
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 @@ class ProjectController extends AbstractController
]);
}
#[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<array<string, mixed>>
* } $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,

View file

@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\Formatters\AmountFormatter;
use App\Services\Formatters\MoneyFormatter;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class ProjectBomExporter
{
public function __construct(
private EntityManagerInterface $entityManager,
private AmountFormatter $amountFormatter,
private MoneyFormatter $moneyFormatter,
private ProjectBuildHelper $projectBuildHelper,
private TranslatorInterface $translator,
) {
}
/**
* Load the requested BOM entries and return them in exactly the same
* order as the supplied ID list.
*
* IDs which do not belong to the supplied project are ignored.
*
* @param list<int> $orderedIds
*
* @return list<ProjectBOMEntry>
*/
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<string> $columns
* @param list<string> $labels
*
* @return array<string, string|int|float|null>
*/
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,
);
}
}