CaiWuXiTong/modules/EcommerceFinance/View/OrderDataPageData.php
renshen 4b97b30a3a 商品推广费用明细:店铺筛选改成和订单售后费用明细一致的多选控件
- 把「店铺名称:可点选的多选 + 模糊搜索」抽成公共组件
  partials/order-data-shop-picker.blade.php,订单售后费用明细改为引用它,
  商品推广费用明细用同一个控件(原来是一个纯文本输入框)。
- 推广页提交 shop_name[](多个取并集),后端镜像查询和非淘系旧查询都改成
  按多个店铺 OR 匹配,仍兼容原来的单个 shop_name。
- 推广表的店铺名带下划线(天猫_卓禾家具旗舰店),和订单镜像的「天猫-…」
  不是同一套命名,所以候选单独取自 ops_item_sales_cost_ratio 当前统计日期
  出现过的店铺,按日期缓存 24 小时(单次约 0.4 秒、约 400 个店铺)。
2026-10-07 13:39:25 +08:00

341 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);
}
/**
* 商品推广费用明细的店铺候选:该统计日期出现过的店铺。
*
* @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->text(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 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 : '';
}
}