实现订单费用上游数据读取

This commit is contained in:
renshen 2026-08-11 14:37:09 +08:00
parent 68f1faac46
commit f04e935eca
8 changed files with 1288 additions and 129 deletions

View File

@ -15,8 +15,508 @@ class OrderDataRepository
{
}
/** @return array{rows:array<int,array<string,mixed>>,date:?string} */
public function afterSales(array $filters, int $page, int $limit): array
{
$where = [];
$bindings = [];
if (($filters['from'] ?? '') !== '') {
$where[] = 'o.[order_date] >= CAST(? AS date)';
$bindings[] = $filters['from'];
}
if (($filters['to'] ?? '') !== '') {
$where[] = 'o.[order_date] < DATEADD(day, 1, CAST(? AS date))';
$bindings[] = $filters['to'];
}
if (($filters['order_no'] ?? '') !== '') {
$needle = $this->contains($filters['order_no']);
$where[] = "(CONVERT(varchar(30), o.[o_id]) LIKE ? ESCAPE '\\'"
. " OR o.[so_id] LIKE ? ESCAPE '\\'"
. " OR i.[outer_oi_id] LIKE ? ESCAPE '\\')";
array_push($bindings, $needle, $needle, $needle);
}
if (($filters['shop_name'] ?? '') !== '') {
$where[] = "o.[shop_name] LIKE ? ESCAPE '\\'";
$bindings[] = $this->contains($filters['shop_name']);
}
if (($filters['sku_id'] ?? '') !== '') {
$needle = $this->contains($filters['sku_id']);
$where[] = "(i.[sku_id] LIKE ? ESCAPE '\\'"
. " OR i.[shop_sku_id] LIKE ? ESCAPE '\\'"
. " OR i.[shop_i_id] LIKE ? ESCAPE '\\')";
array_push($bindings, $needle, $needle, $needle);
}
$whereSql = $where === [] ? '1 = 1' : implode(' AND ', $where);
[$offset, $fetch] = $this->pageBounds($page, $limit);
$sql = "WITH paged_rows AS (
SELECT
o.[o_id] AS internal_order_no,
o.[so_id] AS online_order_no,
i.[outer_oi_id] AS sub_order_no,
o.[pay_date],
o.[end_time],
o.[send_date],
o.[order_date],
o.[shop_name],
o.[shop_id],
o.[shop_site],
o.[type] AS order_type,
o.[labels] AS order_tags,
o.[wms_co_id],
o.[l_id] AS express_no,
o.[logistics_company] AS express_company,
o.[receiver_state] AS province,
o.[receiver_city] AS city,
o.[creator_name] AS salesperson,
o.[free_amount] AS merchant_discount,
o.[freight] AS freight_income,
i.[oi_id],
i.[sku_id] AS product_code,
i.[i_id] AS style_code,
i.[shop_sku_id] AS shop_product_code,
i.[shop_i_id] AS shop_style_code,
i.[properties_value] AS color_spec,
i.[name] AS online_product_name,
i.[qty] AS product_quantity,
i.[amount] AS product_amount,
i.[buyer_paid_amount] AS payment_amount,
i.[seller_income_amount],
i.[is_gift],
i.[src_combine_sku_id],
i.[sku_type],
i.[send_warehouse]
FROM [rpt].[jst_Orders] o
INNER JOIN [rpt].[jst_OrderItem] i ON i.[o_id] = o.[o_id]
WHERE {$whereSql}
ORDER BY o.[order_date] DESC, o.[o_id] DESC, i.[oi_id] DESC
OFFSET {$offset} ROWS FETCH NEXT {$fetch} ROWS ONLY
)
SELECT
p.[internal_order_no],
p.[online_order_no],
p.[sub_order_no],
CAST(NULL AS nvarchar(100)) AS after_sales_no,
CONVERT(varchar(19), p.[pay_date], 120) AS pay_time,
CONVERT(varchar(19), p.[end_time], 120) AS order_complete_time,
CONVERT(varchar(19), p.[send_date], 120) AS send_time,
CONVERT(varchar(19), p.[order_date], 120) AS order_time,
p.[shop_name],
p.[shop_id] AS shop_code,
p.[product_code],
p.[style_code],
p.[shop_product_code],
p.[shop_style_code],
p.[shop_site],
s.[brand],
s.[category],
s.[vc_name] AS virtual_category,
s.[cost_price] AS unit_cost,
p.[color_spec],
COALESCE(NULLIF(s.[name], ''), p.[color_spec]) AS product_name,
CAST(p.[product_quantity] AS decimal(18,2)) AS product_quantity,
CAST(ISNULL(p.[product_amount], 0) AS decimal(18,2)) AS product_amount,
CAST(ISNULL(s.[cost_price], 0) * ISNULL(p.[product_quantity], 0) AS decimal(18,2)) AS product_cost,
CAST(CASE WHEN ISNULL(p.[product_amount], 0) < 0
THEN ISNULL(s.[cost_price], 0) * ABS(ISNULL(p.[product_quantity], 0)) ELSE 0 END AS decimal(18,2))
AS reversal_order_cost,
CAST(ISNULL(p.[merchant_discount], 0) AS decimal(18,2)) AS merchant_discount,
CAST(ISNULL(p.[freight_income], 0) AS decimal(18,2)) AS freight_income,
CAST(COALESCE(p.[payment_amount], p.[product_amount], 0) AS decimal(18,2)) AS payment_amount,
CAST(CASE WHEN ISNULL(p.[seller_income_amount], 0) > ISNULL(p.[payment_amount], 0)
THEN p.[seller_income_amount] - ISNULL(p.[payment_amount], 0) ELSE 0 END AS decimal(18,2))
AS platform_discount,
CAST(CASE WHEN ISNULL(p.[product_amount], 0) < 0 THEN ABS(p.[product_amount]) ELSE 0 END AS decimal(18,2))
AS reversal_order_amount,
CAST(ISNULL(p.[product_amount], 0) AS decimal(18,2)) AS actual_shipped_amount,
CAST(ISNULL(p.[product_quantity], 0) AS decimal(18,2)) AS actual_shipped_quantity,
CAST(ISNULL(s.[cost_price], 0) * ISNULL(p.[product_quantity], 0) AS decimal(18,2)) AS actual_shipped_cost,
p.[order_type],
p.[online_product_name],
CAST(0 AS decimal(18,2)) AS pre_application_quantity,
CAST(0 AS decimal(18,2)) AS pre_application_amount,
CAST(0 AS decimal(18,2)) AS pre_application_cost,
CAST(0 AS decimal(18,2)) AS pre_return_quantity,
CAST(0 AS decimal(18,2)) AS pre_return_amount,
CAST(0 AS decimal(18,2)) AS pre_return_cost,
CAST(0 AS decimal(18,2)) AS pre_refund_amount,
CAST(0 AS decimal(18,2)) AS pre_refund_cost,
CAST(0 AS decimal(18,2)) AS pre_platform_subsidy,
CAST(0 AS decimal(18,2)) AS post_application_quantity,
CAST(0 AS decimal(18,2)) AS post_application_amount,
CAST(0 AS decimal(18,2)) AS post_application_cost,
CAST(0 AS decimal(18,2)) AS post_return_quantity,
CAST(0 AS decimal(18,2)) AS post_return_amount,
CAST(0 AS decimal(18,2)) AS post_return_cost,
CAST(0 AS decimal(18,2)) AS post_refund_amount,
CAST(0 AS decimal(18,2)) AS post_refund_cost,
CAST(0 AS decimal(18,2)) AS post_platform_subsidy,
CAST(0 AS decimal(18,2)) AS defective_quantity,
CAST(NULL AS nvarchar(100)) AS refund_type,
CAST(NULL AS nvarchar(100)) AS question_type,
CAST(NULL AS nvarchar(100)) AS detail_return_type,
CASE WHEN p.[is_gift] = 1 THEN N'是' ELSE N'否' END AS is_gift,
CASE WHEN NULLIF(p.[src_combine_sku_id], '') IS NULL THEN N'非组合' ELSE N'组合装' END AS combine_type,
p.[order_tags],
CAST(NULL AS nvarchar(500)) AS after_sales_tags,
s.[labels] AS item_tags,
COALESCE(p.[send_warehouse], p.[wms_co_id]) AS shipping_warehouse_code,
wh.[name] AS shipping_warehouse,
wh.[remark1] AS operation_cloud_warehouse,
s.[supplier_id] AS supplier_code,
s.[supplier_name] AS supplier_name,
p.[express_no],
p.[express_company],
p.[province],
p.[city],
p.[salesperson]
FROM paged_rows p
LEFT JOIN [rpt].[jst_sku] s ON s.[sku_id] = p.[product_code]
LEFT JOIN [rpt].[jst_warehouseInfo] wh
ON wh.[wms_co_id] = COALESCE(p.[send_warehouse], p.[wms_co_id])
ORDER BY p.[order_date] DESC, p.[internal_order_no] DESC, p.[oi_id] DESC";
$rows = $this->hydrateAfterSales($this->query()->select($sql, $bindings));
return $this->rowsResult($rows, fn (array $row): array => $this->normalizeAfterSalesRow($row));
}
/** @return array{rows:array<int,array<string,mixed>>} */
public function promotionExpenses(array $filters, int $page, int $limit): array
{
$date = $this->promotionDate($filters['date'] ?? null);
if ($date === null) {
return ['rows' => [], 'date' => null];
}
$where = ['c.[stat_date] = CAST(? AS date)', 'ISNULL(c.[push_fee], 0) <> 0'];
$bindings = [$date];
if (($filters['shop_name'] ?? '') !== '') {
$where[] = "c.[shop_name] LIKE ? ESCAPE '\\'";
$bindings[] = $this->contains($filters['shop_name']);
}
if (($filters['sku_id'] ?? '') !== '') {
$needle = $this->contains($filters['sku_id']);
$where[] = "(c.[item_id] LIKE ? ESCAPE '\\' OR EXISTS ("
. 'SELECT 1 FROM [rpt].[jst_skumap] filter_map '
. 'WHERE filter_map.[shop_i_id] = c.[item_id] '
. "AND (filter_map.[sku_id] LIKE ? ESCAPE '\\'"
. " OR filter_map.[i_id] LIKE ? ESCAPE '\\'"
. " OR filter_map.[shop_sku_id] LIKE ? ESCAPE '\\')))";
array_push($bindings, $needle, $needle, $needle, $needle);
}
$whereSql = implode(' AND ', $where);
[$offset, $fetch] = $this->pageBounds($page, $limit);
$sql = "WITH paged AS (
SELECT
c.[shop_name],
c.[item_id],
CAST(c.[push_fee] AS decimal(18,2)) AS promotion_fee
FROM [rpt].[ads_itemid_sales_cost_ratio_v2_di] c
WHERE {$whereSql}
ORDER BY c.[push_fee] DESC, c.[shop_name], c.[item_id]
OFFSET {$offset} ROWS FETCH NEXT {$fetch} ROWS ONLY
)
SELECT
p.[shop_name],
p.[item_id] AS shop_style_code,
p.[promotion_fee]
FROM paged p
ORDER BY p.[promotion_fee] DESC, p.[shop_name], p.[item_id]";
$rows = $this->mapPromotionRows($this->query()->select($sql, $bindings));
$result = $this->rowsResult($rows, function (array $row): array {
$row['promotion_fee'] = $this->decimal($row['promotion_fee'] ?? null) ?? 0.0;
return $row;
});
return array_merge($result, ['date' => $date]);
}
private function query(): UpstreamReadOnlyQuery
{
return $this->query ??= $this->queries->forSource(self::SOURCE);
}
private function normalizeAfterSalesRow(array $row): array
{
$link = $this->productLink(
(string) ($row['shop_site'] ?? ''),
(string) ($row['shop_name'] ?? ''),
(string) ($row['shop_style_code'] ?? ''),
);
unset($row['shop_site']);
unset($row['unit_cost']);
$row['product_link'] = $link;
$row['offline_customer_code'] = null;
$row['offline_customer_name'] = null;
$row['unique_code'] = null;
$row['registration_time'] = null;
$row['confirmation_time'] = null;
$row['warehouse_entry_time'] = null;
$row['classification_order'] = null;
$row['receiving_warehouse_code'] = null;
$row['receiving_warehouse'] = null;
foreach ($this->expenseFields() as $field) {
$row[$field] = $this->decimal($row[$field] ?? null);
}
foreach ($this->amountFields() as $field) {
if (array_key_exists($field, $row)) {
$row[$field] = $this->decimal($row[$field]) ?? 0.0;
}
}
return $row;
}
/** @param array<int,array<string,mixed>> $rows */
private function hydrateAfterSales(array $rows): array
{
$orderIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => (string) ($row['internal_order_no'] ?? ''),
$rows,
))));
if ($orderIds === []) {
return $rows;
}
$placeholders = implode(', ', array_fill(0, count($orderIds), '?'));
$refunds = $this->query()->select(
"SELECT
a.[o_id] AS internal_order_no,
CONVERT(nvarchar(100), a.[as_id]) AS after_sales_id,
a.[outer_as_id] AS outside_after_sales_id,
CONVERT(varchar(19), a.[as_date], 120) AS after_sales_time,
a.[type] AS refund_type,
a.[question_type],
a.[labels] AS after_sales_tags,
a.[refund] AS refund_amount,
a.[free_amount] AS platform_subsidy,
ri.[outer_oi_id] AS sub_order_no,
ri.[sku_id] AS product_code,
ri.[type] AS detail_return_type,
ri.[qty] AS application_quantity,
ri.[amount] AS application_amount,
ri.[r_qty] AS return_quantity,
ri.[shop_amount] AS return_amount,
TRY_CONVERT(decimal(18,2), ri.[defective_qty]) AS defective_quantity
FROM [rpt].[jst_returnAndRefundInfo] a
INNER JOIN [rpt].[jst_returnAndRefundItem] ri ON ri.[as_id] = a.[as_id]
WHERE a.[o_id] IN ({$placeholders})
ORDER BY a.[as_date] DESC, a.[as_id] DESC",
$orderIds,
);
$byOrder = [];
foreach ($refunds as $refund) {
$byOrder[(string) ($refund['internal_order_no'] ?? '')][] = $refund;
}
foreach ($rows as &$row) {
$matches = array_values(array_filter(
$byOrder[(string) ($row['internal_order_no'] ?? '')] ?? [],
static function (array $refund) use ($row): bool {
$refundSubOrder = (string) ($refund['sub_order_no'] ?? '');
return $refundSubOrder !== ''
? $refundSubOrder === (string) ($row['sub_order_no'] ?? '')
: (string) ($refund['product_code'] ?? '') === (string) ($row['product_code'] ?? '');
},
));
$first = $matches[0] ?? [];
$row['after_sales_no'] = $first['outside_after_sales_id'] ?? $first['after_sales_id'] ?? null;
$row['refund_type'] = $first['refund_type'] ?? null;
$row['question_type'] = $first['question_type'] ?? null;
$row['detail_return_type'] = $first['detail_return_type'] ?? null;
$row['after_sales_tags'] = $first['after_sales_tags'] ?? null;
$unitCost = (float) ($row['unit_cost'] ?? 0);
$preReturnQuantity = 0.0;
foreach ($matches as $refund) {
$phase = empty($row['send_time'])
|| (string) ($refund['after_sales_time'] ?? '') < (string) $row['send_time']
? 'pre'
: 'post';
$applicationQuantity = (float) ($refund['application_quantity'] ?? 0);
$returnQuantity = (float) ($refund['return_quantity'] ?? 0);
$row[$phase . '_application_quantity'] += $applicationQuantity;
$row[$phase . '_application_amount'] += (float) ($refund['application_amount'] ?? 0);
$row[$phase . '_return_quantity'] += $returnQuantity;
$row[$phase . '_return_amount'] += (float) ($refund['return_amount'] ?? 0);
$row[$phase . '_refund_amount'] += (float) ($refund['refund_amount'] ?? 0);
$row[$phase . '_platform_subsidy'] += (float) ($refund['platform_subsidy'] ?? 0);
$row[$phase . '_application_cost'] += $applicationQuantity * $unitCost;
$row[$phase . '_return_cost'] += $returnQuantity * $unitCost;
$row[$phase . '_refund_cost'] += ($applicationQuantity - $returnQuantity) * $unitCost;
$row['defective_quantity'] += (float) ($refund['defective_quantity'] ?? 0);
if ($phase === 'pre') {
$preReturnQuantity += $returnQuantity;
}
}
$row['actual_shipped_quantity'] = (float) ($row['product_quantity'] ?? 0) - $preReturnQuantity;
$row['actual_shipped_amount'] = (float) ($row['product_amount'] ?? 0)
- (float) ($row['pre_return_amount'] ?? 0);
$row['actual_shipped_cost'] = $row['actual_shipped_quantity'] * $unitCost;
}
unset($row);
return $rows;
}
/** @param array<int,array<string,mixed>> $rows */
private function mapPromotionRows(array $rows): array
{
$itemIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => (string) ($row['shop_style_code'] ?? ''),
$rows,
))));
if ($itemIds === []) {
return $rows;
}
$placeholders = implode(', ', array_fill(0, count($itemIds), '?'));
$mappings = $this->query()->select(
"SELECT
m.[shop_i_id] AS shop_style_code,
m.[sku_id] AS product_code,
m.[i_id] AS style_code,
m.[shop_sku_id] AS shop_product_code,
m.[properties_value] AS online_color_spec,
shop.[shop_name],
m.[modified]
FROM [rpt].[jst_skumap] m
LEFT JOIN [rpt].[jst_shopInfo] shop ON shop.[shop_id] = m.[shop_id]
WHERE m.[shop_i_id] IN ({$placeholders})
ORDER BY m.[shop_i_id], m.[modified] DESC",
$itemIds,
);
$byItem = [];
foreach ($mappings as $mapping) {
$byItem[(string) ($mapping['shop_style_code'] ?? '')][] = $mapping;
}
foreach ($rows as &$row) {
$candidates = $byItem[(string) ($row['shop_style_code'] ?? '')] ?? [];
$mapping = collect($candidates)->firstWhere('shop_name', $row['shop_name'] ?? null)
?? ($candidates[0] ?? []);
foreach (['product_code', 'style_code', 'shop_product_code', 'online_color_spec'] as $field) {
$row[$field] = $mapping[$field] ?? null;
}
}
unset($row);
return $rows;
}
/** @return array<int,string> */
private function amountFields(): array
{
return [
'product_quantity', 'product_amount', 'product_cost', 'reversal_order_cost',
'merchant_discount', 'freight_income', 'payment_amount', 'platform_discount',
'reversal_order_amount', 'actual_shipped_amount', 'actual_shipped_quantity',
'actual_shipped_cost', 'pre_application_quantity', 'pre_application_amount',
'pre_application_cost', 'pre_return_quantity', 'pre_return_amount', 'pre_return_cost',
'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost',
'post_refund_amount', 'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
];
}
/** @return array<int,string> */
private function expenseFields(): array
{
return [
'account_500112', 'account_60031', 'account_800101', 'account_800103',
'account_800104', 'account_800105', 'account_800106', 'account_800107',
'account_800108', 'account_800109', 'account_800110', 'account_800114',
'account_8001201', 'account_8001202', 'account_800122', 'account_800125',
'account_800127', 'account_800129', 'account_800130', 'account_800132',
'account_800304', 'account_800501', 'account_800503',
];
}
/** @return array{0:int,1:int} */
private function pageBounds(int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$offset = ($page - 1) * $limit;
return [$offset, $limit + 1];
}
/** @return array{rows:array<int,array<string,mixed>>} */
private function rowsResult(array $rows, callable $normalizer): array
{
foreach ($rows as &$row) {
$row = $normalizer($row);
}
unset($row);
return ['rows' => $rows];
}
private function productLink(string $site, string $shopName, string $itemId): ?string
{
if ($itemId === '') {
return null;
}
$platform = mb_strtolower($site . ' ' . $shopName);
if (str_contains($platform, '京东') || str_contains($platform, '京喜') || str_contains($platform, 'jd')) {
return 'https://item.jd.com/' . rawurlencode($itemId) . '.html';
}
if (str_contains($platform, '抖音')) {
return 'https://haohuo.jinritemai.com/ecommerce/trade/detail/index.html?id=' . rawurlencode($itemId);
}
return null;
}
private function decimal(mixed $value): ?float
{
return $value === null || $value === '' ? null : round((float) $value, 2);
}
private function promotionDate(mixed $value): ?string
{
$date = trim((string) $value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) {
return $date;
}
$row = $this->query()->first(
'SELECT TOP 1 CONVERT(varchar(10), [stat_date], 23) AS [latest_date] '
. 'FROM [rpt].[ads_itemid_sales_cost_ratio_v2_di] ORDER BY [stat_date] DESC'
);
return isset($row['latest_date']) ? (string) $row['latest_date'] : null;
}
private function contains(mixed $value): string
{
return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], (string) $value) . '%';
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Services;
use App\Business\UpstreamBusinessInformation;
use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
/**
* 将经营分析副本库已登记的业务信息查询还原为共享快照刷新任务。
@ -15,6 +16,7 @@ class OperationsReplicaSnapshotRefresher implements UpstreamSnapshotRefresher
public function __construct(
private readonly UpstreamQueryCacheFactory $caches,
private readonly UpstreamBusinessInformation $information,
private readonly OrderDataRepository $orders,
) {
}
@ -31,7 +33,19 @@ class OperationsReplicaSnapshotRefresher implements UpstreamSnapshotRefresher
return $this->caches->forSource(self::SOURCE)->refresh(
$scope,
$parameters,
fn () => $this->information->snapshot($scope, $parameters),
fn () => match ($scope) {
'order-data.after-sales.list' => $this->orders->afterSales(
(array) ($parameters['filters'] ?? []),
(int) ($parameters['page'] ?? 1),
(int) ($parameters['limit'] ?? 25),
),
'order-data.promotion.list' => $this->orders->promotionExpenses(
(array) ($parameters['filters'] ?? []),
(int) ($parameters['page'] ?? 1),
(int) ($parameters['limit'] ?? 25),
),
default => $this->information->snapshot($scope, $parameters),
},
);
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
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,
) {
}
public function afterSales(): array
{
$filters = [
'from' => $this->date(request('from')) ?: now()->subDays(6)->toDateString(),
'to' => $this->date(request('to')) ?: now()->toDateString(),
'order_no' => $this->text(request('order_no')),
'shop_name' => $this->text(request('shop_name')),
'sku_id' => $this->text(request('sku_id')),
];
[$page, $limit] = $this->pageSettings();
$cache = $this->caches->forSource(self::SOURCE);
try {
$result = $cache->remember(
'order-data.after-sales.list',
compact('filters', 'page', 'limit'),
fn () => $this->orders->afterSales($filters, $page, $limit),
);
return [
'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit),
'orderDataFilters' => $filters,
'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, '订单售后费用数据暂时无法读取,请稍后重试。');
}
}
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();
$cache = $this->caches->forSource(self::SOURCE);
try {
$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,
];
}
private function paginator(array $rows, int $page, int $limit): Paginator
{
return new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
/** @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 : '';
}
}

View File

@ -1,134 +1,238 @@
@php
$headers = [
'内部订单号',
'线上订单号',
'子订单号',
'售后单号',
'支付时间',
'订单完成时间',
'发货时间',
'订单时间',
'店铺名称',
'店铺编码',
'商品编码',
'款式编码(参考)',
'店铺商品编码',
'店铺款式编码',
'链接',
'品牌',
'分类',
'虚拟分类',
'颜色及规格',
'商品名称',
'线下客户编号',
'线下客户名称',
'唯一码',
'商品数量',
'商品金额',
'商品成本',
'其中:红冲订单成本',
'商家优惠',
'运费收入',
'付款金额',
'其中:平台优惠',
'其中:红冲订单金额',
'实发金额',
'实发数量',
'实发成本',
'订单类型',
'登记时间',
'确认时间',
'进仓时间',
'线上商品名称',
'发货前申请数量',
'发货前申请金额',
'发货前申请成本',
'发货前退回数量',
'发货前退回金额',
'发货前退回成本',
'发货前退款金额',
'发货前退款成本',
'发货前退平台补贴',
'发货后申请数量',
'发货后申请金额',
'发货后申请成本',
'发货后退回数量',
'发货后退回金额',
'发货后退回成本',
'发货后退款金额',
'发货后退款成本',
'发货后退平台补贴',
'其中:次品商品数量',
'退款类型',
'问题类型',
'明细退货类型',
'分类单',
'赠品',
'组合装',
'订单标签',
'售后单标签',
'商品标签',
'发货仓编号',
'发货仓库',
'运营云仓',
'收货仓编号',
'收货仓',
'供销商编号',
'供销商',
'快递单号',
'快递公司',
'省',
'市',
'业务员',
'500112冲减收入',
'60031erp快递费',
'800101其他服务费',
'800103其他活动费用',
'800104违约赔付',
'800105佣金',
'800106保证金',
'800107推广充值',
'800108品牌新享',
'800109淘宝客/多多进宝',
'800110运费险',
'800114公益宝贝',
'8001201直通车/钻展/快车等',
'8001202推广红包冲减',
'800122售后补偿',
'800125红包',
'800127退货运费',
'800129退款',
'800130订单',
'800132其他费用',
'800304售后补偿',
'800501种菜佣金',
'800503发空包/赠品费用',
$orderData = app(\Modules\EcommerceFinance\View\OrderDataPageData::class)->afterSales();
$columns = [
'internal_order_no' => '内部订单号',
'online_order_no' => '线上订单号',
'sub_order_no' => '子订单号',
'after_sales_no' => '售后单号',
'pay_time' => '支付时间',
'order_complete_time' => '订单完成时间',
'send_time' => '发货时间',
'order_time' => '订单时间',
'shop_name' => '店铺名称',
'shop_code' => '店铺编码',
'product_code' => '商品编码',
'style_code' => '款式编码(参考)',
'shop_product_code' => '店铺商品编码',
'shop_style_code' => '店铺款式编码',
'product_link' => '链接',
'brand' => '品牌',
'category' => '分类',
'virtual_category' => '虚拟分类',
'color_spec' => '颜色及规格',
'product_name' => '商品名称',
'offline_customer_code' => '线下客户编号',
'offline_customer_name' => '线下客户名称',
'unique_code' => '唯一码',
'product_quantity' => '商品数量',
'product_amount' => '商品金额',
'product_cost' => '商品成本',
'reversal_order_cost' => '其中:红冲订单成本',
'merchant_discount' => '商家优惠',
'freight_income' => '运费收入',
'payment_amount' => '付款金额',
'platform_discount' => '其中:平台优惠',
'reversal_order_amount' => '其中:红冲订单金额',
'actual_shipped_amount' => '实发金额',
'actual_shipped_quantity' => '实发数量',
'actual_shipped_cost' => '实发成本',
'order_type' => '订单类型',
'registration_time' => '登记时间',
'confirmation_time' => '确认时间',
'warehouse_entry_time' => '进仓时间',
'online_product_name' => '线上商品名称',
'pre_application_quantity' => '发货前申请数量',
'pre_application_amount' => '发货前申请金额',
'pre_application_cost' => '发货前申请成本',
'pre_return_quantity' => '发货前退回数量',
'pre_return_amount' => '发货前退回金额',
'pre_return_cost' => '发货前退回成本',
'pre_refund_amount' => '发货前退款金额',
'pre_refund_cost' => '发货前退款成本',
'pre_platform_subsidy' => '发货前退平台补贴',
'post_application_quantity' => '发货后申请数量',
'post_application_amount' => '发货后申请金额',
'post_application_cost' => '发货后申请成本',
'post_return_quantity' => '发货后退回数量',
'post_return_amount' => '发货后退回金额',
'post_return_cost' => '发货后退回成本',
'post_refund_amount' => '发货后退款金额',
'post_refund_cost' => '发货后退款成本',
'post_platform_subsidy' => '发货后退平台补贴',
'defective_quantity' => '其中:次品商品数量',
'refund_type' => '退款类型',
'question_type' => '问题类型',
'detail_return_type' => '明细退货类型',
'classification_order' => '分类单',
'is_gift' => '赠品',
'combine_type' => '组合装',
'order_tags' => '订单标签',
'after_sales_tags' => '售后单标签',
'item_tags' => '商品标签',
'shipping_warehouse_code' => '发货仓编号',
'shipping_warehouse' => '发货仓库',
'operation_cloud_warehouse' => '运营云仓',
'receiving_warehouse_code' => '收货仓编号',
'receiving_warehouse' => '收货仓',
'supplier_code' => '供销商编号',
'supplier_name' => '供销商',
'express_no' => '快递单号',
'express_company' => '快递公司',
'province' => '省',
'city' => '市',
'salesperson' => '业务员',
'account_500112' => '500112冲减收入',
'account_60031' => '60031erp快递费',
'account_800101' => '800101其他服务费',
'account_800103' => '800103其他活动费用',
'account_800104' => '800104违约赔付',
'account_800105' => '800105佣金',
'account_800106' => '800106保证金',
'account_800107' => '800107推广充值',
'account_800108' => '800108品牌新享',
'account_800109' => '800109淘宝客/多多进宝',
'account_800110' => '800110运费险',
'account_800114' => '800114公益宝贝',
'account_8001201' => '8001201直通车/钻展/快车等',
'account_8001202' => '8001202推广红包冲减',
'account_800122' => '800122售后补偿',
'account_800125' => '800125红包',
'account_800127' => '800127退货运费',
'account_800129' => '800129退款',
'account_800130' => '800130订单',
'account_800132' => '800132其他费用',
'account_800304' => '800304售后补偿',
'account_800501' => '800501种菜佣金',
'account_800503' => '800503发空包/赠品费用',
];
$numberFields = array_flip([
'product_quantity', 'product_amount', 'product_cost', 'reversal_order_cost',
'merchant_discount', 'freight_income', 'payment_amount', 'platform_discount',
'reversal_order_amount', 'actual_shipped_amount', 'actual_shipped_quantity',
'actual_shipped_cost', 'pre_application_quantity', 'pre_application_amount',
'pre_application_cost', 'pre_return_quantity', 'pre_return_amount', 'pre_return_cost',
'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost',
'post_refund_amount', 'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
'account_500112', 'account_60031', 'account_800101', 'account_800103',
'account_800104', 'account_800105', 'account_800106', 'account_800107',
'account_800108', 'account_800109', 'account_800110', 'account_800114',
'account_8001201', 'account_8001202', 'account_800122', 'account_800125',
'account_800127', 'account_800129', 'account_800130', 'account_800132',
'account_800304', 'account_800501', 'account_800503',
]);
$rows = $orderData['orderDataRows'];
$filters = $orderData['orderDataFilters'];
$status = data_get($orderData, 'orderDataStatus.X-Caiwu-Data-Status');
$orderDataError = $orderData['orderDataError'];
@endphp
<div class="upstream-data-page upstream-data-page--spaced">
@include('business.pages.partials.upstream-data-styles')
<style>
.order-data-table--wide { min-width: 590rem; }
.order-data-link { color: var(--vip-content-accent-dark, #856632); }
.order-data-link .material-icons-outlined { font-size: 1.125rem; }
</style>
<form method="GET" class="upstream-data-toolbar" aria-label="订单售后费用明细筛选">
<div class="upstream-data-field">
<label for="order-data-from">订单开始日期</label>
<input id="order-data-from" name="from" type="date" value="{{ $filters['from'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="order-data-to">订单结束日期</label>
<input id="order-data-to" name="to" type="date" value="{{ $filters['to'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="order-data-order-no">订单号</label>
<input id="order-data-order-no" name="order_no" type="text" value="{{ $filters['order_no'] ?? '' }}" placeholder="内部、线上或子订单号">
</div>
<div class="upstream-data-field">
<label for="order-data-shop">店铺名称</label>
<input id="order-data-shop" name="shop_name" type="text" value="{{ $filters['shop_name'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="order-data-sku">商品编码</label>
<input id="order-data-sku" name="sku_id" type="text" value="{{ $filters['sku_id'] ?? '' }}" placeholder="商品、款式或店铺编码">
</div>
<div class="upstream-data-actions">
<button type="submit" class="upstream-data-button upstream-data-button--primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="upstream-data-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
</div>
</form>
@if ($orderDataError ?? null)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $orderDataError }}</span>
</div>
@endif
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table order-data-table" aria-label="订单售后费用明细">
<table class="upstream-data-table order-data-table order-data-table--wide" aria-label="订单售后费用明细">
<thead>
<tr>
@foreach ($headers as $header)
<th>{{ $header }}</th>
@foreach ($columns as $field => $header)
<th @class(['is-number' => isset($numberFields[$field])])>{{ $header }}</th>
@endforeach
</tr>
</thead>
<tbody>
<tr class="is-empty">
<td colspan="{{ count($headers) }}">暂无数据</td>
</tr>
@forelse ($rows ?? [] as $row)
<tr>
@foreach ($columns as $field => $header)
@php $value = data_get($row, $field); @endphp
<td @class(['is-number' => isset($numberFields[$field])])>
@if ($field === 'product_link' && $value)
<a class="order-data-link" href="{{ $value }}" target="_blank" rel="noopener" title="打开商品链接" aria-label="打开商品链接">
<span class="material-icons-outlined" aria-hidden="true">open_in_new</span>
</a>
@elseif (isset($numberFields[$field]))
{{ $value === null || $value === '' ? '-' : number_format((float) $value, 2) }}
@else
{{ $value === null || $value === '' ? '-' : $value }}
@endif
</td>
@endforeach
</tr>
@empty
<tr class="is-empty">
<td colspan="{{ count($columns) }}">暂无数据</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>共 0 条记录</span>
<span>第 1 / 1 页</span>
<span>
当前页 {{ $rows?->count() ?? 0 }} 条
@if ($status) · {{ strtoupper($status) }} @endif
</span>
<div class="upstream-data-pagination__controls">
<a
href="{{ $rows?->previousPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows || $rows->onFirstPage()])
aria-label="上一页"
title="上一页"
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<span>第 {{ $rows?->currentPage() ?? 1 }} 页</span>
<a
href="{{ $rows?->nextPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows?->hasMorePages()])
aria-label="下一页"
title="下一页"
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</div>
</div>
</div>
</div>

View File

@ -1,38 +1,107 @@
@php
$headers = [
'店铺',
'商品编码',
'款式编码',
'店铺商品编码',
'店铺款式编码',
'线上颜色规格',
'8001201直通车/钻展/快车等',
$orderData = app(\Modules\EcommerceFinance\View\OrderDataPageData::class)->promotionExpenses();
$columns = [
'shop_name' => '店铺',
'product_code' => '商品编码',
'style_code' => '款式编码',
'shop_product_code' => '店铺商品编码',
'shop_style_code' => '店铺款式编码',
'online_color_spec' => '线上颜色规格',
'promotion_fee' => '8001201直通车/钻展/快车等',
];
$rows = $orderData['orderDataRows'];
$filters = $orderData['orderDataFilters'];
$status = data_get($orderData, 'orderDataStatus.X-Caiwu-Data-Status');
$orderDataError = $orderData['orderDataError'];
@endphp
<div class="upstream-data-page upstream-data-page--spaced">
@include('business.pages.partials.upstream-data-styles')
<form method="GET" class="upstream-data-toolbar" aria-label="商品推广费用明细筛选">
<div class="upstream-data-field">
<label for="promotion-expense-date">统计日期</label>
<input id="promotion-expense-date" name="date" type="date" value="{{ $filters['date'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="promotion-expense-shop">店铺名称</label>
<input id="promotion-expense-shop" name="shop_name" type="text" value="{{ $filters['shop_name'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="promotion-expense-sku">商品编码</label>
<input id="promotion-expense-sku" name="sku_id" type="text" value="{{ $filters['sku_id'] ?? '' }}" placeholder="商品、款式或店铺编码">
</div>
<div class="upstream-data-actions">
<button type="submit" class="upstream-data-button upstream-data-button--primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="upstream-data-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
</div>
</form>
@if ($orderDataError ?? null)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $orderDataError }}</span>
</div>
@endif
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table order-data-table" aria-label="商品推广费用明细">
<thead>
<tr>
@foreach ($headers as $header)
<th>{{ $header }}</th>
@foreach ($columns as $field => $header)
<th @class(['is-number' => $field === 'promotion_fee'])>{{ $header }}</th>
@endforeach
</tr>
</thead>
<tbody>
<tr class="is-empty">
<td colspan="{{ count($headers) }}">暂无数据</td>
</tr>
@forelse ($rows ?? [] as $row)
<tr>
@foreach ($columns as $field => $header)
@php $value = data_get($row, $field); @endphp
<td @class(['is-number' => $field === 'promotion_fee'])>
@if ($field === 'promotion_fee')
{{ $value === null || $value === '' ? '-' : number_format((float) $value, 2) }}
@else
{{ $value === null || $value === '' ? '-' : $value }}
@endif
</td>
@endforeach
</tr>
@empty
<tr class="is-empty">
<td colspan="{{ count($columns) }}">暂无数据</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>共 0 条记录</span>
<span>第 1 / 1 页</span>
<span>
当前页 {{ $rows?->count() ?? 0 }} 条
@if ($status) · {{ strtoupper($status) }} @endif
</span>
<div class="upstream-data-pagination__controls">
<a
href="{{ $rows?->previousPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows || $rows->onFirstPage()])
aria-label="上一页"
title="上一页"
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<span>第 {{ $rows?->currentPage() ?? 1 }} 页</span>
<a
href="{{ $rows?->nextPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows?->hasMorePages()])
aria-label="下一页"
title="下一页"
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,102 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Tests\Feature\FeatureTestCase;
class OrderDataPagesUpstreamTest extends FeatureTestCase
{
public function test_order_data_pages_render_cached_upstream_rows_and_filters(): void
{
$this->allowOrderDataPages();
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('remember')->twice()->andReturnUsing(
function (string $scope, array $parameters): array {
if ($scope === 'order-data.after-sales.list') {
$this->assertSame('ORDER-1', $parameters['filters']['order_no']);
return ['rows' => [[
'internal_order_no' => 'ORDER-1',
'shop_name' => '测试店铺',
'product_amount' => 123.45,
]]];
}
$this->assertSame('order-data.promotion.list', $scope);
return [
'date' => '2026-08-09',
'rows' => [[
'shop_name' => '推广测试店铺',
'product_code' => 'SKU-1',
'promotion_fee' => 9.05,
]],
];
},
);
$cache->shouldReceive('responseHeaders')->twice()->andReturn([
'X-Caiwu-Data-Status' => 'direct',
]);
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')->twice()->with('operations_replica')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'order-data',
'page' => 'after-sales-expenses',
'order_no' => 'ORDER-1',
'from' => '2026-08-01',
'to' => '2026-08-09',
]))
->assertOk()
->assertSeeText('ORDER-1')
->assertSeeText('测试店铺')
->assertSeeText('123.45')
->assertSeeText('DIRECT');
$this->get(route('business.pages.show', [
'section' => 'order-data',
'page' => 'promotion-expenses',
]))
->assertOk()
->assertSeeText('推广测试店铺')
->assertSeeText('SKU-1')
->assertSeeText('9.05')
->assertSee('value="2026-08-09"', false);
}
public function test_order_data_page_keeps_rendering_when_the_upstream_is_unavailable(): void
{
$this->allowOrderDataPages();
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('remember')->once()->andThrow(
new ServiceUnavailableHttpException(30, '首份共享快照正在准备。')
);
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')->once()->with('operations_replica')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'order-data',
'page' => 'promotion-expenses',
]))
->assertOk()
->assertSeeText('首份共享快照正在准备。')
->assertSeeText('暂无数据');
}
private function allowOrderDataPages(): void
{
$this->user->update(['locale' => 'zh-CN']);
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
}

View File

@ -5,6 +5,7 @@ namespace Tests\Unit\EcommerceFinance;
use App\Business\UpstreamBusinessInformation;
use Closure;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
use Modules\EcommerceFinance\Services\OperationsReplicaSnapshotRefresher;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
@ -68,7 +69,8 @@ class OperationsReplicaSnapshotRefresherTest extends TestCase
}
};
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information);
$orders = $this->createMock(OrderDataRepository::class);
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information, $orders);
$this->assertSame('operations_replica', $refresher->sourceKey());
$this->assertSame('fresh', $refresher->refresh(compact('scope', 'parameters')));
@ -78,6 +80,66 @@ class OperationsReplicaSnapshotRefresherTest extends TestCase
$this->assertSame(['scope' => $scope, 'parameters' => $parameters], $cache->value);
}
/**
* @dataProvider orderDataScopes
*/
public function test_order_data_scopes_use_the_order_data_repository(
string $scope,
string $method,
array $parameters,
): void {
$cache = new class extends UpstreamQueryCache {
public mixed $value = null;
public function __construct()
{
}
public function refresh(string $scope, array $parameters, Closure $resolver): string
{
$this->value = $resolver();
return 'fresh';
}
};
$caches = new class($cache) extends UpstreamQueryCacheFactory {
public function __construct(private readonly UpstreamQueryCache $cache)
{
}
public function forSource(string $source): UpstreamQueryCache
{
return $this->cache;
}
};
$information = $this->createMock(UpstreamBusinessInformation::class);
$information->expects($this->never())->method('snapshot');
$orders = $this->createMock(OrderDataRepository::class);
$orders->expects($this->once())
->method($method)
->with($parameters['filters'], $parameters['page'], $parameters['limit'])
->willReturn(['rows' => [['id' => 1]]]);
$refresher = new OperationsReplicaSnapshotRefresher($caches, $information, $orders);
$this->assertSame('fresh', $refresher->refresh(compact('scope', 'parameters')));
$this->assertSame(['rows' => [['id' => 1]]], $cache->value);
}
public static function orderDataScopes(): array
{
$parameters = [
'filters' => ['date' => '2026-08-09'],
'page' => 1,
'limit' => 25,
];
return [
'after sales' => ['order-data.after-sales.list', 'afterSales', $parameters],
'promotion' => ['order-data.promotion.list', 'promotionExpenses', $parameters],
];
}
public static function snapshotScopes(): array
{
return [

View File

@ -0,0 +1,173 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\OrderDataRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class OrderDataRepositoryTest extends TestCase
{
public function test_after_sales_uses_fixed_joins_parameterized_filters_and_overfetch_pagination(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$call = 0;
$query->expects($this->exactly(2))->method('select')->willReturnCallback(
function (string $sql, array $bindings) use (&$call): array {
$call++;
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
if ($call === 2) {
$this->assertStringContainsString('[rpt].[jst_returnAndRefundInfo]', $sql);
$this->assertSame(['1'], $bindings);
return [[
'internal_order_no' => '1',
'after_sales_id' => 'AF-1',
'after_sales_time' => '2026-08-02 12:00:00',
'sub_order_no' => 'SUB-1',
'product_code' => 'SKU-1',
'application_quantity' => '1',
'application_amount' => '6.00',
'return_quantity' => '1',
'return_amount' => '6.00',
]];
}
$this->assertStringContainsString('[rpt].[jst_Orders]', $sql);
$this->assertStringContainsString('[rpt].[jst_OrderItem]', $sql);
$this->assertStringContainsString('OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY', $sql);
$this->assertStringNotContainsString('COUNT(*) OVER', $sql);
$this->assertSame([
'2026-08-01',
'2026-08-02',
'%SO\%\_%',
'%SO\%\_%',
'%SO\%\_%',
'%JD%',
'%SKU%',
'%SKU%',
'%SKU%',
], $bindings);
return [[
'internal_order_no' => '1',
'sub_order_no' => 'SUB-1',
'product_code' => 'SKU-1',
'shop_site' => 'jd',
'shop_style_code' => '1001',
'send_time' => '2026-08-02 10:00:00',
'unit_cost' => '3.00',
'product_amount' => '12.34',
'product_quantity' => '2',
'actual_shipped_amount' => '12.34',
'actual_shipped_quantity' => '2',
'actual_shipped_cost' => '6.00',
'pre_application_quantity' => '0',
'pre_application_amount' => '0',
'pre_application_cost' => '0',
'pre_return_quantity' => '0',
'pre_return_amount' => '0',
'pre_return_cost' => '0',
'pre_refund_amount' => '0',
'pre_refund_cost' => '0',
'pre_platform_subsidy' => '0',
'post_application_quantity' => '0',
'post_application_amount' => '0',
'post_application_cost' => '0',
'post_return_quantity' => '0',
'post_return_amount' => '0',
'post_return_cost' => '0',
'post_refund_amount' => '0',
'post_refund_cost' => '0',
'post_platform_subsidy' => '0',
'defective_quantity' => '0',
]];
},
);
$result = (new OrderDataRepository($this->factory($query)))->afterSales([
'from' => '2026-08-01',
'to' => '2026-08-02',
'order_no' => 'SO%_',
'shop_name' => 'JD',
'sku_id' => 'SKU',
], 2, 2);
$this->assertCount(1, $result['rows']);
$this->assertSame(12.34, $result['rows'][0]['product_amount']);
$this->assertSame(2.0, $result['rows'][0]['product_quantity']);
$this->assertSame('https://item.jd.com/1001.html', $result['rows'][0]['product_link']);
$this->assertSame('AF-1', $result['rows'][0]['after_sales_no']);
$this->assertSame(1.0, $result['rows'][0]['post_return_quantity']);
$this->assertNull($result['rows'][0]['account_8001201']);
}
public function test_promotion_expenses_use_one_statistics_date_and_map_only_the_page_rows(): void
{
$query = $this->createMock(UpstreamReadOnlyQuery::class);
$call = 0;
$query->expects($this->exactly(2))->method('select')->willReturnCallback(
function (string $sql, array $bindings) use (&$call): array {
$call++;
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
if ($call === 2) {
$this->assertStringContainsString('[rpt].[jst_skumap]', $sql);
$this->assertSame(['ITEM-1'], $bindings);
return [[
'shop_style_code' => 'ITEM-1',
'shop_name' => 'JD',
'product_code' => 'SKU-1',
'style_code' => 'STYLE-1',
'shop_product_code' => 'SHOP-SKU-1',
'online_color_spec' => '黑色',
]];
}
$this->assertStringContainsString('[rpt].[ads_itemid_sales_cost_ratio_v2_di]', $sql);
$this->assertStringContainsString('OFFSET 3 ROWS FETCH NEXT 4 ROWS ONLY', $sql);
$this->assertStringContainsString('FROM paged p', $sql);
$this->assertStringNotContainsString('SUM(c.[push_fee])', $sql);
$this->assertStringNotContainsString('COUNT(*) OVER', $sql);
$this->assertSame([
'2026-08-09',
'%JD%',
'%SKU\_%',
'%SKU\_%',
'%SKU\_%',
'%SKU\_%',
], $bindings);
return [[
'shop_name' => 'JD',
'shop_style_code' => 'ITEM-1',
'promotion_fee' => '9.05',
]];
},
);
$result = (new OrderDataRepository($this->factory($query)))->promotionExpenses([
'date' => '2026-08-09',
'shop_name' => 'JD',
'sku_id' => 'SKU_',
], 2, 3);
$this->assertSame('2026-08-09', $result['date']);
$this->assertSame(9.05, $result['rows'][0]['promotion_fee']);
$this->assertSame('SKU-1', $result['rows'][0]['product_code']);
}
private function factory(UpstreamReadOnlyQuery $query): UpstreamReadOnlyQueryFactory
{
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->expects($this->once())
->method('forSource')
->with('operations_replica')
->willReturn($query);
return $factory;
}
}