From 9c21ff97204ae3725246beb2369e2645bcc72c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 25 Feb 2026 09:35:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[receivables]=20=EB=AF=B8=EC=88=98?= =?UTF-8?q?=EA=B8=88=20=EC=9D=B4=EC=9B=94=EC=9E=94=EC=95=A1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summary() 메서드: 거래처별 이월잔액(priorBalance) 계산 추가 - ledger() 메서드: 원장 누적잔액이 이월잔액부터 시작하도록 변경 - 프론트엔드: LedgerTab 이월잔액 통계카드 추가 (조건부 표시) - 프론트엔드: SummaryTab 이월잔액 컬럼 추가, 라벨 당기발생/당기회수로 변경 - CSV 다운로드에 이월잔액 포함 --- .../Finance/ReceivableController.php | 172 ++++++++++++++++-- resources/views/finance/receivables.blade.php | 41 ++++- 2 files changed, 183 insertions(+), 30 deletions(-) diff --git a/app/Http/Controllers/Finance/ReceivableController.php b/app/Http/Controllers/Finance/ReceivableController.php index 99b5c363..4719d8e9 100644 --- a/app/Http/Controllers/Finance/ReceivableController.php +++ b/app/Http/Controllers/Finance/ReceivableController.php @@ -9,6 +9,7 @@ use App\Models\Finance\Receivable; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class ReceivableController extends Controller { @@ -194,6 +195,53 @@ public function ledger(Request $request): JsonResponse $partner = $request->input('partner'); $source = $request->input('source', 'all'); + // 분개 완료된 매출세금계산서 ID + $journaledInvoiceIds = []; + if ($source !== 'journal') { + $journaledInvoiceIds = HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('invoice_type', 'sales') + ->distinct() + ->pluck('hometax_invoice_id') + ->toArray(); + } + + // 이월 잔액 계산 (startDate 이전) + $priorBalance = 0; + if ($startDate) { + if ($source !== 'journal') { + $priorHtQuery = HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('account_code', '108') + ->where('invoice_type', 'sales') + ->where('write_date', '<', $startDate); + if ($partner) { + $priorHtQuery->where('trading_partner_name', 'like', "%{$partner}%"); + } + $priorHtData = $priorHtQuery->selectRaw('SUM(debit_amount) as td, SUM(credit_amount) as tc')->first(); + $priorBalance += ($priorHtData->td ?? 0) - ($priorHtData->tc ?? 0); + + $priorUnjQuery = HometaxInvoice::where('tenant_id', $tenantId) + ->where('invoice_type', 'sales') + ->whereNotIn('id', $journaledInvoiceIds) + ->where('write_date', '<', $startDate); + if ($partner) { + $priorUnjQuery->where('invoicee_corp_name', 'like', "%{$partner}%"); + } + $priorBalance += (int) $priorUnjQuery->sum('total_amount'); + } + if ($source !== 'hometax') { + $priorJnlQuery = JournalEntryLine::where('journal_entry_lines.tenant_id', $tenantId) + ->where('journal_entry_lines.account_code', '108') + ->join('journal_entries', 'journal_entries.id', '=', 'journal_entry_lines.journal_entry_id') + ->whereNull('journal_entries.deleted_at') + ->where('journal_entries.entry_date', '<', $startDate); + if ($partner) { + $priorJnlQuery->where('journal_entry_lines.trading_partner_name', 'like', "%{$partner}%"); + } + $priorJnlData = $priorJnlQuery->selectRaw('SUM(journal_entry_lines.debit_amount) as td, SUM(journal_entry_lines.credit_amount) as tc')->first(); + $priorBalance += ($priorJnlData->td ?? 0) - ($priorJnlData->tc ?? 0); + } + } + $items = collect(); // 1) 홈택스 매출세금계산서에서 외상매출금 조회 @@ -213,12 +261,6 @@ public function ledger(Request $request): JsonResponse $hometaxQuery->where('trading_partner_name', 'like', "%{$partner}%"); } - $journaledInvoiceIds = HometaxInvoiceJournal::where('tenant_id', $tenantId) - ->where('invoice_type', 'sales') - ->distinct() - ->pluck('hometax_invoice_id') - ->toArray(); - $hometaxItems = $hometaxQuery->get()->map(fn ($j) => [ 'date' => $j->write_date?->format('Y-m-d'), 'source' => 'hometax', @@ -293,9 +335,9 @@ public function ledger(Request $request): JsonResponse $items = $items->merge($journalItems); } - // 날짜순 정렬 + 누적잔액 계산 + // 날짜순 정렬 + 누적잔액 계산 (이월잔액부터 시작) $sorted = $items->sortBy('date')->values(); - $balance = 0; + $balance = $priorBalance; $sorted = $sorted->map(function ($item) use (&$balance) { $balance += $item['debitAmount'] - $item['creditAmount']; $item['balance'] = $balance; @@ -311,16 +353,17 @@ public function ledger(Request $request): JsonResponse 'success' => true, 'data' => $sorted->values(), 'stats' => [ + 'priorBalance' => $priorBalance, 'totalDebit' => $totalDebit, 'totalCredit' => $totalCredit, - 'balance' => $totalDebit - $totalCredit, + 'balance' => $priorBalance + $totalDebit - $totalCredit, 'partnerCount' => $partnerCount, ], ]); } /** - * 거래처별 외상매출금 요약 + * 거래처별 외상매출금 요약 (이월잔액 포함) */ public function summary(Request $request): JsonResponse { @@ -328,6 +371,79 @@ public function summary(Request $request): JsonResponse $startDate = $request->input('startDate'); $endDate = $request->input('endDate'); + // 분개 완료된 매출세금계산서 ID (전 기간) + $journaledInvoiceIds = HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('invoice_type', 'sales') + ->distinct() + ->pluck('hometax_invoice_id') + ->toArray(); + + // 0. 이월 잔액 계산 (startDate 이전의 누적 잔액) + $priorBalanceMap = []; + + if ($startDate) { + // 이월 - 홈택스 분개 완료건 (account 108, sales) + HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('account_code', '108') + ->where('invoice_type', 'sales') + ->where('write_date', '<', $startDate) + ->select( + 'trading_partner_name', + DB::raw('SUM(debit_amount) as total_debit'), + DB::raw('SUM(credit_amount) as total_credit') + ) + ->groupBy('trading_partner_name') + ->get() + ->each(function ($row) use (&$priorBalanceMap) { + $key = $row->trading_partner_name; + if (! isset($priorBalanceMap[$key])) { + $priorBalanceMap[$key] = ['debit' => 0, 'credit' => 0]; + } + $priorBalanceMap[$key]['debit'] += $row->total_debit; + $priorBalanceMap[$key]['credit'] += $row->total_credit; + }); + + // 이월 - 미분개 매출세금계산서 (차변 발생) + HometaxInvoice::where('tenant_id', $tenantId) + ->where('invoice_type', 'sales') + ->whereNotIn('id', $journaledInvoiceIds) + ->where('write_date', '<', $startDate) + ->select('invoicee_corp_name', DB::raw('SUM(total_amount) as total_amount')) + ->groupBy('invoicee_corp_name') + ->get() + ->each(function ($row) use (&$priorBalanceMap) { + $key = $row->invoicee_corp_name; + if (! isset($priorBalanceMap[$key])) { + $priorBalanceMap[$key] = ['debit' => 0, 'credit' => 0]; + } + $priorBalanceMap[$key]['debit'] += $row->total_amount; + }); + + // 이월 - 일반전표 (account 108) + JournalEntryLine::where('journal_entry_lines.tenant_id', $tenantId) + ->where('journal_entry_lines.account_code', '108') + ->join('journal_entries', function ($join) { + $join->on('journal_entries.id', '=', 'journal_entry_lines.journal_entry_id') + ->whereNull('journal_entries.deleted_at'); + }) + ->where('journal_entries.entry_date', '<', $startDate) + ->select( + 'journal_entry_lines.trading_partner_name', + DB::raw('SUM(journal_entry_lines.debit_amount) as total_debit'), + DB::raw('SUM(journal_entry_lines.credit_amount) as total_credit') + ) + ->groupBy('journal_entry_lines.trading_partner_name') + ->get() + ->each(function ($row) use (&$priorBalanceMap) { + $key = $row->trading_partner_name; + if (! isset($priorBalanceMap[$key])) { + $priorBalanceMap[$key] = ['debit' => 0, 'credit' => 0]; + } + $priorBalanceMap[$key]['debit'] += $row->total_debit; + $priorBalanceMap[$key]['credit'] += $row->total_credit; + }); + } + $items = collect(); // 1) 홈택스 매출세금계산서에서 외상매출금 @@ -343,12 +459,6 @@ public function summary(Request $request): JsonResponse $hometaxQuery->where('write_date', '<=', $endDate); } - $journaledInvoiceIds = HometaxInvoiceJournal::where('tenant_id', $tenantId) - ->where('invoice_type', 'sales') - ->distinct() - ->pluck('hometax_invoice_id') - ->toArray(); - $hometaxItems = $hometaxQuery->get()->map(fn ($j) => [ 'tradingPartnerName' => $j->trading_partner_name, 'debitAmount' => $j->dc_type === 'debit' ? (int) $j->debit_amount : 0, @@ -399,20 +509,42 @@ public function summary(Request $request): JsonResponse ]); $items = $items->merge($journalItems); - // 거래처별 그룹핑 - $grouped = $items->groupBy('tradingPartnerName')->map(function ($group, $partnerName) { + // 거래처별 그룹핑 (이월 잔액 포함) + $grouped = $items->groupBy('tradingPartnerName')->map(function ($group, $partnerName) use ($priorBalanceMap) { $totalDebit = $group->sum('debitAmount'); $totalCredit = $group->sum('creditAmount'); + $prior = $priorBalanceMap[$partnerName] ?? null; + $priorBalance = $prior ? ($prior['debit'] - $prior['credit']) : 0; return [ 'tradingPartnerName' => $partnerName ?: '(미지정)', + 'priorBalance' => $priorBalance, 'totalDebit' => $totalDebit, 'totalCredit' => $totalCredit, - 'balance' => $totalDebit - $totalCredit, + 'balance' => $priorBalance + $totalDebit - $totalCredit, 'lastTransactionDate' => $group->pluck('date')->filter()->sort()->last(), 'transactionCount' => $group->count(), ]; - })->sortByDesc('balance')->values(); + }); + + // 이월 잔액만 있고 당기 거래가 없는 거래처도 포함 + $existingPartners = $grouped->keys()->toArray(); + foreach ($priorBalanceMap as $partnerName => $prior) { + $priorBalance = $prior['debit'] - $prior['credit']; + if ($priorBalance != 0 && ! in_array($partnerName, $existingPartners)) { + $grouped[$partnerName] = [ + 'tradingPartnerName' => $partnerName ?: '(미지정)', + 'priorBalance' => $priorBalance, + 'totalDebit' => 0, + 'totalCredit' => 0, + 'balance' => $priorBalance, + 'lastTransactionDate' => null, + 'transactionCount' => 0, + ]; + } + } + + $grouped = $grouped->sortByDesc('balance')->values(); return response()->json([ 'success' => true, diff --git a/resources/views/finance/receivables.blade.php b/resources/views/finance/receivables.blade.php index 05ffb49e..b463cb00 100644 --- a/resources/views/finance/receivables.blade.php +++ b/resources/views/finance/receivables.blade.php @@ -67,7 +67,7 @@ function LoadingSpinner() { // ==================== 탭 1: 외상매출금 원장 ==================== function LedgerTab({ startDate, endDate, source, partnerSearch }) { const [items, setItems] = useState([]); - const [stats, setStats] = useState({ totalDebit: 0, totalCredit: 0, balance: 0, partnerCount: 0 }); + const [stats, setStats] = useState({ priorBalance: 0, totalDebit: 0, totalCredit: 0, balance: 0, partnerCount: 0 }); const [loading, setLoading] = useState(true); const fetchLedger = async () => { @@ -112,18 +112,25 @@ function LedgerTab({ startDate, endDate, source, partnerSearch }) { return (
{/* 통계 카드 */} -
+
+ {stats.priorBalance !== 0 && ( +
+
이월잔액
+

{formatCurrency(stats.priorBalance)}원

+
+ )}
-
발생액 (차변)
+
{stats.priorBalance ? '당기발생 (차변)' : '발생액 (차변)'}

{formatCurrency(stats.totalDebit)}원

-
회수액 (대변)
+
{stats.priorBalance ? '당기회수 (대변)' : '회수액 (대변)'}

{formatCurrency(stats.totalCredit)}원

미수잔액

{formatCurrency(stats.balance)}원

+ {stats.priorBalance !== 0 &&

이월 + 발생 - 회수

}
거래처 수
@@ -217,18 +224,29 @@ function SummaryTab({ startDate, endDate, onSelectPartner }) { useEffect(() => { fetchSummary(); }, [startDate, endDate]); + const totalPriorBalance = items.reduce((sum, item) => sum + (item.priorBalance || 0), 0); const totalDebit = items.reduce((sum, item) => sum + item.totalDebit, 0); const totalCredit = items.reduce((sum, item) => sum + item.totalCredit, 0); const totalBalance = items.reduce((sum, item) => sum + item.balance, 0); + const hasPrior = totalPriorBalance !== 0; const handleDownload = () => { + const headers = hasPrior + ? ['거래처', '이월잔액', '당기발생(차변)', '당기회수(대변)', '미수잔액', '최종거래일', '거래건수'] + : ['거래처', '발생액(차변)', '회수액(대변)', '미수잔액', '최종거래일', '거래건수']; + const dataRows = hasPrior + ? items.map(item => [item.tradingPartnerName, item.priorBalance || 0, item.totalDebit, item.totalCredit, item.balance, item.lastTransactionDate, item.transactionCount]) + : items.map(item => [item.tradingPartnerName, item.totalDebit, item.totalCredit, item.balance, item.lastTransactionDate, item.transactionCount]); + const footerRow = hasPrior + ? ['합계', totalPriorBalance, totalDebit, totalCredit, totalBalance, '', items.reduce((sum, item) => sum + item.transactionCount, 0)] + : ['합계', totalDebit, totalCredit, totalBalance, '', items.reduce((sum, item) => sum + item.transactionCount, 0)]; const rows = [ ['거래처별 외상매출금 요약', `${startDate} ~ ${endDate}`], [], - ['거래처', '발생액(차변)', '회수액(대변)', '미수잔액', '최종거래일', '거래건수'], - ...items.map(item => [item.tradingPartnerName, item.totalDebit, item.totalCredit, item.balance, item.lastTransactionDate, item.transactionCount]), + headers, + ...dataRows, [], - ['합계', totalDebit, totalCredit, totalBalance, '', items.reduce((sum, item) => sum + item.transactionCount, 0)] + footerRow ]; const csvContent = rows.map(row => row.join(',')).join('\n'); const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); @@ -254,8 +272,9 @@ function SummaryTab({ startDate, endDate, onSelectPartner }) { 거래처 - 발생액(차변) - 회수액(대변) + {hasPrior && 이월잔액} + {hasPrior ? '당기발생(차변)' : '발생액(차변)'} + {hasPrior ? '당기회수(대변)' : '회수액(대변)'} 미수잔액 최종거래일 거래건수 @@ -263,7 +282,7 @@ function SummaryTab({ startDate, endDate, onSelectPartner }) { {loading ? : items.length === 0 ? ( - + 거래처별 외상매출금 데이터가 없습니다. ) : items.map((item, idx) => ( @@ -273,6 +292,7 @@ className="hover:bg-blue-50 cursor-pointer">

{item.tradingPartnerName}

+ {hasPrior && {item.priorBalance ? formatCurrency(item.priorBalance) : '-'}} {formatCurrency(item.totalDebit)} {formatCurrency(item.totalCredit)} @@ -289,6 +309,7 @@ className="hover:bg-blue-50 cursor-pointer"> 합계 ({items.length}개 거래처) + {hasPrior && {formatCurrency(totalPriorBalance)}} {formatCurrency(totalDebit)} {formatCurrency(totalCredit)} {formatCurrency(totalBalance)}