业绩明细页的「差额合计」「差额异常备注」「差额(有/无)」原先都在查询时 逐行现算同一套规则链(生成 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。
207 lines
6.6 KiB
PHP
207 lines
6.6 KiB
PHP
<?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'),
|
||
];
|
||
}
|
||
}
|