合并线上绩效物化分支并保留商品成本仓库修复 (#647)
This commit is contained in:
parent
ca673ee239
commit
d3a77ba941
217
app/Business/StoreReceiptDifferenceMaterialization.php
Normal file
217
app/Business/StoreReceiptDifferenceMaterialization.php
Normal file
@ -0,0 +1,217 @@
|
||||
<?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();
|
||||
|
||||
if ($row === null || (string) ($row->rule_version ?? '') !== self::ruleVersion()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 过渡期保护:尚未部署新代码的同步任务重刷账期时不会写物化列,
|
||||
// 一旦出现 NULL 就回退实时口径,避免页面把未物化的行当成 0 差额。
|
||||
return ! DB::connection(self::connection())
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->whereNull(self::HAS_DIFFERENCE_COLUMN)
|
||||
->limit(1)
|
||||
->exists();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 该账期的数据版本:物化后自增,页面聚合/联动统计的缓存键带上它即可精确失效。
|
||||
*/
|
||||
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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
211
app/Console/Commands/MaterializeStoreReceiptDifferences.php
Normal file
211
app/Console/Commands/MaterializeStoreReceiptDifferences.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?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)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
{--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}
|
||||
{--amounts-only : 只回填「优惠金额 / 冲减金额 / 种菜金额 / 标签」,不重跑订单侧匹配}
|
||||
{--planting-only : 只回填「种菜金额 / 标签」(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}
|
||||
{--skip-materialize : 只同步,不重算差额物化列}
|
||||
{--dry-run : 只计算并打印统计,不写库(用于评估口径调整的影响)}';
|
||||
|
||||
protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像';
|
||||
@ -215,6 +216,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
trim((string) $this->argument('month')),
|
||||
(string) $this->option('platform'),
|
||||
);
|
||||
// 跨月标记更新后再物化差额,保证备注分类与最新标记一致。
|
||||
$this->materializeDifferences(trim((string) $this->argument('month')));
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
@ -225,6 +228,25 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步完成后重算本账期与相邻账期的差额物化列。
|
||||
*
|
||||
* 页面「差额异常备注」「差额(有/无)」「差额合计」改为读物化列,不再在查询时
|
||||
* 逐行执行规则链;规则本身仍只有 Pages 里的那一份表达式。
|
||||
*/
|
||||
private function materializeDifferences(string $month): void
|
||||
{
|
||||
if ((bool) $this->option('skip-materialize')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! \App\Business\StoreReceiptDifferenceMaterialization::available(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->call('store-receipt:materialize-differences', ['month' => $month]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一平台、店铺、交易单号在相邻月份的收入/优惠/冲减/违约赔付合并后,
|
||||
* 与订单实付(各月重复为同一订单级金额,取最大值)在 2 分钱内对平,
|
||||
|
||||
@ -14,6 +14,7 @@ use App\Business\TableRegistry;
|
||||
use App\Business\UpstreamBusinessInformation;
|
||||
use App\Business\StoreReceivingAccountTemplate;
|
||||
use App\Business\StoreReceivingAccountWorkbookImporter;
|
||||
use App\Business\StoreReceiptDifferenceMaterialization as DifferenceMaterialization;
|
||||
use App\Business\StoreViolationWorkbookImporter;
|
||||
use App\Business\StoreViolationAlertCalculator;
|
||||
use App\Business\WithdrawalManagementReport;
|
||||
@ -159,7 +160,7 @@ class Pages extends Controller
|
||||
];
|
||||
|
||||
/** 差额按实收/应付口径兜底时使用的备注;这部分行才需要判断复杂差额原因。 */
|
||||
private const RECEIPT_BASIS_FALLBACK_REMARKS = [
|
||||
public const RECEIPT_BASIS_FALLBACK_REMARKS = [
|
||||
'订单实付按卖家实收核对',
|
||||
'订单实付按应付/已付核对',
|
||||
];
|
||||
@ -543,7 +544,7 @@ class Pages extends Controller
|
||||
* 单次 CASE 命中后即停止,筛选统计与备注筛选共用同一口径。相比把
|
||||
* 18 条条件逐条带上前序 NOT 再分别 SUM,避免重复计算退款等关联子查询。
|
||||
*/
|
||||
private static function receiptDifferenceRemarkIndexSql(): string
|
||||
public static function receiptDifferenceRemarkIndexSql(): string
|
||||
{
|
||||
$effective = 'COALESCE(difference, ' . self::receiptNetSql() . ')';
|
||||
$sql = 'CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 0';
|
||||
@ -642,7 +643,7 @@ class Pages extends Controller
|
||||
* 页面口径的「有效差额」SQL:口径兜底行(备注为空、但能对上实收/应付)按 0 计。
|
||||
* 合计、差额筛选、联动计数共用,保证三个地方的数字一致。
|
||||
*/
|
||||
private static function receiptEffectiveDifferenceSql(): string
|
||||
public static function receiptEffectiveDifferenceSql(): string
|
||||
{
|
||||
// 备注序号 > 0 表示命中了明确原因;只有口径兜底且没有明确原因时才归零。
|
||||
$indexSql = self::receiptDifferenceRemarkIndexSql();
|
||||
@ -652,7 +653,7 @@ class Pages extends Controller
|
||||
}
|
||||
|
||||
/** 未命中复杂原因时,差额的默认口径。 */
|
||||
private static function receiptDefaultDifferenceSql(): string
|
||||
public static function receiptDefaultDifferenceSql(): string
|
||||
{
|
||||
return 'COALESCE(difference, ' . self::receiptNetSql() . ')';
|
||||
}
|
||||
@ -1504,7 +1505,7 @@ class Pages extends Controller
|
||||
$differenceState = $this->receiptOrderMatchFilterValues(request('difference_state'));
|
||||
$orderNo = trim((string) request('online_order_no', ''));
|
||||
// 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static
|
||||
$applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $crossMonthOnly, $unmatchedReasonFilter, $differenceState): void {
|
||||
$applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $crossMonthOnly, $unmatchedReasonFilter, $differenceState, $month): void {
|
||||
if ($platformFilter !== []) {
|
||||
$query->whereIn('platform', $platformFilter);
|
||||
}
|
||||
@ -1519,13 +1520,13 @@ class Pages extends Controller
|
||||
$query->whereIn('match_status', $statusFilter);
|
||||
}
|
||||
if ($remarkFilter !== []) {
|
||||
$this->applyReceiptRemarkFilter($query, $remarkFilter);
|
||||
$this->applyReceiptRemarkFilter($query, $remarkFilter, $month);
|
||||
}
|
||||
if ($unmatchedReasonFilter !== []) {
|
||||
$this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter);
|
||||
}
|
||||
if ($differenceState !== []) {
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState, $crossMonthOnly);
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState, $crossMonthOnly, $month);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1535,40 +1536,68 @@ class Pages extends Controller
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
|
||||
// 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。
|
||||
$aggQuery = $base();
|
||||
$applyFilters($aggQuery);
|
||||
// 口径兜底行(备注为空、口径对上)的差额按 0 计——与页面行展示、差额筛选同一套判定。
|
||||
$differenceBaseExpr = self::receiptDefaultDifferenceSql();
|
||||
$fallbackSql = self::receiptBasisFallbackSqlList();
|
||||
$agg = $aggQuery->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(payment_count) as payments,
|
||||
SUM(income_amount) as income_total,
|
||||
SUM(discount_amount) as discount_total,
|
||||
SUM(offset_amount) as offset_total,
|
||||
SUM(default_compensation_amount) as default_compensation_total,
|
||||
SUM(refund_amount) as refund_total,
|
||||
SUM(planting_amount) as planting_total,
|
||||
SUM(" . self::receiptComparablePaidSql() . ") as paid_total,
|
||||
SUM({$differenceBaseExpr}) as difference_base,
|
||||
SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows,
|
||||
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
|
||||
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
|
||||
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
|
||||
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
|
||||
")->first();
|
||||
// 计数与合计合并为一次扫描;已物化的账期直接读差额列,不再逐行执行规则链。
|
||||
// 数据按天同步,同一组筛选 5 分钟内复用结果,避免来回切换筛选项重复扫整月。
|
||||
$differenceMaterialized = DifferenceMaterialization::materialized($month);
|
||||
$statsCacheKey = 'performance-details-aggregate-v2:' . md5(serialize([
|
||||
$month,
|
||||
[$platformFilter, $storeFilter, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState, $orderNo],
|
||||
$crossMonthOnly,
|
||||
$differenceMaterialized ? 'materialized' : 'live',
|
||||
DifferenceMaterialization::dataVersion($month),
|
||||
]));
|
||||
|
||||
[$agg, $differenceTotal] = cache()->remember($statsCacheKey, now()->addMinutes(5), function () use (
|
||||
$base,
|
||||
$applyFilters,
|
||||
$crossMonthOnly,
|
||||
$differenceMaterialized
|
||||
): array {
|
||||
$aggQuery = $base();
|
||||
$applyFilters($aggQuery);
|
||||
// 口径兜底行(备注为空、口径对上)的差额按 0 计——与页面行展示、差额筛选同一套判定。
|
||||
$differenceBaseExpr = $differenceMaterialized
|
||||
? 'COALESCE(' . DifferenceMaterialization::BASE_DIFFERENCE_COLUMN . ', 0)'
|
||||
: self::receiptDefaultDifferenceSql();
|
||||
$fallbackSql = self::receiptBasisFallbackSqlList();
|
||||
$agg = $aggQuery->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(payment_count) as payments,
|
||||
SUM(income_amount) as income_total,
|
||||
SUM(discount_amount) as discount_total,
|
||||
SUM(offset_amount) as offset_total,
|
||||
SUM(default_compensation_amount) as default_compensation_total,
|
||||
SUM(refund_amount) as refund_total,
|
||||
SUM(planting_amount) as planting_total,
|
||||
SUM(" . self::receiptComparablePaidSql() . ") as paid_total,
|
||||
SUM({$differenceBaseExpr}) as difference_base,
|
||||
SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows,
|
||||
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
|
||||
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
|
||||
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
|
||||
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
|
||||
")->first();
|
||||
|
||||
$differenceTotal = (float) ($agg->difference_base ?? 0);
|
||||
if (! $crossMonthOnly && (int) ($agg->basis_fallback_rows ?? 0) > 0) {
|
||||
$adjustmentQuery = $base();
|
||||
$applyFilters($adjustmentQuery);
|
||||
$adjustmentExpression = $differenceMaterialized
|
||||
? 'CASE WHEN COALESCE(' . DifferenceMaterialization::REASON_INDEX_COLUMN . ', 0) = 0 THEN -COALESCE('
|
||||
. DifferenceMaterialization::BASE_DIFFERENCE_COLUMN . ', 0) ELSE 0 END'
|
||||
: self::receiptFallbackDifferenceAdjustmentSql();
|
||||
$adjustment = $adjustmentQuery
|
||||
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
|
||||
->selectRaw('SUM(' . $adjustmentExpression . ') as adjustment')
|
||||
->first();
|
||||
$differenceTotal += (float) ($adjustment->adjustment ?? 0);
|
||||
}
|
||||
|
||||
return [(array) $agg, $differenceTotal];
|
||||
});
|
||||
|
||||
$agg = (object) $agg;
|
||||
$total = (int) ($agg->total ?? 0);
|
||||
$differenceTotal = (float) ($agg->difference_base ?? 0);
|
||||
if (! $crossMonthOnly && (int) ($agg->basis_fallback_rows ?? 0) > 0) {
|
||||
$adjustmentQuery = $base();
|
||||
$applyFilters($adjustmentQuery);
|
||||
$adjustment = $adjustmentQuery
|
||||
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
|
||||
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
|
||||
->first();
|
||||
$differenceTotal += (float) ($adjustment->adjustment ?? 0);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'month' => $month,
|
||||
@ -1888,13 +1917,13 @@ class Pages extends Controller
|
||||
$query->whereIn('match_status', $statusFilter);
|
||||
}
|
||||
if ($remarkFilter !== []) {
|
||||
$this->applyReceiptRemarkFilter($query, $remarkFilter);
|
||||
$this->applyReceiptRemarkFilter($query, $remarkFilter, $month);
|
||||
}
|
||||
if ($unmatchedReasonFilter !== []) {
|
||||
$this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter);
|
||||
}
|
||||
if ($differenceState !== []) {
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState, $crossMonthOnly);
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState, $crossMonthOnly, $month);
|
||||
}
|
||||
|
||||
return $query;
|
||||
@ -1937,7 +1966,7 @@ class Pages extends Controller
|
||||
* @param \Illuminate\Database\Query\Builder $query
|
||||
* @param list<string> $remarkFilter
|
||||
*/
|
||||
private function applyReceiptRemarkFilter($query, array $remarkFilter): void
|
||||
private function applyReceiptRemarkFilter($query, array $remarkFilter, ?string $month = null): void
|
||||
{
|
||||
if ($remarkFilter === []) {
|
||||
return;
|
||||
@ -1965,12 +1994,16 @@ class Pages extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($group) use ($crossMonthSelected, $indexes): void {
|
||||
$query->where(function ($group) use ($crossMonthSelected, $indexes, $month): void {
|
||||
if ($crossMonthSelected) {
|
||||
$group->where('cross_month_balanced', 1);
|
||||
}
|
||||
if ($indexes !== []) {
|
||||
$condition = self::receiptDifferenceRemarkIndexSql()
|
||||
// 已物化的账期直接读规则序号列,避免每次筛选都重算整串规则链。
|
||||
$indexExpression = ($month !== null && DifferenceMaterialization::materialized($month))
|
||||
? 'COALESCE(' . DifferenceMaterialization::REASON_INDEX_COLUMN . ', 0)'
|
||||
: self::receiptDifferenceRemarkIndexSql();
|
||||
$condition = $indexExpression
|
||||
. ' IN (' . implode(', ', array_unique($indexes)) . ')';
|
||||
$crossMonthSelected ? $group->orWhereRaw($condition) : $group->whereRaw($condition);
|
||||
}
|
||||
@ -2017,7 +2050,7 @@ class Pages extends Controller
|
||||
* @param \Illuminate\Database\Query\Builder $query
|
||||
* @param string|list<string> $state
|
||||
*/
|
||||
private function applyReceiptDifferenceStateFilter($query, $state, bool $crossMonthOnly = false): void
|
||||
private function applyReceiptDifferenceStateFilter($query, $state, bool $crossMonthOnly = false, ?string $month = null): void
|
||||
{
|
||||
$states = is_array($state) ? $state : [$state];
|
||||
$has = in_array('有差额', $states, true);
|
||||
@ -2025,6 +2058,12 @@ class Pages extends Controller
|
||||
if ($has === $none) {
|
||||
return;
|
||||
}
|
||||
// 已物化的账期直接命中 has_difference 索引,不再对整月逐行算规则链。
|
||||
if ($month !== null && DifferenceMaterialization::materialized($month)) {
|
||||
$query->where(DifferenceMaterialization::HAS_DIFFERENCE_COLUMN, $has ? 1 : 0);
|
||||
|
||||
return;
|
||||
}
|
||||
// 已限定跨月第一优先级时,跨月行不会再落入口径兜底归零,
|
||||
// “有/无差额”可直接使用默认差额,避免整串原因 CASE 重复执行。
|
||||
$effective = $crossMonthOnly
|
||||
@ -2055,7 +2094,7 @@ class Pages extends Controller
|
||||
continue;
|
||||
}
|
||||
if ($key === 'remark') {
|
||||
$this->applyReceiptRemarkFilter($query, (array) $values);
|
||||
$this->applyReceiptRemarkFilter($query, (array) $values, $month);
|
||||
continue;
|
||||
}
|
||||
if ($key === 'unmatched_reason') {
|
||||
@ -2068,11 +2107,17 @@ class Pages extends Controller
|
||||
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
|
||||
}
|
||||
|
||||
$effective = $crossMonthOnly
|
||||
? self::receiptDefaultDifferenceSql()
|
||||
: self::receiptEffectiveDifferenceSql();
|
||||
$row = $query->selectRaw('SUM(CASE WHEN ABS(' . $effective . ') > 0.005 THEN 1 ELSE 0 END) as has_diff, '
|
||||
. 'SUM(CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 1 ELSE 0 END) as none_diff')->first();
|
||||
if (DifferenceMaterialization::materialized($month)) {
|
||||
$column = DifferenceMaterialization::HAS_DIFFERENCE_COLUMN;
|
||||
$row = $query->selectRaw("SUM({$column}) as has_diff, "
|
||||
. "SUM(1 - {$column}) as none_diff")->first();
|
||||
} else {
|
||||
$effective = $crossMonthOnly
|
||||
? self::receiptDefaultDifferenceSql()
|
||||
: self::receiptEffectiveDifferenceSql();
|
||||
$row = $query->selectRaw('SUM(CASE WHEN ABS(' . $effective . ') > 0.005 THEN 1 ELSE 0 END) as has_diff, '
|
||||
. 'SUM(CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 1 ELSE 0 END) as none_diff')->first();
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
if ((int) ($row->has_diff ?? 0) > 0) {
|
||||
@ -2115,14 +2160,22 @@ class Pages extends Controller
|
||||
private function receiptOrderMatchMonths(): array
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->select('report_month')
|
||||
->distinct()
|
||||
->orderByDesc('report_month')
|
||||
->pluck('report_month')
|
||||
->map(static fn ($month): string => substr((string) $month, 0, 10))
|
||||
->all();
|
||||
// 月份去重要扫整月索引(近百万行),每次开页都会跑一遍;账期按月增长且数据按天同步,
|
||||
// 缓存 6 小时即可,同步新月份后最迟 6 小时可见(也可手动清缓存)。
|
||||
$months = cache()->remember(
|
||||
'performance-details-months-v1:' . md5(implode(',', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)),
|
||||
now()->addHours(6),
|
||||
static function () use ($connection): array {
|
||||
return DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->select('report_month')
|
||||
->distinct()
|
||||
->orderByDesc('report_month')
|
||||
->pluck('report_month')
|
||||
->map(static fn ($month): string => substr((string) $month, 0, 10))
|
||||
->all();
|
||||
},
|
||||
);
|
||||
|
||||
$requested = substr(trim((string) request('month', '')), 0, 10);
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
|
||||
@ -2178,7 +2231,15 @@ class Pages extends Controller
|
||||
$orderNo = trim((string) request('online_order_no', ''));
|
||||
|
||||
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
|
||||
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
|
||||
// 缓存键带上规则指纹、物化状态与数据版本:规则调整或重新物化后不会复用旧口径的结果。
|
||||
$cacheKey = 'performance-details-facets-v2:' . md5(serialize([
|
||||
$month,
|
||||
$filters,
|
||||
$orderNo,
|
||||
DifferenceMaterialization::ruleVersion(),
|
||||
DifferenceMaterialization::materialized($month),
|
||||
DifferenceMaterialization::dataVersion($month),
|
||||
]));
|
||||
|
||||
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
|
||||
@ -2279,12 +2340,21 @@ class Pages extends Controller
|
||||
$wantNoDifference = in_array('无差额', $differenceValues, true);
|
||||
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
// 已物化的账期直接读规则序号列与默认差额列;未物化时回退实时规则表达式。
|
||||
$materialized = DifferenceMaterialization::materialized($month);
|
||||
$baseDifferenceExpression = $materialized
|
||||
? 'COALESCE(' . DifferenceMaterialization::BASE_DIFFERENCE_COLUMN . ', 0)'
|
||||
: self::receiptDefaultDifferenceSql();
|
||||
$reasonIndexExpression = $materialized
|
||||
? 'COALESCE(' . DifferenceMaterialization::REASON_INDEX_COLUMN . ', 0)'
|
||||
: self::receiptDifferenceRemarkIndexSql();
|
||||
|
||||
$inner = DB::connection($connection)
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->select(['platform', 'store_name', 'match_status', 'remark'])
|
||||
->selectRaw(self::receiptDefaultDifferenceSql() . ' AS base_difference')
|
||||
->selectRaw(self::receiptDifferenceRemarkIndexSql() . ' AS reason_index');
|
||||
->selectRaw($baseDifferenceExpression . ' AS base_difference')
|
||||
->selectRaw($reasonIndexExpression . ' AS reason_index');
|
||||
|
||||
foreach ($filters as $key => $values) {
|
||||
if ($key === 'remark' || $key === 'difference_state' || $values === []) {
|
||||
@ -2392,7 +2462,7 @@ class Pages extends Controller
|
||||
}
|
||||
// 差额异常备注按公式条件判定
|
||||
if ($key === 'remark') {
|
||||
$this->applyReceiptRemarkFilter($query, (array) $values);
|
||||
$this->applyReceiptRemarkFilter($query, (array) $values, $month);
|
||||
continue;
|
||||
}
|
||||
// 未匹配原因:只看未匹配行、按库里的原因过滤
|
||||
@ -2402,7 +2472,7 @@ class Pages extends Controller
|
||||
}
|
||||
// 差额(有/无):按页面差额口径过滤
|
||||
if ($key === 'difference_state') {
|
||||
$this->applyReceiptDifferenceStateFilter($query, (array) $values, $crossMonthOnly);
|
||||
$this->applyReceiptDifferenceStateFilter($query, (array) $values, $crossMonthOnly, $month);
|
||||
continue;
|
||||
}
|
||||
$query->whereIn($key, $values);
|
||||
@ -2414,7 +2484,10 @@ class Pages extends Controller
|
||||
$counts = [];
|
||||
if ($column === 'remark') {
|
||||
// 一行只按优先级命中一次,再按序号分组,避免 18 条规则各跑一遍全表。
|
||||
$indexSql = self::receiptDifferenceRemarkIndexSql();
|
||||
// 已物化的账期直接读规则序号列。
|
||||
$indexSql = DifferenceMaterialization::materialized($month)
|
||||
? 'COALESCE(' . DifferenceMaterialization::REASON_INDEX_COLUMN . ', 0)'
|
||||
: self::receiptDifferenceRemarkIndexSql();
|
||||
$rows = (clone $query)
|
||||
->selectRaw($indexSql . ' AS reason_index')
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
@ -2571,7 +2644,7 @@ class Pages extends Controller
|
||||
$query->whereIn('match_status', $statusValues);
|
||||
}
|
||||
if ($remarkValues !== []) {
|
||||
$this->applyReceiptRemarkFilter($query, $remarkValues);
|
||||
$this->applyReceiptRemarkFilter($query, $remarkValues, $month);
|
||||
}
|
||||
$unmatchedReasonValues = $this->receiptOrderMatchFilterValues($request->query('unmatched_reason'));
|
||||
if ($unmatchedReasonValues !== []) {
|
||||
@ -2579,7 +2652,7 @@ class Pages extends Controller
|
||||
}
|
||||
$differenceState = $this->receiptOrderMatchFilterValues($request->query('difference_state'));
|
||||
if ($differenceState !== []) {
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState, $crossMonthOnly);
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState, $crossMonthOnly, $month);
|
||||
}
|
||||
|
||||
// 「订单实付含已退款部分」「订单实付未减联合营销费」判定所需证据列(与页面同一口径;仅对应平台负差额行会真正计算子查询)。
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TARGET = 'store_receipt_order_match_rows';
|
||||
|
||||
private const STATE_TABLE = 'store_receipt_order_match_materializations';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->mirrorConnection());
|
||||
|
||||
if (! $schema->hasTable(self::TARGET)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 页面上的「差额异常备注」「差额(有/无)」「差额合计」原先都在查询时逐行现算
|
||||
// 一套含关联子查询的规则链,整月 30 多万行要几十秒。这里把规则结果落库,
|
||||
// 查询退化成读列 + 索引聚合。规则表达式仍以 Pages 控制器里的
|
||||
// receiptDifferenceRemarkIndexSql()/receiptDefaultDifferenceSql() 为唯一口径,
|
||||
// 由 store-receipt:materialize-differences 写入。
|
||||
if (! $schema->hasColumn(self::TARGET, 'difference_reason_index')) {
|
||||
$schema->table(self::TARGET, function (Blueprint $table): void {
|
||||
$table->unsignedSmallInteger('difference_reason_index')->nullable()->after('remark')
|
||||
->comment('差额异常备注规则序号;0=无明确原因,NULL=尚未物化');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn(self::TARGET, 'base_difference')) {
|
||||
$schema->table(self::TARGET, function (Blueprint $table): void {
|
||||
$table->decimal('base_difference', 18, 2)->nullable()->after('difference_reason_index')
|
||||
->comment('默认口径差额(COALESCE(difference, 净额)),物化结果');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn(self::TARGET, 'has_difference')) {
|
||||
$schema->table(self::TARGET, function (Blueprint $table): void {
|
||||
$table->boolean('has_difference')->nullable()->after('base_difference')
|
||||
->comment('有效差额是否非零;用于「差额(有/无)」筛选与计数');
|
||||
});
|
||||
}
|
||||
|
||||
$this->ensureIndex($schema, self::TARGET, 'store_receipt_difference_reason_index',
|
||||
['report_month', 'difference_reason_index']);
|
||||
$this->ensureIndex($schema, self::TARGET, 'store_receipt_difference_state_index',
|
||||
['report_month', 'has_difference']);
|
||||
// 店铺下拉去重按 (report_month, platform) 过滤后再按 store_name 排序,
|
||||
// 现有 (report_month, store_name) 不含 platform,必须回表逐行判断。
|
||||
$this->ensureIndex($schema, self::TARGET, 'store_receipt_store_platform_index',
|
||||
['report_month', 'store_name', 'platform']);
|
||||
|
||||
if (! $schema->hasTable(self::STATE_TABLE)) {
|
||||
$schema->create(self::STATE_TABLE, function (Blueprint $table): void {
|
||||
$table->string('report_month', 10)->primary()->comment('账期 Y-m-d');
|
||||
$table->string('rule_version', 32)->comment('物化时使用的规则表达式指纹');
|
||||
$table->unsignedBigInteger('row_count')->default(0);
|
||||
$table->dateTime('materialized_at')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->mirrorConnection());
|
||||
|
||||
if (! $schema->hasTable(self::TARGET)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'store_receipt_difference_reason_index',
|
||||
'store_receipt_difference_state_index',
|
||||
'store_receipt_store_platform_index',
|
||||
] as $indexName) {
|
||||
if ($this->hasIndex($schema, self::TARGET, $indexName)) {
|
||||
$schema->table(self::TARGET, function (Blueprint $table) use ($indexName): void {
|
||||
$table->dropIndex($indexName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['has_difference', 'base_difference', 'difference_reason_index'] as $column) {
|
||||
if ($schema->hasColumn(self::TARGET, $column)) {
|
||||
$schema->table(self::TARGET, function (Blueprint $table) use ($column): void {
|
||||
$table->dropColumn($column);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$schema->dropIfExists(self::STATE_TABLE);
|
||||
}
|
||||
|
||||
private function mirrorConnection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
private function hasIndex(\Illuminate\Database\Schema\Builder $schema, string $table, string $indexName): bool
|
||||
{
|
||||
return collect($schema->getIndexes($table))
|
||||
->contains(static fn (array $index): bool => ($index['name'] ?? null) === $indexName);
|
||||
}
|
||||
|
||||
private function ensureIndex(
|
||||
\Illuminate\Database\Schema\Builder $schema,
|
||||
string $table,
|
||||
string $indexName,
|
||||
array $columns
|
||||
): void {
|
||||
if ($this->hasIndex($schema, $table, $indexName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table($table, function (Blueprint $blueprint) use ($columns, $indexName): void {
|
||||
$blueprint->index($columns, $indexName);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
private const INDEX = 'store_receipt_order_sort_index';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->mirrorConnection());
|
||||
if (! $schema->hasTable(self::TABLE) || $this->hasIndex($schema)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 业绩明细默认按「发生时间 → 店铺 → 线上单号」排序并只取当前页,
|
||||
// 没有这条索引时整月 30 多万行都要走 filesort 才能取前 25 行。
|
||||
$schema->table(self::TABLE, function (Blueprint $table): void {
|
||||
$table->index(
|
||||
['report_month', 'occurred_date', 'store_name', 'online_order_no'],
|
||||
self::INDEX,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->mirrorConnection());
|
||||
if (! $schema->hasTable(self::TABLE) || ! $this->hasIndex($schema)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table(self::TABLE, function (Blueprint $table): void {
|
||||
$table->dropIndex(self::INDEX);
|
||||
});
|
||||
}
|
||||
|
||||
private function mirrorConnection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
private function hasIndex(\Illuminate\Database\Schema\Builder $schema): bool
|
||||
{
|
||||
return collect($schema->getIndexes(self::TABLE))
|
||||
->contains(static fn (array $index): bool => ($index['name'] ?? null) === self::INDEX);
|
||||
}
|
||||
};
|
||||
@ -32,6 +32,7 @@
|
||||
| [电商财务扩展开发说明](ecommerce-finance-development.md) | 订单、结算、成本、资金和利润分析的业务模型与扩展边界 |
|
||||
| [商品成本占位行规则清单](商品成本占位行规则清单.md) | 商品成本同步的取数顺序、占位行去重、价格兜底和月度验收口径 |
|
||||
| [主体维度经营报表需求说明](主体维度经营报表需求说明.md) | 主体维度经营报表的字段、口径、来源和验收契约 |
|
||||
| [业绩明细差额物化](业绩明细差额物化.md) | 业绩明细差额规则落库、物化命令、发布与回退步骤 |
|
||||
|
||||
上游数据库按只读黑盒处理。正式页面优先读取共享业务镜像;尚未迁移的旧页面才使用兼容快照。不要在网页中接受任意 SQL,也不要把真实业务数据或连接凭据写进文档、测试和截图。
|
||||
|
||||
|
||||
77
docs/业绩明细差额物化.md
Normal file
77
docs/业绩明细差额物化.md
Normal file
@ -0,0 +1,77 @@
|
||||
# 业绩明细差额物化
|
||||
|
||||
财务经营报表 › 业绩明细(`financial-operations/performance-details`)的「差额合计」「差额异常备注」「差额(有/无)」原先都在查询时按同一套规则链逐行现算。规则链包含 40 多处 `SELECT`、其中 36 处是对 `ops_jst_orders`(140 万行)的关联子查询,生成 SQL 有 24 KB 和 50 KB 两条。
|
||||
|
||||
实测(2026-10,共享镜像库,91.7 万行;2026-07 全平台 32.4 万行):
|
||||
|
||||
| 场景 | 优化前 | 优化后(冷缓存) | 优化后(缓存内) |
|
||||
| --- | --- | --- | --- |
|
||||
| 切月份后的联动统计(下拉「统计中…」) | 14.5~84.9 秒 | 约 1.7 秒 | 约 2 毫秒 |
|
||||
| 页面主体(8 个指标卡 + 表格) | 2.7~7.7 秒 | 约 1.7 秒 | 约 24 毫秒 |
|
||||
| 带平台+备注筛选的联动统计 | 数十秒 | 约 0.8 秒 | 约 2 毫秒 |
|
||||
|
||||
冷缓存下的剩余成本主要是远端镜像库的全月扫描(约 300 毫秒/次 × 5~6 次),
|
||||
规则链已不再是瓶颈;进一步优化需要按月预聚合汇总表(未实现)。
|
||||
|
||||
## 做法
|
||||
|
||||
把规则结果落成三个列,查询退化为读列 + 索引聚合:
|
||||
|
||||
| 列 | 含义 |
|
||||
| --- | --- |
|
||||
| `difference_reason_index` | 差额异常备注规则序号(1 开始,0=无明确原因,NULL=尚未物化) |
|
||||
| `base_difference` | 默认口径差额 `COALESCE(difference, 净额)` |
|
||||
| `has_difference` | 有效差额是否非零(口径兜底行按 0 计),用于「有/无差额」筛选与计数 |
|
||||
|
||||
迁移同时补了四个索引:`(report_month, difference_reason_index)`、`(report_month, has_difference)`、
|
||||
`(report_month, store_name, platform)`(店铺下拉去重)、`(report_month, occurred_date, store_name, online_order_no)`
|
||||
(明细页默认排序,避免整月 filesort)。
|
||||
|
||||
规则的唯一口径仍然是 `app/Http/Controllers/Business/Pages.php` 里的
|
||||
`receiptDefaultDifferenceSql()` / `receiptDifferenceRemarkIndexSql()` /
|
||||
`receiptEffectiveDifferenceSql()`;物化命令直接复用这三份表达式,不在 PHP 里重写规则。
|
||||
|
||||
## 物化命令
|
||||
|
||||
```bash
|
||||
# 物化指定账期(会连带重算前一个月和后一个月,跨月备注依赖相邻月份)
|
||||
php artisan store-receipt:materialize-differences 2026-07
|
||||
|
||||
# 物化镜像里所有已有数据的月份
|
||||
php artisan store-receipt:materialize-differences
|
||||
|
||||
# 只比对不写库:检查已落库的值与按当前规则重算的结果是否一致
|
||||
php artisan store-receipt:materialize-differences 2026-07 --dry-run
|
||||
```
|
||||
|
||||
分批写入(`--chunk`,默认 5000 行),先写 `base_difference` / `difference_reason_index`,再用这两列推导 `has_difference`,避免重复执行规则链。
|
||||
|
||||
`store-receipt:order-match:sync` 成功后会自动对同一账期执行物化(可用 `--skip-materialize` 关闭)。
|
||||
|
||||
## 发布步骤
|
||||
|
||||
物化列位于 `caiwu_business_mirror`(共享业务镜像库),加列属于数据库迁移:
|
||||
|
||||
1. 按 SOP 取得负责人授权并完成备份后,随正式发布执行迁移;
|
||||
2. 迁移完成后先跑 `--dry-run` 校验(应显示三项不一致均为 0,`未物化` 行数为总行数);
|
||||
3. 再执行物化命令回填所有已有账期;
|
||||
4. 页面无需额外操作,读到状态表即自动切换到物化列。
|
||||
|
||||
每台开发机、每个实例共用同一份镜像库,因此物化一次即可全团队生效。
|
||||
|
||||
## 回退
|
||||
|
||||
- 页面层有回退:状态表里没有该账期、或规则指纹不一致时,自动回到实时规则表达式,功能不受影响(只是慢)。
|
||||
- 需要整体回退时,执行迁移的 `down()` 删除列与状态表即可。
|
||||
- 规则调整后,`ruleVersion()` 指纹会变化,旧物化结果自动视为过期;重新执行物化命令即可。
|
||||
|
||||
## 缓存
|
||||
|
||||
- 月份下拉:6 小时;
|
||||
- 指标卡聚合:5 分钟;
|
||||
- 联动统计:1 小时;
|
||||
- 三者的缓存键都带规则指纹、物化状态和数据版本;物化完成后数据版本自增,旧缓存自动失效。
|
||||
|
||||
## 前端
|
||||
|
||||
联动统计改为按需触发:页面加载不再自动请求,只有展开下拉或改变勾选时才请求(250 ms 防抖)。因此切月份时不再出现空白表格卡在「统计中…」的情况。
|
||||
@ -2042,7 +2042,7 @@
|
||||
@endif
|
||||
@endif
|
||||
@if (($page['slug'] ?? '') === 'store-report' && ! $directoryUsesFieldQuery)
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="店铺维度报表筛选查询栏">
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar installation-filter-bar--grid" aria-label="店铺维度报表筛选查询栏">
|
||||
@php
|
||||
$storeFilterTextFields = [
|
||||
['name' => 'store_name', 'label' => '店铺名称:'],
|
||||
@ -2151,7 +2151,7 @@
|
||||
.receipt-remark-tag--warn { background: #fdf3e3; color: #96690a; }
|
||||
.receipt-remark-tag--info { background: #eaf1fb; color: #1f5fa8; }
|
||||
</style>
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="业绩明细筛选查询栏">
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar installation-filter-bar--grid" aria-label="业绩明细筛选查询栏">
|
||||
{{-- 查询时保留当前每页条数 --}}
|
||||
<input type="hidden" name="per_page" value="{{ (int) ($receiptStats['per_page'] ?? 25) }}">
|
||||
<div class="installation-filter-field cw-ms" id="rdPlatform" data-name="platform"
|
||||
@ -2270,7 +2270,13 @@
|
||||
].forEach(function (def) {
|
||||
var el = document.getElementById(def[0]);
|
||||
if (el) {
|
||||
widgets.push(api.create(el, { options: def[1], searchable: true, searchPlaceholder: def[2] }));
|
||||
widgets.push(api.create(el, {
|
||||
options: def[1],
|
||||
searchable: true,
|
||||
searchPlaceholder: def[2],
|
||||
// 首次展开下拉时才请求联动统计(整月计数较重,不随页面加载触发)。
|
||||
onOpen: function () { schedule(); }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@ -2350,8 +2356,8 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
// 首次加载立即取联动数量;只有勾选变化时才做 250ms 防抖。
|
||||
refresh();
|
||||
// 联动数量改为按需:展开下拉或改变勾选时才请求(250ms 防抖),
|
||||
// 不再随页面加载自动统计整月,避免切月份时页面卡在「统计中…」。
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
@ -2725,7 +2731,7 @@
|
||||
})();
|
||||
</script>
|
||||
@elseif (in_array(($page['slug'] ?? ''), ['inventory-report', 'monthly-weighted-average-price'], true))
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="进销存报表筛选查询栏">
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar installation-filter-bar--grid" aria-label="进销存报表筛选查询栏">
|
||||
<label class="installation-filter-field">
|
||||
<span>月份:</span>
|
||||
@php
|
||||
@ -3114,7 +3120,7 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">cloud_done</span>
|
||||
<span>已从共享业务镜像读取 {{ $inboundTotal }} 条入库明细;可通过“导入”上传 Excel 整体更新。</span>
|
||||
</div>
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="入库明细筛选查询栏">
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar installation-filter-bar--grid" aria-label="入库明细筛选查询栏">
|
||||
<label class="installation-filter-field">
|
||||
<span>月份:</span>
|
||||
<select name="detail_month" aria-label="月份">
|
||||
|
||||
@ -265,6 +265,9 @@
|
||||
panel.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
if (searchEl) { searchEl.focus(); }
|
||||
// 统计口径可能很重:默认等到用户真正展开下拉时才触发,
|
||||
// 避免页面一加载就对整月数据做一次全量计数。
|
||||
if (typeof opts.onOpen === 'function') { opts.onOpen(api); }
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
|
||||
@ -209,6 +209,33 @@
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
/* 店铺维度报表 / 业绩明细 / 进销存报表 / 入库明细仍直接以字段为栅格项;
|
||||
安装明细已改用 .installation-filter-primary 等内部容器排版,不受这里影响。 */
|
||||
.installation-filter-bar--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(12rem, 1fr));
|
||||
gap: 0.65rem 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.installation-filter-bar--grid .installation-filter-actions {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.installation-filter-bar--grid {
|
||||
grid-template-columns: repeat(2, minmax(14rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.installation-filter-bar--grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.installation-filter-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user