348 lines
13 KiB
PHP
348 lines
13 KiB
PHP
<?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 : '';
|
||
}
|
||
}
|