CaiWuXiTong/modules/EcommerceFinance/View/OrderDataPageData.php
苏木 a03372c770 fix: 账单冲减复用订单售后费用口径 (#146)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-09-09 17:42:42 +08:00

253 lines
9.3 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,
'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,sku_id:string}
*/
public function afterSalesFilters(?array $input = null): array
{
$input ??= request()->query();
return [
'from' => $this->date($input['from'] ?? null) ?: now()->subDays(6)->toDateString(),
'to' => $this->date($input['to'] ?? null) ?: now()->toDateString(),
'order_no' => $this->text($input['order_no'] ?? null),
'shop_name' => $this->text($input['shop_name'] ?? null),
'sku_id' => $this->text($input['sku_id'] ?? null),
];
}
/**
* 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,
'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 : '';
}
}