mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-28 20:21:41 +00:00
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
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:
parent
af2eae75d4
commit
be03d1f503
7 changed files with 1610 additions and 17 deletions
176
assets/controllers/pages/project_bom_export_controller.js
Normal file
176
assets/controllers/pages/project_bom_export_controller.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,10 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
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;
|
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+'])]
|
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
|
||||||
public function importBOM(
|
public function importBOM(
|
||||||
Request $request,
|
Request $request,
|
||||||
|
|
|
||||||
320
src/Services/ImportExportSystem/ProjectBomExporter.php
Normal file
320
src/Services/ImportExportSystem/ProjectBomExporter.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
{% import "components/datatables.macro.html.twig" as datatables %}
|
{% import "components/datatables.macro.html.twig" as datatables %}
|
||||||
|
|
||||||
<div class="btn-group mb-2 mt-2">
|
<div class="d-flex align-items-center gap-2"
|
||||||
<a class="btn btn-success" {% if not is_granted('@projects.edit') %}disabled{% endif %}
|
{{ stimulus_controller('pages/project_bom_export', {url: path('project_bom_export', {id: project.id})}) }}>
|
||||||
href="{{ path('project_add_parts', {"id": project.id, "_redirect": uri_without_host(app.request)}) }}">
|
|
||||||
<i class="fa-solid fa-square-plus fa-fw"></i>
|
<div class="btn-group mb-2 mt-2">
|
||||||
{% trans %}project.info.bom_add_parts{% endtrans %}
|
<a class="btn btn-success" {% if not is_granted('@projects.edit') %}disabled{% endif %}
|
||||||
</a>
|
href="{{ path('project_add_parts', {"id": project.id, "_redirect": uri_without_host(app.request)}) }}">
|
||||||
<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
<i class="fa-solid fa-square-plus fa-fw"></i>
|
||||||
<span class="visually-hidden">Toggle Dropdown</span>
|
{% trans %}project.info.bom_add_parts{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ path('project_import_bom', {'id': project.id}) }}" {% if not is_granted('edit', project) %}disabled="disabled"{% endif %}>
|
||||||
|
<i class="fa-solid fa-file-import fa-fw"></i>
|
||||||
|
{% trans %}project.edit.bom.import_bom{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
{{ stimulus_action('pages/project_bom_export', 'export', 'click') }}>
|
||||||
|
<i class="fa-solid fa-file-csv fa-fw"></i>
|
||||||
|
{% trans %}project.bom.export_csv{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="{{ path('project_import_bom', {'id': project.id}) }}" {% if not is_granted('edit', project) %}disabled="disabled"{% endif %}>
|
|
||||||
<i class="fa-solid fa-file-import fa-fw"></i>
|
|
||||||
{% trans %}project.edit.bom.import_bom{% endtrans %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'projects') }}
|
{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'projects') }}
|
||||||
|
|
|
||||||
558
tests/Controller/ProjectControllerTest.php
Normal file
558
tests/Controller/ProjectControllerTest.php
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\ProjectSystem\Project;
|
||||||
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
|
||||||
|
final class ProjectControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<object>
|
||||||
|
*/
|
||||||
|
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<string> $exportColumns
|
||||||
|
* @param list<string> $exportLabels
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
private function getColumnRequest(
|
||||||
|
string $data,
|
||||||
|
bool $searchable,
|
||||||
|
bool $orderable,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'data' => $data,
|
||||||
|
'name' => '',
|
||||||
|
'searchable' => $searchable,
|
||||||
|
'orderable' => $orderable,
|
||||||
|
'search' => [
|
||||||
|
'value' => '',
|
||||||
|
'regex' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
377
tests/Services/ImportExportSystem/ProjectBomExporterTest.php
Normal file
377
tests/Services/ImportExportSystem/ProjectBomExporterTest.php
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Services\ImportExportSystem;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Category;
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\ProjectSystem\Project;
|
||||||
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use App\Services\ImportExportSystem\ProjectBomExporter;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class ProjectBomExporterTest extends WebTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var ProjectBomExporter
|
||||||
|
*/
|
||||||
|
protected $service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var EntityManagerInterface
|
||||||
|
*/
|
||||||
|
protected $entityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<object>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7248,6 +7248,12 @@ Element 1 -> Element 1.2</target>
|
||||||
<target>Add BOM entries</target>
|
<target>Add BOM entries</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id=".kp4e.H" name="project.bom.export_csv">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>project.bom.export_csv</source>
|
||||||
|
<target>Export as CSV</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="mo8KjGV" name="project.info.info.label">
|
<unit id="mo8KjGV" name="project.info.info.label">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>project.info.info.label</source>
|
<source>project.info.info.label</source>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue