CaiWuXiTong/modules/EcommerceFinance/View/OrderDataPageData.php
renshen 83e5108ce6 订单售后费用明细:重置后不再自动带日期
- 点“重置”或提交空日期时,不再回填近 7 天默认区间,日期框保持为空并做不限日期查询
- 首次进入页面仍保留近 7 天默认区间;导出文件名在无日期时显示“不限日期”
- 补充过滤归一化与页面渲染用例
2026-10-07 10:00:20 +08:00

319 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 Illuminate\Support\Facades\Cache;
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);
}
/**
* 店铺多选候选:订单镜像里出现过的店铺名称(缓存 1 小时,避免每次页面请求都扫表去重)。
*
* @return array<int,string>
*/
public function afterSalesShopOptions(): array
{
try {
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
$names = Cache::remember(
'order-data.after-sales.shop-options',
now()->addHour(),
fn (): array => $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 : '';
}
}