renshen 916fb30f37 修复批量筛选:纯数字单号当数组键会被 PHP 转成 int
batchValues() 原来拿值本身当数组键去重,纯数字订单号(如 3317080368285022883)会被
PHP 转成 int,后续 whereIn 就成了数字和长文本比较,走不上 so_id / outer_oi_id 的
前缀索引,实测退化成全表扫描(70 秒以上)。

改成键加 v 前缀、返回原字符串,ctype_digit 显式传字符串。新增用例覆盖前导零单号
0001234 与 1234,防止再回归。
2026-10-07 14:05:53 +08:00

584 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\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'] ?? '') !== '') {
// 支持批量订单号:每个单号一组匹配,多组取并集。
$groups = [];
foreach ($this->batchValues($filters['order_no']) as $value) {
$needle = $this->contains($value);
$groups[] = "(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 ($groups !== []) {
$where[] = '(' . implode(' OR ', $groups) . ')';
}
}
if (($filters['shop_name'] ?? '') !== '') {
$where[] = "o.[shop_name] LIKE ? ESCAPE '\\'";
$bindings[] = $this->contains($filters['shop_name']);
}
if (($filters['sku_id'] ?? '') !== '') {
$groups = [];
foreach ($this->batchValues($filters['sku_id']) as $value) {
$needle = $this->contains($value);
$groups[] = "(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);
}
if ($groups !== []) {
$where[] = '(' . implode(' OR ', $groups) . ')';
}
}
$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 NULL 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];
$shopNames = array_values(array_filter(array_map(
static fn (mixed $name): string => trim((string) $name),
(array) ($filters['shop_names'] ?? []),
), static fn (string $name): bool => $name !== ''));
if ($shopNames === [] && ($filters['shop_name'] ?? '') !== '') {
$shopNames = [trim((string) $filters['shop_name'])];
}
if ($shopNames !== []) {
$where[] = '(' . implode(' OR ', array_fill(0, count($shopNames), "c.[shop_name] LIKE ? ESCAPE '\\'")) . ')';
foreach ($shopNames as $name) {
$bindings[] = $this->contains($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);
}
// 500112 冲减收入:暂以「退货红冲金额(reversal_order_amount)」作为收入冲减口径,
// 待财务确认最终取数规则后替换。其余 account_* 科目字段仍为空(科目归集未接入)。
$row['account_500112'] = $this->decimal($row['reversal_order_amount'] ?? 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) . '%';
}
/**
* 批量筛选值:支持换行、逗号、分号、顿号、制表符和空格分隔,去重后最多 50 个。
*
* @return list<string>
*/
private function batchValues(mixed $raw): array
{
$raw = trim((string) $raw);
if ($raw === '') {
return [];
}
$parts = preg_split('/[\s,;,;、]+/u', $raw) ?: [];
$values = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '') {
continue;
}
// 键加 'v' 前缀:纯数字单号当数组键会被 PHP 转成 int,会丢掉原字符串。
if (! isset($values['v' . $part])) {
$values['v' . $part] = $part;
}
if (count($values) >= 50) {
break;
}
}
return array_values($values);
}
}