CaiWuXiTong/modules/EcommerceFinance/Repositories/PromotionMarketingExpenseRepository.php
qiuxiang 3be1639fb2 feat: 推广费营销账户明细汇总
新增推广费(营销账户)页签、平台与原始店铺汇总、备注筛选和明细弹窗。
2026-10-27 19:27:41 +08:00

492 lines
21 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use RuntimeException;
/**
* 七个营销账户平台的统一推广费支出明细。
*
* 所有平台先按交易日期做 COUNT/SUM 直方图确定全局分页窗口,再只读取当前页
* 涉及的日期。这样不需要把单月十几万条拼多多流水一次性拉进网页进程。
*/
class PromotionMarketingExpenseRepository
{
private const CACHE_SECONDS = 120;
/** @var array<string,UpstreamReadOnlyQuery> */
private array $queries = [];
public function __construct(private readonly UpstreamReadOnlyQueryFactory $factory)
{
}
/**
* @return array{
* rows:array<int,array<string,mixed>>,
* 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<int,array{platform:string,store_name:string,amount:float}>,
* platform_summary:array<int,array{platform:string,amount:float}>,
* 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<int,string> */
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<string,array<string,array{count:int,expense:float}>> $histograms
* @param array<int,string> $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<string,int> $countByDate
* @return array{0:array<int,string>,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<int,mixed>}
*/
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<string,array<string,mixed>> */
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<int,array<string,mixed>> */
protected function select(string $connection, string $sql, array $bindings): array
{
$query = $this->queries[$connection] ??= $this->factory->forSourceWithTimeout($connection, 45);
return $query->select($sql, $bindings);
}
}