CaiWuXiTong/modules/EcommerceFinance/View/OrderDataPageData.php

348 lines
13 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->batchText($input['order_no'] ?? null),
// shop_name 保留单值形态,兼容只认单值的调用方;多选统一用 shop_names。
'shop_name' => $shopNames[0] ?? '',
'shop_names' => $shopNames,
'shop_names_exact' => ! empty($input['shop_names_exact']) || is_array($input['shop_name'] ?? null),
'sku_id' => $this->batchText($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);
}
/**
* 商品推广费用明细的店铺候选:该统计日期出现过的店铺。
*
* @return array<int,string>
*/
private function promotionShopOptions(OrderDataMirrorRepository $mirror, string $date): array
{
try {
$names = $mirror->promotionShopNames($date);
return is_array($names) ? array_values($names) : [];
} catch (Throwable $exception) {
report($exception);
return [];
}
}
/**
* 店铺多选候选:订单镜像里出现过的店铺名称。
*
* 完整候选由 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
{
// 店铺筛选与订单售后费用明细一致:多选 shop_name[],兼容原来的单个 shop_name。
$shopNames = $this->shopNames(request('shop_name') ?? request('shop_names'));
$filters = [
'date' => $this->date(request('date')),
'shop_name' => $shopNames[0] ?? '',
'shop_names' => $shopNames,
'sku_id' => $this->batchText(request('sku_id')),
];
[$page, $limit] = $this->pageSettings();
try {
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
if ($mirror->promotionReady()) {
$result = $mirror->promotionExpenses($filters, $page, $limit);
$resolvedDate = (string) ($result['date'] ?? $filters['date']);
return [
'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit),
'orderDataFilters' => array_replace($filters, ['date' => $resolvedDate]),
'orderDataShopOptions' => $this->promotionShopOptions($mirror, $resolvedDate),
'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']]),
'orderDataShopOptions' => [],
'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 batchText(mixed $value): string
{
return mb_substr(trim((string) $value), 0, 2000);
}
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 : '';
}
}