352 lines
15 KiB
PHP
352 lines
15 KiB
PHP
<?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) ? '否' : '是';
|
||
}
|
||
}
|