persist($transactions, $recalculate, true); } /** 只更新/插入本次快照,不删除历史明细;用于每日增量。 */ public function merge(array $transactions, bool $recalculate = true): array { return $this->persist($transactions, $recalculate, false); } private function persist(array $transactions, bool $recalculate, bool $replaceExisting): array { if ($transactions === []) { throw new RuntimeException('没有可写入的动账明细。'); } $connection = $this->connection(); $now = now(); $rows = []; $occurrences = []; foreach ($transactions as $item) { $date = $this->date($item['txn_date'] ?? null); if ($date === null) { continue; } $source = (string) ($item['source'] ?? ''); if ($source === '') { throw new RuntimeException('动账明细缺少 source。'); } $income = round((float) ($item['income'] ?? 0), 2); $expense = round((float) ($item['expense'] ?? 0), 2); $detail = mb_substr($this->normalizeText($item['detail'] ?? ''), 0, 4000); $approvalNo = mb_substr(trim((string) ($item['approval_no'] ?? '')), 0, 100); $sourceValue = mb_substr($this->normalizeText($item['source_value'] ?? ''), 0, 200); $rawAccount = mb_substr($this->normalizeText($item['account_short'] ?? ''), 0, 100); $processType = mb_substr(trim((string) ($item['process_type'] ?? '')), 0, 50); $pid = trim((string) ($item['pid'] ?? '')); $seq = trim((string) ($item['seq'] ?? '')); $acctCode = trim((string) ($item['acct_code'] ?? '')); $acctName = trim((string) ($item['acct_name'] ?? '')); $canonical = implode("\0", [ $source, $processType, $pid, $seq, $approvalNo, $acctCode, $acctName, number_format($income, 2, '.', ''), number_format($expense, 2, '.', ''), $detail, ]); $occurrences[$canonical] = ($occurrences[$canonical] ?? 0) + 1; $rows[] = [ 'txn_date' => $date, 'account_short' => $rawAccount, 'detail' => $detail, 'income' => $income, 'expense' => $expense, 'approval_no' => $approvalNo, 'source' => $source, 'source_value' => $sourceValue, 'process_type' => $processType, 'row_key' => substr(hash('sha256', $canonical), 0, 56) . ':' . $occurrences[$canonical], 'synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; } if ($rows === []) { throw new RuntimeException('动账明细没有有效日期。'); } $connection->transaction(function () use ($connection, $rows, $now, $replaceExisting): void { if ($replaceExisting) { $connection->table('fin_transactions') ->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE]) ->delete(); } foreach (array_chunk($rows, 500) as $chunk) { if ($replaceExisting) { $connection->table('fin_transactions')->insert($chunk); } else { $connection->table('fin_transactions')->upsert( $chunk, ['row_key'], ['txn_date', 'account_short', 'detail', 'income', 'expense', 'approval_no', 'source', 'source_value', 'process_type', 'synced_at', 'updated_at'], ); } } $this->refreshMappingStats($connection, $now, $replaceExisting); }); $result = [ 'rows' => count($rows), 'income' => round(array_sum(array_column($rows, 'income')), 2), 'expense' => round(array_sum(array_column($rows, 'expense')), 2), 'from' => min(array_column($rows, 'txn_date')), 'to' => max(array_column($rows, 'txn_date')), ]; if ($recalculate) { $result['recalculated'] = app(FinanceLedgerCalculator::class)->recalculate(); } return $result; } private function refreshMappingStats($connection, $now, bool $pruneStale = true): void { $stats = $connection->table('fin_transactions') ->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum') ->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE]) ->whereNotNull('source_value') ->where('source_value', '<>', '') ->groupBy('source', 'source_value') ->get(); $rows = []; $keys = []; foreach ($stats as $stat) { $source = (string) $stat->source; $value = mb_substr((string) $stat->source_value, 0, 200); $keys[$source . "\0" . $value] = true; $rows[] = [ 'source' => $source, 'source_value' => $value, 'txn_count' => (int) $stat->txn_count, 'income_sum' => round((float) $stat->income_sum, 2), 'expense_sum' => round((float) $stat->expense_sum, 2), 'created_at' => $now, 'updated_at' => $now, ]; } // 上游删除或改写的来源值不能继续留在映射表里;只清理本次未出现的行, // 保留已有 account_short,避免重同步覆盖人工映射。 if ($pruneStale) { $stale = []; foreach ($connection->table('fin_account_map')->get(['id', 'source', 'source_value']) as $existing) { if (! isset($keys[(string) $existing->source . "\0" . (string) $existing->source_value])) { $stale[] = (int) $existing->id; } } foreach (array_chunk($stale, 500) as $ids) { $connection->table('fin_account_map')->whereIn('id', $ids)->delete(); } } foreach (array_chunk($rows, 500) as $chunk) { $connection->table('fin_account_map')->upsert( $chunk, ['source', 'source_value'], ['txn_count', 'income_sum', 'expense_sum', 'updated_at'], ); } } private function normalizeText(mixed $value): string { $text = trim((string) ($value ?? '')); return preg_replace('/\s+/u', ' ', $text) ?? $text; } private function connection() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } private function date(mixed $value): ?string { $text = trim((string) ($value ?? '')); if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})/', $text, $matches) !== 1) { return null; } return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]); } }