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 */ 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)), )); } }