CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreReportRepository.php

215 lines
8.3 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use RuntimeException;
/**
* 将经营分析副本库中的店铺资料与本地账单 SQLite 组合为店铺维度报表。
*
* SQLite 只读保存店铺生命周期和平台账单;生命周期区间统一使用
* [effective_at, expires_at),避免主体切换日的账单被重复统计。
*/
class StoreReportRepository
{
// 保留导入命令使用的数据集键;报表页面本身读取本地 SQLite。
public const BILLS_DATASET = 'store-report.platform-bills';
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
private const DEFAULT_EFFECTIVE_AT = '2026-05-01';
private const DEFAULT_EXPIRES_AT = '2999-01-01';
private const REPORT_YEAR = '2026';
private ?ConnectionInterface $connection = null;
public function __construct(private readonly DatabaseManager $database) {}
/**
* @param array<int,array<string,mixed>> $stores
* @return array<int,array<string,mixed>>
*/
public function rows(array $stores): array
{
if ($stores === []) {
return [];
}
$this->assertDatabaseFile();
$this->connection ??= $this->database->connection('bills_sqlite');
$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);
$lifecycleSales = $this->lifecycleSales($names);
$fallbackSales = $this->fallbackSales(array_values(array_diff($names, array_keys($lifecycles))));
$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 = [];
for ($month = 1; $month <= 12; $month++) {
$monthly[sprintf('sales_%02d', $month)] = (float) (
$saleData['months'][sprintf('%s-%02d', self::REPORT_YEAR, $month)] ?? 0
);
}
$rows[] = [
...$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, self::DEFAULT_EFFECTIVE_AT),
'invalid_at' => $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT),
'period_sales' => (float) $saleData['period'],
...$monthly,
];
}
}
return $rows;
}
private function assertDatabaseFile(): void
{
$path = (string) config('database.connections.bills_sqlite.database');
if ($path === '' || ! is_file($path)) {
throw new RuntimeException('店铺账单 SQLite 尚未挂载:' . $path);
}
}
/** @return array<string,array<int,array<string,mixed>>> */
private function lifecycles(array $names): array
{
if (! $this->hasTable('store_lifecycles')) {
return [];
}
$result = [];
foreach (array_chunk($names, 900) as $chunk) {
$rows = $this->connection->table('store_lifecycles')
->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,
];
}
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function lifecycleSales(array $names): array
{
if ($names === [] || ! $this->hasTable('store_lifecycles') || ! $this->hasTable('bills')) {
return [];
}
$result = [];
foreach (array_chunk($names, 900) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->connection->select(
"SELECT l.id AS lifecycle_id, substr(b.occurred_at, 1, 7) AS bill_month,
SUM(COALESCE(b.income, 0)) AS income_total
FROM store_lifecycles AS l
INNER JOIN bills AS b
ON b.store_name = l.erp_store_name
AND b.occurred_at IS NOT NULL AND b.occurred_at <> ''
AND b.occurred_at >= COALESCE(NULLIF(l.effective_at, ''), ?)
AND b.occurred_at < COALESCE(NULLIF(l.expires_at, ''), ?)
WHERE l.erp_store_name IN ($placeholders)
GROUP BY l.id, substr(b.occurred_at, 1, 7)",
[self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT, ...$chunk],
);
$this->collectSales($result, $rows, 'lifecycle_id');
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function fallbackSales(array $names): array
{
if ($names === [] || ! $this->hasTable('bills')) {
return [];
}
$result = [];
foreach (array_chunk($names, 900) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->connection->select(
"SELECT store_name, substr(occurred_at, 1, 7) AS bill_month,
SUM(COALESCE(income, 0)) AS income_total
FROM bills
WHERE store_name IN ($placeholders)
AND occurred_at IS NOT NULL AND occurred_at <> ''
AND occurred_at >= ? AND occurred_at < ?
GROUP BY store_name, substr(occurred_at, 1, 7)",
[...$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] = (float) $row->income_total;
}
}
/** SQLite Schema Builder 在只读/WAL 挂载下可能尝试写入,因此使用普通查询。 */
private function hasTable(string $table): bool
{
return $this->connection->select(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
[$table],
) !== [];
}
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,
};
}
}