CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreReportRepository.php

352 lines
15 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 Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
/**
* 将经营分析副本库中的店铺资料与共享日汇总组合为店铺维度报表。
*
* 页面不扫描千万级原始账单,也不依赖开发机或镜像中不存在的 SQLite。
* 生命周期区间统一使用 [effective_at, expires_at),避免主体切换日重复统计。
*/
class StoreReportRepository
{
// 保留同步命令使用的数据集键;页面只读生命周期与日汇总。
public const BILLS_DATASET = 'store-report.platform-bills';
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
/** @deprecated Kept for existing synchronizer/test contracts. */
public const DAILY_DATASET = 'store-report.daily-sales';
private const DEFAULT_EFFECTIVE_AT = '2026-07-01';
private const DEFAULT_EXPIRES_AT = '2999-01-01';
/** 主表 rpt.fys_shopinfo 中这些平台不支持变更主体(能否变更=否),其余平台均为是。 */
public const UNCHANGEABLE_PLATFORMS = ['唯品会MP', '淘工厂'];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* 店铺维度报表的查询条件;日期统一左闭右开 [from, to)。
*
* @return array<string,string>
*/
public static function requestFilters(): array
{
$rules = [];
foreach (['opened_at', 'effective_at', 'invalid_at'] as $prefix) {
$rules[$prefix . '_from'] = ['nullable', 'date_format:Y-m-d'];
$rules[$prefix . '_to'] = ['nullable', 'date_format:Y-m-d'];
if (request()->filled($prefix . '_from')) {
$rules[$prefix . '_to'][] = 'after_or_equal:' . $prefix . '_from';
}
}
foreach (['erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'entity', 'company_type'] as $key) {
$rules[$key] = ['nullable', 'string', 'max:191'];
}
$rules['status'] = ['nullable', 'string', 'in:0,3,6'];
$rules['can_change'] = ['nullable', 'string', 'in:是,否'];
return validator(request()->only(array_keys($rules)), $rules)->validate();
}
/**
* @param array<int,array<string,mixed>> $stores
* @param array<string,string> $filters 未覆盖字段已在上游 allStores 过滤;这里过滤主体、生命周期区间及归属覆盖字段。
* @return array<int,array<string,mixed>>
*/
public function rows(array $stores, ?int $reportYear = null, array $filters = []): array
{
if ($stores === []) {
return [];
}
$dailyGeneration = $this->activeGeneration(self::DAILY_DATASET);
$lifecycleGeneration = $this->activeGeneration(self::LIFECYCLES_DATASET);
$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, $lifecycleGeneration, $dailyGeneration);
$fallbackSales = $this->fallbackSales(
array_values(array_diff($names, array_keys($lifecycles))),
$dailyGeneration,
);
$reportYear = $reportYear ?? (int) now()->format('Y');
$currentMonth = now()->startOfMonth();
$rollingMonths = collect(range(12, 1))->map(
static fn (int $monthsAgo): string => $currentMonth->copy()->subMonthsNoOverflow($monthsAgo)->format('Y-m'),
)->all();
$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' => self::DEFAULT_EFFECTIVE_AT,
'expires_at' => self::DEFAULT_EXPIRES_AT,
'lifecycle_id' => null,
]];
foreach ($periods as $period) {
$saleData = $period['lifecycle_id'] === null
? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []])
: ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]);
$monthly = [];
$monthlyIncome = [];
for ($month = 1; $month <= 12; $month++) {
$monthKey = sprintf('%d-%02d', $reportYear, $month);
$monthly[sprintf('sales_%02d', $month)] = (float) ($saleData['months'][$monthKey] ?? 0);
$monthlyIncome[sprintf('income_%02d', $month)] = $monthKey >= $currentMonth->format('Y-m')
|| ! array_key_exists($monthKey, $saleData['months'])
? null
: (float) $saleData['months'][$monthKey];
}
$rollingSales = array_sum(array_map(
static fn (string $month): float => (float) ($saleData['months'][$month] ?? 0),
$rollingMonths,
));
$effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT);
$expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT);
$row = [
...$store,
'store_id' => $storeName,
'status' => $this->status($store['store_status'] ?? null),
'can_change' => $this->canChange($store['platform'] ?? null),
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
'effective_at' => $effectiveAt,
'invalid_at' => $expiresAt === self::DEFAULT_EXPIRES_AT ? '存续中' : $expiresAt,
'period_sales' => (float) $saleData['period'],
'rolling_12m_income' => array_intersect($rollingMonths, array_keys($saleData['months'])) === []
? null
: $rollingSales,
...$monthly,
...$monthlyIncome,
];
if (! $this->matchesFilters($row, $expiresAt, $filters)) {
continue;
}
$rows[] = $row;
}
}
return $rows;
}
/** @return array<string,array<int,array<string,mixed>>> */
private function lifecycles(array $names, string $generation): array
{
$result = [];
foreach ($this->nameChunks($names) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->select(
"SELECT id, erp_store_name, company_name, effective_at, expires_at
FROM store_report_lifecycle_rows
WHERE generation = ? AND erp_store_name IN ($placeholders)
ORDER BY erp_store_name, effective_at, id",
[$generation, ...$chunk],
);
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'],
];
}
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function lifecycleSales(array $names, string $lifecycleGeneration, string $dailyGeneration): array
{
if ($names === []) {
return [];
}
$month = $this->connection->getDriverName() === 'sqlite'
? "substr(d.occurred_on, 1, 7)"
: "DATE_FORMAT(d.occurred_on, '%Y-%m')";
$lowerBound = $this->connection->getDriverName() === 'sqlite'
? 'date(d.occurred_on) >= date(COALESCE(l.effective_at, ?))'
: 'd.occurred_on >= COALESCE(l.effective_at, ?)';
$upperBound = $this->connection->getDriverName() === 'sqlite'
? 'date(d.occurred_on) < date(COALESCE(l.expires_at, ?))'
: 'd.occurred_on < COALESCE(l.expires_at, ?)';
$result = [];
foreach ($this->nameChunks($names) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->select(
"SELECT l.id AS lifecycle_id, {$month} AS bill_month,
SUM(COALESCE(d.income, 0)) AS income_total
FROM store_report_lifecycle_rows AS l
INNER JOIN store_report_daily_rows AS d
ON d.store_name = l.erp_store_name
AND d.generation = ?
AND {$lowerBound}
AND {$upperBound}
WHERE l.generation = ? AND l.erp_store_name IN ($placeholders)
GROUP BY l.id, {$month}",
[
$dailyGeneration,
self::DEFAULT_EFFECTIVE_AT,
self::DEFAULT_EXPIRES_AT,
$lifecycleGeneration,
...$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 $dailyGeneration): array
{
if ($names === []) {
return [];
}
$month = $this->connection->getDriverName() === 'sqlite'
? "substr(occurred_on, 1, 7)"
: "DATE_FORMAT(occurred_on, '%Y-%m')";
$result = [];
foreach ($this->nameChunks($names) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->select(
"SELECT store_name, {$month} AS bill_month,
SUM(COALESCE(income, 0)) AS income_total
FROM store_report_daily_rows
WHERE generation = ? AND store_name IN ($placeholders)
AND occurred_on >= ? AND occurred_on < ?
GROUP BY store_name, {$month}",
[$dailyGeneration, ...$chunk, self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT],
);
$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']] = ($result[$key]['months'][(string) $row['bill_month']] ?? 0.0) + (float) $row['income_total'];
}
}
/** @return array<int,array<string,mixed>> */
private function select(string $query, array $bindings = []): array
{
return array_map(
static fn (object $row): array => (array) $row,
$this->connection->select($query, $bindings),
);
}
/** @return list<list<string>> */
private function nameChunks(array $names): array
{
// SQLite fixtures keep the conservative variable limit. Production
// MariaDB uses larger chunks to avoid turning one report page into
// dozens of otherwise identical round trips.
return array_chunk($names, $this->connection->getDriverName() === 'sqlite' ? 100 : 500);
}
private function activeGeneration(string $dataset): string
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('store_report_import_states')) {
throw new RuntimeException('store_report_state_missing');
}
$state = $this->connection->table('store_report_import_states')
->where('dataset_key', $dataset)
->first();
$generation = trim((string) ($state->active_generation ?? ''));
if ($generation === '' || empty($state->initial_completed_at)) {
throw new RuntimeException('store_report_dataset_not_ready:' . $dataset);
}
return $generation;
}
private function date(mixed $value, string $fallback): string
{
$value = trim((string) ($value ?? ''));
return $value === '' ? $fallback : substr($value, 0, 10);
}
/**
* 生命周期字段的行级过滤:主体、能否变更、负责人、利润中心精确匹配;生效、失效时间左闭右开。
*
* 店铺负责人/部门负责人/利润中心展示店铺账号镜像(PostgreSQL)覆盖后的值,
* 与页面筛选保持一致,因此在这里按行值匹配而不是上游镜像 SQL。
*
* @param array<string,mixed> $row
* @param array<string,string> $filters
*/
private function matchesFilters(array $row, string $expiresAt, array $filters): bool
{
foreach (['entity', 'can_change', 'store_manager', 'department_manager', 'profit_center'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected !== '' && trim((string) ($row[$key] ?? '')) !== $expected) {
return false;
}
}
foreach (['effective_at' => (string) $row['effective_at'], 'invalid_at' => $expiresAt] as $key => $value) {
$from = trim((string) ($filters[$key . '_from'] ?? ''));
$to = trim((string) ($filters[$key . '_to'] ?? ''));
if ($from !== '' && strcmp($value, $from) < 0) {
return false;
}
if ($to !== '' && strcmp($value, $to) >= 0) {
return false;
}
}
return true;
}
private function status(mixed $value): ?string
{
return match ((string) $value) {
'0', '正常' => '开店中',
'3', '关闭中' => '关店中',
'6', '已关闭' => '关店',
default => $value === null || $value === '' ? null : (string) $value,
};
}
/** 能否变更:平台为唯品会MP或淘工厂时为“否”,其余(含平台为空)为“是”。 */
private function canChange(mixed $platform): string
{
return in_array(trim((string) ($platform ?? '')), self::UNCHANGEABLE_PLATFORMS, true) ? '否' : '是';
}
}