Files
sam-manage/app/Http/Controllers/Finance/ReceivableController.php
김보곤 9c21ff9720 feat: [receivables] 미수금 이월잔액 반영
- summary() 메서드: 거래처별 이월잔액(priorBalance) 계산 추가
- ledger() 메서드: 원장 누적잔액이 이월잔액부터 시작하도록 변경
- 프론트엔드: LedgerTab 이월잔액 통계카드 추가 (조건부 표시)
- 프론트엔드: SummaryTab 이월잔액 컬럼 추가, 라벨 당기발생/당기회수로 변경
- CSV 다운로드에 이월잔액 포함
2026-02-25 09:35:51 +09:00

555 lines
23 KiB
PHP

<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Barobill\HometaxInvoice;
use App\Models\Barobill\HometaxInvoiceJournal;
use App\Models\Finance\JournalEntryLine;
use App\Models\Finance\Receivable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReceivableController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = Receivable::forTenant($tenantId);
if ($search = $request->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->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,
]);
}
}