mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-11-08 23:43:32 -05:00
feat(items): add server-side "export all" download for items table
Add ability to export the items manage-table results (respecting current search/filters/sort) to downloadable files in multiple formats, instead of only the on-screen page. - app/Controllers/Items.php: new getExportAll(format) endpoint that re-runs the current search/filter query, builds raw export rows via getItemExportRow(), times db/build/write phases for debug logging, and streams back either a PDF response or a generic file download. - app/Helpers/export_helper.php (new): shared writer dispatch (generateExportFile) supporting csv, txt, json, xml, sql, xlsx and pdf formats, with per-format value coercion (currency/quantity/ numeric columns), XLSX temp-file caching with stale-file sweeping, and SQL INSERT generation keyed off the target table name. - app/Helpers/tabular_helper.php: add itemExportHeaders() (manage table columns minus UI-only icon columns) and getItemExportRow() (unformatted raw values for export consumers); rename expand_attribute_values() to expandAttributeValues() and add a $raw flag to skip locale decimal formatting for exports. - app/Controllers/Reports.php: update call sites for the expandAttributeValues() rename. - app/Config/Autoload.php: autoload new 'export' helper. - app/Config/Mimes.php: register .sql mime type for export downloads. - app/Controllers/Items.php: getSearch() now defaults missing 'search' param to '' instead of null. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
This commit is contained in:
1 parent
c450418fbd
commit
c004a3f412
13 files changed
+1670
-29
No files matched your search
@@ -201,6 +201,7 @@ class Autoload extends AutoloadConfig
|
||||
'form',
|
||||
'cookie',
|
||||
'tabular',
|
||||
'export',
|
||||
'locale',
|
||||
'security'
|
||||
];
|
||||
|
||||
@@ -279,6 +279,10 @@ class Mimes
|
||||
],
|
||||
'txt' => 'text/plain',
|
||||
'text' => 'text/plain',
|
||||
'sql' => [
|
||||
'application/sql',
|
||||
'text/plain',
|
||||
],
|
||||
'log' => [
|
||||
'text/plain',
|
||||
'text/x-log',
|
||||
|
||||
@@ -108,7 +108,7 @@ class Items extends Secure_Controller
|
||||
**/
|
||||
public function getSearch(): ResponseInterface
|
||||
{
|
||||
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? '';
|
||||
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
|
||||
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
|
||||
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
@@ -162,6 +162,76 @@ class Items extends Secure_Controller
|
||||
return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams items matching the current search/filters as a downloadable file.
|
||||
* With no limit/offset given, exports every matching row; otherwise exports just that page.
|
||||
* @noinspection PhpUnused
|
||||
**/
|
||||
public function getExportAll(string $format): ResponseInterface
|
||||
{
|
||||
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? '';
|
||||
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
|
||||
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
|
||||
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
|
||||
$this->item_lib->set_item_location($this->request->getGet('stock_location'));
|
||||
|
||||
$definitionNamesWithTypes = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS, true);
|
||||
$definitionIds = array_keys($definitionNamesWithTypes);
|
||||
|
||||
$sort = $this->sanitizeSortColumn(itemSortColumns($definitionIds), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'items.item_id');
|
||||
|
||||
$filters = [
|
||||
'start_date' => $this->request->getGet('start_date'),
|
||||
'end_date' => $this->request->getGet('end_date'),
|
||||
'stock_location_id' => $this->item_lib->get_item_location(),
|
||||
'empty_upc' => false,
|
||||
'low_inventory' => false,
|
||||
'is_serialized' => false,
|
||||
'no_description' => false,
|
||||
'search_custom' => false,
|
||||
'is_deleted' => false,
|
||||
'temporary' => false,
|
||||
'definition_ids' => $definitionIds
|
||||
];
|
||||
|
||||
$requestFilters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
|
||||
$filters = array_merge($filters, $requestFilters);
|
||||
|
||||
if (!empty($filters['search_custom'])) {
|
||||
$searchableDefinitions = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
|
||||
$filters['definition_ids'] = array_keys($searchableDefinitions);
|
||||
}
|
||||
|
||||
$dbStart = microtime(true);
|
||||
$items = $this->item->search($search, $filters, $limit, $offset, $sort, $order);
|
||||
$itemResults = $items->getResult();
|
||||
$dbMs = (int) round((microtime(true) - $dbStart) * 1000);
|
||||
|
||||
$taxPercentsByItemId = $this->buildTaxPercentsByItem($itemResults);
|
||||
|
||||
$buildStart = microtime(true);
|
||||
$exportRows = [];
|
||||
foreach ($itemResults as $item) {
|
||||
$exportRows[] = getItemExportRow($item, $definitionNamesWithTypes, $taxPercentsByItemId);
|
||||
}
|
||||
$buildMs = (int) round((microtime(true) - $buildStart) * 1000);
|
||||
|
||||
$writeStart = microtime(true);
|
||||
$file = generateExportFile($format, itemExportHeaders(), $exportRows, 'items_' . date('Ymd_His'), $this->db->prefixTable('items'));
|
||||
$writeMs = (int) round((microtime(true) - $writeStart) * 1000);
|
||||
|
||||
log_message('debug', "Export $format: db={$dbMs}ms build={$buildMs}ms write={$writeMs}ms rows=" . count($exportRows));
|
||||
|
||||
if ($format === 'pdf') {
|
||||
return $this->response->setContentType('application/pdf')
|
||||
->setHeader('Content-Disposition', 'attachment; filename="' . $file['filename'] . '"')
|
||||
->setBody($file['body']);
|
||||
}
|
||||
|
||||
return $this->response->download($file['filename'], $file['body'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array item_id => formatted tax percents string
|
||||
*/
|
||||
|
||||
@@ -1851,7 +1851,7 @@ class Reports extends Secure_Controller
|
||||
$quantity_purchased .= ' [' . $this->stock_location->get_location_name($drow['item_location']) . ']';
|
||||
}
|
||||
|
||||
$attribute_values = expand_attribute_values($definition_names, $drow);
|
||||
$attribute_values = expandAttributeValues($definition_names, $drow);
|
||||
|
||||
$details_data[$row['sale_id']][] = array_merge([
|
||||
$drow['name'],
|
||||
@@ -1991,7 +1991,7 @@ class Reports extends Secure_Controller
|
||||
$quantity_purchased .= ' [' . $this->stock_location->get_location_name($drow['item_location']) . ']';
|
||||
}
|
||||
|
||||
$attribute_values = expand_attribute_values($definition_names, $drow);
|
||||
$attribute_values = expandAttributeValues($definition_names, $drow);
|
||||
|
||||
$details_data[$row['receiving_id']][] = array_merge([
|
||||
$drow['item_number'],
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared server-side export file generation for manage-table "Export all" downloads.
|
||||
*
|
||||
* @param array $headers Array of single-key [column_key => label] pairs, e.g. item_headers()
|
||||
* @param iterable $rows Array/iterable of objects or arrays keyed by the same column_key values
|
||||
* @param string $tableName Underlying DB table name, used for the SQL export's INSERT statements
|
||||
*/
|
||||
function generateExportFile(string $format, array $headers, iterable $rows, string $baseFilename, string $tableName): array
|
||||
{
|
||||
$writers = [
|
||||
'csv' => fn () => writeExportCsv($headers, $rows, ','),
|
||||
'txt' => fn () => writeExportCsv($headers, $rows, "\t"),
|
||||
'json' => fn () => writeExportJson($headers, $rows),
|
||||
'xml' => fn () => writeExportXml($headers, $rows),
|
||||
'sql' => fn () => writeExportSql($headers, $rows, $tableName),
|
||||
'xlsx' => fn () => writeExportXlsx($headers, $rows),
|
||||
'pdf' => fn () => writeExportPdf($headers, $rows),
|
||||
];
|
||||
|
||||
if (!isset($writers[$format])) {
|
||||
throw new InvalidArgumentException("Unsupported export format: $format");
|
||||
}
|
||||
|
||||
$extensions = [
|
||||
'csv' => 'csv', 'txt' => 'txt', 'json' => 'json', 'xml' => 'xml',
|
||||
'sql' => 'sql', 'xlsx' => 'xlsx', 'pdf' => 'pdf',
|
||||
];
|
||||
|
||||
return [
|
||||
'filename' => $baseFilename . '.' . $extensions[$format],
|
||||
'body' => $writers[$format](),
|
||||
];
|
||||
}
|
||||
|
||||
const EXPORT_BLANKABLE_COLUMNS = ['tax_percents'];
|
||||
const EXPORT_INTEGER_COLUMNS = ['items.item_id'];
|
||||
const EXPORT_QUANTITY_COLUMNS = ['quantity'];
|
||||
const EXPORT_CURRENCY_COLUMNS = ['cost_price', 'unit_price'];
|
||||
const EXPORT_NUMERIC_IF_NUMERIC_COLUMNS = ['item_number'];
|
||||
|
||||
function exportRowValues(array $headers, object|array $row): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
$key = array_key_first($header);
|
||||
$property = str_contains($key, '.') ? substr($key, strrpos($key, '.') + 1) : $key;
|
||||
$value = is_array($row) ? ($row[$property] ?? '') : ($row->$property ?? '');
|
||||
|
||||
if (in_array($key, EXPORT_BLANKABLE_COLUMNS, true) && $value === '-') {
|
||||
$value = '';
|
||||
}
|
||||
|
||||
$values[$key] = $value;
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
function writeExportCsv(array $headers, iterable $rows, string $delimiter): string
|
||||
{
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
|
||||
fputcsv($stream, array_map(static fn ($header) => reset($header), $headers), $delimiter, '"', '\\');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($stream, array_values(exportRowValues($headers, $row)), $delimiter, '"', '\\');
|
||||
}
|
||||
|
||||
rewind($stream);
|
||||
$body = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
|
||||
return "\xEF\xBB\xBF" . $body;
|
||||
}
|
||||
|
||||
function writeExportJson(array $headers, iterable $rows): string
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$data[] = exportRowValues($headers, $row);
|
||||
}
|
||||
|
||||
return json_encode($data, JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
function writeExportXml(array $headers, iterable $rows): string
|
||||
{
|
||||
$writer = new XMLWriter();
|
||||
$writer->openMemory();
|
||||
$writer->setIndent(true);
|
||||
$writer->startDocument('1.0', 'UTF-8');
|
||||
$writer->startElement('rows');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$writer->startElement('row');
|
||||
foreach (exportRowValues($headers, $row) as $key => $value) {
|
||||
$writer->writeElement(sanitizeExportXmlTag($key), (string) $value);
|
||||
}
|
||||
$writer->endElement();
|
||||
}
|
||||
|
||||
$writer->endElement();
|
||||
$writer->endDocument();
|
||||
|
||||
return $writer->outputMemory();
|
||||
}
|
||||
|
||||
function sanitizeExportXmlTag(string $key): string
|
||||
{
|
||||
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key) ?: 'column';
|
||||
}
|
||||
|
||||
function writeExportSql(array $headers, iterable $rows, string $tableName): string
|
||||
{
|
||||
$db = db_connect();
|
||||
$columns = array_map(static fn ($header) => array_key_first($header), $headers);
|
||||
$quotedColumns = implode(', ', array_map(static fn ($col) => '`' . $col . '`', $columns));
|
||||
|
||||
$lines = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = array_values(exportRowValues($headers, $row));
|
||||
$quotedValues = implode(', ', array_map(static fn ($value) => $db->escape($value), $values));
|
||||
$lines[] = "INSERT INTO `$tableName` ($quotedColumns) VALUES ($quotedValues);";
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes export cache files left behind by a prior export that crashed or timed
|
||||
* out before reaching its own cleanup (e.g. memory/timeout kills the request before
|
||||
* the `finally` in writeExportXlsx() runs). Only sweeps files older than 10
|
||||
* minutes - far longer than any real export should take - so a concurrent,
|
||||
* still-in-progress export (different tab/user) is never touched.
|
||||
*/
|
||||
function sweepStaleExportCache(): void
|
||||
{
|
||||
$directory = WRITEPATH . 'cache/exports/';
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$staleBefore = time() - 600;
|
||||
|
||||
foreach (glob($directory . 'xlsx_*') as $file) {
|
||||
if (is_file($file) && filemtime($file) < $staleBefore) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Column index (0-based, matching exportRowValues()'s order) => Excel NumberFormat
|
||||
* code to apply to every cell in that column, or null if the column should stay text.
|
||||
*/
|
||||
function exportXlsxColumnFormats(array $headers): array
|
||||
{
|
||||
$quantityFormat = '0' . (quantity_decimals() > 0 ? '.' . str_repeat('0', quantity_decimals()) : '');
|
||||
$currencyFormat = '0' . (totals_decimals() > 0 ? '.' . str_repeat('0', totals_decimals()) : '');
|
||||
|
||||
$formats = [];
|
||||
|
||||
foreach (array_values($headers) as $index => $header) {
|
||||
$key = array_key_first($header);
|
||||
|
||||
if (in_array($key, EXPORT_INTEGER_COLUMNS, true) || in_array($key, EXPORT_NUMERIC_IF_NUMERIC_COLUMNS, true)) {
|
||||
$formats[$index] = '0';
|
||||
} elseif (in_array($key, EXPORT_QUANTITY_COLUMNS, true)) {
|
||||
$formats[$index] = $quantityFormat;
|
||||
} elseif (in_array($key, EXPORT_CURRENCY_COLUMNS, true)) {
|
||||
$formats[$index] = $currencyFormat;
|
||||
} elseif (($header['type'] ?? null) === DECIMAL) {
|
||||
$formats[$index] = '0.00';
|
||||
} else {
|
||||
$formats[$index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $formats;
|
||||
}
|
||||
|
||||
function writeExportXlsx(array $headers, iterable $rows): string
|
||||
{
|
||||
sweepStaleExportCache();
|
||||
|
||||
$tempPath = WRITEPATH . 'cache/exports/xlsx_' . bin2hex(random_bytes(8)) . '.xlsx';
|
||||
|
||||
$columns = array_map(static fn ($header) => array_key_first($header), $headers);
|
||||
$columnFormats = exportXlsxColumnFormats($headers);
|
||||
$numericIfNumericIndexes = array_keys(array_intersect($columns, EXPORT_NUMERIC_IF_NUMERIC_COLUMNS));
|
||||
|
||||
$columnStyles = [];
|
||||
foreach ($columnFormats as $index => $format) {
|
||||
if ($format !== null) {
|
||||
$columnStyles[$index] = new OpenSpout\Common\Entity\Style\Style(format: $format);
|
||||
}
|
||||
}
|
||||
|
||||
$writer = new OpenSpout\Writer\XLSX\Writer();
|
||||
|
||||
try {
|
||||
$writer->openToFile($tempPath);
|
||||
|
||||
$writer->addRow(OpenSpout\Common\Entity\Row::fromValues(array_map(static fn ($header) => (string) reset($header), $headers)));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = array_values(exportRowValues($headers, $row));
|
||||
|
||||
$cellValues = [];
|
||||
foreach ($values as $index => $value) {
|
||||
$format = $columnFormats[$index] ?? null;
|
||||
|
||||
if ($format !== null && in_array($index, $numericIfNumericIndexes, true) && !is_numeric($value)) {
|
||||
$format = null;
|
||||
}
|
||||
|
||||
$cellValues[$index] = ($format !== null && $value !== '' && is_numeric($value)) ? (float) $value : (string) $value;
|
||||
}
|
||||
|
||||
$writer->addRow(OpenSpout\Common\Entity\Row::fromValuesWithStyles($cellValues, $columnStyles));
|
||||
}
|
||||
|
||||
$writer->close();
|
||||
|
||||
return file_get_contents($tempPath);
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EXPORT_PDF_PAGE_MARGIN = 10.0;
|
||||
const EXPORT_PDF_FONT_SIZE = 9;
|
||||
const EXPORT_PDF_ROW_HEIGHT = 6.0;
|
||||
|
||||
const EXPORT_PDF_AVG_CHAR_WIDTH_MM = 0.42 * EXPORT_PDF_FONT_SIZE * 25.4 / 72;
|
||||
|
||||
function exportPdfColumnWidths(array $headers, float $usableWidth): array
|
||||
{
|
||||
$labelLengths = array_map(static fn ($header) => max(mb_strlen((string) reset($header)), 6), $headers);
|
||||
$totalLength = array_sum($labelLengths);
|
||||
|
||||
return array_map(static fn ($length) => $usableWidth * $length / $totalLength, $labelLengths);
|
||||
}
|
||||
|
||||
function exportPdfTruncateCellText(string $text, float $columnWidth): string
|
||||
{
|
||||
$maxChars = max(1, (int) floor($columnWidth / EXPORT_PDF_AVG_CHAR_WIDTH_MM));
|
||||
|
||||
if (mb_strlen($text) <= $maxChars) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return mb_substr($text, 0, max(1, $maxChars - 1)) . '…';
|
||||
}
|
||||
|
||||
function writeExportPdf(array $headers, iterable $rows): string
|
||||
{
|
||||
$buildStart = microtime(true);
|
||||
|
||||
set_time_limit(300);
|
||||
define('K_PATH_FONTS', realpath(APPPATH . '../vendor/tecnickcom/tc-lib-pdf-font/target/fonts'));
|
||||
|
||||
$pdf = new Com\Tecnick\Pdf\Tcpdf(unit: 'mm', subsetfont: true);
|
||||
$font = $pdf->font->insert($pdf->pon, 'dejavusans', '', EXPORT_PDF_FONT_SIZE);
|
||||
|
||||
$headerValues = array_values(array_map(static fn ($header) => (string) reset($header), $headers));
|
||||
|
||||
$startNewPage = function () use ($pdf, $font): array {
|
||||
$page = $pdf->addPage(['format' => 'A4', 'orientation' => 'L']);
|
||||
$pdf->page->addContent($font['out']);
|
||||
|
||||
return $page;
|
||||
};
|
||||
|
||||
$page = $startNewPage();
|
||||
$pageHeight = $page['height'];
|
||||
$usableWidth = $page['width'] - (2 * EXPORT_PDF_PAGE_MARGIN);
|
||||
$columnWidths = array_values(exportPdfColumnWidths($headers, $usableWidth));
|
||||
|
||||
$drawRow = function (array $values, float $posY) use ($pdf, $columnWidths): void {
|
||||
$posX = EXPORT_PDF_PAGE_MARGIN;
|
||||
foreach ($values as $index => $value) {
|
||||
$text = exportPdfTruncateCellText((string) $value, $columnWidths[$index]);
|
||||
|
||||
$out = $pdf->getTextCell(
|
||||
txt: $text,
|
||||
posx: $posX,
|
||||
posy: $posY,
|
||||
width: $columnWidths[$index],
|
||||
height: EXPORT_PDF_ROW_HEIGHT,
|
||||
halign: 'L',
|
||||
valign: 'T',
|
||||
);
|
||||
$pdf->page->addContent($out);
|
||||
$posX += $columnWidths[$index];
|
||||
}
|
||||
};
|
||||
|
||||
$drawHeaderRow = function (float $posY) use ($pdf, $font, $headerValues, $drawRow): void {
|
||||
$boldFont = $pdf->font->cloneFont($pdf->pon, $font['idx'], 'B');
|
||||
$pdf->page->addContent($boldFont['out']);
|
||||
$drawRow($headerValues, $posY);
|
||||
|
||||
$regularFont = $pdf->font->cloneFont($pdf->pon, $boldFont['idx'], '');
|
||||
$pdf->page->addContent($regularFont['out']);
|
||||
};
|
||||
|
||||
$posY = EXPORT_PDF_PAGE_MARGIN;
|
||||
$drawHeaderRow($posY);
|
||||
$posY += EXPORT_PDF_ROW_HEIGHT;
|
||||
|
||||
$rowCount = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ($posY + EXPORT_PDF_ROW_HEIGHT > $pageHeight - EXPORT_PDF_PAGE_MARGIN) {
|
||||
$startNewPage();
|
||||
$posY = EXPORT_PDF_PAGE_MARGIN;
|
||||
$drawHeaderRow($posY);
|
||||
$posY += EXPORT_PDF_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
$drawRow(array_values(exportRowValues($headers, $row)), $posY);
|
||||
$posY += EXPORT_PDF_ROW_HEIGHT;
|
||||
$rowCount++;
|
||||
}
|
||||
|
||||
$buildMs = (int) round((microtime(true) - $buildStart) * 1000);
|
||||
|
||||
log_message('debug', "Export pdf write: build={$buildMs}ms rows=$rowCount");
|
||||
|
||||
return $pdf->getOutPDFString();
|
||||
}
|
||||
@@ -452,6 +452,34 @@ function getItemsManageTableHeaders(): string
|
||||
return transform_headers($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Column set for exporting items: same data columns the manage table shows,
|
||||
* minus the icon/action columns (image thumbnail, inventory/stock/edit icons)
|
||||
* that only make sense in the live UI.
|
||||
*
|
||||
* @return array Array of single-key [column_key => label] pairs, see item_headers()
|
||||
*/
|
||||
function itemExportHeaders(): array
|
||||
{
|
||||
$attribute = model(Attribute::class);
|
||||
$config = config(OSPOS::class)->settings;
|
||||
$definitionsWithTypes = $attribute->getDefinitionsByFlags($attribute::SHOW_IN_ITEMS, true);
|
||||
|
||||
$headers = item_headers();
|
||||
|
||||
if ($config['use_destination_based_tax']) {
|
||||
$headers[] = ['tax_percents' => lang('Items.tax_category')];
|
||||
} else {
|
||||
$headers[] = ['tax_percents' => lang('Items.tax_percents')];
|
||||
}
|
||||
|
||||
foreach ($definitionsWithTypes as $definitionId => $definitionInfo) {
|
||||
$headers[] = [$definitionId => $definitionInfo['name'], 'type' => $definitionInfo['type']];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the html data row for the item
|
||||
*
|
||||
@@ -528,7 +556,43 @@ function getItemDataRow(object $item, array $definitionNames, array $taxPercents
|
||||
)
|
||||
];
|
||||
|
||||
return $columns + expand_attribute_values($definitionNames, (array) $item) + $icons;
|
||||
return $columns + expandAttributeValues($definitionNames, (array) $item) + $icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export row for an item: raw (unformatted) values, matching itemExportHeaders()'s
|
||||
* column set - no locale display formatting, no icons/HTML, since consumers (CSV/XLSX/etc
|
||||
* writers) each apply their own number formatting as appropriate for the target format.
|
||||
*
|
||||
* @param object $item
|
||||
* @param array $definitionNames Attribute definitions with types, keyed by definition_id (see Attribute::getDefinitionsByFlags(..., true))
|
||||
* @param array $taxPercentsByItemId Pre-computed tax percent strings, keyed by item_id (see Items::buildTaxPercentsByItem())
|
||||
* @return array
|
||||
*/
|
||||
function getItemExportRow(object $item, array $definitionNames, array $taxPercentsByItemId): array
|
||||
{
|
||||
$config = config(OSPOS::class)->settings;
|
||||
|
||||
$taxPercents = $taxPercentsByItemId[$item->item_id] ?? '';
|
||||
|
||||
$name = $item->name;
|
||||
if ($config['multi_pack_enabled']) {
|
||||
$name .= NAME_SEPARATOR . $item->pack_name;
|
||||
}
|
||||
|
||||
$columns = [
|
||||
'items.item_id' => $item->item_id,
|
||||
'item_number' => $item->item_number,
|
||||
'name' => $name,
|
||||
'category' => $item->category,
|
||||
'company_name' => $item->company_name,
|
||||
'cost_price' => $item->cost_price,
|
||||
'unit_price' => $item->unit_price,
|
||||
'quantity' => $item->quantity ?? '',
|
||||
'tax_percents' => $taxPercents
|
||||
];
|
||||
|
||||
return $columns + expandAttributeValues($definitionNames, (array) $item, raw: true);
|
||||
}
|
||||
|
||||
function giftcard_headers(): array
|
||||
@@ -639,39 +703,41 @@ function parse_attribute_values(array $columns, array $row): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $definition_names Array of definition_id => ['name' => name, 'type' => type] or definition_id => name
|
||||
* @param array $definitionNames Array of definition_id => ['name' => name, 'type' => type] or definition_id => name
|
||||
* @param array $row
|
||||
* @param bool $raw When true, skip locale formatting of DECIMAL attributes and return the raw value instead
|
||||
* @return array
|
||||
*/
|
||||
function expand_attribute_values(array $definition_names, array $row): array
|
||||
function expandAttributeValues(array $definitionNames, array $row, bool $raw = false): array
|
||||
{
|
||||
$values = parse_attribute_values(['attribute_values', 'attribute_dtvalues', 'attribute_dvalues'], $row);
|
||||
|
||||
$indexed_values = [];
|
||||
foreach ($values as $attribute_value) {
|
||||
$exploded_value = explode('_', $attribute_value);
|
||||
if (sizeof($exploded_value) > 1) {
|
||||
$indexed_values[$exploded_value[0]] = $exploded_value[1];
|
||||
$indexedValues = [];
|
||||
foreach ($values as $attributeValue) {
|
||||
$explodedValue = explode('_', $attributeValue);
|
||||
if (sizeof($explodedValue) > 1) {
|
||||
$indexedValues[$explodedValue[0]] = $explodedValue[1];
|
||||
}
|
||||
}
|
||||
|
||||
$attribute_values = [];
|
||||
foreach ($definition_names as $definition_id => $definitionInfo) {
|
||||
if (isset($indexed_values[$definition_id])) {
|
||||
$raw_value = $indexed_values[$definition_id];
|
||||
$attributeValues = [];
|
||||
foreach ($definitionNames as $definitionId => $definitionInfo) {
|
||||
if (isset($indexedValues[$definitionId])) {
|
||||
$rawValue = $indexedValues[$definitionId];
|
||||
$isDecimal = is_array($definitionInfo) && isset($definitionInfo['type']) && $definitionInfo['type'] === DECIMAL;
|
||||
|
||||
// Format DECIMAL attributes according to locale
|
||||
if (is_array($definitionInfo) && isset($definitionInfo['type']) && $definitionInfo['type'] === DECIMAL) {
|
||||
$attribute_values["$definition_id"] = to_decimals($raw_value);
|
||||
// Format DECIMAL attributes according to locale, unless the raw value was requested
|
||||
if ($isDecimal && !$raw) {
|
||||
$attributeValues["$definitionId"] = to_decimals($rawValue);
|
||||
} else {
|
||||
$attribute_values["$definition_id"] = $raw_value;
|
||||
$attributeValues["$definitionId"] = $rawValue;
|
||||
}
|
||||
} else {
|
||||
$attribute_values["$definition_id"] = "";
|
||||
$attributeValues["$definitionId"] = "";
|
||||
}
|
||||
}
|
||||
|
||||
return $attribute_values;
|
||||
return $attributeValues;
|
||||
}
|
||||
|
||||
function attribute_definition_headers(): array
|
||||
|
||||
+14
-2
@@ -31,9 +31,12 @@
|
||||
"dompdf/dompdf": "^3.1.6",
|
||||
"ezyang/htmlpurifier": "^4.17",
|
||||
"laminas/laminas-escaper": "2.18.0",
|
||||
"openspout/openspout": "^5.3",
|
||||
"paragonie/random_compat": "^2.0.21",
|
||||
"picqer/php-barcode-generator": "^2.4.0",
|
||||
"tamtamchik/namecase": "^3.0.0"
|
||||
"tamtamchik/namecase": "^3.0.0",
|
||||
"tecnickcom/tc-lib-pdf": "^8.78",
|
||||
"tecnickcom/tc-lib-pdf-font": "^4.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"codeigniter/coding-standard": "^1.8",
|
||||
@@ -71,7 +74,16 @@
|
||||
"sort-packages": true
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit"
|
||||
"test": "phpunit",
|
||||
"tc-lib-pdf-fonts": [
|
||||
"@php vendor/tecnickcom/tc-lib-pdf-font/util/build_fonts.php"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"@tc-lib-pdf-fonts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@tc-lib-pdf-fonts"
|
||||
]
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"test": "Run unit tests"
|
||||
|
||||
Generated
+1104
-1
File diff suppressed because it is too large.
Load diff
@@ -90,5 +90,8 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com/"
|
||||
},
|
||||
"allowScripts": {
|
||||
"core-js@3.37.0": true
|
||||
}
|
||||
}
|
||||
@@ -226,9 +226,11 @@
|
||||
search: options.resource || false,
|
||||
showColumns: true,
|
||||
clickToSelect: true,
|
||||
// showExport/exportTypes only render the button+dropdown; init_export_all_intercept()
|
||||
// always wins the click and routes every format to the server, bypassing this plugin's own export.
|
||||
showExport: true,
|
||||
exportDataType: 'basic',
|
||||
exportTypes: ['json', 'xml', 'csv', 'txt', 'sql', 'excel', 'pdf'],
|
||||
exportTypes: ['json', 'xml', 'csv', 'txt', 'sql', 'xlsx', 'pdf'],
|
||||
exportOptions: {
|
||||
fileName: options.resource.replace(/.*\/(.*?)$/g, '$1') + "_" + export_suffix
|
||||
},
|
||||
@@ -266,9 +268,30 @@
|
||||
init_restore();
|
||||
toggle_column_visibility();
|
||||
init_export_all_toggle();
|
||||
init_export_all_intercept();
|
||||
dialog_support.init("button.modal-dlg");
|
||||
};
|
||||
|
||||
let export_all_enabled = false;
|
||||
|
||||
const init_export_all_intercept = function () {
|
||||
document.addEventListener('click', function(event) {
|
||||
const $exportItem = $(event.target).closest('.export [data-type], .export[data-type]');
|
||||
if (!$exportItem.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const format = $exportItem.attr('data-type') || $exportItem.closest('.export').data('type');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
$exportItem.closest('.dropdown-menu').prev('[data-toggle="dropdown"]').dropdown('toggle');
|
||||
|
||||
const queryString = export_all_enabled ? export_all_query_string() : export_page_query_string();
|
||||
window.location = options.resource + '/exportAll/' + format + '?' + queryString;
|
||||
}, true);
|
||||
};
|
||||
|
||||
const init_export_all_toggle = function () {
|
||||
const $toggleInput = $('<input>', {
|
||||
type: 'checkbox',
|
||||
@@ -301,10 +324,30 @@
|
||||
|
||||
$toggleInput.on('change', function() {
|
||||
$toggleWrapper.attr('aria-checked', this.checked);
|
||||
$('#table').bootstrapTable('refreshOptions', { exportDataType: this.checked ? 'all' : 'basic' });
|
||||
export_all_enabled = this.checked;
|
||||
});
|
||||
};
|
||||
|
||||
const export_all_query_string = function() {
|
||||
const queryParamsFn = $('#table').bootstrapTable('getOptions').queryParams;
|
||||
const params = queryParamsFn ? queryParamsFn({}) : {};
|
||||
delete params.limit;
|
||||
delete params.offset;
|
||||
|
||||
return $.param(params);
|
||||
};
|
||||
|
||||
const export_page_query_string = function() {
|
||||
const tableOptions = $('#table').bootstrapTable('getOptions');
|
||||
const queryParamsFn = tableOptions.queryParams;
|
||||
const params = queryParamsFn ? queryParamsFn({
|
||||
limit: tableOptions.pageSize,
|
||||
offset: (tableOptions.pageNumber - 1) * tableOptions.pageSize
|
||||
}) : {};
|
||||
|
||||
return $.param(params);
|
||||
};
|
||||
|
||||
const init_delete = function (confirmMessage) {
|
||||
$("#delete").click(function(event) {
|
||||
do_action("delete")();
|
||||
|
||||
@@ -18,7 +18,7 @@ class ItemSearchTest extends CIUnitTestCase
|
||||
protected $migrateOnce = true;
|
||||
protected $seed = '';
|
||||
protected $seedOnce = true;
|
||||
protected $refresh = true;
|
||||
protected $refresh = false;
|
||||
protected $namespace = null;
|
||||
|
||||
protected $item;
|
||||
|
||||
@@ -17,7 +17,7 @@ class ItemTaxesMultipleTest extends CIUnitTestCase
|
||||
protected $migrateOnce = true;
|
||||
protected $seed = '';
|
||||
protected $seedOnce = true;
|
||||
protected $refresh = true;
|
||||
protected $refresh = false;
|
||||
protected $namespace = null;
|
||||
|
||||
protected $item_taxes;
|
||||
|
||||
@@ -22,7 +22,7 @@ class ReceivingTest extends CIUnitTestCase
|
||||
|
||||
protected $migrate = true;
|
||||
protected $migrateOnce = true;
|
||||
protected $refresh = true;
|
||||
protected $refresh = false;
|
||||
protected $namespace = null;
|
||||
|
||||
private const LOCATION_ID = 1;
|
||||
|
||||
Reference in new issue
Block a user