229 lines
8.7 KiB
PHP
229 lines
8.7 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
|
|
{
|
|
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;
|
|
private readonly DatabaseManager $database;
|
|
|
|
public function __construct(DatabaseManager $database)
|
|
{
|
|
$this->database = $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);
|
|
$lifecycleNames = array_fill_keys(array_keys($lifecycles), true);
|
|
$fallbackNames = array_values(array_diff($names, array_keys($lifecycleNames)));
|
|
$fallbackSales = $this->fallbackSales($fallbackNames);
|
|
$rows = [];
|
|
|
|
foreach ($stores as $store) {
|
|
$storeName = trim((string) ($store['erp_store_name'] ?? ''));
|
|
$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) {
|
|
$effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT);
|
|
$expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT);
|
|
$saleData = $period['lifecycle_id'] === null
|
|
? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []])
|
|
: ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]);
|
|
$monthly = $saleData['months'];
|
|
$sales = [];
|
|
|
|
for ($month = 1; $month <= 12; $month++) {
|
|
$key = sprintf('%s-%02d', self::REPORT_YEAR, $month);
|
|
$sales[$month] = $monthly[$key] ?? 0.0;
|
|
}
|
|
|
|
$rows[] = [
|
|
...$store,
|
|
'store_id' => $storeName,
|
|
'status' => $this->status($store['store_status'] ?? null),
|
|
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
|
|
'effective_at' => $effectiveAt,
|
|
'invalid_at' => $expiresAt,
|
|
'period_sales' => $saleData['period'],
|
|
...array_combine(
|
|
array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
|
|
array_values($sales),
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
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],
|
|
);
|
|
|
|
foreach ($rows as $row) {
|
|
$key = (string) $row->lifecycle_id;
|
|
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total;
|
|
$result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total;
|
|
}
|
|
}
|
|
|
|
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],
|
|
);
|
|
foreach ($rows as $row) {
|
|
$key = (string) $row->store_name;
|
|
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total;
|
|
$result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 判断 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,
|
|
};
|
|
}
|
|
}
|