diff --git a/modules/EcommerceFinance/Repositories/OrderDataRepository.php b/modules/EcommerceFinance/Repositories/OrderDataRepository.php index dc3700b8..70ba60f5 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataRepository.php @@ -15,8 +15,508 @@ class OrderDataRepository { } + /** @return array{rows:array>,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>} */ + 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> $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> $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 */ + 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 */ + 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>} */ + 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) . '%'; + } } diff --git a/modules/EcommerceFinance/Services/OperationsReplicaSnapshotRefresher.php b/modules/EcommerceFinance/Services/OperationsReplicaSnapshotRefresher.php index 69bf8cb2..03467a1c 100644 --- a/modules/EcommerceFinance/Services/OperationsReplicaSnapshotRefresher.php +++ b/modules/EcommerceFinance/Services/OperationsReplicaSnapshotRefresher.php @@ -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), + }, ); } } diff --git a/modules/EcommerceFinance/View/OrderDataPageData.php b/modules/EcommerceFinance/View/OrderDataPageData.php new file mode 100644 index 00000000..63a3f9c9 --- /dev/null +++ b/modules/EcommerceFinance/View/OrderDataPageData.php @@ -0,0 +1,135 @@ + $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 : ''; + } +} diff --git a/resources/views/business/pages/order-data/after-sales-expenses.blade.php b/resources/views/business/pages/order-data/after-sales-expenses.blade.php index 5c750cd9..79706d7f 100644 --- a/resources/views/business/pages/order-data/after-sales-expenses.blade.php +++ b/resources/views/business/pages/order-data/after-sales-expenses.blade.php @@ -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
@include('business.pages.partials.upstream-data-styles') + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 重置 + +
+
+ + @if ($orderDataError ?? null) + + @endif +
- +
- @foreach ($headers as $header) - + @foreach ($columns as $field => $header) + @endforeach - - - + @forelse ($rows ?? [] as $row) + + @foreach ($columns as $field => $header) + @php $value = data_get($row, $field); @endphp + + @endforeach + + @empty + + + + @endforelse
{{ $header }} isset($numberFields[$field])])>{{ $header }}
暂无数据
isset($numberFields[$field])])> + @if ($field === 'product_link' && $value) + + + + @elseif (isset($numberFields[$field])) + {{ $value === null || $value === '' ? '-' : number_format((float) $value, 2) }} + @else + {{ $value === null || $value === '' ? '-' : $value }} + @endif +
暂无数据
- 共 0 条记录 - 第 1 / 1 页 + + 当前页 {{ $rows?->count() ?? 0 }} 条 + @if ($status) · {{ strtoupper($status) }} @endif + +
diff --git a/resources/views/business/pages/order-data/promotion-expenses.blade.php b/resources/views/business/pages/order-data/promotion-expenses.blade.php index 6c96dd79..27438517 100644 --- a/resources/views/business/pages/order-data/promotion-expenses.blade.php +++ b/resources/views/business/pages/order-data/promotion-expenses.blade.php @@ -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
@include('business.pages.partials.upstream-data-styles') +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 重置 + +
+
+ + @if ($orderDataError ?? null) + + @endif +
- @foreach ($headers as $header) - + @foreach ($columns as $field => $header) + @endforeach - - - + @forelse ($rows ?? [] as $row) + + @foreach ($columns as $field => $header) + @php $value = data_get($row, $field); @endphp + + @endforeach + + @empty + + + + @endforelse
{{ $header }} $field === 'promotion_fee'])>{{ $header }}
暂无数据
$field === 'promotion_fee'])> + @if ($field === 'promotion_fee') + {{ $value === null || $value === '' ? '-' : number_format((float) $value, 2) }} + @else + {{ $value === null || $value === '' ? '-' : $value }} + @endif +
暂无数据
- 共 0 条记录 - 第 1 / 1 页 + + 当前页 {{ $rows?->count() ?? 0 }} 条 + @if ($status) · {{ strtoupper($status) }} @endif + +
diff --git a/tests/Feature/Business/OrderDataPagesUpstreamTest.php b/tests/Feature/Business/OrderDataPagesUpstreamTest.php new file mode 100644 index 00000000..adbe7833 --- /dev/null +++ b/tests/Feature/Business/OrderDataPagesUpstreamTest.php @@ -0,0 +1,102 @@ +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() + ); + } +} diff --git a/tests/Unit/EcommerceFinance/OperationsReplicaSnapshotRefresherTest.php b/tests/Unit/EcommerceFinance/OperationsReplicaSnapshotRefresherTest.php index f703e93e..4b704355 100644 --- a/tests/Unit/EcommerceFinance/OperationsReplicaSnapshotRefresherTest.php +++ b/tests/Unit/EcommerceFinance/OperationsReplicaSnapshotRefresherTest.php @@ -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 [ diff --git a/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php new file mode 100644 index 00000000..2e061c04 --- /dev/null +++ b/tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php @@ -0,0 +1,173 @@ +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; + } +}