Takes in a file, process it and output a new file with additional columns.
This commit is contained in:
Stefan Lazic
2026-07-10 15:45:02 +01:00
parent 2f387a4c1c
commit a451060c1b
876 changed files with 169373 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Import;
use PhpOffice\PhpSpreadsheet\Reader\IReadFilter;
final class ChunkReadFilter implements IReadFilter
{
public function __construct(
private readonly int $startRow,
private readonly int $endRow,
) {
}
public function readCell($columnAddress, $row, $worksheetName = ''): bool
{
return $row >= $this->startRow && $row <= $this->endRow;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Import;
use RuntimeException;
final class ImportJobStore
{
public function __construct(
private readonly string $storageDir = '',
) {
}
public function create(array $state): string
{
$token = bin2hex(random_bytes(16));
$state['token'] = $token;
$this->write($token, $state);
return $token;
}
public function get(string $token): ?array
{
$path = $this->path($token);
if (!is_file($path)) {
return null;
}
$contents = file_get_contents($path);
if ($contents === false || $contents === '') {
return null;
}
$state = json_decode($contents, true);
if (!is_array($state)) {
throw new RuntimeException('The import job state is corrupt.');
}
return $state;
}
public function save(string $token, array $state): void
{
$state['token'] = $token;
$this->write($token, $state);
}
public function delete(string $token): void
{
$path = $this->path($token);
if (is_file($path)) {
@unlink($path);
}
}
public function pathForToken(string $token): string
{
return $this->path($token);
}
private function write(string $token, array $state): void
{
$path = $this->path($token);
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new RuntimeException('Unable to create the import job storage directory.');
}
$json = json_encode($state, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmpPath = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
if (file_put_contents($tmpPath, $json) === false) {
throw new RuntimeException('Unable to write the import job state.');
}
if (!@rename($tmpPath, $path)) {
@unlink($tmpPath);
throw new RuntimeException('Unable to persist the import job state.');
}
}
private function path(string $token): string
{
$safeToken = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
if ($safeToken === '') {
throw new RuntimeException('Invalid import job token.');
}
$storageDir = $this->storageDir !== '' ? $this->storageDir : sys_get_temp_dir() . '/warner-import-jobs';
return rtrim($storageDir, '/\\') . '/' . $safeToken . '.json';
}
}
+722
View File
@@ -0,0 +1,722 @@
<?php
declare(strict_types=1);
namespace App\Import;
use App\Database;
use PDO;
final class ImportRepository
{
public function __construct(private readonly PDO $pdo)
{
}
public static function fromDatabase(Database $database): self
{
return new self($database->pdo());
}
public function beginTransaction(): void
{
$this->pdo->beginTransaction();
}
public function commit(): void
{
$this->pdo->commit();
}
public function rollBack(): void
{
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
}
public function clearAll(): void
{
$this->pdo->exec('TRUNCATE TABLE import_rows, import_batches RESTART IDENTITY CASCADE');
}
public function createBatch(string $sourceFilename, array $originalHeaders, array $calculatedHeaders, string $status = 'processing'): int
{
$statement = $this->pdo->prepare(
'INSERT INTO import_batches (source_filename, original_headers, calculated_headers, status) VALUES (:source_filename, CAST(:original_headers AS jsonb), CAST(:calculated_headers AS jsonb), :status) RETURNING id'
);
$statement->execute([
'source_filename' => $sourceFilename,
'original_headers' => json_encode(array_values($originalHeaders), JSON_THROW_ON_ERROR),
'calculated_headers' => json_encode(array_values($calculatedHeaders), JSON_THROW_ON_ERROR),
'status' => $status,
]);
return (int) $statement->fetchColumn();
}
public function updateBatchSummary(int $batchId, int $rowCount, int $warningsCount, string $status = 'processing'): void
{
$statement = $this->pdo->prepare('UPDATE import_batches SET row_count = :row_count, warnings_count = :warnings_count, status = :status WHERE id = :id');
$statement->execute([
'row_count' => $rowCount,
'warnings_count' => $warningsCount,
'status' => $status,
'id' => $batchId,
]);
}
public function insertRow(int $batchId, int $rowNumber, array $sourceRow, array $normalizedRow, array $calculatedRow, array $mergedRow): void
{
$statement = $this->pdo->prepare(
'INSERT INTO import_rows (batch_id, row_number, source_row, normalized_row, calculated_row, merged_row) VALUES (:batch_id, :row_number, CAST(:source_row AS jsonb), CAST(:normalized_row AS jsonb), CAST(:calculated_row AS jsonb), CAST(:merged_row AS jsonb))'
);
$statement->execute([
'batch_id' => $batchId,
'row_number' => $rowNumber,
'source_row' => json_encode($sourceRow, JSON_THROW_ON_ERROR),
'normalized_row' => json_encode($normalizedRow, JSON_THROW_ON_ERROR),
'calculated_row' => json_encode($calculatedRow, JSON_THROW_ON_ERROR),
'merged_row' => json_encode($mergedRow, JSON_THROW_ON_ERROR),
]);
}
public function getLatestBatch(): ?array
{
$statement = $this->pdo->query("SELECT * FROM import_batches WHERE status = 'complete' ORDER BY id DESC LIMIT 1");
$batch = $statement->fetch();
return $batch === false ? null : $this->decodeBatchRow($batch);
}
public function countRowsForBatch(int $batchId): int
{
$statement = $this->pdo->prepare('SELECT COUNT(*) FROM import_rows WHERE batch_id = :batch_id');
$statement->execute(['batch_id' => $batchId]);
return (int) $statement->fetchColumn();
}
public function getDistinctValuesForBatch(int $batchId, string $header, int $limit = 200): array
{
$statement = $this->pdo->prepare(
"SELECT DISTINCT NULLIF(TRIM(COALESCE(jsonb_extract_path_text(merged_row, :header), '')), '') AS value
FROM import_rows
WHERE batch_id = :batch_id
ORDER BY value ASC
LIMIT :limit"
);
$statement->bindValue('batch_id', $batchId, PDO::PARAM_INT);
$statement->bindValue('header', $header, PDO::PARAM_STR);
$statement->bindValue('limit', $limit, PDO::PARAM_INT);
$statement->execute();
$values = [];
while ($row = $statement->fetch()) {
$value = (string) ($row['value'] ?? '');
if ($value !== '') {
$values[] = $value;
}
}
return $values;
}
public function getDistinctValuesForBatchGrid(
int $batchId,
array $tableHeaders,
string $columnKey,
string $distinctSearch = '',
int $limit = 500
): array
{
if ($columnKey === 'row_number') {
return [];
}
$columnSql = $this->resolveGridColumnSql($tableHeaders, $columnKey);
if ($columnSql === null) {
return [];
}
$limit = max(1, min(500, $limit));
$distinctSearch = trim($distinctSearch);
$valueSql = 'COALESCE(' . $columnSql . ", '')";
$whereParts = ['batch_id = :batch_id'];
$parameters = [
'batch_id' => $batchId,
];
if ($distinctSearch !== '') {
$whereParts[] = $valueSql . ' ILIKE :distinct_search ESCAPE :distinct_search_esc';
$parameters['distinct_search'] = '%' . $this->escapeLikePattern($distinctSearch) . '%';
$parameters['distinct_search_esc'] = '\\';
}
$statement = $this->pdo->prepare(
'SELECT distinct_values.value
FROM (
SELECT DISTINCT ' . $valueSql . ' AS value
FROM import_rows
WHERE ' . implode(' AND ', $whereParts) . '
) AS distinct_values
ORDER BY LOWER(distinct_values.value) ASC, distinct_values.value ASC
LIMIT :limit'
);
foreach ($parameters as $name => $value) {
$statement->bindValue(':' . $name, $value, $name === 'batch_id' ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
$statement->execute();
$values = [];
while ($row = $statement->fetch()) {
$value = (string) ($row['value'] ?? '');
if (!in_array($value, $values, true)) {
$values[] = $value;
}
}
return $values;
}
public function paginateRows(int $batchId, int $limit, int $offset, string $sortDirection = 'asc'): array
{
return $this->paginateRowsFiltered($batchId, $limit, $offset, '', [], [], null, $sortDirection);
}
public function countRowsForBatchFiltered(
int $batchId,
string $query = '',
array $tableHeaders = [],
array $columnSearches = []
): int
{
[$whereClause, $parameters] = $this->buildDataTableClause($tableHeaders, $query, $columnSearches);
$statement = $this->pdo->prepare(
'SELECT COUNT(*)
FROM import_rows
WHERE batch_id = :batch_id' . $whereClause
);
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
foreach ($parameters as $name => $value) {
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
}
$statement->execute();
return (int) $statement->fetchColumn();
}
public function countRowsForBatchGrid(
int $batchId,
string $query = '',
array $tableHeaders = [],
array $filterModel = []
): int
{
[$whereClause, $parameters] = $this->buildGridWhereClause($tableHeaders, $query, $filterModel);
$statement = $this->pdo->prepare(
'SELECT COUNT(*)
FROM import_rows
WHERE batch_id = :batch_id' . $whereClause
);
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
foreach ($parameters as $name => $value) {
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
}
$statement->execute();
return (int) $statement->fetchColumn();
}
public function paginateRowsFiltered(
int $batchId,
int $limit,
int $offset,
string $query = '',
array $tableHeaders = [],
array $columnSearches = [],
?int $orderColumn = null,
string $sortDirection = 'asc'
): array
{
[$whereClause, $parameters] = $this->buildDataTableClause($tableHeaders, $query, $columnSearches);
[$orderClause, $orderParameters] = $this->buildOrderClause($tableHeaders, $orderColumn, $sortDirection);
$statement = $this->pdo->prepare(
'SELECT row_number, source_row, normalized_row, calculated_row, merged_row
FROM import_rows
WHERE batch_id = :batch_id' . $whereClause . '
' . $orderClause . '
LIMIT :limit OFFSET :offset'
);
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
foreach ($parameters as $name => $value) {
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
}
foreach ($orderParameters as $name => $value) {
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
}
$statement->execute();
$rows = [];
while ($row = $statement->fetch()) {
$rows[] = $this->decodeRowPayload($row);
}
return $rows;
}
public function paginateRowsGrid(
int $batchId,
int $limit,
int $offset,
string $query = '',
array $tableHeaders = [],
array $filterModel = [],
array $sortModel = []
): array
{
[$whereClause, $parameters] = $this->buildGridWhereClause($tableHeaders, $query, $filterModel);
[$orderClause, $orderParameters] = $this->buildGridOrderClause($tableHeaders, $sortModel);
$statement = $this->pdo->prepare(
'SELECT row_number, merged_row
FROM import_rows
WHERE batch_id = :batch_id' . $whereClause . '
' . $orderClause . '
LIMIT :limit OFFSET :offset'
);
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
foreach ($parameters as $name => $value) {
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
}
foreach ($orderParameters as $name => $value) {
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
}
$statement->execute();
$rows = [];
while ($row = $statement->fetch()) {
$rows[] = $this->decodeGridRowPayload($row);
}
return $rows;
}
public function getAllRowsCount(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM import_rows')->fetchColumn();
}
/**
* @return array<int, array<string, mixed>>
*/
public function exportRowsForBatch(int $batchId, int $limit, int $offset): array
{
$statement = $this->pdo->prepare(
'SELECT row_number, merged_row
FROM import_rows
WHERE batch_id = :batch_id
ORDER BY row_number ASC
LIMIT :limit OFFSET :offset'
);
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
$statement->execute();
$rows = [];
while ($row = $statement->fetch()) {
$rows[] = $this->decodeGridRowPayload($row);
}
return $rows;
}
public function pruneExceptBatch(int $batchId): void
{
$statement = $this->pdo->prepare('DELETE FROM import_batches WHERE id <> :batch_id');
$statement->execute(['batch_id' => $batchId]);
}
private function decodeBatchRow(array $row): array
{
$row['original_headers'] = json_decode((string) $row['original_headers'], true, 512, JSON_THROW_ON_ERROR);
$row['calculated_headers'] = json_decode((string) $row['calculated_headers'], true, 512, JSON_THROW_ON_ERROR);
return $row;
}
private function decodeRowPayload(array $row): array
{
$row['source_row'] = json_decode((string) $row['source_row'], true, 512, JSON_THROW_ON_ERROR);
$row['normalized_row'] = json_decode((string) $row['normalized_row'], true, 512, JSON_THROW_ON_ERROR);
$row['calculated_row'] = json_decode((string) $row['calculated_row'], true, 512, JSON_THROW_ON_ERROR);
$row['merged_row'] = json_decode((string) $row['merged_row'], true, 512, JSON_THROW_ON_ERROR);
return $row;
}
private function decodeGridRowPayload(array $row): array
{
$mergedRow = json_decode((string) $row['merged_row'], true, 512, JSON_THROW_ON_ERROR);
if (!is_array($mergedRow)) {
$mergedRow = [];
}
return array_merge($mergedRow, [
'row_number' => (int) $row['row_number'],
]);
}
private function resolveGridColumnSql(array $tableHeaders, string $columnKey): ?string
{
if ($columnKey === 'row_number') {
return 'row_number';
}
$headerIndex = array_search($columnKey, $tableHeaders, true);
if ($headerIndex === false) {
return null;
}
return $this->columnSqlForHeader((string) $tableHeaders[(int) $headerIndex]);
}
/**
* @return array{0:string,1:array<string,string>}
*/
private function buildGridWhereClause(array $tableHeaders, string $globalSearch, array $filterModel): array
{
$conditions = [];
$parameters = [];
$globalSearch = trim($globalSearch);
if ($globalSearch !== '') {
$globalQueryRow = 'grid_global_query_row';
$globalQueryMerged = 'grid_global_query_merged';
$conditions[] = "(
CAST(row_number AS TEXT) ILIKE :{$globalQueryRow} ESCAPE :{$globalQueryRow}_esc
OR CAST(merged_row AS TEXT) ILIKE :{$globalQueryMerged} ESCAPE :{$globalQueryMerged}_esc
)";
$escapedGlobalSearch = '%' . $this->escapeLikePattern($globalSearch) . '%';
$parameters[$globalQueryRow] = $escapedGlobalSearch;
$parameters[$globalQueryRow . '_esc'] = '\\';
$parameters[$globalQueryMerged] = $escapedGlobalSearch;
$parameters[$globalQueryMerged . '_esc'] = '\\';
}
foreach ($filterModel as $columnId => $model) {
if (!is_array($model)) {
continue;
}
$columnKey = (string) $columnId;
$columnSql = $this->resolveGridColumnSql($tableHeaders, $columnKey);
if ($columnSql === null) {
continue;
}
$counter = 0;
$clause = $this->buildGridFilterClause($columnSql, $model, 'grid_' . preg_replace('/[^a-zA-Z0-9_]+/', '_', $columnKey), $parameters, $counter);
if ($clause !== '') {
$conditions[] = $clause;
}
}
if ($conditions === []) {
return ['', []];
}
return [
' AND (' . implode(' AND ', $conditions) . ')',
$parameters,
];
}
/**
* @return array{0:string,1:array<string,string>}
*/
private function buildGridOrderClause(array $tableHeaders, array $sortModel): array
{
$orderParts = [];
$parameters = [];
$hasRowNumberSort = false;
foreach ($sortModel as $index => $sortItem) {
if (!is_array($sortItem)) {
continue;
}
$columnKey = (string) ($sortItem['colId'] ?? '');
$sortDirection = strtolower((string) ($sortItem['sort'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
$columnSql = $this->resolveGridColumnSql($tableHeaders, $columnKey);
if ($columnSql === null) {
continue;
}
if ($columnKey === 'row_number') {
$orderParts[] = 'row_number ' . $sortDirection;
$hasRowNumberSort = true;
continue;
}
$orderParts[] = 'LOWER(COALESCE(' . $columnSql . ", '')) " . $sortDirection;
}
if ($orderParts === []) {
return ['ORDER BY row_number ASC', []];
}
if (!$hasRowNumberSort) {
$orderParts[] = 'row_number ASC';
}
return ['ORDER BY ' . implode(', ', $orderParts), $parameters];
}
/**
* @return array<string,string>
*/
private function buildGridFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
{
if (isset($model['operator'], $model['condition1'], $model['condition2']) && is_array($model['condition1']) && is_array($model['condition2'])) {
$left = $this->buildGridFilterClause($columnSql, $model['condition1'], $paramPrefix . '_c1', $parameters, $counter);
$right = $this->buildGridFilterClause($columnSql, $model['condition2'], $paramPrefix . '_c2', $parameters, $counter);
$operator = strtoupper((string) $model['operator']) === 'OR' ? 'OR' : 'AND';
return '(' . $left . ' ' . $operator . ' ' . $right . ')';
}
$filterType = strtolower((string) ($model['filterType'] ?? 'text'));
if ($filterType === 'set') {
return $this->buildGridSetFilterClause($columnSql, $model, $paramPrefix, $parameters, $counter);
}
if ($filterType === 'number') {
return $this->buildGridNumberFilterClause($columnSql, $model, $paramPrefix, $parameters, $counter);
}
return $this->buildGridTextFilterClause($columnSql, $model, $paramPrefix, $parameters, $counter);
}
/**
* @return array<string,string>
*/
private function buildGridTextFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
{
$type = strtolower((string) ($model['type'] ?? 'contains'));
$filter = trim((string) ($model['filter'] ?? ''));
$paramName = $paramPrefix . '_' . $counter++;
$escapedFilter = $this->escapeLikePattern($filter);
return match ($type) {
'blank' => 'COALESCE(TRIM(' . $columnSql . "), '') = ''",
'notblank' => 'COALESCE(TRIM(' . $columnSql . "), '') <> ''",
'equals' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true),
'notequal' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, false),
'startswith' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true, false, true),
'endswith' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true, true, false),
'notcontains' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, false, true, true),
default => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true, true, true),
};
}
/**
* @return array<string,string>
*/
private function buildGridNumberFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
{
$type = strtolower((string) ($model['type'] ?? 'equals'));
$filter = $model['filter'] ?? null;
$filterTo = $model['filterTo'] ?? null;
$paramName = $paramPrefix . '_' . $counter++;
$from = is_numeric($filter) ? (string) $filter : '';
$to = is_numeric($filterTo) ? (string) $filterTo : '';
return match ($type) {
'blank' => '1 = 0',
'notblank' => '1 = 1',
'notequal' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '<>'),
'lessthan' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '<'),
'lessthanorequal' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '<='),
'greaterthan' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '>'),
'greaterthanorequal' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '>='),
'inrange' => $this->addNumericRangeFilterParameter($columnSql, $paramName, $from, $to, $parameters),
default => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '='),
};
}
/**
* @return array<string,string>
*/
private function buildGridSetFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
{
$values = $model['values'] ?? [];
if (!is_array($values) || $values === []) {
return '1 = 1';
}
$placeholders = [];
foreach ($values as $value) {
if (!is_scalar($value) && $value !== null) {
continue;
}
$paramName = $paramPrefix . '_' . $counter++;
$parameters[$paramName] = (string) $value;
$placeholders[] = ':' . $paramName;
}
if ($placeholders === []) {
return '1 = 1';
}
return $columnSql . ' IN (' . implode(', ', $placeholders) . ')';
}
private function addTextFilterParameter(
string $columnSql,
string $paramName,
string $escapedFilter,
array &$parameters,
bool $positive = true,
bool $prependWildcard = false,
bool $appendWildcard = false
): string
{
if ($escapedFilter === '') {
return '1 = 1';
}
$parameters[$paramName] = ($prependWildcard ? '%' : '') . $escapedFilter . ($appendWildcard ? '%' : '');
$parameters[$paramName . '_esc'] = '\\';
$clause = $columnSql . " ILIKE :{$paramName} ESCAPE :{$paramName}_esc";
return $positive ? $clause : 'NOT (' . $clause . ')';
}
private function addNumericFilterParameter(string $columnSql, string $paramName, string $value, array &$parameters, string $operator): string
{
if ($value === '') {
return '1 = 1';
}
$parameters[$paramName] = $value;
return $columnSql . " {$operator} :{$paramName}";
}
private function addNumericRangeFilterParameter(string $columnSql, string $paramName, string $from, string $to, array &$parameters): string
{
if ($from === '' || $to === '') {
return '1 = 1';
}
$fromName = $paramName . '_from';
$toName = $paramName . '_to';
$parameters[$fromName] = $from;
$parameters[$toName] = $to;
return $columnSql . " BETWEEN :{$fromName} AND :{$toName}";
}
private function columnSqlForHeader(string $header): string
{
return 'COALESCE(merged_row ->> ' . $this->pdo->quote($header) . ", '')";
}
private function escapeLikePattern(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}
private function isAssocArray(array $value): bool
{
return array_keys($value) !== range(0, count($value) - 1);
}
/**
* @return array{0:string,1:array<string,string>}
*/
private function buildDataTableClause(array $tableHeaders, string $globalSearch, array $columnSearches): array
{
$conditions = [];
$parameters = [];
$globalSearch = trim($globalSearch);
if ($globalSearch !== '') {
$conditions[] = "(
CAST(row_number AS TEXT) ILIKE :global_query ESCAPE :global_query_esc
OR CAST(merged_row AS TEXT) ILIKE :global_query ESCAPE :global_query_esc
)";
$parameters['global_query'] = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $globalSearch) . '%';
$parameters['global_query_esc'] = '\\';
}
foreach ($columnSearches as $index => $search) {
$search = trim((string) $search);
if ($search === '') {
continue;
}
$parameterName = 'column_query_' . (int) $index;
if ((int) $index === 0) {
$conditions[] = "CAST(row_number AS TEXT) = :{$parameterName}";
$parameters[$parameterName] = $search;
continue;
}
$headerIndex = (int) $index - 1;
if (!isset($tableHeaders[$headerIndex])) {
continue;
}
$headerParameter = 'column_header_' . (int) $index;
$conditions[] = "COALESCE(jsonb_extract_path_text(merged_row, :{$headerParameter}), '') = :{$parameterName}";
$parameters[$headerParameter] = (string) $tableHeaders[$headerIndex];
$parameters[$parameterName] = $search;
}
if ($conditions === []) {
return ['', []];
}
return [
' AND (' . implode(' AND ', $conditions) . ')',
$parameters,
];
}
/**
* @return array{0:string,1:array<string,string>}
*/
private function buildOrderClause(array $tableHeaders, ?int $orderColumn, string $sortDirection): array
{
$sortDirection = strtoupper($sortDirection) === 'DESC' ? 'DESC' : 'ASC';
if ($orderColumn === null || $orderColumn < 0) {
return ['ORDER BY row_number DESC', []];
}
if ($orderColumn === 0) {
return ['ORDER BY row_number ' . $sortDirection, []];
}
$headerIndex = $orderColumn - 1;
if (!isset($tableHeaders[$headerIndex])) {
return ['ORDER BY row_number DESC', []];
}
return [
'ORDER BY LOWER(COALESCE(jsonb_extract_path_text(merged_row, :order_header), \'\')) ' . $sortDirection . ', row_number ASC',
[
'order_header' => (string) $tableHeaders[$headerIndex],
],
];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Import;
final class ImportResult
{
public function __construct(
public readonly ?int $batchId,
public readonly int $importedRows,
public readonly int $warningsCount,
public readonly array $warnings,
public readonly array $originalHeaders,
public readonly array $calculatedHeaders,
) {
}
}
+457
View File
@@ -0,0 +1,457 @@
<?php
declare(strict_types=1);
namespace App\Import;
use RuntimeException;
use Throwable;
final class ImportService
{
private const REQUIRED_COLUMNS = [
'Accrual Category',
'Local Selling Company (S/T)',
'Contracting Party',
'Rights Holder Liable Flag',
'Global Transfer Pricing Flag',
'Domestic/ Foreign Flag',
'Ownership Type',
];
public function __construct(
private readonly SpreadsheetReader $reader,
private readonly SpreadsheetRowProcessor $processor,
private readonly ImportRepository $repository,
private readonly int $chunkSize = 5,
) {
}
public function requiredColumns(): array
{
return self::REQUIRED_COLUMNS;
}
public function import(string $filePath, string $sourceFilename): ImportResult
{
$state = [
'filePath' => $filePath,
'sourceFilename' => $sourceFilename,
'sourceExtension' => strtolower(pathinfo($filePath, PATHINFO_EXTENSION)),
'status' => 'pending',
'importedRows' => 0,
'warningsCount' => 0,
'warnings' => [],
];
while (!in_array(($state['status'] ?? 'pending'), ['completed', 'error'], true)) {
$state = $this->processJobState($state, $this->chunkSize);
}
if (($state['status'] ?? '') === 'error') {
throw new RuntimeException('Import failed: ' . ($state['error'] ?? 'Unknown error'));
}
return new ImportResult(
isset($state['batchId']) ? (int) $state['batchId'] : null,
(int) ($state['importedRows'] ?? 0),
(int) ($state['warningsCount'] ?? 0),
is_array($state['warnings'] ?? null) ? array_values($state['warnings']) : [],
is_array($state['headers'] ?? null) ? array_values($state['headers']) : [],
$this->processor->calculatedHeaders()
);
}
public function processJobState(array $state, ?int $chunkSize = null): array
{
$chunkSize = max(1, $chunkSize ?? $this->chunkSize);
$extension = strtolower((string) ($state['sourceExtension'] ?? pathinfo((string) ($state['filePath'] ?? ''), PATHINFO_EXTENSION)));
if ($extension === 'xls') {
return $this->processLegacyJobState($state, $chunkSize);
}
return $this->processStreamingJobState($state, $chunkSize);
}
private function processStreamingJobState(array $state, int $chunkSize): array
{
if (($state['status'] ?? 'pending') === 'completed' || ($state['status'] ?? 'pending') === 'error') {
return $state;
}
try {
$state['importedRows'] = (int) ($state['importedRows'] ?? 0);
$state['warningsCount'] = (int) ($state['warningsCount'] ?? 0);
$state['warnings'] = is_array($state['warnings'] ?? null) ? array_values($state['warnings']) : [];
if (!isset($state['cacheBasePath'])) {
$state['cacheBasePath'] = $this->cacheBasePath($state);
$cache = $this->reader->buildChunkCache(
(string) $state['filePath'],
(string) $state['cacheBasePath'],
self::REQUIRED_COLUMNS,
$chunkSize
);
$this->reader->validateRequiredColumns($cache['headers'], self::REQUIRED_COLUMNS);
$state['headers'] = $cache['headers'];
$state['headerLookup'] = $cache['header_lookup'];
$state['headerRowIndex'] = (int) $cache['header_row_index'];
$state['totalRows'] = (int) $cache['total_rows'];
$state['chunkCount'] = (int) $cache['chunk_count'];
$state['chunkSize'] = (int) $cache['chunk_size'];
$state['nextChunkIndex'] = (int) ($state['nextChunkIndex'] ?? 0);
$state['status'] = 'cached';
$state['done'] = false;
$state['message'] = 'Workbook cached and ready for chunked processing.';
return $state;
}
if (!isset($state['batchId'])) {
$state['batchId'] = $this->repository->createBatch(
(string) $state['sourceFilename'],
is_array($state['headers'] ?? null) ? $state['headers'] : [],
$this->processor->calculatedHeaders(),
'processing'
);
}
$chunkCount = (int) ($state['chunkCount'] ?? 0);
$nextChunkIndex = (int) ($state['nextChunkIndex'] ?? 0);
if ($chunkCount <= 0 || $nextChunkIndex >= $chunkCount) {
$this->repository->updateBatchSummary(
(int) $state['batchId'],
(int) $state['importedRows'],
(int) $state['warningsCount'],
'complete'
);
$this->repository->pruneExceptBatch((int) $state['batchId']);
$state['status'] = 'completed';
$state['done'] = true;
$state['message'] = 'Import complete.';
return $state;
}
$chunk = $this->reader->readChunkFromCache((string) $state['cacheBasePath'], $nextChunkIndex);
$rows = is_array($chunk['rows'] ?? null) ? $chunk['rows'] : [];
$processedInChunk = 0;
$this->repository->beginTransaction();
try {
foreach ($rows as $row) {
if (!is_array($row) || !isset($row['row_number'], $row['data']) || !is_array($row['data'])) {
continue;
}
$processedInChunk++;
$rowNumber = (int) $row['row_number'];
$sourceRow = $row['data'];
try {
$processed = $this->processor->process($sourceRow);
$this->repository->insertRow(
(int) $state['batchId'],
$rowNumber,
$sourceRow,
$processed['normalized'],
$processed['calculated'],
$processed['merged']
);
$state['importedRows']++;
} catch (Throwable $throwable) {
$state['warningsCount']++;
if (count($state['warnings']) < 100) {
$state['warnings'][] = sprintf('Row %d skipped: %s', $rowNumber, $throwable->getMessage());
}
}
}
$state['processedRows'] = (int) ($state['processedRows'] ?? 0) + $processedInChunk;
$state['nextChunkIndex'] = $nextChunkIndex + 1;
$isComplete = ((int) $state['nextChunkIndex']) >= $chunkCount;
$this->repository->updateBatchSummary(
(int) $state['batchId'],
(int) $state['importedRows'],
(int) $state['warningsCount'],
$isComplete ? 'complete' : 'processing'
);
$this->repository->commit();
if ($isComplete) {
$this->repository->pruneExceptBatch((int) $state['batchId']);
$state['status'] = 'completed';
$state['done'] = true;
$state['message'] = 'Import complete.';
} else {
$state['status'] = 'running';
$state['done'] = false;
$state['message'] = sprintf('Processed %d row(s) in the latest chunk.', $processedInChunk);
}
} catch (Throwable $throwable) {
$this->repository->rollBack();
throw $throwable;
}
} catch (Throwable $throwable) {
$state['status'] = 'error';
$state['done'] = true;
$state['message'] = 'Import failed: ' . $throwable->getMessage();
$state['error'] = $throwable->getMessage();
}
return $state;
}
private function processLegacyJobState(array $state, int $chunkSize): array
{
if (($state['status'] ?? 'pending') === 'completed' || ($state['status'] ?? 'pending') === 'error') {
return $state;
}
try {
if (!isset($state['batchId'])) {
$headerInfo = $this->reader->detectHeader((string) $state['filePath'], self::REQUIRED_COLUMNS);
$this->reader->validateRequiredColumns($headerInfo['headers'], self::REQUIRED_COLUMNS);
$state['batchId'] = $this->repository->createBatch(
(string) $state['sourceFilename'],
$headerInfo['headers'],
$this->processor->calculatedHeaders(),
'processing'
);
$state['headers'] = $headerInfo['headers'];
$state['headerLookup'] = $headerInfo['header_lookup'];
$state['headerRowIndex'] = (int) $headerInfo['header_row_index'];
$state['highestDataRow'] = (int) $headerInfo['highest_data_row'];
$state['nextRow'] = ((int) $headerInfo['header_row_index']) + 1;
$state['totalRows'] = max(0, (int) $headerInfo['highest_data_row'] - (int) $headerInfo['header_row_index']);
$state['importedRows'] = (int) ($state['importedRows'] ?? 0);
$state['warningsCount'] = (int) ($state['warningsCount'] ?? 0);
$state['warnings'] = is_array($state['warnings'] ?? null) ? array_values($state['warnings']) : [];
}
$nextRow = (int) ($state['nextRow'] ?? 0);
$highestDataRow = (int) ($state['highestDataRow'] ?? 0);
if ($nextRow > $highestDataRow) {
$this->repository->updateBatchSummary(
(int) $state['batchId'],
(int) ($state['importedRows'] ?? 0),
(int) ($state['warningsCount'] ?? 0),
'complete'
);
$this->repository->pruneExceptBatch((int) $state['batchId']);
$state['status'] = 'completed';
$state['done'] = true;
$state['message'] = 'Import complete.';
return $state;
}
$rowsInWindow = max(0, min($chunkSize, $highestDataRow - $nextRow + 1));
$chunk = $this->reader->readChunk(
(string) $state['filePath'],
$nextRow,
$chunkSize,
$state['headerLookup']
);
$processedInChunk = 0;
$this->repository->beginTransaction();
try {
foreach ($chunk['rows'] as $row) {
$processedInChunk++;
$rowNumber = (int) $row['row_number'];
$sourceRow = $row['data'];
try {
$processed = $this->processor->process($sourceRow);
$this->repository->insertRow(
(int) $state['batchId'],
$rowNumber,
$sourceRow,
$processed['normalized'],
$processed['calculated'],
$processed['merged']
);
$state['importedRows'] = (int) ($state['importedRows'] ?? 0) + 1;
} catch (Throwable $throwable) {
$state['warningsCount'] = (int) ($state['warningsCount'] ?? 0) + 1;
if (!isset($state['warnings']) || !is_array($state['warnings'])) {
$state['warnings'] = [];
}
if (count($state['warnings']) < 100) {
$state['warnings'][] = sprintf('Row %d skipped: %s', $rowNumber, $throwable->getMessage());
}
}
}
$state['nextRow'] = $nextRow + $rowsInWindow;
$state['processedRows'] = (int) ($state['processedRows'] ?? 0) + $rowsInWindow;
$isComplete = (int) $state['nextRow'] > $highestDataRow;
$this->repository->updateBatchSummary(
(int) $state['batchId'],
(int) $state['importedRows'],
(int) $state['warningsCount'],
$isComplete ? 'complete' : 'processing'
);
$this->repository->commit();
if ($isComplete) {
$this->repository->pruneExceptBatch((int) $state['batchId']);
$state['status'] = 'completed';
$state['done'] = true;
$state['message'] = 'Import complete.';
} else {
$state['status'] = 'running';
$state['done'] = false;
$state['message'] = sprintf('Processed %d row(s) in the latest chunk.', $processedInChunk);
}
} catch (Throwable $throwable) {
$this->repository->rollBack();
throw $throwable;
}
} catch (Throwable $throwable) {
$state['status'] = 'error';
$state['done'] = true;
$state['message'] = 'Import failed: ' . $throwable->getMessage();
$state['error'] = $throwable->getMessage();
}
return $state;
}
public function latestBatch(): ?array
{
return $this->repository->getLatestBatch();
}
public function clear(): void
{
$this->repository->beginTransaction();
try {
$this->repository->clearAll();
$this->repository->commit();
} catch (Throwable $throwable) {
$this->repository->rollBack();
throw new RuntimeException('Clear failed: ' . $throwable->getMessage(), 0, $throwable);
}
}
public function getPreview(int $batchId, int $limit, int $offset, string $sortDirection = 'asc'): array
{
return $this->repository->paginateRows($batchId, $limit, $offset, $sortDirection);
}
public function getFilteredPreview(
int $batchId,
int $limit,
int $offset,
string $query,
array $tableHeaders = [],
array $columnSearches = [],
?int $orderColumn = null,
string $sortDirection = 'asc'
): array
{
return $this->repository->paginateRowsFiltered(
$batchId,
$limit,
$offset,
$query,
$tableHeaders,
$columnSearches,
$orderColumn,
$sortDirection
);
}
public function getGridRows(
int $batchId,
int $limit,
int $offset,
string $query = '',
array $tableHeaders = [],
array $filterModel = [],
array $sortModel = []
): array
{
return $this->repository->paginateRowsGrid(
$batchId,
$limit,
$offset,
$query,
$tableHeaders,
$filterModel,
$sortModel
);
}
public function countRowsForBatch(int $batchId): int
{
return $this->repository->countRowsForBatch($batchId);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getExportRows(int $batchId, int $limit, int $offset): array
{
return $this->repository->exportRowsForBatch($batchId, $limit, $offset);
}
public function getDistinctValuesForBatch(int $batchId, string $header, int $limit = 200): array
{
return $this->repository->getDistinctValuesForBatch($batchId, $header, $limit);
}
public function getDistinctValuesForBatchGrid(
int $batchId,
array $tableHeaders,
string $columnKey,
string $distinctSearch = '',
int $limit = 500
): array
{
return $this->repository->getDistinctValuesForBatchGrid($batchId, $tableHeaders, $columnKey, $distinctSearch, $limit);
}
public function countFilteredRowsForBatch(int $batchId, string $query, array $tableHeaders = [], array $columnSearches = []): int
{
return $this->repository->countRowsForBatchFiltered($batchId, $query, $tableHeaders, $columnSearches);
}
public function countGridRows(
int $batchId,
string $query = '',
array $tableHeaders = [],
array $filterModel = []
): int
{
return $this->repository->countRowsForBatchGrid($batchId, $query, $tableHeaders, $filterModel);
}
public function chunkSize(): int
{
return $this->chunkSize;
}
public function deleteCache(string $cacheBasePath): void
{
$this->reader->deleteCache($cacheBasePath);
}
private function cacheBasePath(array $state): string
{
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($state['token'] ?? ''));
if ($token === '') {
throw new RuntimeException('Missing import token.');
}
return sys_get_temp_dir() . '/warner-import-cache/' . $token;
}
}
+456
View File
@@ -0,0 +1,456 @@
<?php
declare(strict_types=1);
namespace App\Import;
use OpenSpout\Reader\CSV\Reader as CsvReader;
use OpenSpout\Reader\ODS\Reader as OdsReader;
use OpenSpout\Reader\ReaderInterface as SpoutReaderInterface;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Throwable;
final class SpreadsheetReader
{
public function detectHeader(string $filePath, array $expectedHeaders = [], int $scanChunkSize = 100, int $maxScanRows = 5000): array
{
for ($startRow = 1; $startRow <= $maxScanRows; $startRow += $scanChunkSize) {
$endRow = min($maxScanRows, $startRow + $scanChunkSize - 1);
$window = $this->loadWindow($filePath, $startRow, $endRow);
foreach ($window['rows'] as $rowIndex => $row) {
$normalizedRow = [];
foreach ($row as $columnLetter => $cellValue) {
$normalizedRow[$columnLetter] = $this->normalizeHeader($cellValue);
}
if (!$this->isHeaderRow($normalizedRow, $expectedHeaders)) {
continue;
}
$headers = [];
$headerLookup = [];
foreach ($normalizedRow as $columnLetter => $headerValue) {
if ($headerValue === '' || $headerValue === 'Revenue Type') {
continue;
}
$headers[] = $headerValue;
$headerLookup[$columnLetter] = $headerValue;
}
return [
'header_row_index' => (int) $rowIndex,
'headers' => $headers,
'header_lookup' => $headerLookup,
'highest_data_row' => (int) $window['highest_data_row'],
];
}
if ((int) $window['highest_data_row'] < $endRow) {
break;
}
}
throw new RuntimeException('Could not locate the header row in the uploaded workbook.');
}
public function readChunk(string $filePath, int $startRow, int $chunkSize, array $headerLookup): array
{
$endRow = $startRow + $chunkSize - 1;
$window = $this->loadWindow($filePath, $startRow, $endRow);
$rows = [];
foreach ($window['rows'] as $rowIndex => $row) {
$mappedRow = [];
foreach ($headerLookup as $columnLetter => $header) {
$mappedRow[$header] = $this->normalizeCellValue($row[$columnLetter] ?? null);
}
if ($this->isBlankRow($mappedRow)) {
continue;
}
$rows[] = [
'row_number' => (int) $rowIndex,
'data' => $mappedRow,
];
}
return [
'rows' => $rows,
'highest_data_row' => (int) $window['highest_data_row'],
];
}
public function read(string $filePath, array $expectedHeaders = []): array
{
$header = $this->detectHeader($filePath, $expectedHeaders);
$allRows = [];
$nextRow = $header['header_row_index'] + 1;
while ($nextRow <= $header['highest_data_row']) {
$chunk = $this->readChunk($filePath, $nextRow, 500, $header['header_lookup']);
$allRows = array_merge($allRows, $chunk['rows']);
$nextRow += 500;
}
return [
'headers' => $header['headers'],
'rows' => $allRows,
];
}
public function buildChunkCache(string $filePath, string $cacheBasePath, array $expectedHeaders = [], int $chunkSize = 50): array
{
if ($chunkSize < 1) {
throw new RuntimeException('Chunk size must be at least 1.');
}
$reader = $this->createSpoutReader($filePath);
$metaPath = $this->cacheMetaPath($cacheBasePath);
$rowsPath = $this->cacheRowsPath($cacheBasePath);
$cacheDir = dirname($metaPath);
if (!is_dir($cacheDir) && !mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) {
throw new RuntimeException('Unable to create the spreadsheet cache directory.');
}
$rowsHandle = fopen($rowsPath, 'wb');
if ($rowsHandle === false) {
throw new RuntimeException('Unable to create the spreadsheet cache file.');
}
$headerFound = false;
$rowIndex = 0;
$chunkRows = [];
$chunkOffsets = [];
$totalRows = 0;
$headerRowIndex = 0;
$headers = [];
$headerLookup = [];
try {
$reader->open($filePath);
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$rowIndex++;
$values = $row->toArray();
if (!$headerFound) {
$normalizedRow = [];
foreach ($values as $columnIndex => $cellValue) {
$normalizedRow[$columnIndex] = $this->normalizeHeader($cellValue);
}
if (!$this->isHeaderRow($normalizedRow, $expectedHeaders)) {
continue;
}
foreach ($normalizedRow as $columnIndex => $headerValue) {
if ($headerValue === '' || $headerValue === 'Revenue Type') {
continue;
}
$headers[] = $headerValue;
$headerLookup[(int) $columnIndex] = $headerValue;
}
$headerRowIndex = $rowIndex;
$headerFound = true;
continue;
}
$mappedRow = [];
foreach ($headerLookup as $columnIndex => $header) {
$mappedRow[$header] = $this->normalizeCellValue($values[$columnIndex] ?? null);
}
if ($this->isBlankRow($mappedRow)) {
continue;
}
$chunkRows[] = [
'row_number' => $rowIndex,
'data' => $mappedRow,
];
$totalRows++;
if (count($chunkRows) >= $chunkSize) {
$chunkOffsets[] = ftell($rowsHandle);
$this->writeChunkRow($rowsHandle, $chunkRows);
$chunkRows = [];
}
}
break;
}
} catch (Throwable $throwable) {
throw new RuntimeException('Unable to build spreadsheet cache: ' . $throwable->getMessage(), 0, $throwable);
} finally {
$reader->close();
fclose($rowsHandle);
}
if (!$headerFound) {
@unlink($rowsPath);
throw new RuntimeException('Could not locate the header row in the uploaded workbook.');
}
if ($chunkRows !== []) {
$chunkOffsets[] = file_exists($rowsPath) ? filesize($rowsPath) : 0;
$rowsHandle = fopen($rowsPath, 'ab');
if ($rowsHandle === false) {
throw new RuntimeException('Unable to reopen the spreadsheet cache file.');
}
try {
$this->writeChunkRow($rowsHandle, $chunkRows);
} finally {
fclose($rowsHandle);
}
}
$cache = [
'file_path' => $filePath,
'headers' => $headers,
'header_lookup' => $headerLookup,
'header_row_index' => $headerRowIndex,
'total_rows' => $totalRows,
'chunk_size' => $chunkSize,
'chunk_count' => count($chunkOffsets),
'chunk_offsets' => $chunkOffsets,
];
if (file_put_contents(
$metaPath,
json_encode($cache, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
) === false) {
throw new RuntimeException('Unable to write the spreadsheet cache metadata.');
}
return $cache;
}
public function readChunkFromCache(string $cacheBasePath, int $chunkIndex): array
{
$cache = $this->readCacheMeta($cacheBasePath);
$chunkOffsets = $cache['chunk_offsets'] ?? [];
if (!is_array($chunkOffsets) || !array_key_exists($chunkIndex, $chunkOffsets)) {
return [
'rows' => [],
'cache' => $cache,
];
}
$rowsPath = $this->cacheRowsPath($cacheBasePath);
$rowsHandle = fopen($rowsPath, 'rb');
if ($rowsHandle === false) {
throw new RuntimeException('Unable to open the spreadsheet cache file.');
}
try {
if (fseek($rowsHandle, (int) $chunkOffsets[$chunkIndex]) !== 0) {
throw new RuntimeException('Unable to seek to the requested spreadsheet chunk.');
}
$line = fgets($rowsHandle);
if ($line === false) {
throw new RuntimeException('Unable to read the requested spreadsheet chunk.');
}
$rows = json_decode(trim($line), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($rows)) {
throw new RuntimeException('Spreadsheet chunk data is invalid.');
}
} finally {
fclose($rowsHandle);
}
return [
'rows' => $rows,
'cache' => $cache,
];
}
public function deleteCache(string $cacheBasePath): void
{
@unlink($this->cacheMetaPath($cacheBasePath));
@unlink($this->cacheRowsPath($cacheBasePath));
}
public function validateRequiredColumns(array $headers, array $requiredColumns): void
{
$missing = [];
$headerMap = array_fill_keys($headers, true);
foreach ($requiredColumns as $requiredColumn) {
if (!isset($headerMap[$requiredColumn])) {
$missing[] = $requiredColumn;
}
}
if ($missing !== []) {
throw new RuntimeException('Missing required column(s): ' . implode(', ', $missing));
}
}
private function normalizeHeader(mixed $value): string
{
if ($value === null) {
return '';
}
return trim((string) $value);
}
private function isHeaderRow(array $row, array $expectedHeaders): bool
{
$nonEmptyValues = array_values(array_filter($row, static fn (?string $value): bool => $value !== null && $value !== ''));
if ($nonEmptyValues === []) {
return false;
}
if ($expectedHeaders === []) {
return count($nonEmptyValues) >= 2;
}
$lookup = array_fill_keys($nonEmptyValues, true);
foreach ($expectedHeaders as $expectedHeader) {
if (!isset($lookup[$expectedHeader])) {
return false;
}
}
return true;
}
private function loadWindow(string $filePath, int $startRow, int $endRow): array
{
try {
$reader = IOFactory::createReaderForFile($filePath);
$reader->setReadDataOnly(true);
$sheetNames = $reader->listWorksheetNames($filePath);
if ($sheetNames !== []) {
$reader->setLoadSheetsOnly([$sheetNames[0]]);
}
$reader->setReadFilter(new ChunkReadFilter($startRow, $endRow));
$spreadsheet = $reader->load($filePath);
} catch (Throwable $throwable) {
throw new RuntimeException('Unable to read the uploaded Excel file: ' . $throwable->getMessage(), 0, $throwable);
}
try {
$worksheet = $spreadsheet->getSheet(0);
$rows = $worksheet->toArray(null, false, true, true);
return [
'rows' => $rows,
'highest_data_row' => $worksheet->getHighestDataRow(),
];
} finally {
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
}
}
private function createSpoutReader(string $filePath): SpoutReaderInterface
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
return match ($extension) {
'csv' => new CsvReader(),
'ods' => new OdsReader(),
'xlsx', 'xlsm' => new XlsxReader(),
'xls' => throw new RuntimeException('XLS files are not supported by the streaming reader. Please save the workbook as XLSX before importing.'),
default => new XlsxReader(),
};
}
private function cacheMetaPath(string $cacheBasePath): string
{
return rtrim($cacheBasePath, '/\\') . '.meta.json';
}
private function cacheRowsPath(string $cacheBasePath): string
{
return rtrim($cacheBasePath, '/\\') . '.rows.ndjson';
}
private function readCacheMeta(string $cacheBasePath): array
{
$metaPath = $this->cacheMetaPath($cacheBasePath);
if (!is_file($metaPath)) {
throw new RuntimeException('Spreadsheet cache metadata is missing. Please restart the import.');
}
$contents = file_get_contents($metaPath);
if ($contents === false || $contents === '') {
throw new RuntimeException('Spreadsheet cache metadata is unreadable. Please restart the import.');
}
$cache = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($cache)) {
throw new RuntimeException('Spreadsheet cache metadata is invalid. Please restart the import.');
}
return $cache;
}
private function writeChunkRow($handle, array $chunkRows): void
{
$json = json_encode($chunkRows, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (fwrite($handle, $json . PHP_EOL) === false) {
throw new RuntimeException('Unable to write spreadsheet cache chunk data.');
}
}
public function normalizeCellValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_int($value)) {
return (string) $value;
}
if (is_float($value)) {
if (fmod($value, 1.0) === 0.0) {
return (string) (int) $value;
}
return rtrim(rtrim(sprintf('%.15F', $value), '0'), '.');
}
$string = trim((string) $value);
if ($string === '') {
return null;
}
return $string;
}
private function isBlankRow(array $row): bool
{
foreach ($row as $value) {
if ($value !== null && $value !== '') {
return false;
}
}
return true;
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Import;
final class SpreadsheetRowProcessor
{
private const CALCULATED_HEADERS = [
'Actual/Accrual',
'US/ex-US sale',
'Contracting Party Filtered',
'String',
'2110 Applicable X201',
'Class',
];
private const X201_ELIGIBLE_STRINGS = [
'1756YYFDI',
'1756YYFLI',
'1756YYFPA',
'1756YYFWO',
'1756YYDDI',
'1756YYDWO',
'1756YYDLI',
'1756YYDPA',
'1100NYDDI',
'1100NYDWO',
'1100YYDWO',
'1100YYFDI',
'1100YYFLI',
'1100YYFPA',
'1100YYFWO',
'1100NYDLI',
'1100NYDPA',
'1100YYDLI',
'1100YYDDI',
'1100YYDPA',
'1902NYDDI',
'1902NYDLI',
'1902NYDWO',
'1902YYFDI',
'1902YYFLI',
'1902YYFPA',
'1902YYFWO',
'1902YYDDI',
'1902YYDWO',
'1902NYDPA',
'1902YYDLI',
'1902YYDPA',
'LOCALYYDWO',
'LOCALYYDDI',
'LOCALYYDLI',
];
public function calculatedHeaders(): array
{
return self::CALCULATED_HEADERS;
}
public function process(array $row): array
{
$normalized = $this->normalize($row);
$calculated = $this->calculateDerivedFields($normalized);
return [
'normalized' => $normalized,
'calculated' => $calculated,
'merged' => array_merge($normalized, $calculated),
];
}
private function normalize(array $row): array
{
$normalized = [];
foreach ($row as $header => $value) {
$normalized[$header] = $this->normalizeValue($value);
}
return $normalized;
}
private function normalizeValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function calculateDerivedFields(array $row): array
{
$contractingParty = $this->normalizeCode($row['Contracting Party'] ?? null);
$localSellingCompany = $this->normalizeCode($row['Local Selling Company (S/T)'] ?? null);
$rightsHolderLiableFlag = $this->normalizeFlag($row['Rights Holder Liable Flag'] ?? null);
$globalTransferPricingFlag = $this->normalizeFlag($row['Global Transfer Pricing Flag'] ?? null);
$domesticForeignFlag = $this->normalizeFlag($row['Domestic/ Foreign Flag'] ?? null);
$ownershipType = $this->normalizeValue($row['Ownership Type'] ?? null) ?? '';
// Business rule: Actual vs accrual is driven by the raw Accrual Category value.
$actualAccrual = (($row['Accrual Category'] ?? null) === '##') ? 'Actual' : 'Accrual';
// Business rule: US/ex-US depends on the local selling company range.
$usExUs = $this->inRange($localSellingCompany, 1700, 1809) ? 'US' : 'ex-US';
// Business rule: Contracting Party is narrowed to the special codes; all others become LOCAL.
$contractingPartyFiltered = in_array($contractingParty, ['1756', '1100', '1902'], true)
? $contractingParty
: 'LOCAL';
// Business rule: String is a direct concatenation of the filtered contracting party plus the flags.
$string = $contractingPartyFiltered
. $rightsHolderLiableFlag
. $globalTransferPricingFlag
. $domesticForeignFlag
. $ownershipType;
// Business rule: 2110 applicability is a fixed whitelist lookup against the derived String.
$applicableX201 = in_array($string, self::X201_ELIGIBLE_STRINGS, true) ? 'X' : '';
// Business rule: Class uses ordered precedence. The first matching rule wins.
$class = $this->calculateClass($contractingParty, $localSellingCompany, $rightsHolderLiableFlag, $globalTransferPricingFlag, $applicableX201, $contractingPartyFiltered);
return [
'Actual/Accrual' => $actualAccrual,
'US/ex-US sale' => $usExUs,
'Contracting Party Filtered' => $contractingPartyFiltered,
'String' => $string,
'2110 Applicable X201' => $applicableX201,
'Class' => $class,
];
}
private function calculateClass(
?string $contractingParty,
?string $localSellingCompany,
string $rightsHolderLiableFlag,
string $globalTransferPricingFlag,
string $applicableX201,
string $contractingPartyFiltered
): string {
if ($contractingParty === '1902') {
return 'A';
}
if (
$contractingParty === '1756'
&& $rightsHolderLiableFlag === 'Y'
&& !$this->inRange($localSellingCompany, 1700, 1808)
) {
return 'B';
}
if ($contractingParty === '1756' && $this->inRange($localSellingCompany, 1700, 1808) && $applicableX201 === 'X') {
return 'C';
}
if ($contractingParty === '1756' && $this->inRange($localSellingCompany, 1700, 1808) && $applicableX201 !== 'X') {
return 'D';
}
if ($contractingParty === '1100' && !$this->inRange($localSellingCompany, 1700, 1808)) {
return 'E';
}
if ($contractingParty === '1100' && $this->inRange($localSellingCompany, 1700, 1808)) {
return 'F';
}
if ($contractingPartyFiltered === 'LOCAL') {
return 'G';
}
if (
$contractingParty === '1756'
&& $rightsHolderLiableFlag === 'N'
&& $globalTransferPricingFlag === 'Y'
&& !$this->inRange($localSellingCompany, 1700, 1808)
) {
return 'H';
}
return '';
}
private function normalizeCode(mixed $value): ?string
{
$value = $this->normalizeValue($value);
if ($value === null) {
return null;
}
if (is_numeric($value) && (float) $value == (int) $value) {
return (string) (int) $value;
}
return $value;
}
private function normalizeFlag(mixed $value): string
{
$normalized = $this->normalizeValue($value);
return $normalized ?? '';
}
private function inRange(?string $value, int $min, int $max): bool
{
if ($value === null || $value === '') {
return false;
}
if (!is_numeric($value)) {
return false;
}
$number = (int) $value;
return $number >= $min && $number <= $max;
}
}