业绩明细页的「差额合计」「差额异常备注」「差额(有/无)」原先都在查询时 逐行现算同一套规则链(生成 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。
212 lines
7.8 KiB
PHP
212 lines
7.8 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Business\StoreReceiptDifferenceMaterialization as Materialization;
|
||
use App\Http\Controllers\Business\Pages;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class MaterializeStoreReceiptDifferences extends Command
|
||
{
|
||
protected $signature = 'store-receipt:materialize-differences
|
||
{month? : 账期 YYYY-MM;留空则物化镜像里已有数据的全部月份}
|
||
{--chunk=5000 : 每批更新的行数}
|
||
{--dry-run : 只统计与比对,不写库}';
|
||
|
||
protected $description = '把业绩明细的差额备注分类与有效差额物化到镜像表,页面改为读列';
|
||
|
||
public function handle(): int
|
||
{
|
||
if (! Materialization::available(true)) {
|
||
$this->components->error('物化列或状态表不存在,请先执行数据库迁移。');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$months = $this->targetMonths();
|
||
if ($months === []) {
|
||
$this->components->warn('镜像里没有可物化的账期。');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
$chunk = max(200, (int) $this->option('chunk'));
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
|
||
foreach ($months as $month) {
|
||
$this->line('');
|
||
$this->components->info(($dryRun ? '[dry-run] ' : '') . '账期 ' . substr($month, 0, 7));
|
||
|
||
if ($dryRun) {
|
||
$this->reportDryRun($month);
|
||
continue;
|
||
}
|
||
|
||
$started = microtime(true);
|
||
$updated = $this->materializeMonth($month, $chunk);
|
||
if ($updated === null) {
|
||
continue;
|
||
}
|
||
|
||
Materialization::markMaterialized($month, $updated);
|
||
$this->line(sprintf(' %s 行,用时 %.1f 秒', number_format($updated), microtime(true) - $started));
|
||
}
|
||
|
||
if ($dryRun) {
|
||
$this->line('');
|
||
$this->components->info('dry-run 完成,未写入任何数据。');
|
||
}
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function targetMonths(): array
|
||
{
|
||
$month = trim((string) $this->argument('month'));
|
||
if ($month !== '') {
|
||
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||
$month .= '-01';
|
||
}
|
||
|
||
// 跨月备注规则依赖相邻月份,指定月份时一起重算前后两个月。
|
||
return array_values(array_unique(Materialization::adjacentMonths($month)));
|
||
}
|
||
|
||
return DB::connection(Materialization::connection())
|
||
->table(Materialization::table())
|
||
->select('report_month')
|
||
->distinct()
|
||
->orderBy('report_month')
|
||
->pluck('report_month')
|
||
->map(static fn ($value): string => substr((string) $value, 0, 10))
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* 分批把规则结果写进物化列;返回更新行数。
|
||
*/
|
||
private function materializeMonth(string $month, int $chunk): ?int
|
||
{
|
||
$connection = Materialization::connection();
|
||
$table = Materialization::table();
|
||
|
||
$exists = DB::connection($connection)->table($table)->where('report_month', $month)->exists();
|
||
if (! $exists) {
|
||
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
|
||
|
||
return null;
|
||
}
|
||
|
||
$baseExpression = Pages::receiptDefaultDifferenceSql();
|
||
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
|
||
|
||
$updated = 0;
|
||
$lastKey = '';
|
||
$batch = 0;
|
||
|
||
while (true) {
|
||
$keys = DB::connection($connection)->table($table)
|
||
->where('report_month', $month)
|
||
->where('row_key', '>', $lastKey)
|
||
->orderBy('row_key')
|
||
->limit($chunk)
|
||
->pluck('row_key')
|
||
->all();
|
||
|
||
if ($keys === []) {
|
||
break;
|
||
}
|
||
|
||
$lastKey = (string) end($keys);
|
||
$batch++;
|
||
|
||
// 第一遍:规则表达式只算一次,结果落列。
|
||
DB::connection($connection)->table($table)
|
||
->where('report_month', $month)
|
||
->whereIn('row_key', $keys)
|
||
->update([
|
||
Materialization::BASE_DIFFERENCE_COLUMN => DB::raw($baseExpression),
|
||
Materialization::REASON_INDEX_COLUMN => DB::raw($indexExpression),
|
||
]);
|
||
|
||
// 第二遍:直接用刚写入的列推导「有/无差额」,避免重复执行规则链,
|
||
// 也避免依赖 SET 子句从左到右求值的库间差异。
|
||
DB::connection($connection)->table($table)
|
||
->where('report_month', $month)
|
||
->whereIn('row_key', $keys)
|
||
->update([
|
||
Materialization::HAS_DIFFERENCE_COLUMN => DB::raw($this->hasDifferenceExpression()),
|
||
]);
|
||
|
||
$updated += count($keys);
|
||
$this->output->write('.');
|
||
}
|
||
|
||
if ($updated === 0) {
|
||
$this->line('');
|
||
} else {
|
||
$this->line(' (' . $batch . ' 批)');
|
||
}
|
||
|
||
return $updated;
|
||
}
|
||
|
||
/**
|
||
* 基于物化列的「有/无差额」推导:口径兜底行按 0 计。
|
||
*/
|
||
private function hasDifferenceExpression(): string
|
||
{
|
||
$basisMarks = implode(', ', array_map(
|
||
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
|
||
Pages::RECEIPT_BASIS_FALLBACK_REMARKS,
|
||
));
|
||
|
||
return 'CASE WHEN ABS(CASE WHEN remark IN (' . $basisMarks . ')'
|
||
. ' AND COALESCE(' . Materialization::REASON_INDEX_COLUMN . ', 0) = 0 THEN 0'
|
||
. ' ELSE COALESCE(' . Materialization::BASE_DIFFERENCE_COLUMN . ', 0) END) > 0.005 THEN 1 ELSE 0 END';
|
||
}
|
||
|
||
/**
|
||
* dry-run:对比「库里的物化值」与「按当前规则重算」的差异,不写库。
|
||
*/
|
||
private function reportDryRun(string $month): void
|
||
{
|
||
$connection = Materialization::connection();
|
||
$table = Materialization::table();
|
||
|
||
$total = (int) DB::connection($connection)->table($table)->where('report_month', $month)->count();
|
||
if ($total === 0) {
|
||
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
|
||
|
||
return;
|
||
}
|
||
|
||
$baseExpression = Pages::receiptDefaultDifferenceSql();
|
||
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
|
||
|
||
$row = DB::connection($connection)->table($table)
|
||
->where('report_month', $month)
|
||
->selectRaw('COUNT(*) AS total')
|
||
->selectRaw('SUM(CASE WHEN ' . Materialization::REASON_INDEX_COLUMN
|
||
. ' IS NULL OR ' . Materialization::REASON_INDEX_COLUMN . ' <> (' . $indexExpression . ') THEN 1 ELSE 0 END) AS reason_mismatch')
|
||
->selectRaw('SUM(CASE WHEN ' . Materialization::BASE_DIFFERENCE_COLUMN
|
||
. ' IS NULL OR ABS(' . Materialization::BASE_DIFFERENCE_COLUMN . ' - (' . $baseExpression . ')) > 0.005 THEN 1 ELSE 0 END) AS base_mismatch')
|
||
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NULL THEN 1 ELSE 0 END) AS unreasoned')
|
||
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NOT NULL AND '
|
||
. Materialization::HAS_DIFFERENCE_COLUMN . ' <> (' . $this->hasDifferenceExpression() . ') THEN 1 ELSE 0 END) AS state_mismatch')
|
||
->first();
|
||
|
||
$this->line(sprintf(' 行数 %s|备注序号不一致 %s|差额不一致 %s|有/无不一致 %s|未物化 %s',
|
||
number_format((int) ($row->total ?? 0)),
|
||
number_format((int) ($row->reason_mismatch ?? 0)),
|
||
number_format((int) ($row->base_mismatch ?? 0)),
|
||
number_format((int) ($row->state_mismatch ?? 0)),
|
||
number_format((int) ($row->unreasoned ?? 0)),
|
||
));
|
||
}
|
||
}
|