Skip to content
48 changes: 40 additions & 8 deletions src/Eccube/Controller/Admin/Order/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,7 @@ public function index(Request $request, ?int $page_no = null): array

$qb = $this->orderRepository->getQueryBuilderBySearchDataForAdmin($searchData);

// null を配列オフセットに使うのは PHP 8.5 で非推奨。null は '' として扱われるため挙動は変わらない
$sortKey = $searchData['sortkey'] ?? '';
$paginate_options = ['wrap-queries' => true];
if (empty($this->orderRepository::COLUMNS[$sortKey]) || $sortKey == 'order_status') {
$paginate_options = [];
}
$paginate_options = $this->createPaginateOptions($this->extractSortKey($searchData));

$event = new EventArgs(
[
Expand Down Expand Up @@ -299,8 +294,13 @@ protected function exportCsv(Request $request, int $csvTypeId, string $fileName)
// タイムアウトを無効にする.
set_time_limit(0);

// 一覧画面と同じ paginate オプションを使う.
// sortkey は HiddenType なので, セッションに入っている値をそのまま参照できる.
$sortKey = $this->extractSortKey($this->session->get('eccube.admin.order.search', []));
$paginate_options = $this->createPaginateOptions($sortKey);

$response = new StreamedResponse();
$response->setCallback(function () use ($request, $csvTypeId): void {
$response->setCallback(function () use ($request, $csvTypeId, $paginate_options): void {
// CSV種別を元に初期化.
$this->csvExportService->initCsvType($csvTypeId);

Expand Down Expand Up @@ -353,7 +353,7 @@ protected function exportCsv(Request $request, int $csvTypeId, string $fileName)
// 出力.
$csvService->fputcsv($ExportCsvRow->getRow());
}
});
}, $paginate_options);
});

$response->headers->set('Content-Type', 'application/octet-stream');
Expand All @@ -362,6 +362,38 @@ protected function exportCsv(Request $request, int $csvTypeId, string $fileName)
return $response;
}

/**
* 検索条件からソートキーを取り出す.
*
* セッション由来の値も渡るため, 文字列以外は未指定として扱う.
* (null をそのまま配列オフセットに使うのは PHP 8.5 で非推奨)
*/
private function extractSortKey(mixed $searchData): string
{
$sortKey = is_array($searchData) ? $searchData['sortkey'] ?? null : null;

return is_string($sortKey) ? $sortKey : '';
}

/**
* 受注一覧・受注CSV・配送CSVで共通の paginate オプションを組み立てる.
*
* 受注検索のクエリは Shipping を fetch join しているため (OrderItem は join のみ), to-many 側の列
* (s.shipping_date, s.tracking_number, s.name01 等) でソートすると LimitSubqueryWalker が例外を投げる.
* wrap-queries を有効にするとサブクエリで包まれ, ソートを保ったまま解消できる.
* order_status は association (o.OrderStatus) をソート対象にするため, 従来どおり対象外とする.
*
* @return array<string, mixed>
*/
private function createPaginateOptions(string $sortKey): array
{
if (empty($this->orderRepository::COLUMNS[$sortKey]) || $sortKey === 'order_status') {
return [];
}

return ['wrap-queries' => true];
}

/**
* Update to order status
*/
Expand Down
64 changes: 56 additions & 8 deletions src/Eccube/Controller/Admin/Product/ProductController.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,7 @@ public function index(Request $request, $page_no = null): array

$qb = $this->productRepository->getQueryBuilderBySearchDataForAdmin($searchData);

// null を配列オフセットに使うのは PHP 8.5 で非推奨。null は '' として扱われるため挙動は変わらない
$sortKey = $searchData['sortkey'] ?? '';
$paginate_options = ['wrap-queries' => true];
if (empty($this->productRepository::COLUMNS[$sortKey]) || $sortKey == 'code' || $sortKey == 'status') {
$paginate_options = [];
}
$paginate_options = $this->createPaginateOptions($this->extractSortKey($searchData));

$event = new EventArgs(
[
Expand Down Expand Up @@ -935,8 +930,17 @@ public function export(Request $request): StreamedResponse
// タイムアウトを無効にする.
set_time_limit(0);

// 一覧画面と同じ paginate オプションを使う.
// sortkey は HiddenType なので, セッションに入っている値をそのまま参照できる.
$sortKey = $this->extractSortKey($this->session->get('eccube.admin.product.search', []));
$paginate_options = $this->createPaginateOptions($sortKey);

// ProductClass の列でソートしている場合は, その列を select 句に載せる必要がある.
$sortColumn = $this->productRepository::COLUMNS[$sortKey] ?? '';
$hiddenSortColumn = str_starts_with($sortColumn, 'pc.') ? $sortColumn : null;

$response = new StreamedResponse();
$response->setCallback(function () use ($request): void {
$response->setCallback(function () use ($request, $paginate_options, $hiddenSortColumn): void {
// CSV種別を元に初期化.
$this->csvExportService->initCsvType(CsvType::CSV_TYPE_PRODUCT);

Expand All @@ -963,12 +967,24 @@ public function export(Request $request): StreamedResponse
// http://uedatakeshi.blogspot.jp/2010/04/distinct-oeder-by-postgresmysql.html
$qb->resetDQLPart('select');

// stock_status は SearchProductType に無く, コアからは設定されない
// (管理画面の在庫切れ絞り込みは別キーの stock を使う). プラグイン等が
// セッションへ入れたときだけ通る経路なので, 従来の形を維持する.
if ($isOutOfStock) {
$qb->select('p, pc')
->distinct();
} else {
$qb->select('p')
->distinct();

// ProductClass の列でソートしている場合は, その列を HIDDEN で select 句に載せる.
// DISTINCT と併用するため, ORDER BY の対象が select 句に無いと
// PostgreSQL が「ORDER BY expressions must appear in select list」で拒否する.
// pc を fetch join すると ProductClasses が pc.visible の条件で部分初期化され,
// 非表示の規格の行が出力から落ちてしまうため, HIDDEN で取得対象には含めない.
if ($hiddenSortColumn !== null) {
$qb->addSelect($hiddenSortColumn.' AS HIDDEN sort_key_value');
}
}
// データ行の出力.
$this->csvExportService->setExportQueryBuilder($qb);
Expand Down Expand Up @@ -1012,7 +1028,7 @@ public function export(Request $request): StreamedResponse
// 出力.
$csvService->fputcsv($ExportCsvRow->getRow());
}
});
}, $paginate_options);
});

$now = new \DateTime();
Expand All @@ -1025,6 +1041,38 @@ public function export(Request $request): StreamedResponse
return $response;
}

/**
* 検索条件からソートキーを取り出す.
*
* セッション由来の値も渡るため, 文字列以外は未指定として扱う.
* (null をそのまま配列オフセットに使うのは PHP 8.5 で非推奨)
*/
private function extractSortKey(mixed $searchData): string
{
$sortKey = is_array($searchData) ? $searchData['sortkey'] ?? null : null;

return is_string($sortKey) ? $sortKey : '';
}

/**
* 商品一覧・商品CSVで共通の paginate オプションを組み立てる.
*
* 商品検索のクエリは ProductClass を to-many で join しているため, ProductClass 側の列
* (pc.code, pc.stock) でソートすると LimitSubqueryWalker が例外を投げる.
* wrap-queries を有効にするとサブクエリで包まれ, ソートを保ったまま解消できる.
* status は association (p.Status) をソート対象にするため, 従来どおり対象外とする.
*
* @return array<string, mixed>
*/
private function createPaginateOptions(string $sortKey): array
{
if (empty($this->productRepository::COLUMNS[$sortKey]) || $sortKey === 'status') {
return [];
}

return ['wrap-queries' => true];
}

/**
* ProductCategory作成
*/
Expand Down
6 changes: 4 additions & 2 deletions src/Eccube/Service/CsvExportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,10 @@ public function exportHeader(): void
/**
* クエリビルダにもとづいてデータ行を出力する.
* このメソッドを使う場合は, 事前にsetExportQueryBuilder($qb)で出力対象のクエリビルダをわたしておく必要がある.
*
* @param array<string, mixed> $paginateOptions KnpPaginator に渡すオプション. 一覧画面と同じ値をわたす.
*/
public function exportData(\Closure $closure): void
public function exportData(\Closure $closure, array $paginateOptions = []): void
{
if (is_null($this->qb) || is_null($this->entityManager)) {
throw new \LogicException('query builder not set.');
Expand All @@ -183,7 +185,7 @@ public function exportData(\Closure $closure): void

$page = 1;
$limit = 100;
while ($results = $this->paginator->paginate($this->qb, $page, $limit)) {
while ($results = $this->paginator->paginate($this->qb, $page, $limit, $paginateOptions)) {
/** @var AbstractPagination<int, mixed> $results */
if (!$results->valid()) {
break;
Expand Down
41 changes: 41 additions & 0 deletions tests/Eccube/Tests/Web/Admin/AbstractAdminWebTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

namespace Eccube\Tests\Web\Admin;

use Eccube\Common\EccubeConfig;
use Eccube\Tests\Web\AbstractWebTestCase;

abstract class AbstractAdminWebTestCase extends AbstractWebTestCase
Expand All @@ -41,4 +42,44 @@ public function logIn(mixed $user = null)

return $user;
}

/**
* CSV のレコード数を返す(ヘッダ行を除く).
*/
protected function countCsvRows(string $csv): int
{
return count($this->parseCsv($csv)) - 1;
}

/**
* CSV をレコード単位にパースする(ヘッダ行を含む).
*
* 項目の値に改行が含まれるため, 行数は改行では数えられない.
* 出力は eccube_csv_export_encoding のエンコーディングなので UTF-8 に戻してから読む
* (SJIS は 2 バイト目に 0x5C を含む文字があり, escape と誤認して行が結合される).
* escape は PHP 8.4 以降の既定値に合わせて '' を明示する
* (省略すると deprecation。'\\' はデータ中のバックスラッシュで行が結合される).
*
* @return array<int, array<int, string|null>>
*/
protected function parseCsv(string $csv): array
{
$eccubeConfig = static::getContainer()->get(EccubeConfig::class);
$csv = (string) mb_convert_encoding($csv, 'UTF-8', $eccubeConfig->get('eccube_csv_export_encoding'));

$fp = fopen('php://memory', 'r+');
$this->assertNotFalse($fp);
fwrite($fp, $csv);
rewind($fp);

$records = [];
while (($row = fgetcsv($fp, null, $eccubeConfig->get('eccube_csv_export_separator'), '"', '')) !== false) {
if ($row !== [null]) {
$records[] = $row;
}
}
fclose($fp);

return $records;
}
}
Loading
Loading