CaiWuXiTong/modules/EcommerceFinance/View/OrderDataPageData.php
renshen af8f1403fb 修复运费对账汇总卡死与订单店铺候选查询过慢
- 运费对账全量汇总:哈希关联的 join buffer 从 16MB 提到 128MB(空间上限 384MB)。
  300 万行明细 × 400 万行最新关联表在 16MB 下会退化成磁盘多趟扫描,实测超过
  max_statement_time(300s) 也跑不完,页面「记录数/运费合计」永远停在计算中;
  调整后约 110 秒出结果并写入缓存。
- 订单售后费用明细店铺候选:改回两个来源各自 DISTINCT 后在 PHP 合并(UNION 会
  把两个上百万行结果一起物化去重排序)。同时完整候选只算一次并缓存 24 小时,
  冷缓存时先返回毫秒级的店铺目录,完整列表放到响应结束后补齐,避免页面为了
  下拉候选把工作进程占住两分钟。
2026-10-07 10:52:36 +08:00

317 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
use Modules\EcommerceFinance\Repositories\OrderDataMirrorRepository;
use Modules\EcommerceFinance\Repositories\WorkOrderMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class OrderDataPageData
{
private const SOURCE = 'operations_replica';
public function __construct(
private readonly OrderDataRepository $orders,
private readonly UpstreamQueryCacheFactory $caches,
private readonly ?OrderDataMirrorRepository $mirrorOrders = null,
private readonly ?WorkOrderMirrorRepository $workOrderMirror = null,
) {
}
public function afterSales(?array $filters = null, ?int $page = null, ?int $limit = null, bool $includeWorkOrders = true): array
{
$filters = $this->afterSalesFilters($filters);
[$requestPage, $requestLimit] = $this->pageSettings();
$page = max($page ?? $requestPage, 1);
$limit = min(max($limit ?? $requestLimit, 1), 100);
try {
$result = $this->afterSalesRows($filters, $page, $limit);
return [
'orderDataRows' => $this->paginator(
(array) ($result['rows'] ?? []),
$page,
$limit,
(bool) ($result['has_more'] ?? false),
),
'orderDataFilters' => $filters,
'orderDataStatus' => $result['status'] ?? [],
'orderDataError' => null,
'orderDataShopOptions' => $this->afterSalesShopOptions(),
'afterSalesWorkOrders' => $includeWorkOrders ? $this->afterSalesWorkOrders($filters) : $this->emptyAfterSalesWorkOrders(),
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->failed($filters, $page, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->failed($filters, $page, $limit, '订单售后费用数据暂时无法读取,请稍后重试。');
}
}
/**
* Return the same normalized filters used by the raw after-sales page.
* Exporters and sibling views must use this contract instead of rebuilding
* date and text normalization independently.
*
* @param array<string,mixed>|null $input
* @return array{from:string,to:string,order_no:string,shop_name:string,shop_names:array<int,string>,sku_id:string}
*/
public function afterSalesFilters(?array $input = null): array
{
$input ??= request()->query();
$shopNames = $this->shopNames($input['shop_name'] ?? $input['shop_names'] ?? null);
$from = $this->date($input['from'] ?? null);
$to = $this->date($input['to'] ?? null);
// 首次进入页面时给一个近 7 天的默认区间;点了“重置”或提交了空日期后,
// 明确不加日期条件(页面上的日期框保持为空),由用户自己决定下一步再看什么。
$cleared = ! empty($input['reset']) || array_key_exists('from', $input) || array_key_exists('to', $input);
return [
'from' => $from !== '' ? $from : ($cleared ? '' : now()->subDays(6)->toDateString()),
'to' => $to !== '' ? $to : ($cleared ? '' : now()->toDateString()),
'order_no' => $this->text($input['order_no'] ?? null),
// shop_name 保留单值形态,兼容只认单值的调用方;多选统一用 shop_names。
'shop_name' => $shopNames[0] ?? '',
'shop_names' => $shopNames,
'sku_id' => $this->text($input['sku_id'] ?? null),
];
}
/**
* 店铺筛选支持多选(shop_name[]=A&shop_name[]=B),也兼容原来的单个 shop_name。
*
* @return array<int,string>
*/
private function shopNames(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
$names = [];
foreach ($values as $item) {
if (! is_scalar($item)) {
continue;
}
$name = $this->text($item);
if ($name === '') {
continue;
}
$names[$name] = true;
// 单次最多 50 个店铺,避免拼出过长的 WHERE。
if (count($names) >= 50) {
break;
}
}
return array_keys($names);
}
/**
* 店铺多选候选:订单镜像里出现过的店铺名称。
*
* 完整候选由 OrderDataMirrorRepository::shopNames() 自己缓存 24 小时;冷缓存时
* 它先返回毫秒级的店铺目录,避免页面为了下拉候选卡住两分钟。
*
* @return array<int,string>
*/
public function afterSalesShopOptions(): array
{
try {
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
$names = $mirror->shopNames();
return is_array($names) ? array_values($names) : [];
} catch (Throwable $exception) {
report($exception);
return [];
}
}
/**
* Fetch raw after-sales rows for a page. Both the page and CSV export use
* this method so source selection, filters, joins and row normalization stay
* identical. Repositories over-fetch one row to determine whether another
* page exists; the sentinel is removed before this shared contract returns.
*
* @return array{rows:array<int,array<string,mixed>>,has_more:bool,status:array<string,string>}
*/
public function afterSalesRows(array $filters, int $page, int $limit): array
{
$limit = min(max($limit, 1), 100);
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
if ($mirror->ready()) {
$result = $mirror->afterSales($filters, $page, $limit);
$rows = array_values((array) ($result['rows'] ?? []));
$hasMore = count($rows) > $limit;
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => $hasMore,
'status' => ['X-Caiwu-Data-Status' => 'mirror'],
];
}
$cache = $this->caches->forSource(self::SOURCE);
$result = $cache->remember(
'order-data.after-sales.list',
compact('filters', 'page', 'limit'),
fn () => $this->orders->afterSales($filters, $page, $limit),
);
$rows = array_values((array) ($result['rows'] ?? []));
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
'status' => $cache->responseHeaders(),
];
}
public function promotionExpenses(): array
{
$filters = [
'date' => $this->date(request('date')),
'shop_name' => $this->text(request('shop_name')),
'sku_id' => $this->text(request('sku_id')),
];
[$page, $limit] = $this->pageSettings();
try {
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
if ($mirror->promotionReady()) {
$result = $mirror->promotionExpenses($filters, $page, $limit);
return [
'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit),
'orderDataFilters' => array_replace($filters, ['date' => $result['date'] ?? $filters['date']]),
'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'],
'orderDataError' => null,
];
}
$cache = $this->caches->forSource(self::SOURCE);
$result = $cache->remember(
'order-data.promotion.list',
compact('filters', 'page', 'limit'),
fn () => $this->orders->promotionExpenses($filters, $page, $limit),
);
return [
'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit),
'orderDataFilters' => array_replace($filters, ['date' => $result['date'] ?? $filters['date']]),
'orderDataStatus' => $cache->responseHeaders(),
'orderDataError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->failed($filters, $page, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->failed($filters, $page, $limit, '商品推广费用数据暂时无法读取,请稍后重试。');
}
}
private function failed(array $filters, int $page, int $limit, string $message): array
{
return [
'orderDataRows' => $this->paginator([], $page, $limit),
'orderDataFilters' => $filters,
'orderDataStatus' => [],
'orderDataError' => $message,
'orderDataShopOptions' => $this->afterSalesShopOptions(),
'afterSalesWorkOrders' => $this->emptyAfterSalesWorkOrders(),
];
}
/** @param array<string,string> $filters */
private function afterSalesWorkOrders(array $filters): array
{
if ($this->workOrderMirror === null) {
return $this->emptyAfterSalesWorkOrders();
}
try {
return $this->workOrderMirror->paginateAfterSales(
max((int) request('work_order_page', 1), 1),
10,
$filters,
);
} catch (Throwable) {
return $this->emptyAfterSalesWorkOrders();
}
}
/** @return array{data:list<array<string,mixed>>,total:int,page:int,per_page:int,last_page:int,ready:bool,sync_status:array<string,mixed>} */
private function emptyAfterSalesWorkOrders(): array
{
return [
'data' => [],
'total' => 0,
'page' => max((int) request('work_order_page', 1), 1),
'per_page' => 10,
'last_page' => 1,
'ready' => false,
'sync_status' => [
'ready' => false,
'code' => 'unavailable',
'last_succeeded_at' => null,
'datasets' => [],
],
];
}
private function paginator(array $rows, int $page, int $limit, ?bool $hasMore = null): Paginator
{
$paginator = new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
if ($hasMore !== null) {
$paginator->hasMorePagesWhen($hasMore);
}
return $paginator;
}
/** @return array{0:int,1:int} */
private function pageSettings(): array
{
return [
max((int) request('page', 1), 1),
min(max((int) setting('default.list_limit', 25), 1), 100),
];
}
private function text(mixed $value): string
{
return mb_substr(trim((string) $value), 0, 100);
}
private function date(mixed $value): string
{
$value = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) {
return '';
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : '';
}
}