diff --git a/app/Http/Controllers/Finance/JournalEntryController.php b/app/Http/Controllers/Finance/JournalEntryController.php index 18dba6d3..fd6fea52 100644 --- a/app/Http/Controllers/Finance/JournalEntryController.php +++ b/app/Http/Controllers/Finance/JournalEntryController.php @@ -5,6 +5,8 @@ use App\Http\Controllers\Controller; use App\Models\Barobill\AccountCode; use App\Models\Barobill\BankTransaction; +use App\Models\Barobill\CardTransaction; +use App\Models\Barobill\CardTransactionHide; use App\Models\Finance\JournalEntry; use App\Models\Finance\JournalEntryLine; use App\Models\Finance\TradingPartner; @@ -861,4 +863,322 @@ public function accountCodeDestroy(int $id): JsonResponse 'message' => '계정과목이 삭제되었습니다.', ]); } + + // ================================================================ + // 카드거래 기반 분개 API + // ================================================================ + + /** + * 카드거래 목록 조회 (DB 직접 조회 + 분개상태 병합) + */ + public function cardTransactions(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', 1); + $startDate = $request->input('startDate', date('Ymd')); + $endDate = $request->input('endDate', date('Ymd')); + $cardNum = $request->input('cardNum', ''); + + $query = CardTransaction::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if (! empty($cardNum)) { + $query->where('card_num', $cardNum); + } + + $transactions = $query->orderBy('use_date', 'desc') + ->orderBy('use_time', 'desc') + ->get(); + + // 숨김 처리된 거래 제외 + $hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate); + $hiddenKeysMap = $hiddenKeys->flip(); + + $transactions = $transactions->filter(function ($tx) use ($hiddenKeysMap) { + return ! $hiddenKeysMap->has($tx->unique_key); + }); + + // 로그 데이터 변환 + $logs = []; + foreach ($transactions as $tx) { + $supplyAmount = $tx->modified_supply_amount !== null + ? (int) $tx->modified_supply_amount + : (int) $tx->approval_amount - (int) $tx->tax; + $taxAmount = $tx->modified_tax !== null + ? (int) $tx->modified_tax + : (int) $tx->tax; + + $logs[] = [ + 'uniqueKey' => $tx->unique_key, + 'useDate' => $tx->use_date, + 'useTime' => $tx->use_time, + 'cardNum' => $tx->card_num, + 'cardCompanyName' => $tx->card_company_name, + 'approvalNum' => $tx->approval_num, + 'approvalType' => $tx->approval_type, + 'approvalAmount' => (int) $tx->approval_amount, + 'supplyAmount' => $supplyAmount, + 'taxAmount' => $taxAmount, + 'merchantName' => $tx->merchant_name, + 'merchantBizNum' => $tx->merchant_biz_num, + 'deductionType' => $tx->deduction_type, + 'accountCode' => $tx->account_code, + 'accountName' => $tx->account_name, + 'memo' => $tx->memo, + 'description' => $tx->description, + ]; + } + + // 각 거래의 uniqueKey 수집 + $uniqueKeys = array_column($logs, 'uniqueKey'); + + // 분개 완료된 source_key 조회 + $journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'ecard_transaction', $uniqueKeys); + $journaledKeysMap = array_flip($journaledKeys); + + // 분개된 전표 ID 조회 + $journalMap = []; + if (! empty($journaledKeys)) { + $journals = JournalEntry::where('tenant_id', $tenantId) + ->where('source_type', 'ecard_transaction') + ->whereIn('source_key', $journaledKeys) + ->select('id', 'source_key', 'entry_no') + ->get(); + foreach ($journals as $j) { + $journalMap[$j->source_key] = ['id' => $j->id, 'entry_no' => $j->entry_no]; + } + } + + // 각 거래에 분개 상태 추가 + foreach ($logs as &$log) { + $key = $log['uniqueKey'] ?? ''; + $log['hasJournal'] = isset($journaledKeysMap[$key]); + $log['journalId'] = $journalMap[$key]['id'] ?? null; + $log['journalEntryNo'] = $journalMap[$key]['entry_no'] ?? null; + } + unset($log); + + // 통계 + $totalCount = count($logs); + $totalAmount = array_sum(array_column($logs, 'approvalAmount')); + $deductibleSum = 0; + $nonDeductibleSum = 0; + foreach ($logs as $log) { + if ($log['deductionType'] === 'non_deductible') { + $nonDeductibleSum += $log['approvalAmount']; + } else { + $deductibleSum += $log['approvalAmount']; + } + } + $journaledCount = count($journaledKeys); + + // 카드 목록 (드롭다운용) + $cards = CardTransaction::where('tenant_id', $tenantId) + ->select('card_num', 'card_company_name') + ->distinct() + ->get() + ->toArray(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'logs' => $logs, + 'cards' => $cards, + 'summary' => [ + 'totalCount' => $totalCount, + 'totalAmount' => $totalAmount, + 'deductibleSum' => $deductibleSum, + 'nonDeductibleSum' => $nonDeductibleSum, + ], + 'journalStats' => [ + 'journaledCount' => $journaledCount, + 'unjournaledCount' => $totalCount - $journaledCount, + ], + ], + ]); + } catch (\Throwable $e) { + Log::error('카드거래 목록 조회 오류: '.$e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => '카드거래 목록 조회 실패: '.$e->getMessage(), + ], 500); + } + } + + /** + * 카드거래 기반 전표 생성 + */ + public function storeFromCard(Request $request): JsonResponse + { + $request->validate([ + 'source_key' => 'required|string|max:255', + 'entry_date' => 'required|date', + 'description' => 'nullable|string|max:500', + 'lines' => 'required|array|min:2', + 'lines.*.dc_type' => 'required|in:debit,credit', + 'lines.*.account_code' => 'required|string|max:10', + 'lines.*.account_name' => 'required|string|max:100', + 'lines.*.trading_partner_id' => 'nullable|integer', + 'lines.*.trading_partner_name' => 'nullable|string|max:100', + 'lines.*.debit_amount' => 'required|integer|min:0', + 'lines.*.credit_amount' => 'required|integer|min:0', + 'lines.*.description' => 'nullable|string|max:300', + ]); + + $tenantId = session('selected_tenant_id', 1); + $lines = $request->lines; + + $totalDebit = collect($lines)->sum('debit_amount'); + $totalCredit = collect($lines)->sum('credit_amount'); + + if ($totalDebit !== $totalCredit || $totalDebit === 0) { + return response()->json([ + 'success' => false, + 'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.', + ], 422); + } + + // 중복 분개 체크 + $existing = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $request->source_key); + if ($existing) { + return response()->json([ + 'success' => false, + 'message' => '이미 분개가 완료된 거래입니다. (전표번호: '.$existing->entry_no.')', + ], 422); + } + + $maxRetries = 3; + $lastError = null; + + for ($attempt = 0; $attempt < $maxRetries; $attempt++) { + try { + $entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) { + $entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date); + + $entry = JournalEntry::create([ + 'tenant_id' => $tenantId, + 'entry_no' => $entryNo, + 'entry_date' => $request->entry_date, + 'description' => $request->description, + 'total_debit' => $totalDebit, + 'total_credit' => $totalCredit, + 'status' => 'draft', + 'source_type' => 'ecard_transaction', + 'source_key' => $request->source_key, + 'created_by_name' => auth()->user()?->name ?? '시스템', + ]); + + foreach ($lines as $i => $line) { + JournalEntryLine::create([ + 'tenant_id' => $tenantId, + 'journal_entry_id' => $entry->id, + 'line_no' => $i + 1, + 'dc_type' => $line['dc_type'], + 'account_code' => $line['account_code'], + 'account_name' => $line['account_name'], + 'trading_partner_id' => $line['trading_partner_id'] ?? null, + 'trading_partner_name' => $line['trading_partner_name'] ?? null, + 'debit_amount' => $line['debit_amount'], + 'credit_amount' => $line['credit_amount'], + 'description' => $line['description'] ?? null, + ]); + } + + return $entry; + }); + + return response()->json([ + 'success' => true, + 'message' => '분개가 저장되었습니다.', + 'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no], + ]); + } catch (\Illuminate\Database\QueryException $e) { + $lastError = $e; + if ($e->errorInfo[1] === 1062) { + continue; + } + break; + } catch (\Throwable $e) { + $lastError = $e; + break; + } + } + + Log::error('카드거래 분개 저장 오류: '.$lastError->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => '분개 저장 실패: '.$lastError->getMessage(), + ], 500); + } + + /** + * 특정 카드거래의 기존 분개 조회 + */ + public function cardJournals(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $sourceKey = $request->get('source_key'); + + if (! $sourceKey) { + return response()->json(['success' => false, 'message' => 'source_key가 필요합니다.'], 422); + } + + $entry = JournalEntry::forTenant($tenantId) + ->where('source_type', 'ecard_transaction') + ->where('source_key', $sourceKey) + ->with('lines') + ->first(); + + if (! $entry) { + return response()->json(['success' => true, 'data' => null]); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $entry->id, + 'entry_no' => $entry->entry_no, + 'entry_date' => $entry->entry_date->format('Y-m-d'), + 'description' => $entry->description, + 'total_debit' => $entry->total_debit, + 'total_credit' => $entry->total_credit, + 'status' => $entry->status, + 'lines' => $entry->lines->map(function ($line) { + return [ + 'id' => $line->id, + 'line_no' => $line->line_no, + 'dc_type' => $line->dc_type, + 'account_code' => $line->account_code, + 'account_name' => $line->account_name, + 'trading_partner_id' => $line->trading_partner_id, + 'trading_partner_name' => $line->trading_partner_name, + 'debit_amount' => $line->debit_amount, + 'credit_amount' => $line->credit_amount, + 'description' => $line->description, + ]; + }), + ], + ]); + } + + /** + * 카드거래 분개 삭제 (soft delete) + */ + public function deleteCardJournal(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + + $entry = JournalEntry::forTenant($tenantId) + ->where('source_type', 'ecard_transaction') + ->findOrFail($id); + + $entry->delete(); + + return response()->json([ + 'success' => true, + 'message' => '분개가 삭제되었습니다.', + ]); + } } diff --git a/resources/views/finance/journal-entries.blade.php b/resources/views/finance/journal-entries.blade.php index b510bcb0..e8aefaf3 100644 --- a/resources/views/finance/journal-entries.blade.php +++ b/resources/views/finance/journal-entries.blade.php @@ -57,6 +57,7 @@ const RefreshCw = createIcon('refresh-cw'); const Settings = createIcon('settings'); const Calendar = createIcon('calendar'); +const CreditCard = createIcon('credit-card'); const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); @@ -898,21 +899,29 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f // ============================================================ const JournalEntryList = ({ accountCodes, tradingPartners, onPartnerAdded, refreshKey, onEdit }) => { const [transactions, setTransactions] = useState([]); + const [cardTransactionsList, setCardTransactionsList] = useState([]); const [journalEntries, setJournalEntries] = useState([]); const [loading, setLoading] = useState(false); const [accounts, setAccounts] = useState([]); + const [cards, setCards] = useState([]); const [journalStats, setJournalStats] = useState({}); + const [cardJournalStats, setCardJournalStats] = useState({}); const [dateRange, setDateRange] = useState({ start: getKoreanDate(-30).replace(/-/g, ''), end: getKoreanDate().replace(/-/g, ''), }); const [selectedAccount, setSelectedAccount] = useState(''); - const [viewFilter, setViewFilter] = useState('all'); // all, bank, manual, unjournaled + const [selectedCard, setSelectedCard] = useState(''); + const [viewFilter, setViewFilter] = useState('all'); // all, bank, card, manual, unjournaled // 분개 모달 상태 (은행거래 → 전표 생성) const [showJournalModal, setShowJournalModal] = useState(false); const [modalTransaction, setModalTransaction] = useState(null); + // 카드 분개 모달 상태 + const [showCardJournalModal, setShowCardJournalModal] = useState(false); + const [cardModalTransaction, setCardModalTransaction] = useState(null); + const setMonthRange = (offset) => { const now = new Date(); const target = new Date(now.getFullYear(), now.getMonth() + offset, 1); @@ -928,16 +937,20 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f try { const bankParams = new URLSearchParams({ startDate: dateRange.start, endDate: dateRange.end }); if (selectedAccount) bankParams.set('accountNum', selectedAccount); + const cardParams = new URLSearchParams({ startDate: dateRange.start, endDate: dateRange.end }); + if (selectedCard) cardParams.set('cardNum', selectedCard); const journalParams = new URLSearchParams({ start_date: formatDate(dateRange.start), end_date: formatDate(dateRange.end), }); - const [bankRes, journalRes] = await Promise.all([ + const [bankRes, cardRes, journalRes] = await Promise.all([ fetch(`/finance/journal-entries/bank-transactions?${bankParams}`), + fetch(`/finance/journal-entries/card-transactions?${cardParams}`), fetch(`/finance/journal-entries/list?${journalParams}`), ]); const bankData = await bankRes.json(); + const cardData = await cardRes.json(); const journalData = await journalRes.json(); if (bankData.success) { @@ -945,6 +958,11 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f setJournalStats(bankData.data?.journalStats || {}); if (bankData.data?.accounts) setAccounts(bankData.data.accounts); } + if (cardData.success) { + setCardTransactionsList(cardData.data?.logs || []); + setCardJournalStats(cardData.data?.journalStats || {}); + if (cardData.data?.cards) setCards(cardData.data.cards); + } if (journalData.success) { setJournalEntries(journalData.data || []); } @@ -963,13 +981,13 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f journalEntries.forEach(je => { journalMap[je.id] = je; }); // 통합 행 생성 - const bankLinkedJournalIds = new Set(); + const linkedJournalIds = new Set(); const rows = []; // 1) 은행거래 행 transactions.forEach(tx => { const je = tx.journalId ? journalMap[tx.journalId] : null; - if (tx.journalId) bankLinkedJournalIds.add(tx.journalId); + if (tx.journalId) linkedJournalIds.add(tx.journalId); rows.push({ type: 'bank', key: `bank-${tx.uniqueKey}`, @@ -986,14 +1004,40 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f totalDebit: je ? je.total_debit : 0, totalCredit: je ? je.total_credit : 0, bankTx: tx, + cardTx: null, sortKey: tx.transDate + (tx.transTime || '000000'), }); }); - // 2) 수동 전표 행 (은행거래에 연결되지 않은 것만) + // 2) 카드거래 행 + cardTransactionsList.forEach(ctx => { + const je = ctx.journalId ? journalMap[ctx.journalId] : null; + if (ctx.journalId) linkedJournalIds.add(ctx.journalId); + rows.push({ + type: 'card', + key: `card-${ctx.uniqueKey}`, + date: ctx.useDate, + time: ctx.useTime, + description: ctx.merchantName || '-', + deposit: 0, + withdraw: ctx.approvalAmount || 0, + balance: null, + hasJournal: ctx.hasJournal, + journalId: ctx.journalId, + entryNo: ctx.journalEntryNo || (je && je.entry_no) || null, + lines: je ? je.lines : [], + totalDebit: je ? je.total_debit : 0, + totalCredit: je ? je.total_credit : 0, + bankTx: null, + cardTx: ctx, + sortKey: ctx.useDate + (ctx.useTime || '000000'), + }); + }); + + // 3) 수동 전표 행 (은행/카드거래에 연결되지 않은 것만) journalEntries.forEach(je => { - if (bankLinkedJournalIds.has(je.id)) return; - if (je.source_type === 'bank_transaction') return; + if (linkedJournalIds.has(je.id)) return; + if (je.source_type === 'bank_transaction' || je.source_type === 'ecard_transaction') return; rows.push({ type: 'manual', key: `manual-${je.id}`, @@ -1010,6 +1054,7 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f totalDebit: je.total_debit, totalCredit: je.total_credit, bankTx: null, + cardTx: null, sortKey: je.entry_date.replace(/-/g, '') + '999999', }); }); @@ -1020,24 +1065,34 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f // 필터 적용 const filtered = rows.filter(r => { if (viewFilter === 'bank') return r.type === 'bank'; + if (viewFilter === 'card') return r.type === 'card'; if (viewFilter === 'manual') return r.type === 'manual'; - if (viewFilter === 'unjournaled') return r.type === 'bank' && !r.hasJournal; + if (viewFilter === 'unjournaled') return (r.type === 'bank' || r.type === 'card') && !r.hasJournal; return true; }); // 통계 const bankRows = rows.filter(r => r.type === 'bank'); + const cardRows = rows.filter(r => r.type === 'card'); const manualRows = rows.filter(r => r.type === 'manual'); const depositSum = bankRows.reduce((s, r) => s + r.deposit, 0); const withdrawSum = bankRows.reduce((s, r) => s + r.withdraw, 0); + const cardAmountSum = cardRows.reduce((s, r) => s + r.withdraw, 0); const totalDebit = filtered.reduce((s, r) => s + (r.totalDebit || 0), 0); const totalCredit = filtered.reduce((s, r) => s + (r.totalCredit || 0), 0); + const allJournaledCount = (journalStats.journaledCount || 0) + (cardJournalStats.journaledCount || 0); + const allUnjournaledCount = (journalStats.unjournaledCount || 0) + (cardJournalStats.unjournaledCount || 0); const handleJournal = (tx) => { setModalTransaction(tx); setShowJournalModal(true); }; + const handleCardJournal = (ctx) => { + setCardModalTransaction(ctx); + setShowCardJournalModal(true); + }; + const handleJournalSaved = () => { setShowJournalModal(false); setModalTransaction(null); @@ -1050,6 +1105,18 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f fetchData(); }; + const handleCardJournalSaved = () => { + setShowCardJournalModal(false); + setCardModalTransaction(null); + fetchData(); + }; + + const handleCardJournalDeleted = () => { + setShowCardJournalModal(false); + setCardModalTransaction(null); + fetchData(); + }; + return (
카드사용
+{formatCurrency(cardAmountSum)}
+분개완료
-{journalStats.journaledCount || 0}건
+{allJournaledCount}건
미분개
-{journalStats.unjournaledCount || 0}건
+{allUnjournaledCount}건