*/ private array $queries = []; public function __construct(private readonly UpstreamReadOnlyQueryFactory $factory) { } /** * @return array{ * rows:array>, * total:int, * current_page:int, * last_page:int, * per_page:int, * summary:array{expense:float,count:int} * } */ public function paginate(array $filters, int $page = 1, int $limit = 25): array { [$start, $end] = $this->dateRange($filters); $store = trim((string) ($filters['store_name'] ?? '')); $page = max(1, $page); $limit = min(max($limit, 1), 100); $cacheKey = 'promotion-marketing-expense:page:' . hash('sha256', json_encode([ $start, $end, $store, $page, $limit, ], JSON_UNESCAPED_UNICODE)); return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use ($start, $end, $store, $page, $limit): array { $histograms = []; $countByDate = []; $expenseByPlatform = []; $total = 0; $expenseTotal = 0.0; foreach ($this->sources() as $key => $source) { $histograms[$key] = $this->histogram($source, $start, $end, $store); foreach ($histograms[$key] as $date => $values) { $countByDate[$date] = ($countByDate[$date] ?? 0) + $values['count']; $expenseByPlatform[$source['platform_category']] = ($expenseByPlatform[$source['platform_category']] ?? 0.0) + $values['expense']; $total += $values['count']; $expenseTotal += $values['expense']; } } krsort($countByDate, SORT_STRING); [$dates, $skip] = $this->pageWindow($countByDate, ($page - 1) * $limit, $limit); $rows = $dates === [] ? [] : $this->rowsForDates($histograms, $dates, $start, $end, $store); usort($rows, static function (array $left, array $right): int { foreach ([ strcmp($right['report_date'], $left['report_date']), $left['source_order'] <=> $right['source_order'], strcmp($left['store_name'], $right['store_name']), strcmp($left['notes'], $right['notes']), $right['expense'] <=> $left['expense'], ] as $comparison) { if ($comparison !== 0) { return $comparison; } } return 0; }); $rows = array_slice($rows, $skip, $limit); foreach ($rows as $index => &$row) { unset($row['report_date'], $row['source'], $row['source_order']); $row['id'] = hash('sha256', $index . '|' . json_encode($row, JSON_UNESCAPED_UNICODE)); } unset($row); $platformSummary = []; foreach ($expenseByPlatform as $platform => $amount) { $platformSummary[] = ['platform' => $platform, 'amount' => round($amount, 2)]; } usort($platformSummary, static fn (array $left, array $right): int => [$right['amount'], $left['platform']] <=> [$left['amount'], $right['platform']]); return [ 'rows' => array_values($rows), 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $limit)), 'per_page' => $limit, 'summary' => [ 'expense' => round($expenseTotal, 2), 'count' => $total, ], 'platform_summary' => $platformSummary, ]; }); } /** * 平台 + 原始店铺的推广费汇总,供弹窗“平台汇总 / 店铺明细”使用。 * * 弹窗内的平台和店铺筛选在已汇总的小结果集上执行,不再重复查询上游。 * * @return array{ * store_summary:array, * platform_summary:array, * total:float * } */ public function storeSummary(array $filters): array { [$start, $end] = $this->dateRange($filters); $note = trim((string) ($filters['note'] ?? '')); $cacheKey = 'promotion-marketing-expense:stores-summary:' . hash('sha256', $start . '|' . $end . '|' . $note); return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use ($start, $end, $note): array { $merged = []; foreach ($this->sources() as $source) { [$where, $bindings] = $this->where($source, $start, $end, '', false, $note); $sql = 'SELECT ' . $source['store'] . ' AS store_name,' . ' COALESCE(SUM(' . $source['expense'] . '), 0) AS amount' . ' FROM ' . $source['table'] . ' c ' . $where . ' AND ' . $source['store'] . " IS NOT NULL AND LTRIM(RTRIM(" . $source['store'] . ")) <> N''" . ' GROUP BY ' . $source['store'] . ' HAVING ABS(SUM(' . $source['expense'] . ')) > 0.004'; foreach ($this->select($source['connection'], $sql, $bindings) as $row) { $store = $source['store_label'] ?? trim((string) ($row['store_name'] ?? '')); if ($store === '') { continue; } $key = $source['platform_category'] . '|' . $store; $merged[$key] = ($merged[$key] ?? 0.0) + (float) ($row['amount'] ?? 0); } } $stores = []; $platforms = []; $total = 0.0; foreach ($merged as $key => $amount) { [$platform, $store] = explode('|', $key, 2); $amount = round($amount, 2); $stores[] = ['platform' => $platform, 'store_name' => $store, 'amount' => $amount]; $platforms[$platform] = ($platforms[$platform] ?? 0.0) + $amount; $total += $amount; } usort($stores, static fn (array $left, array $right): int => [$right['amount'], $left['platform'], $left['store_name']] <=> [$left['amount'], $right['platform'], $right['store_name']]); $platformSummary = []; foreach ($platforms as $platform => $amount) { $platformSummary[] = ['platform' => $platform, 'amount' => round($amount, 2)]; } usort($platformSummary, static fn (array $left, array $right): int => [$right['amount'], $left['platform']] <=> [$left['amount'], $right['platform']]); return [ 'store_summary' => $stores, 'platform_summary' => $platformSummary, 'total' => round($total, 2), ]; }); } /** @return array */ public function storeOptions(array $filters = []): array { [$start, $end] = $this->dateRange($filters); $cacheKey = 'promotion-marketing-expense:stores:' . hash('sha256', $start . '|' . $end); return Cache::remember($cacheKey, 3600, function () use ($start, $end): array { $stores = []; foreach ($this->sources() as $source) { [$where, $bindings] = $this->where($source, $start, $end, ''); $sql = 'SELECT DISTINCT TOP (500) ' . $source['store'] . ' AS store_name' . ' FROM ' . $source['table'] . ' c ' . $where . ' AND ' . $source['store'] . " IS NOT NULL AND LTRIM(RTRIM(" . $source['store'] . ")) <> N''" . ' ORDER BY store_name'; foreach ($this->select($source['connection'], $sql, $bindings) as $row) { $name = $source['store_label'] ?? trim((string) ($row['store_name'] ?? '')); if ($name !== '') { $stores[$name] = true; } } } $names = array_keys($stores); sort($names, SORT_NATURAL); return array_slice($names, 0, 500); }); } private function histogram(array $source, string $start, string $end, string $store): array { [$where, $bindings] = $this->where($source, $start, $end, $store); $date = 'CONVERT(varchar(10), ' . $source['date'] . ', 23)'; $sql = 'SELECT ' . $date . ' AS report_date, COUNT(*) AS row_count,' . ' COALESCE(SUM(' . $source['expense'] . '), 0) AS expense' . ' FROM ' . $source['table'] . ' c ' . $where . ' GROUP BY ' . $date; $result = []; foreach ($this->select($source['connection'], $sql, $bindings) as $row) { $dateValue = substr(trim((string) ($row['report_date'] ?? '')), 0, 10); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateValue) !== 1) { continue; } $result[$dateValue] = [ 'count' => max(0, (int) ($row['row_count'] ?? 0)), 'expense' => round((float) ($row['expense'] ?? 0), 2), ]; } return $result; } /** * @param array> $histograms * @param array $dates */ private function rowsForDates(array $histograms, array $dates, string $start, string $end, string $store): array { $first = min($dates); $last = max($dates); $lastExclusive = CarbonImmutable::parse($last, 'Asia/Shanghai')->addDay()->toDateString(); $rows = []; foreach ($this->sources() as $key => $source) { if (! isset($histograms[$key]) || $histograms[$key] === []) { continue; } [$where, $bindings] = $this->where($source, $first, $lastExclusive, $store, true); $sql = 'SELECT ' . $source['row'] . ', ' . 'CONVERT(varchar(10), ' . $source['date'] . ', 23) AS report_date, ' . $source['store'] . ' AS store_name, ' . $source['expense'] . ' AS expense' . ' FROM ' . $source['table'] . ' c ' . $where; foreach ($this->select($source['connection'], $sql, $bindings) as $row) { $reportDate = substr(trim((string) ($row['report_date'] ?? '')), 0, 10); if (! isset($histograms[$key][$reportDate])) { continue; } $normalized = $this->normalize($key, $source, $row, $reportDate); if ($normalized !== null) { $rows[] = $normalized; } } } return $rows; } /** * @param array $countByDate * @return array{0:array,1:int} */ private function pageWindow(array $countByDate, int $offset, int $limit): array { $dates = []; $skip = 0; $cumulative = 0; $target = $offset + $limit; foreach ($countByDate as $date => $count) { $next = $cumulative + $count; if ($next <= $offset) { $cumulative = $next; continue; } if ($dates === []) { $skip = $offset - $cumulative; } $dates[] = $date; $cumulative = $next; if ($cumulative >= $target) { break; } } return [$dates, max(0, $skip)]; } private function normalize(string $key, array $source, array $row, string $reportDate): ?array { $expense = round((float) ($row['expense'] ?? 0), 2); if ($expense <= 0) { return null; } $primary = trim((string) ($row['note_primary'] ?? '')); $secondary = trim((string) ($row['note_secondary'] ?? '')); if ($key === 'pinduoduo') { $secondary = ['1' => '现金', '3' => '红包/券'][$secondary] ?? ''; } $notes = implode(' · ', array_values(array_filter( [$primary, $secondary], static fn (string $value): bool => $value !== '', ))); return [ 'source' => $key, 'source_order' => $source['order'], 'report_date' => $reportDate, 'store_name' => $source['store_label'] ?? trim((string) ($row['store_name'] ?? '')), 'occurred_at' => trim((string) ($row['occurred_at'] ?? '')), 'notes' => $notes, 'expense' => $expense, ]; } /** * @return array{0:string,1:array} */ private function where( array $source, string $start, string $end, string $store, bool $endExclusive = false, ?string $note = null, ): array { $conditions = [$source['expense_condition']]; $bindings = [$start]; $conditions[] = $source['date'] . ' >= CAST(? AS date)'; if ($endExclusive) { $conditions[] = $source['date'] . ' < CAST(? AS date)'; $bindings[] = $end; } else { $conditions[] = $source['date'] . ' < DATEADD(day, 1, CAST(? AS date))'; $bindings[] = $end; } if ($store !== '') { if (isset($source['store_label'])) { if ($store !== $source['store_label']) { $conditions[] = '1 = 0'; } } else { $conditions[] = $source['store'] . ' = ?'; $bindings[] = $store; } } if ($note !== null && $note !== '') { $conditions[] = '(' . $source['note_filter'] . ") LIKE ? ESCAPE '!'"; $bindings[] = $this->contains($note); } return [' WHERE ' . implode(' AND ', $conditions), $bindings]; } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; } /** @return array{0:string,1:string} */ private function dateRange(array $filters): array { $start = trim((string) ($filters['start'] ?? '')); $end = trim((string) ($filters['end'] ?? '')); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) !== 1) { $start = now('Asia/Shanghai')->startOfMonth()->toDateString(); } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) !== 1) { $end = now('Asia/Shanghai')->endOfMonth()->toDateString(); } if ($start > $end) { throw new RuntimeException('推广费查询的结束日期不能早于开始日期。'); } return [$start, $end]; } /** @return array> */ private function sources(): array { return [ 'taobao' => [ 'order' => 1, 'connection' => 'jiansun', 'table' => '[dbo].[tmall_spread_bills]', 'date' => 'c.[transTime]', 'store' => 'c.[shopName]', 'expense' => '(CAST(c.[amount] AS decimal(18,2)) / 100.0)', 'expense_condition' => "c.[finType] = 'CREDIT' AND ISNULL(c.[amount], 0) <> 0", 'note_filter' => 'c.[comment]', 'platform_category' => '淘系', 'row' => "CONVERT(varchar(10), c.[transTime], 23) AS occurred_at, c.[comment] AS note_primary, NULL AS note_secondary", ], 'alibaba' => [ 'order' => 2, 'connection' => 'jiansun', 'table' => '[dbo].[alibaba_bill]', 'date' => 'CAST(c.[tradeDay] AS date)', 'store' => 'c.[shopName]', 'expense' => 'CAST(c.[tradeAMTOut] AS decimal(18,2))', 'expense_condition' => 'ISNULL(c.[tradeAMTOut], 0) > 0', 'note_filter' => 'c.[tradeTypeName]', 'platform_category' => '淘系', 'row' => "CONVERT(varchar(10), CAST(c.[tradeDay] AS date), 23) AS occurred_at, c.[tradeTypeName] AS note_primary, NULL AS note_secondary", ], 'pinduoduo' => [ 'order' => 3, 'connection' => 'jiansun', 'table' => '[dbo].[pdd_spread_bills]', 'date' => 'c.[createTime]', 'store' => 'c.[shopName]', 'expense' => 'CAST(c.[amount] AS decimal(18,2))', 'expense_condition' => 'c.[flowType] = 2 AND ISNULL(c.[amount], 0) <> 0', 'note_filter' => "CONCAT(ISNULL(c.[summary], N''), N' ', CASE c.[toAdvertiserFundType] WHEN 1 THEN N'现金' WHEN 3 THEN N'红包/券' ELSE N'' END)", 'platform_category' => '拼多多', 'row' => "CONVERT(varchar(19), c.[createTime], 120) AS occurred_at, c.[summary] AS note_primary, CONVERT(varchar(10), c.[toAdvertiserFundType]) AS note_secondary", ], 'jd' => [ 'order' => 4, 'connection' => 'js_data_middle_platform', 'table' => '[JSDataMiddlePlatform].[dbo].[jd_jzt_finance_records]', 'date' => 'CAST(c.[show_date] AS date)', 'store' => 'c.[shop_name]', 'expense' => 'CAST(c.[amount] AS decimal(18,2))', 'expense_condition' => "c.[in_out_type] = 'OUT' AND ISNULL(c.[amount], 0) <> 0", 'note_filter' => "CONCAT(ISNULL(c.[remark], N''), N' ', ISNULL(c.[trade_type_name], N''))", 'platform_category' => '京东', 'row' => "CONVERT(varchar(10), CAST(c.[show_date] AS date), 23) AS occurred_at, c.[remark] AS note_primary, c.[trade_type_name] AS note_secondary", ], 'douyin' => [ 'order' => 5, 'connection' => 'js_data_middle_platform', 'table' => '[JSDataMiddlePlatform].[dbo].[dy_qianchuan_finance_transactions]', 'date' => 'c.[statDate]', 'store' => 'c.[shopName]', 'expense' => 'CAST(c.[cost] AS decimal(18,2))', 'expense_condition' => "c.[rowType] = N'daily' AND c.[status] = N'success' AND ISNULL(c.[cost], 0) <> 0", 'note_filter' => 'c.[remark]', 'platform_category' => '抖音', 'row' => "CONVERT(varchar(10), c.[statDate], 23) AS occurred_at, c.[remark] AS note_primary, NULL AS note_secondary", ], 'dewu' => [ 'order' => 6, 'connection' => 'js_data_middle_platform', 'table' => '[JSDataMiddlePlatform].[dbo].[dw_ad_finance_records]', 'date' => 'c.[trade_date]', 'store' => 'c.[shop_name]', 'expense' => 'CAST(c.[trade_amount_yuan] AS decimal(18,2))', 'expense_condition' => "c.[flow_type] = N'支出' AND ISNULL(c.[trade_amount_yuan], 0) <> 0", 'note_filter' => "CONCAT(ISNULL(c.[remark], N''), N' ', ISNULL(c.[amount_type], N''))", 'platform_category' => '得物', 'row' => "CONVERT(varchar(10), c.[trade_date], 23) AS occurred_at, c.[remark] AS note_primary, c.[amount_type] AS note_secondary", ], 'vip' => [ 'order' => 7, 'connection' => 'js_data_middle_platform', 'table' => '[JSDataMiddlePlatform].[dbo].[vip_marketingDailyReport]', 'date' => 'c.[reportDate]', 'store' => 'c.[accountName]', 'store_label' => '唯品会JITX_亿家达', 'expense' => 'CAST(c.[costYuan] AS decimal(18,2))', 'expense_condition' => 'ISNULL(c.[costYuan], 0) <> 0', 'note_filter' => "N'推广费'", 'platform_category' => '唯品会', 'row' => "CONVERT(varchar(10), c.[reportDate], 23) AS occurred_at, N'推广费' AS note_primary, NULL AS note_secondary", ], ]; } /** @return array> */ protected function select(string $connection, string $sql, array $bindings): array { $query = $this->queries[$connection] ??= $this->factory->forSourceWithTimeout($connection, 45); return $query->select($sql, $bindings); } }