CaiWuXiTong/app/Business/KukaPostingPerformance/KukaPostingPerformancePageData.php
chenxi 0c92a3c567 fix: 优化灵珊店铺业绩月份查询 (#1061)
fix: 优化灵珊店铺业绩月份查询 (#1061)
2026-11-10 19:24:07 +08:00

315 lines
11 KiB
PHP

<?php
namespace App\Business\KukaPostingPerformance;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Throwable;
class KukaPostingPerformancePageData
{
private const EXCLUDED_PLATFORMS = ['京东网银钱包'];
private const INCOME_SQL = 'COALESCE(income_amount, 0)'
. ' + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0)'
. ' + COALESCE(refund_amount, 0)'
. ' + COALESCE(default_compensation_amount, 0)';
/**
* 业绩明细的 12 家店铺月度收入汇总。
*
* 收入口径:账单收入 + 优惠金额 + 冲减金额 + 退款金额 + 违约赔付(冲减收入)。
*
* @return array<string,mixed>
*/
public function build(Request $request): array
{
$stores = array_values(array_unique(array_filter(array_map(
static fn (mixed $store): string => trim((string) $store),
(array) config('kuka_posting.stores', []),
))));
if ($stores === []) {
return $this->emptyResult($stores, '尚未配置灵珊店铺业绩的店铺范围。');
}
try {
$months = $this->availableMonths();
$month = $this->selectedMonth((string) $request->query('month', ''), $months);
if ($month === null) {
return $this->emptyResult($stores, '业绩明细暂时没有可汇总的月份。');
}
$storeRows = cache()->remember(
$this->cacheKey('stores', $month, $stores),
now()->addMinutes(10),
fn (): array => $this->storeRows($month, $stores),
);
$monthlyTotals = cache()->remember(
$this->cacheKey('months', implode('|', $months), $stores),
now()->addMinutes(10),
fn (): array => $this->monthlyTotals($months, $stores),
);
$summary = $this->summary($storeRows);
return [
'stores' => $storeRows,
'months' => $months,
'month' => $month,
'month_label' => $this->monthLabel($month),
'summary' => $summary,
'monthly_totals' => $monthlyTotals,
'error' => null,
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyResult(
$stores,
'灵珊店铺业绩暂时无法读取,请稍后重试;这不代表该月收入为 0。',
);
}
}
/**
* @return list<string>
*/
private function availableMonths(): array
{
return cache()->remember(
$this->cacheKey('available-months'),
now()->addMinutes(10),
function (): array {
$range = $this->connection()
->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::EXCLUDED_PLATFORMS)
->whereNotNull('report_month')
->selectRaw('MIN(report_month) as min_month, MAX(report_month) as max_month')
->first();
if (($range->min_month ?? null) === null || ($range->max_month ?? null) === null) {
return [];
}
$end = CarbonImmutable::parse((string) $range->max_month)->startOfMonth();
$start = CarbonImmutable::parse((string) $range->min_month)->startOfMonth();
$earliest = now()->startOfMonth()->subMonthsNoOverflow(11);
if ($start->lt($earliest)) {
$start = $earliest;
}
$months = [];
for ($cursor = $end; $cursor->gte($start); $cursor = $cursor->subMonthNoOverflow()) {
$months[] = $cursor->format('Y-m');
}
return $months;
},
);
}
/**
* @param list<string> $months
*/
private function selectedMonth(string $requested, array $months): ?string
{
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1 && in_array($requested, $months, true)) {
return $requested;
}
$previousMonth = now()->subMonthNoOverflow()->format('Y-m');
return in_array($previousMonth, $months, true) ? $previousMonth : ($months[0] ?? null);
}
/**
* @param list<string> $stores
* @return list<array<string,mixed>>
*/
private function storeRows(string $month, array $stores): array
{
$aggregates = $this->connection()
->table('store_receipt_order_match_rows')
->where('report_month', $month . '-01')
->whereIn('store_name', $stores)
->whereNotIn('platform', self::EXCLUDED_PLATFORMS)
->selectRaw(
'store_name,
COUNT(*) as row_count,
SUM(COALESCE(income_amount, 0)) as income_amount,
SUM(COALESCE(discount_amount, 0)) as discount_amount,
SUM(COALESCE(offset_amount, 0)) as offset_amount,
SUM(COALESCE(refund_amount, 0)) as refund_amount,
SUM(COALESCE(default_compensation_amount, 0)) as default_compensation_amount,
SUM(' . self::INCOME_SQL . ') as total_income,
MAX(imported_at) as imported_at'
)
->groupBy('store_name')
->get()
->keyBy('store_name');
return array_map(static function (string $store) use ($aggregates): array {
$row = $aggregates->get($store);
return [
'store_name' => $store,
'row_count' => (int) ($row->row_count ?? 0),
'income_amount' => round((float) ($row->income_amount ?? 0), 2),
'discount_amount' => round((float) ($row->discount_amount ?? 0), 2),
'offset_amount' => round((float) ($row->offset_amount ?? 0), 2),
'refund_amount' => round((float) ($row->refund_amount ?? 0), 2),
'default_compensation_amount' => round((float) ($row->default_compensation_amount ?? 0), 2),
'total_income' => round((float) ($row->total_income ?? 0), 2),
'imported_at' => $row->imported_at ?? null,
'has_data' => $row !== null,
];
}, $stores);
}
/**
* @param list<string> $months
* @param list<string> $stores
* @return list<array{month:string,label:string,total_income:float,row_count:int}>
*/
private function monthlyTotals(array $months, array $stores): array
{
$totals = [];
foreach ($months as $month) {
$row = $this->connection()
->table('store_receipt_order_match_rows')
->where('report_month', $month . '-01')
->whereIn('store_name', $stores)
->whereNotIn('platform', self::EXCLUDED_PLATFORMS)
->selectRaw(
'COUNT(*) as row_count,
SUM(' . self::INCOME_SQL . ') as total_income'
)
->first();
$totals[] = [
'month' => $month,
'label' => $this->monthLabel($month),
'total_income' => round((float) ($row->total_income ?? 0), 2),
'row_count' => (int) ($row->row_count ?? 0),
];
}
return $totals;
}
/**
* @param list<array<string,mixed>> $rows
* @return array<string,mixed>
*/
private function summary(array $rows): array
{
$summary = [
'row_count' => 0,
'stores_with_data' => 0,
'income_amount' => 0.0,
'discount_amount' => 0.0,
'offset_amount' => 0.0,
'refund_amount' => 0.0,
'default_compensation_amount' => 0.0,
'total_income' => 0.0,
'imported_at' => null,
];
foreach ($rows as $row) {
$summary['row_count'] += (int) $row['row_count'];
$summary['stores_with_data'] += $row['has_data'] ? 1 : 0;
foreach ([
'income_amount',
'discount_amount',
'offset_amount',
'refund_amount',
'default_compensation_amount',
'total_income',
] as $field) {
$summary[$field] += (float) $row[$field];
}
if ($row['imported_at'] !== null
&& ($summary['imported_at'] === null || $row['imported_at'] > $summary['imported_at'])) {
$summary['imported_at'] = $row['imported_at'];
}
}
foreach ([
'income_amount',
'discount_amount',
'offset_amount',
'refund_amount',
'default_compensation_amount',
'total_income',
] as $field) {
$summary[$field] = round((float) $summary[$field], 2);
}
return $summary;
}
/**
* @param list<string> $stores
* @return array<string,mixed>
*/
private function emptyResult(array $stores, string $error): array
{
return [
'stores' => array_map(static fn (string $store): array => [
'store_name' => $store,
'row_count' => 0,
'income_amount' => 0.0,
'discount_amount' => 0.0,
'offset_amount' => 0.0,
'refund_amount' => 0.0,
'default_compensation_amount' => 0.0,
'total_income' => 0.0,
'imported_at' => null,
'has_data' => false,
], $stores),
'months' => [],
'month' => null,
'month_label' => '—',
'summary' => [
'row_count' => 0,
'stores_with_data' => 0,
'income_amount' => 0.0,
'discount_amount' => 0.0,
'offset_amount' => 0.0,
'refund_amount' => 0.0,
'default_compensation_amount' => 0.0,
'total_income' => 0.0,
'imported_at' => null,
],
'monthly_totals' => [],
'error' => $error,
];
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
private function monthLabel(string $month): string
{
return substr($month, 0, 4) . '年' . (int) substr($month, 5, 2) . '月';
}
/**
* @param list<string> $stores
*/
private function cacheKey(string $scope, string $period = '', array $stores = []): string
{
return 'kuka-posting-performance-v1:' . $scope . ':' . md5(json_encode([
$period,
$stores,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
}