Features: - Implement two fee types: entrance fee and annual fee (both NT$1,000) - Add 50% discount for disability certificate holders - Add disability certificate upload in member profile - Integrate disability verification into cashier approval workflow - Add membership fee settings in system admin Document permissions: - Fix hard-coded role logic in Document model - Use permission-based authorization instead of role checks Additional features: - Add announcements, general ledger, and trial balance modules - Add income management and accounting entries - Add comprehensive test suite with factories - Update UI translations to Traditional Chinese 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
437 lines
14 KiB
PHP
437 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\AccountingEntry;
|
|
use App\Models\ChartOfAccount;
|
|
use App\Models\FinanceDocument;
|
|
use Illuminate\Console\Command;
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
|
|
|
class ImportAccountingData extends Command
|
|
{
|
|
protected $signature = 'import:accounting-data
|
|
{file? : Path to Excel file}
|
|
{--dry-run : Preview without importing}';
|
|
|
|
protected $description = 'Import accounting data from Excel files';
|
|
|
|
protected $mapping;
|
|
protected $expenseKeywords;
|
|
protected $accountCache = [];
|
|
protected $stats = [
|
|
'income_count' => 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));
|
|
}
|
|
}
|
|
}
|