CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreReportRepository.php

280 lines
12 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
/** 店铺维度报表只读取团队共享业务镜像。 */
class StoreReportRepository
{
public const BILLS_DATASET = 'store-report.platform-bills';
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
private const DEFAULT_EXPIRES_AT = '2999-01-01';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param array<int,array<string,mixed>> $stores
* @return array<int,array<string,mixed>>
*/
public function rows(array $stores, ?int $reportYear = null): array
{
if ($stores === []) {
return [];
}
[$billGeneration, $lifecycleGeneration] = $this->activeGenerations();
$reportYear ??= (int) now()->format('Y');
if ($reportYear < 2000 || $reportYear > 2999) {
throw new RuntimeException('店铺维度报表年份无效。');
}
$names = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['erp_store_name'] ?? '')),
$stores,
))));
$lifecycles = $this->lifecycles($names, $lifecycleGeneration);
$lifecycleSales = $this->lifecycleSales($names, $billGeneration, $lifecycleGeneration);
$fallbackSales = $this->fallbackSales(array_values(array_diff($names, array_keys($lifecycles))), $billGeneration);
$rows = [];
foreach ($stores as $store) {
$storeName = trim((string) ($store['erp_store_name'] ?? ''));
if ($storeName === '') {
continue;
}
$periods = $lifecycles[$storeName] ?? [[
'company_name' => $store['company_name'] ?? null,
'effective_at' => $store['opened_at'] ?? sprintf('%d-01-01', $reportYear),
'expires_at' => self::DEFAULT_EXPIRES_AT,
'lifecycle_id' => null,
]];
foreach ($periods as $period) {
$saleData = $period['lifecycle_id'] === null
? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []])
: $this->combinedSales($lifecycleSales, (array) ($period['lifecycle_ids'] ?? [$period['lifecycle_id']]));
$monthly = [];
for ($month = 1; $month <= 12; $month++) {
$monthly[sprintf('sales_%02d', $month)] = (float) (
$saleData['months'][sprintf('%d-%02d', $reportYear, $month)] ?? 0
);
}
$rollingMonths = collect(range(12, 1))->map(
static fn (int $monthsAgo): string => now()->startOfMonth()->subMonthsNoOverflow($monthsAgo)->format('Y-m'),
)->all();
$recentMonths = collect(range(3, 1))->map(
static fn (int $monthsAgo): string => now()->startOfMonth()->subMonthsNoOverflow($monthsAgo)->format('Y-m'),
)->values()->all();
$rows[] = [
...$store,
'store_id' => $store['store_id'] ?? $storeName,
'status' => $this->status($store['store_status'] ?? null),
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
'effective_at' => $this->date($period['effective_at'] ?? null, sprintf('%d-01-01', $reportYear)),
'invalid_at' => $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT),
'period_sales' => (float) $saleData['period'],
'store_sales_12m' => array_sum(array_map(
static fn (string $month): float => (float) ($saleData['months'][$month] ?? 0),
$rollingMonths,
)),
'recent_three_month_sales_1' => (float) ($saleData['months'][$recentMonths[0]] ?? 0),
'recent_three_month_sales_2' => (float) ($saleData['months'][$recentMonths[1]] ?? 0),
'recent_three_month_sales_3' => (float) ($saleData['months'][$recentMonths[2]] ?? 0),
...$monthly,
];
}
}
return $rows;
}
/** @return array{0:string,1:string} */
private function activeGenerations(): array
{
$schema = Schema::connection($this->connection->getName());
foreach (['store_report_import_states', 'store_report_bill_rows', 'store_report_lifecycle_rows'] as $table) {
if (! $schema->hasTable($table)) {
throw new RuntimeException('店铺维度报表共享镜像尚未完成迁移。');
}
}
$states = $this->connection->table('store_report_import_states')
->whereIn('dataset_key', [self::BILLS_DATASET, self::LIFECYCLES_DATASET])
->get(['dataset_key', 'status', 'active_generation', 'row_count'])
->keyBy('dataset_key');
foreach ([self::BILLS_DATASET, self::LIFECYCLES_DATASET] as $dataset) {
$state = $states->get($dataset);
if ($state === null || empty($state->active_generation)) {
$label = $dataset === self::BILLS_DATASET ? '账单' : '主体历史';
$rows = number_format((int) ($state->row_count ?? 0));
throw new RuntimeException("店铺维度报表中央同步中:{$label}已写入 {$rows} 行,完整数据就绪后自动显示。");
}
}
return [
(string) $states->get(self::BILLS_DATASET)->active_generation,
(string) $states->get(self::LIFECYCLES_DATASET)->active_generation,
];
}
/** @return array<string,array<int,array<string,mixed>>> */
private function lifecycles(array $names, string $generation): array
{
$result = [];
foreach (array_chunk($names, 500) as $chunk) {
$rows = $this->connection->table('store_report_lifecycle_rows')
->where('generation', $generation)
->whereIn('erp_store_name', $chunk)
->orderBy('erp_store_name')->orderBy('effective_at')->orderBy('id')
->get(['id', 'erp_store_name', 'company_name', 'effective_at', 'expires_at']);
foreach ($rows as $row) {
$result[(string) $row->erp_store_name][] = [
'lifecycle_id' => (int) $row->id,
'company_name' => $row->company_name,
'effective_at' => $row->effective_at,
'expires_at' => $row->expires_at,
];
}
}
foreach ($result as $storeName => $periods) {
$normalized = [];
foreach ($periods as $period) {
$last = array_key_last($normalized);
if ($last !== null
&& (string) ($normalized[$last]['company_name'] ?? '') === (string) ($period['company_name'] ?? '')) {
$normalized[$last]['lifecycle_ids'][] = $period['lifecycle_id'];
continue;
}
$period['lifecycle_ids'] = [$period['lifecycle_id']];
$normalized[] = $period;
}
foreach ($normalized as $index => &$period) {
$period['expires_at'] = $period['expires_at']
?: ($normalized[$index + 1]['effective_at'] ?? self::DEFAULT_EXPIRES_AT);
}
unset($period);
$result[$storeName] = $normalized;
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function lifecycleSales(array $names, string $billGeneration, string $lifecycleGeneration): array
{
$result = [];
$month = $this->monthExpression('b.occurred_at');
foreach (array_chunk($names, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->connection->select(
"WITH lifecycle_periods AS (
SELECT id, erp_store_name, effective_at,
COALESCE(expires_at,
LEAD(effective_at) OVER (PARTITION BY erp_store_name ORDER BY effective_at, id),
'2999-01-01 00:00:00') AS computed_expires_at
FROM store_report_lifecycle_rows
WHERE generation = ?
)
SELECT l.id AS lifecycle_id, {$month} AS bill_month,
SUM(COALESCE(b.income, 0)) AS income_total
FROM lifecycle_periods AS l
INNER JOIN store_report_bill_rows AS b
ON b.generation = ? AND b.store_name = l.erp_store_name AND b.occurred_at IS NOT NULL
AND b.occurred_at >= COALESCE(l.effective_at, '1000-01-01 00:00:00')
AND b.occurred_at < l.computed_expires_at
WHERE l.erp_store_name IN ($placeholders)
GROUP BY l.id, {$month}",
[$lifecycleGeneration, $billGeneration, ...$chunk],
);
$this->collectSales($result, $rows, 'lifecycle_id');
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function fallbackSales(array $names, string $generation): array
{
$result = [];
$month = $this->monthExpression('occurred_at');
foreach (array_chunk($names, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->connection->select(
"SELECT store_name, {$month} AS bill_month,
SUM(COALESCE(income, 0)) AS income_total
FROM store_report_bill_rows
WHERE generation = ? AND store_name IN ($placeholders) AND occurred_at IS NOT NULL
GROUP BY store_name, {$month}",
[$generation, ...$chunk],
);
$this->collectSales($result, $rows, 'store_name');
}
return $result;
}
/** @param array<string,array{period:float,months:array<string,float>}> $result */
private function collectSales(array &$result, array $rows, string $keyColumn): void
{
foreach ($rows as $row) {
$key = (string) $row->{$keyColumn};
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total;
$result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total;
}
}
/** @param array<string,array{period:float,months:array<string,float>}> $sales */
private function combinedSales(array $sales, array $ids): array
{
$combined = ['period' => 0.0, 'months' => []];
foreach ($ids as $id) {
$row = $sales[(string) $id] ?? ['period' => 0.0, 'months' => []];
$combined['period'] += (float) $row['period'];
foreach ($row['months'] as $month => $amount) {
$combined['months'][$month] = ($combined['months'][$month] ?? 0.0) + (float) $amount;
}
}
return $combined;
}
private function monthExpression(string $column): string
{
return $this->connection->getDriverName() === 'sqlite'
? "strftime('%Y-%m', {$column})"
: "DATE_FORMAT({$column}, '%Y-%m')";
}
private function date(mixed $value, string $fallback): string
{
$value = trim((string) ($value ?? ''));
return $value === '' ? $fallback : substr($value, 0, 10);
}
private function status(mixed $value): ?string
{
return match ((string) $value) {
'0', '正常' => '开店中',
'3', '关闭中' => '关店中',
'6', '已关闭' => '关店',
default => $value === null || $value === '' ? null : (string) $value,
};
}
}