'dingtalk', '种菜报销流程' => 'dingtalk', '应付申请流程' => 'dingtalk', '快递面单应付流程' => 'dingtalk', '线上代发业务报销' => 'dingtalk', '固定资产报废或出售流程' => 'dingtalk', '聚工单查账流程' => 'workorder', '供应商应付' => 'payable', '店铺开店费用' => 'store', '店铺提现' => 'shop', ]; public function import(string $path, bool $recalculate = true): array { if (! is_file($path)) { throw new RuntimeException('台账快照文件不存在:' . $path); } $payload = json_decode((string) file_get_contents($path), true); if (! is_array($payload) || $payload === []) { throw new RuntimeException('台账快照不是有效的 JSON 数组。'); } $connection = $this->connection(); $now = now(); $rows = []; $occurrences = []; foreach ($payload as $index => $item) { if (! is_array($item)) { continue; } $process = trim((string) ($item['process'] ?? '')); $source = self::SOURCES[$process] ?? null; if ($source === null) { throw new RuntimeException('未知的台账流程:' . $process); } $date = $this->date($item['date'] ?? null); if ($date === null) { continue; } $approvalNo = $this->text($item['process_no'] ?? ''); $rawAccount = $this->text($item['account_short'] ?? ''); $accountName = $this->text($item['acct_name'] ?? ''); $sourceValue = $source === 'dingtalk' ? $accountName : $rawAccount; $income = round($this->number($item['income'] ?? 0), 2); $expense = round($this->number($item['expense'] ?? 0), 2); $detail = $this->text($item['detail'] ?? '', 4000); $canonical = implode("\0", [ $source, $this->text($item['pid'] ?? ''), $this->text($item['seq'] ?? ''), $approvalNo, $this->text($item['acct_code'] ?? ''), $accountName, 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' => $process, '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): void { $connection->table('fin_transactions') ->where('source', '<>', FinanceLedgerTransactionWriter::MANUAL_SOURCE) ->delete(); foreach (array_chunk($rows, 500) as $chunk) { $connection->table('fin_transactions')->insert($chunk); } $this->refreshMappingStats($connection, $now); }); $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): void { $stats = $connection->table('fin_transactions') ->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum') ->where('source', '<>', FinanceLedgerTransactionWriter::MANUAL_SOURCE) ->whereNotNull('source_value') ->where('source_value', '<>', '') ->groupBy('source', 'source_value') ->get(); $rows = []; foreach ($stats as $stat) { $rows[] = [ 'source' => (string) $stat->source, 'source_value' => mb_substr((string) $stat->source_value, 0, 200), '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, ]; } 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 connection() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } private function text(mixed $value, int $max = 200): string { $text = trim((string) ($value ?? '')); $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return mb_substr($text, 0, $max); } private function number(mixed $value): float { if (is_int($value) || is_float($value)) { return (float) $value; } $text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? ''))); return is_numeric($text) ? (float) $text : 0.0; } 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]); } }