Add membership fee system with disability discount and fix document permissions

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>
This commit is contained in:
2025-12-01 09:56:01 +08:00
parent 83ce1f7fc8
commit 642b879dd4
207 changed files with 19487 additions and 3048 deletions

View File

@@ -0,0 +1,436 @@
<?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));
}
}
}