renshen 09d593dd2d 订单数据接入聚水潭上游只读数据 (#27)
## 任务背景
订单数据页面已完成附件表头,本任务由 renshen 接入聚水潭上游真实只读数据。

## 任务目标
- 订单售后费用明细:按附件字段展示订单、商品、SKU 成本及售后关联数据。
- 商品推广费用明细:按商品/款式汇总推广费用。
- 不硬编码附件演示值;缺失字段保持空值或零。

## 预计修改路径
- modules/EcommerceFinance/Repositories/OrderDataRepository.php
- modules/EcommerceFinance/View/Composers/OrderDataPageComposer.php
- modules/EcommerceFinance/Providers/Main.php
- resources/views/business/pages/order-data/*.blade.php
- tests/Unit/EcommerceFinance/OrderDataRepositoryTest.php
- tests/Feature/Business/OrderDataPagesUpstreamTest.php

## 页面和路由
复用 order-data/after-sales-expenses、order-data/promotion-expenses 和 business.pages.show;不新增或修改 routes。

## Controller / Repository
新增 OrderDataRepository 和专用 View Composer;不修改 app/Http/Controllers/Business/Pages.php。

## 数据源、表和字段
source_key=operations_replica,数据库 JSReportReplica,schema=rpt。固定参数化只读查询使用 jst_Orders、jst_OrderItem、jst_sku、jst_skumap、jst_returnAndRefundInfo、jst_returnAndRefundItem、jst_warehouseInfo、ads_itemid_sales_cost_ratio_v2_di。字段限于订单/商品标识、时间、店铺、SKU 元数据、金额、售后数量金额及 push_fee。

## scope
- order-data.after-sales.list
- order-data.promotion.list

## 权限、菜单和公共组件
复用现有页面权限、菜单、UpstreamReadOnlyQueryFactory 和 UpstreamQueryCacheFactory;不修改权限和导航。

## 明确不修改
不修改 routes、Pages Controller、数据库迁移、上游连接配置、权限、菜单、京东钱包功能、共享数据库结构或任何上游数据。

## 范围补充:后台快照刷新
- modules/EcommerceFinance/Services/OperationsReplicaSnapshotRefresher.php
- tests/Unit/EcommerceFinance/OperationsReplicaSnapshotRefresherTest.php

刷新器仅为 order-data.after-sales.list 和 order-data.promotion.list 分派同一 OrderDataRepository 固定查询;其他 scope 继续复用现有 UpstreamBusinessInformation。

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/27
2026-08-11 14:50:56 +08:00

523 lines
23 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
class OrderDataRepository
{
private const SOURCE = 'operations_replica';
private ?UpstreamReadOnlyQuery $query = null;
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
/** @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) . '%';
}
}