CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreReportRepository.php
WuYongJie d48cb7d639 feat: 店铺维度报表条件查询与主体归属字段接入
实际修改:
- 店铺维度报表新增真实数据库条件查询:店铺字段走共享镜像 SQL
  WHERE,主体/能否变更/生效/失效走生命周期行级过滤(左闭右开)
- 店铺负责人/部门负责人/利润中心改为店铺账号 PostgreSQL 镜像值,
  按店铺名称关联,未匹配时回退经营分析副本库原值
- 能否变更按主表 platform 判定(唯品会MP/淘工厂=否,其余=是),
  筛选改为 是/否 下拉;默认生效时间 2026-07-01,失效未记录显示存续中
- 主体维度经营报表接入生命周期收入窗口与注册地址解析市字段
- 同步更新视图测试并重写 StoreReportRepositoryTest 匹配 SQLite 实现

验证:容器内 tests/Feature/Business + tests/Unit/EcommerceFinance +
tests/Unit/Business 共 324 passed / 4 skipped;真实数据端到端验证
(无筛选 1935 行、负责人/利润中心/失效区间筛选抽样全对)。
无新增数据库迁移,不涉及生产数据写入。
2026-09-06 19:27:51 +08:00

333 lines
14 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\DatabaseManager;
use PDO;
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';
/** @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 ?PDO $connection = null;
public function __construct(private readonly DatabaseManager $database) {}
/**
* 店铺维度报表的查询条件;日期统一左闭右开 [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 [];
}
$this->assertDatabaseFile();
$this->connection ??= $this->readOnlyConnection();
$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))));
$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;
}
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, 100) 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_lifecycles
WHERE erp_store_name IN ($placeholders)
ORDER BY erp_store_name, effective_at, id",
$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): array
{
if ($names === [] || ! $this->hasTable('store_lifecycles') || ! $this->hasTable('bills')) {
return [];
}
$result = [];
foreach (array_chunk($names, 100) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->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, 100) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->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']] = ($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
{
$statement = $this->connection->prepare($query);
$statement->execute($bindings);
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
private function readOnlyConnection(): PDO
{
$path = realpath((string) config('database.connections.bills_sqlite.database'));
if ($path === false) {
throw new RuntimeException('店铺账单 SQLite 尚未挂载。');
}
return new PDO(
'sqlite:file:' . $path . '?mode=ro&immutable=1',
null,
null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
);
}
/** SQLite Schema Builder 在只读/WAL 挂载下可能尝试写入,因此使用普通查询。 */
private function hasTable(string $table): bool
{
// 本地账单文件由导入脚本一次性创建,避免 Laravel 在只读挂载上执行元数据写检查。
return in_array($table, ['bills', 'store_lifecycles'], true);
}
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) ? '否' : '是';
}
}