CaiWuXiTong/app/Business/StoreReceiptDifferenceMaterialization.php
j.jiang 4ea833d0d8 perf: 业绩明细差额规则落库,切月份统计从数十秒降到亚秒级
业绩明细页的「差额合计」「差额异常备注」「差额(有/无)」原先都在查询时
逐行现算同一套规则链(生成 SQL 24KB/50KB,含 36 处 ops_jst_orders 关联
子查询)。整月 32.4 万行实测:联动统计 14.5~41.6 秒,页面主体 2.7~4.6 秒。

- 新增物化列 difference_reason_index / base_difference / has_difference,
  以及 (report_month, difference_reason_index)、(report_month, has_difference)、
  (report_month, store_name, platform) 三个索引和物化状态表;
- 新增 store-receipt:materialize-differences 命令(分批写入、支持 --dry-run
  三项一致性校验,默认连带重算相邻月份以覆盖跨月备注);
- store-receipt:order-match:sync 成功后自动物化(--skip-materialize 可关闭);
- 页面聚合、联动统计、备注/差额筛选改读物化列,规则指纹不匹配或未物化时
  自动回退实时表达式,功能不受影响;
- 月份下拉缓存 6 小时、指标卡聚合 5 分钟、联动统计 1 小时,缓存键带规则
  指纹、物化状态与数据版本,物化后自动失效;
- 前端联动统计改为展开下拉时按需请求,页面加载不再同步统计整月。

规则表达式仍以 Pages 的静态方法为唯一口径,物化命令直接复用,不在 PHP 中
重写规则。本地用 7,907 行副本验证:三项一致性差异为 0,数字与实时口径完全
一致;联动统计 76ms→21ms、页面 56ms→41ms(同一数据)。详见
docs/业绩明细差额物化.md。
2026-10-26 16:36:13 +08:00

207 lines
6.6 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 App\Business;
use App\Http\Controllers\Business\Pages;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 「业绩明细」差额规则的物化状态。
*
* 备注分类、有效差额、有/无差额原先都在查询时按同一套规则链逐行现算,整月
* 30 多万行需要十几到几十秒。store-receipt:materialize-differences 把结果写进
* store_receipt_order_match_rows 的 difference_reason_index / base_difference /
* has_difference 三列,页面改读列。
*
* 规则表达式始终取自 Pages 的静态方法(唯一口径),这里只负责:
* - 用规则表达式的指纹判断物化结果是否仍与当前代码一致;
* - 记录/读取每个月是否已物化。
*/
class StoreReceiptDifferenceMaterialization
{
public const REASON_INDEX_COLUMN = 'difference_reason_index';
public const BASE_DIFFERENCE_COLUMN = 'base_difference';
public const HAS_DIFFERENCE_COLUMN = 'has_difference';
private const TABLE = 'store_receipt_order_match_rows';
private const STATE_TABLE = 'store_receipt_order_match_materializations';
/** @var array<string,bool> */
private static array $memo = [];
public static function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public static function table(): string
{
return self::TABLE;
}
public static function stateTable(): string
{
return self::STATE_TABLE;
}
/**
* 当前规则表达式的指纹。规则改动后指纹变化,旧的物化结果会自动视为过期。
*/
public static function ruleVersion(): string
{
return md5(implode('|', [
Pages::receiptDefaultDifferenceSql(),
Pages::receiptDifferenceRemarkIndexSql(),
Pages::receiptEffectiveDifferenceSql(),
]));
}
/**
* 物化列与状态表是否已就绪(迁移是否执行过)。
*
* 结果缓存 10 分钟:schema 探测在共享镜像库上每次要 0.3~0.5 秒,
* 不能放在每个页面请求的热路径上。迁移执行后由 markMaterialized() 主动失效。
*/
public static function available(bool $fresh = false): bool
{
static $memo = null;
if (! $fresh) {
if ($memo !== null) {
return $memo;
}
return $memo = (bool) Cache::remember(
self::availabilityCacheKey(),
now()->addMinutes(10),
static fn (): bool => self::detectAvailability(),
);
}
$available = self::detectAvailability();
if ($available) {
Cache::put(self::availabilityCacheKey(), true, now()->addMinutes(10));
}
return $memo = $available;
}
private static function detectAvailability(): bool
{
try {
$schema = Schema::connection(self::connection());
return $schema->hasTable(self::TABLE)
&& $schema->hasTable(self::STATE_TABLE)
&& $schema->hasColumn(self::TABLE, self::REASON_INDEX_COLUMN)
&& $schema->hasColumn(self::TABLE, self::BASE_DIFFERENCE_COLUMN)
&& $schema->hasColumn(self::TABLE, self::HAS_DIFFERENCE_COLUMN);
} catch (\Throwable) {
return false;
}
}
private static function availabilityCacheKey(): string
{
return 'store-receipt-materialization-available:v1';
}
/**
* 该账期是否已经按当前规则物化完成。未物化时页面回退到实时规则表达式。
*/
public static function materialized(string $month): bool
{
$month = substr(trim($month), 0, 10);
if ($month === '' || ! self::available()) {
return false;
}
if (array_key_exists($month, self::$memo)) {
return self::$memo[$month];
}
$cacheKey = 'store-receipt-materialized:' . $month . ':' . self::ruleVersion();
return self::$memo[$month] = (bool) Cache::remember($cacheKey, now()->addMinutes(10), function () use ($month): bool {
$row = DB::connection(self::connection())
->table(self::STATE_TABLE)
->where('report_month', $month)
->first();
return $row !== null && (string) ($row->rule_version ?? '') === self::ruleVersion();
});
}
/**
* 该账期的数据版本:物化后自增,页面聚合/联动统计的缓存键带上它即可精确失效。
*/
public static function dataVersion(string $month): string
{
return (string) Cache::remember(
self::dataVersionKey($month),
now()->addDays(30),
static fn (): int => 1,
);
}
public static function bumpDataVersion(string $month): void
{
$key = self::dataVersionKey($month);
$current = (int) Cache::get($key, 1);
Cache::put($key, $current + 1, now()->addDays(30));
}
private static function dataVersionKey(string $month): string
{
return 'store-receipt-data-version:' . substr(trim($month), 0, 10);
}
public static function markMaterialized(string $month, int $rowCount): void
{
$month = substr(trim($month), 0, 10);
$connection = self::connection();
DB::connection($connection)->table(self::STATE_TABLE)->updateOrInsert(
['report_month' => $month],
[
'rule_version' => self::ruleVersion(),
'row_count' => $rowCount,
'materialized_at' => now(),
],
);
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
Cache::forget(self::availabilityCacheKey());
self::bumpDataVersion($month);
unset(self::$memo[$month]);
}
public static function forget(string $month): void
{
$month = substr(trim($month), 0, 10);
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
unset(self::$memo[$month]);
}
/**
* 账期相邻月份(物化跨月备注规则时需要一起重算)。
*
* @return list<string>
*/
public static function adjacentMonths(string $month): array
{
$date = \Illuminate\Support\Carbon::parse(substr($month, 0, 10) . ' 00:00:00');
return [
$date->copy()->subMonthNoOverflow()->format('Y-m-01'),
$date->copy()->format('Y-m-01'),
$date->copy()->addMonthNoOverflow()->format('Y-m-01'),
];
}
}