0, 'expense_count' => 0, 'skipped_count' => 0, 'error_count' => 0, ]; public function handle() { $this->info('=== 會計資料匯入工具 ==='); $this->newLine(); // Load configuration $this->mapping = config('accounting_mapping.excel_to_system', []); $this->expenseKeywords = config('accounting_mapping.expense_keywords', []); // Get file path $filePath = $this->argument('file') ?? $this->askForFile(); if (!file_exists($filePath)) { $this->error("檔案不存在: {$filePath}"); return 1; } $this->info("📂 檔案: {$filePath}"); $this->info("📅 檔案日期: " . date('Y-m-d H:i:s', filemtime($filePath))); $this->newLine(); if ($this->option('dry-run')) { $this->warn('⚠️ DRY RUN MODE - 不會實際寫入資料庫'); $this->newLine(); } try { $spreadsheet = IOFactory::load($filePath); // Import income $this->info('📊 匯入收入資料...'); $this->importIncome($spreadsheet); $this->newLine(); // Import expenses $this->info('📊 匯入支出資料...'); $this->importExpenses($spreadsheet); $this->newLine(); // Show summary $this->showSummary(); // Verify balance if (!$this->option('dry-run')) { $this->verifyBalance(); } return 0; } catch (\Exception $e) { $this->error('匯入失敗: ' . $e->getMessage()); $this->error($e->getTraceAsString()); return 1; } } protected function askForFile(): string { $defaultPath = '協會行政資料/協會帳務/2025 收入支出總表 (含會計科目編號).xlsx'; $this->info('可用檔案:'); $this->line('1. ' . $defaultPath); return $this->ask('請輸入檔案路徑', $defaultPath); } protected function importIncome($spreadsheet) { // Find the "收入" sheet $sheet = null; foreach ($spreadsheet->getAllSheets() as $s) { if (in_array($s->getTitle(), ['收入', 'Income', '收入明細'])) { $sheet = $s; break; } } if (!$sheet) { $this->warn('找不到「收入」工作表,跳過'); return; } $this->info("工作表: {$sheet->getTitle()}"); // Read header row to find columns $headerRow = 1; $headers = []; $maxCol = $sheet->getHighestColumn(); for ($col = 'A'; $col <= $maxCol; $col++) { $value = $sheet->getCell($col . $headerRow)->getValue(); if ($value) { $headers[$col] = $value; } } $this->line('欄位: ' . implode(', ', $headers)); // Detect columns $dateCol = $this->findColumn($headers, ['日期', 'Date']); $accountCodeCol = $this->findColumn($headers, ['科目編號', '科目代碼', 'Code']); $accountNameCol = $this->findColumn($headers, ['科目名稱', 'Account']); $amountCol = $this->findColumn($headers, ['收入金額', '金額', 'Amount']); $descCol = $this->findColumn($headers, ['收入來源備註', '備註', '說明', 'Description']); if (!$dateCol || !$amountCol) { $this->error('缺少必要欄位(日期、金額)'); return; } // Import rows $highestRow = $sheet->getHighestRow(); $bar = $this->output->createProgressBar($highestRow - 1); for ($row = $headerRow + 1; $row <= $highestRow; $row++) { $amount = $sheet->getCell($amountCol . $row)->getValue(); if (empty($amount) || $amount == 0) { $this->stats['skipped_count']++; $bar->advance(); continue; } try { $date = $this->parseDate($sheet->getCell($dateCol . $row)->getValue()); $excelAccountCode = $accountCodeCol ? $sheet->getCell($accountCodeCol . $row)->getValue() : null; $description = $descCol ? $sheet->getCell($descCol . $row)->getValue() : ''; // Map to system account $systemAccountCode = $this->mapAccountCode($excelAccountCode); $account = $this->getAccount($systemAccountCode); if (!$account) { $this->stats['error_count']++; $this->warn("\n找不到科目: {$systemAccountCode} (Excel: {$excelAccountCode})"); $bar->advance(); continue; } if (!$this->option('dry-run')) { $this->createIncomeEntry($date, $account, $amount, $description); } $this->stats['income_count']++; } catch (\Exception $e) { $this->stats['error_count']++; $this->warn("\nRow {$row} 錯誤: " . $e->getMessage()); } $bar->advance(); } $bar->finish(); $this->newLine(); } protected function importExpenses($spreadsheet) { // Find the "支出" sheet $sheet = null; foreach ($spreadsheet->getAllSheets() as $s) { if (in_array($s->getTitle(), ['支出', 'Expense', '支出明細'])) { $sheet = $s; break; } } if (!$sheet) { $this->warn('找不到「支出」工作表,跳過'); return; } $this->info("工作表: {$sheet->getTitle()}"); // Read header row $headerRow = 1; $headers = []; $maxCol = $sheet->getHighestColumn(); for ($col = 'A'; $col <= $maxCol; $col++) { $value = $sheet->getCell($col . $headerRow)->getValue(); if ($value) { $headers[$col] = $value; } } $this->line('欄位: ' . implode(', ', $headers)); // Detect columns $dateCol = $this->findColumn($headers, ['日期', 'Date']); $accountCodeCol = $this->findColumn($headers, ['科目編號', '科目代碼', 'Code']); $amountCol = $this->findColumn($headers, ['支出金額', '金額', 'Amount']); $descCol = $this->findColumn($headers, ['支出用途備註', '用途', '備註', 'Description']); if (!$dateCol || !$amountCol) { $this->error('缺少必要欄位(日期、金額)'); return; } // Import rows $highestRow = $sheet->getHighestRow(); $bar = $this->output->createProgressBar($highestRow - 1); for ($row = $headerRow + 1; $row <= $highestRow; $row++) { $amount = $sheet->getCell($amountCol . $row)->getValue(); if (empty($amount) || $amount == 0) { $this->stats['skipped_count']++; $bar->advance(); continue; } try { $date = $this->parseDate($sheet->getCell($dateCol . $row)->getValue()); $excelAccountCode = $accountCodeCol ? $sheet->getCell($accountCodeCol . $row)->getValue() : '5100'; $description = $descCol ? $sheet->getCell($descCol . $row)->getValue() : ''; // For 5100, classify by keywords if ($excelAccountCode == '5100') { $systemAccountCode = $this->classifyExpense($description); } else { $systemAccountCode = $this->mapAccountCode($excelAccountCode); } $account = $this->getAccount($systemAccountCode); if (!$account) { $this->stats['error_count']++; $this->warn("\n找不到科目: {$systemAccountCode}"); $bar->advance(); continue; } if (!$this->option('dry-run')) { $this->createExpenseEntry($date, $account, $amount, $description); } $this->stats['expense_count']++; } catch (\Exception $e) { $this->stats['error_count']++; $this->warn("\nRow {$row} 錯誤: " . $e->getMessage()); } $bar->advance(); } $bar->finish(); $this->newLine(); } protected function createIncomeEntry($date, $account, $amount, $description) { // Create finance document $document = FinanceDocument::create([ 'title' => '收入 - ' . $account->account_name_zh, 'amount' => $amount, 'description' => $description, 'chart_of_account_id' => $account->id, 'submitted_at' => $date, 'status' => FinanceDocument::STATUS_APPROVED_CHAIR, ]); // Create accounting entries (double-entry) // Debit: Cash AccountingEntry::create([ 'finance_document_id' => $document->id, 'chart_of_account_id' => $this->getAccount('1101')->id, 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, 'amount' => $amount, 'entry_date' => $date, 'description' => '收入 - ' . $description, ]); // Credit: Income account AccountingEntry::create([ 'finance_document_id' => $document->id, 'chart_of_account_id' => $account->id, 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, 'amount' => $amount, 'entry_date' => $date, 'description' => $description, ]); } protected function createExpenseEntry($date, $account, $amount, $description) { // Create finance document $document = FinanceDocument::create([ 'title' => '支出 - ' . $account->account_name_zh, 'amount' => $amount, 'description' => $description, 'chart_of_account_id' => $account->id, 'submitted_at' => $date, 'status' => FinanceDocument::STATUS_APPROVED_CHAIR, ]); // Create accounting entries (double-entry) // Debit: Expense account AccountingEntry::create([ 'finance_document_id' => $document->id, 'chart_of_account_id' => $account->id, 'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT, 'amount' => $amount, 'entry_date' => $date, 'description' => $description, ]); // Credit: Cash AccountingEntry::create([ 'finance_document_id' => $document->id, 'chart_of_account_id' => $this->getAccount('1101')->id, 'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT, 'amount' => $amount, 'entry_date' => $date, 'description' => '支出 - ' . $description, ]); } protected function mapAccountCode($excelCode) { return $this->mapping[$excelCode] ?? $excelCode; } protected function classifyExpense($description): string { foreach ($this->expenseKeywords as $rule) { if (empty($rule['keywords'])) { if ($rule['is_default'] ?? false) { return $rule['account_code']; } continue; } foreach ($rule['keywords'] as $keyword) { if (mb_strpos($description, $keyword) !== false) { return $rule['account_code']; } } } return '5901'; // Default: 雜項支出 } protected function getAccount($accountCode) { if (!isset($this->accountCache[$accountCode])) { $this->accountCache[$accountCode] = ChartOfAccount::where('account_code', $accountCode)->first(); } return $this->accountCache[$accountCode]; } protected function parseDate($value) { if (is_numeric($value)) { return ExcelDate::excelToDateTimeObject($value); } if ($value instanceof \DateTime) { return $value; } return new \DateTime($value); } protected function findColumn($headers, $patterns) { foreach ($headers as $col => $header) { foreach ($patterns as $pattern) { if (mb_strpos($header, $pattern) !== false) { return $col; } } } return null; } protected function showSummary() { $this->info('=== 匯入統計 ==='); $this->table( ['項目', '數量'], [ ['收入筆數', $this->stats['income_count']], ['支出筆數', $this->stats['expense_count']], ['跳過筆數', $this->stats['skipped_count']], ['錯誤筆數', $this->stats['error_count']], ] ); } protected function verifyBalance() { $this->info('=== 驗證借貸平衡 ==='); $debitTotal = AccountingEntry::where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT)->sum('amount'); $creditTotal = AccountingEntry::where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT)->sum('amount'); $this->line("借方總計: " . number_format($debitTotal, 2)); $this->line("貸方總計: " . number_format($creditTotal, 2)); if (bccomp((string)$debitTotal, (string)$creditTotal, 2) === 0) { $this->info('✅ 借貸平衡'); } else { $diff = $debitTotal - $creditTotal; $this->error("❌ 借貸不平衡,差額: " . number_format($diff, 2)); } } }