connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** * @return array */ public function import( string $path, ?string $accountShort = null, ?float $openingBalance = null, bool $dryRun = false, ): array { if (! is_file($path) || ! is_readable($path)) { throw new InvalidArgumentException('微信收支表文件不存在或不可读取。'); } $accountShort = trim((string) ($accountShort ?: self::DEFAULT_ACCOUNT_SHORT)); if (! FinanceLedgerIndependentAccounts::contains($accountShort)) { throw new InvalidArgumentException('微信月度收支表只能导入独立账单账户。'); } if (! $this->connection->table('fin_accounts')->where('account_short', $accountShort)->exists()) { throw new InvalidArgumentException('账户总表中没有账户:' . $accountShort); } [$fileOpening, $rows] = $this->rows($path, $accountShort); if ($rows === []) { throw new InvalidArgumentException('微信收支表中没有有效明细。'); } if ($openingBalance !== null && abs(round($openingBalance, 2) - $fileOpening) > 0.001) { throw new InvalidArgumentException(sprintf( '期初余额不一致:指定 %.2f,文件为 %.2f。', $openingBalance, $fileOpening, )); } $openingBalance = $openingBalance === null ? $fileOpening : round($openingBalance, 2); $income = round(array_sum(array_column($rows, 'income')), 2); $expense = round(array_sum(array_column($rows, 'expense')), 2); $closingBalance = round((float) $rows[array_key_last($rows)]['balance'], 2); $expectedClosing = round($openingBalance + $income - $expense, 2); if (abs($expectedClosing - $closingBalance) > 0.001) { throw new InvalidArgumentException(sprintf( '微信收支未对平:期初加收支应为 %.2f,末笔余额为 %.2f。', $expectedClosing, $closingBalance, )); } $firstDate = (string) $rows[0]['txn_date']; $lastDate = (string) $rows[array_key_last($rows)]['txn_date']; $from = CarbonImmutable::parse($firstDate)->startOfMonth()->toDateString(); $to = CarbonImmutable::parse($lastDate)->endOfMonth()->toDateString(); $replaceCount = (int) $this->connection->table('fin_transactions') ->where('source', self::SOURCE) ->where('source_value', $accountShort) ->whereBetween('txn_date', [$from, $to]) ->count(); $result = [ 'dry_run' => $dryRun, 'account_short' => $accountShort, 'from' => $from, 'to' => $to, 'opening_balance' => $openingBalance, 'closing_balance' => $closingBalance, 'rows' => count($rows), 'income' => $income, 'expense' => $expense, 'replaced' => $replaceCount, 'recalculated' => null, ]; if ($dryRun) { return $result; } $result['recalculated'] = $this->connection->transaction(function () use ( $accountShort, $from, $to, $openingBalance, $rows, ): array { $this->connection->table('fin_transactions') ->where('source', self::SOURCE) ->where('source_value', $accountShort) ->whereBetween('txn_date', [$from, $to]) ->delete(); foreach (array_chunk($rows, 500) as $chunk) { $this->connection->table('fin_transactions')->insert($chunk); } $now = now(); $this->connection->table('fin_account_opening') ->where('account_short', $accountShort) ->delete(); $this->connection->table('fin_account_opening')->insert([ 'account_short' => $accountShort, 'opening_date' => $from, 'opening_balance' => $openingBalance, 'remark' => '微信月度收支表期初', 'created_at' => $now, 'updated_at' => $now, ]); $this->connection->table('fin_account_map')->updateOrInsert( ['source' => self::SOURCE, 'source_value' => $accountShort], [ 'account_short' => $accountShort, 'txn_count' => count($rows), 'income_sum' => round(array_sum(array_column($rows, 'income')), 2), 'expense_sum' => round(array_sum(array_column($rows, 'expense')), 2), 'updated_by' => '系统自动同步', 'updated_at' => $now, 'created_at' => $now, ], ); return $this->calculator->recalculate(); }); return $result; } /** * @return array{0:float,1:list>} */ private function rows(string $path, string $accountShort): array { $spreadsheet = IOFactory::load($path); $matrix = $spreadsheet->getActiveSheet()->toArray(null, true, true, false); $spreadsheet->disconnectWorksheets(); $headerIndex = null; foreach ($matrix as $index => $row) { $headers = array_map(fn (mixed $value): string => $this->header((string) $value), $row); if (in_array('日期', $headers, true) && in_array('收入', $headers, true) && in_array('支出', $headers, true) && in_array('余额', $headers, true)) { $headerIndex = $index; break; } } if ($headerIndex === null) { throw new InvalidArgumentException('未找到微信收支表头:日期、收入、支出、余额。'); } $headers = array_map(fn (mixed $value): string => $this->header((string) $value), $matrix[$headerIndex]); $indexes = []; foreach (['date' => '日期', 'account' => '简称', 'income' => '收入', 'expense' => '支出', 'balance' => '余额'] as $key => $label) { $position = array_search($label, $headers, true); if ($position === false) { throw new InvalidArgumentException('微信收支表缺少字段:' . $label); } $indexes[$key] = $position; } $opening = null; $rows = []; $previousBalance = null; $occurrences = []; foreach (array_slice($matrix, $headerIndex + 1, null, true) as $rowIndex => $values) { $value = static fn (string $key): string => trim((string) ($values[$indexes[$key]] ?? '')); $balance = $value('balance'); if ($opening === null && $value('date') === '' && $balance !== '') { $opening = $this->money($balance); $previousBalance = $opening; continue; } if ($value('date') === '') { continue; } $date = $this->date($value('date')); $income = $value('income') === '' ? 0.0 : $this->money($value('income')); $expense = $value('expense') === '' ? 0.0 : $this->money($value('expense')); if (($income > 0) === ($expense > 0)) { throw new InvalidArgumentException('微信收支第 ' . ($rowIndex + 1) . ' 行必须且只能填写收入或支出。'); } if ($balance === '') { throw new InvalidArgumentException('微信收支第 ' . ($rowIndex + 1) . ' 行缺少余额。'); } $balanceValue = $this->money($balance); if ($previousBalance !== null) { $expected = round($previousBalance + $income - $expense, 2); if (abs($expected - $balanceValue) > 0.001) { throw new InvalidArgumentException(sprintf( '微信收支第 %d 行余额未对平:应为 %.2f,实际 %.2f。', $rowIndex + 1, $expected, $balanceValue, )); } } $previousBalance = $balanceValue; $occurrenceKey = implode("\0", [$date, $income, $expense, $balanceValue]); $occurrences[$occurrenceKey] = ($occurrences[$occurrenceKey] ?? 0) + 1; $now = now(); $rows[] = [ 'txn_date' => $date, 'account_short' => $accountShort, 'counterparty_info' => null, 'detail' => '朱俊祥微信收支', 'income' => $income, 'expense' => $expense, 'balance' => $balanceValue, 'approval_no' => 'WX-' . str_replace('-', '', $date) . '-' . str_pad((string) ($rowIndex + 1), 4, '0', STR_PAD_LEFT), 'payment_remark' => null, 'source' => self::SOURCE, 'source_value' => $accountShort, 'process_type' => self::PROCESS_TYPE, 'row_key' => hash('sha256', implode("\0", [ self::SOURCE, $accountShort, $occurrenceKey, $occurrences[$occurrenceKey], ])), 'synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, 'created_by' => '系统自动同步', 'updated_by' => '系统自动同步', ]; } if ($opening === null) { throw new InvalidArgumentException('微信收支表未找到期初余额行。'); } return [round($opening, 2), $rows]; } private function date(string $value): string { foreach (['!n/j/Y', '!Y-m-d', '!Y/m/d'] as $format) { $date = CarbonImmutable::createFromFormat($format, trim($value)); if ($date instanceof CarbonImmutable && $date->format(ltrim($format, '!')) === trim($value)) { return $date->format('Y-m-d'); } } if (is_numeric($value)) { return \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject((float) $value) ->format('Y-m-d'); } throw new InvalidArgumentException('微信收支日期格式无法识别:' . $value); } private function money(string $value): float { $text = trim(str_replace([',', ',', '¥', '¥', ' '], '', $value)); return $text === '' ? 0.0 : round((float) $text, 2); } private function header(string $value): string { return preg_replace('/[\s ]+/u', '', trim($value)) ?? trim($value); } }