CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreReportRepository.php
WuYongJie 561c42d830 feat: 店铺主体期间与爬虫数据逻辑
- 店铺维度报表按期初数据+系统主体+爬虫新主体生成主体期间,一行一期间,修复流程历史重复行
- 变更日当天归新主体、老主体失效显示变更日前一天;关店和其他非正常状态两列显示 -
- 新增店铺维度报表逻辑配置页(R1-R8)、主体商标数据逻辑配置页和预警页面
- 爬虫店铺主体按税号归一,首次抓到的新主体写入期间变更表并自动生成新期间
- 店铺基础信息、商标展示按爬虫优先;新增爬虫数据质量检查与预警处理
2026-10-03 13:28:08 +08:00

417 lines
18 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 Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
use RuntimeException;
/**
* 将店铺主体期间与共享日汇总组合为店铺维度报表。
*
* 页面不扫描千万级原始账单,也不依赖开发机或镜像中不存在的 SQLite。
* 期间由 StoreReportPeriodBuilder 按期初数据和爬虫变更生成:变更店铺的老主体
* 失效时间为变更日前一天、新主体从变更日当天生效,收入按实际日期归集,不重复统计。
*/
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;
private readonly StoreReportPeriodBuilder $periods;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
$this->periods = new StoreReportPeriodBuilder($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'];
}
foreach (['period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12))] as $key) {
$rules[$key] = ['nullable', 'string', 'max:64'];
}
$rules['status'] = ['nullable', 'string', 'in:0,3,6'];
$rules['can_change'] = ['nullable', 'string', 'in:是,否'];
$sortFields = [
'erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager',
'opened_at', 'status', 'can_change', 'entity', 'company_type', 'effective_at',
'invalid_at', 'period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
];
$rules['sort'] = ['nullable', 'string', 'in:' . implode(',', $sortFields)];
$rules['direction'] = ['nullable', 'string', 'in:asc,desc'];
$filters = validator(request()->only(array_keys($rules)), $rules)->validate();
if (! empty($filters['sort'])) {
$filters['_sort'] = $filters['sort'];
$filters['_direction'] = ($filters['direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
}
unset($filters['sort'], $filters['direction']);
return $filters;
}
/**
* @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);
// 期间由期初数据、系统主体和爬虫变更生成,同一店铺不再展开流程历史重复行。
$periods = $this->periods->build($stores);
$multiPeriod = [];
$singleNames = [];
foreach ($periods as $name => $list) {
if (count($list) > 1) {
$multiPeriod[$name] = $list;
} else {
$singleNames[] = $name;
}
}
$singleSales = $this->singlePeriodSales($singleNames, $dailyGeneration);
$splitSales = $this->periodSales($multiPeriod, $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;
}
$list = $periods[$storeName] ?? [[
'company_name' => $store['company_name'] ?? null,
'effective_at' => self::DEFAULT_EFFECTIVE_AT,
'expires_at' => null,
'source' => 'fallback',
]];
$split = count($list) > 1;
foreach ($list as $index => $period) {
$saleData = $split
? ($splitSales[$storeName][$index] ?? ['period' => 0.0, 'months' => []])
: ($singleSales[$storeName] ?? ['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);
$status = $this->status($store['store_status'] ?? null);
// 关店和其他非正常状态不展示期间日期,避免把已关闭店铺计入在营期间。
$normal = $status === '开店中';
$row = [
...$store,
'store_id' => $storeName,
'status' => $status,
'can_change' => $this->canChange($store['platform'] ?? null),
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
'effective_at' => $normal ? $effectiveAt : '-',
'invalid_at' => $normal
? ($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, $effectiveAt, $expiresAt, $filters)) {
continue;
}
$rows[] = $row;
}
}
return $this->sortRows($rows, $filters);
}
/** @param array<int,array<string,mixed>> $rows @param array<string,string> $filters */
private function sortRows(array $rows, array $filters): array
{
$key = (string) ($filters['_sort'] ?? '');
if ($key === '') {
return $rows;
}
$allowed = [
'erp_store_name' => 'erp_store_name', 'store_id' => 'store_id',
'profit_center' => 'profit_center', 'store_manager' => 'store_manager',
'department_manager' => 'department_manager', 'opened_at' => 'opened_at',
'status' => 'status', 'can_change' => 'can_change', 'entity' => 'entity',
'company_type' => 'company_type', 'effective_at' => 'effective_at',
'invalid_at' => 'invalid_at', 'period_sales' => 'period_sales',
...array_combine(
array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
),
];
$column = $allowed[$key] ?? null;
if ($column === null) {
return $rows;
}
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
usort($rows, static function (array $left, array $right) use ($column, $direction): int {
$leftValue = $left[$column] ?? null;
$rightValue = $right[$column] ?? null;
if (is_numeric($leftValue) && is_numeric($rightValue)) {
return ((float) $leftValue <=> (float) $rightValue) * $direction;
}
return strnatcasecmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction;
});
return $rows;
}
/**
* 多期间店铺按每个期间的主体生效日、失效日(含当天)拆分日汇总收入。
*
* @param array<string,list<array<string,mixed>>> $periodsByStore
* @return array<string,array<int,array{period:float,months:array<string,float>}>>
*/
private function periodSales(array $periodsByStore, string $dailyGeneration): array
{
$flat = [];
foreach ($periodsByStore as $name => $list) {
foreach ($list as $index => $period) {
$flat[] = [
'key' => $name . "\x1f" . $index,
'store' => $name,
'effective_at' => $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT),
'expires_at' => $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT),
];
}
}
$month = $this->connection->getDriverName() === 'sqlite'
? "substr(d.occurred_on, 1, 7)"
: "DATE_FORMAT(d.occurred_on, '%Y-%m')";
$collected = [];
foreach (array_chunk($flat, 200) as $chunk) {
$selects = [];
$bindings = [];
foreach ($chunk as $row) {
$selects[] = 'SELECT ? AS period_key, ? AS store_name, ? AS effective_at, ? AS expires_at';
array_push($bindings, $row['key'], $row['store'], $row['effective_at'], $row['expires_at']);
}
$rows = $this->select(
"SELECT p.period_key, {$month} AS bill_month,
SUM(COALESCE(d.income, 0)) AS income_total
FROM (" . implode(' UNION ALL ', $selects) . ") AS p
INNER JOIN store_report_daily_rows AS d
ON d.store_name = p.store_name
AND d.generation = ?
AND d.occurred_on >= p.effective_at
AND d.occurred_on <= p.expires_at
GROUP BY p.period_key, {$month}",
[...$bindings, $dailyGeneration],
);
$this->collectSales($collected, $rows, 'period_key');
}
$result = [];
foreach ($collected as $key => $sales) {
[$store, $index] = explode("\x1f", (string) $key, 2) + [1 => '0'];
$result[$store][(int) $index] = $sales;
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function singlePeriodSales(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 string $effectiveAt 期间真实生效日,不受状态展示影响
* @param string $expiresAt 期间真实失效日,存续中按 2999-01-01 参与过滤
* @param array<string,string> $filters
*/
private function matchesFilters(array $row, string $effectiveAt, string $expiresAt, array $filters): bool
{
foreach (['entity', 'store_manager', 'department_manager', 'profit_center'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected !== '' && ! str_contains(mb_strtolower(trim((string) ($row[$key] ?? ''))), mb_strtolower($expected))) {
return false;
}
}
$canChange = trim((string) ($filters['can_change'] ?? ''));
if ($canChange !== '' && trim((string) ($row['can_change'] ?? '')) !== $canChange) {
return false;
}
foreach (['effective_at' => $effectiveAt, '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;
}
}
foreach (['period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12))] as $key) {
$needle = trim((string) ($filters[$key] ?? ''));
if ($needle !== '' && ! str_contains((string) ($row[$key] ?? ''), $needle)) {
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) ? '否' : '是';
}
}