input('search')) { $query->where(function ($q) use ($search) { $q->where('customer_name', 'like', "%{$search}%") ->orWhere('invoice_no', 'like', "%{$search}%"); }); } if ($status = $request->input('status')) { if ($status !== 'all') { $query->where('status', $status); } } if ($category = $request->input('category')) { if ($category !== 'all') { $query->where('category', $category); } } $receivables = $query->orderBy('created_at', 'desc') ->get() ->map(function ($item) { return [ 'id' => $item->id, 'customerName' => $item->customer_name, 'invoiceNo' => $item->invoice_no, 'issueDate' => $item->issue_date?->format('Y-m-d'), 'dueDate' => $item->due_date?->format('Y-m-d'), 'category' => $item->category, 'amount' => $item->amount, 'collectedAmount' => $item->collected_amount, 'status' => $item->status, 'description' => $item->description, 'memo' => $item->memo, ]; }); $allQuery = Receivable::forTenant($tenantId); $all = (clone $allQuery)->get(); $totalAmount = $all->sum('amount'); $totalCollected = $all->sum('collected_amount'); $overdueAmount = $all->where('status', 'overdue')->sum(function ($item) { return $item->amount - $item->collected_amount; }); $stats = [ 'totalAmount' => $totalAmount, 'totalCollected' => $totalCollected, 'totalOutstanding' => $totalAmount - $totalCollected, 'overdueAmount' => $overdueAmount, 'count' => $all->count(), ]; return response()->json([ 'success' => true, 'data' => $receivables, 'stats' => $stats, ]); } public function store(Request $request): JsonResponse { $request->validate([ 'customerName' => 'required|string|max:100', 'invoiceNo' => 'required|string|max:50', 'amount' => 'required|integer|min:0', ]); $tenantId = session('selected_tenant_id', 1); $receivable = Receivable::create([ 'tenant_id' => $tenantId, 'customer_name' => $request->input('customerName'), 'invoice_no' => $request->input('invoiceNo'), 'issue_date' => $request->input('issueDate'), 'due_date' => $request->input('dueDate'), 'category' => $request->input('category', '서비스'), 'amount' => $request->input('amount', 0), 'collected_amount' => 0, 'status' => 'outstanding', 'description' => $request->input('description'), 'memo' => $request->input('memo'), ]); return response()->json([ 'success' => true, 'message' => '미수금이 등록되었습니다.', ]); } public function update(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $receivable = Receivable::forTenant($tenantId)->findOrFail($id); $request->validate([ 'customerName' => 'required|string|max:100', 'invoiceNo' => 'required|string|max:50', 'amount' => 'required|integer|min:0', ]); $receivable->update([ 'customer_name' => $request->input('customerName'), 'invoice_no' => $request->input('invoiceNo'), 'issue_date' => $request->input('issueDate'), 'due_date' => $request->input('dueDate'), 'category' => $request->input('category'), 'amount' => $request->input('amount'), 'status' => $request->input('status', $receivable->status), 'description' => $request->input('description'), 'memo' => $request->input('memo'), ]); return response()->json([ 'success' => true, 'message' => '미수금이 수정되었습니다.', ]); } public function collect(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $receivable = Receivable::forTenant($tenantId)->findOrFail($id); $request->validate([ 'collectAmount' => 'required|integer|min:1', ]); $collectAmount = $request->input('collectAmount'); $remaining = $receivable->amount - $receivable->collected_amount; if ($collectAmount > $remaining) { return response()->json([ 'success' => false, 'message' => '수금액이 잔액을 초과합니다.', ], 422); } $newCollected = $receivable->collected_amount + $collectAmount; $newStatus = $newCollected >= $receivable->amount ? 'collected' : 'partial'; $receivable->update([ 'collected_amount' => $newCollected, 'status' => $newStatus, ]); return response()->json([ 'success' => true, 'message' => '수금 처리되었습니다.', ]); } public function destroy(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $receivable = Receivable::forTenant($tenantId)->findOrFail($id); $receivable->delete(); return response()->json([ 'success' => true, 'message' => '미수금이 삭제되었습니다.', ]); } /** * 외상매출금 원장 (홈택스 분개 + 일반전표에서 계정코드 108 집계) */ public function ledger(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $startDate = $request->input('startDate'); $endDate = $request->input('endDate'); $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) 홈택스 매출세금계산서에서 외상매출금 조회 if ($source !== 'journal') { // 1-a) 분개 완료된 건: hometax_invoice_journals에서 계정코드 108 $hometaxQuery = HometaxInvoiceJournal::where('tenant_id', $tenantId) ->where('account_code', '108') ->where('invoice_type', 'sales'); if ($startDate) { $hometaxQuery->where('write_date', '>=', $startDate); } if ($endDate) { $hometaxQuery->where('write_date', '<=', $endDate); } if ($partner) { $hometaxQuery->where('trading_partner_name', 'like', "%{$partner}%"); } $hometaxItems = $hometaxQuery->get()->map(fn ($j) => [ 'date' => $j->write_date?->format('Y-m-d'), 'source' => 'hometax', 'sourceLabel' => '홈택스 매출', 'refNo' => $j->nts_confirm_num, 'tradingPartnerName' => $j->trading_partner_name, 'description' => $j->description ?: '매출세금계산서', 'debitAmount' => $j->dc_type === 'debit' ? (int) $j->debit_amount : 0, 'creditAmount' => $j->dc_type === 'credit' ? (int) $j->credit_amount : 0, ]); $items = $items->merge($hometaxItems); // 1-b) 미분개 매출세금계산서: total_amount를 차변(발생)으로 표시 $unjournaledQuery = HometaxInvoice::where('tenant_id', $tenantId) ->where('invoice_type', 'sales') ->whereNotIn('id', $journaledInvoiceIds); if ($startDate) { $unjournaledQuery->where('write_date', '>=', $startDate); } if ($endDate) { $unjournaledQuery->where('write_date', '<=', $endDate); } if ($partner) { $unjournaledQuery->where('invoicee_corp_name', 'like', "%{$partner}%"); } $unjournaledItems = $unjournaledQuery->get()->map(fn ($inv) => [ 'date' => $inv->write_date?->format('Y-m-d'), 'source' => 'hometax', 'sourceLabel' => '홈택스 매출 (미분개)', 'refNo' => $inv->nts_confirm_num, 'tradingPartnerName' => $inv->invoicee_corp_name, 'description' => '매출세금계산서 (미분개)', 'debitAmount' => (int) $inv->total_amount, 'creditAmount' => 0, ]); $items = $items->merge($unjournaledItems); } // 2) 일반전표에서 외상매출금(108) 조회 if ($source !== 'hometax') { $journalQuery = 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') ->select('journal_entry_lines.*', 'journal_entries.entry_date', 'journal_entries.entry_no'); if ($startDate) { $journalQuery->where('journal_entries.entry_date', '>=', $startDate); } if ($endDate) { $journalQuery->where('journal_entries.entry_date', '<=', $endDate); } if ($partner) { $journalQuery->where('journal_entry_lines.trading_partner_name', 'like', "%{$partner}%"); } $journalItems = $journalQuery->get()->map(fn ($l) => [ 'date' => $l->entry_date instanceof \Carbon\Carbon ? $l->entry_date->format('Y-m-d') : $l->entry_date, 'source' => 'journal', 'sourceLabel' => '일반전표 '.$l->entry_no, 'refNo' => $l->entry_no, 'tradingPartnerName' => $l->trading_partner_name, 'description' => $l->description ?: '일반전표', 'debitAmount' => (int) $l->debit_amount, 'creditAmount' => (int) $l->credit_amount, ]); $items = $items->merge($journalItems); } // 날짜순 정렬 + 누적잔액 계산 (이월잔액부터 시작) $sorted = $items->sortBy('date')->values(); $balance = $priorBalance; $sorted = $sorted->map(function ($item) use (&$balance) { $balance += $item['debitAmount'] - $item['creditAmount']; $item['balance'] = $balance; return $item; }); $totalDebit = $sorted->sum('debitAmount'); $totalCredit = $sorted->sum('creditAmount'); $partnerCount = $sorted->pluck('tradingPartnerName')->filter()->unique()->count(); return response()->json([ 'success' => true, 'data' => $sorted->values(), 'stats' => [ 'priorBalance' => $priorBalance, 'totalDebit' => $totalDebit, 'totalCredit' => $totalCredit, 'balance' => $priorBalance + $totalDebit - $totalCredit, 'partnerCount' => $partnerCount, ], ]); } /** * 거래처별 외상매출금 요약 (이월잔액 포함) */ public function summary(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $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) 홈택스 매출세금계산서에서 외상매출금 // 1-a) 분개 완료된 건 $hometaxQuery = HometaxInvoiceJournal::where('tenant_id', $tenantId) ->where('account_code', '108') ->where('invoice_type', 'sales'); if ($startDate) { $hometaxQuery->where('write_date', '>=', $startDate); } if ($endDate) { $hometaxQuery->where('write_date', '<=', $endDate); } $hometaxItems = $hometaxQuery->get()->map(fn ($j) => [ 'tradingPartnerName' => $j->trading_partner_name, 'debitAmount' => $j->dc_type === 'debit' ? (int) $j->debit_amount : 0, 'creditAmount' => $j->dc_type === 'credit' ? (int) $j->credit_amount : 0, 'date' => $j->write_date?->format('Y-m-d'), ]); $items = $items->merge($hometaxItems); // 1-b) 미분개 매출세금계산서 $unjournaledQuery = HometaxInvoice::where('tenant_id', $tenantId) ->where('invoice_type', 'sales') ->whereNotIn('id', $journaledInvoiceIds); if ($startDate) { $unjournaledQuery->where('write_date', '>=', $startDate); } if ($endDate) { $unjournaledQuery->where('write_date', '<=', $endDate); } $unjournaledItems = $unjournaledQuery->get()->map(fn ($inv) => [ 'tradingPartnerName' => $inv->invoicee_corp_name, 'debitAmount' => (int) $inv->total_amount, 'creditAmount' => 0, 'date' => $inv->write_date?->format('Y-m-d'), ]); $items = $items->merge($unjournaledItems); // 2) 일반전표에서 외상매출금(108) $journalQuery = 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') ->select('journal_entry_lines.*', 'journal_entries.entry_date'); if ($startDate) { $journalQuery->where('journal_entries.entry_date', '>=', $startDate); } if ($endDate) { $journalQuery->where('journal_entries.entry_date', '<=', $endDate); } $journalItems = $journalQuery->get()->map(fn ($l) => [ 'tradingPartnerName' => $l->trading_partner_name, 'debitAmount' => (int) $l->debit_amount, 'creditAmount' => (int) $l->credit_amount, 'date' => $l->entry_date instanceof \Carbon\Carbon ? $l->entry_date->format('Y-m-d') : $l->entry_date, ]); $items = $items->merge($journalItems); // 거래처별 그룹핑 (이월 잔액 포함) $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' => $priorBalance + $totalDebit - $totalCredit, 'lastTransactionDate' => $group->pluck('date')->filter()->sort()->last(), 'transactionCount' => $group->where('debitAmount', '>', 0)->count(), ]; }); // 이월 잔액만 있고 당기 거래가 없는 거래처도 포함 $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, 'data' => $grouped, ]); } }