tenantId(); $startDate = $params['start_date'] ?? null; $endDate = $params['end_date'] ?? null; $search = $params['search'] ?? null; $perPage = (int) ($params['per_page'] ?? 20); $page = (int) ($params['page'] ?? 1); // 1) 입금(transfer) UNION 출금(transfer) UNION 수기전표 $depositsQuery = DB::table('deposits') ->leftJoin('journal_entries', function ($join) use ($tenantId) { $join->on('journal_entries.source_key', '=', DB::raw("CONCAT('deposit_', deposits.id)")) ->where('journal_entries.tenant_id', $tenantId) ->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION) ->whereNull('journal_entries.deleted_at'); }) ->where('deposits.tenant_id', $tenantId) ->where('deposits.payment_method', 'transfer') ->whereNull('deposits.deleted_at') ->select([ 'deposits.id', 'deposits.deposit_date as date', DB::raw("'deposit' as division"), 'deposits.amount', 'deposits.description', DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'), 'deposits.amount as deposit_amount', DB::raw('0 as withdrawal_amount'), DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'), DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'), DB::raw("'linked' as source"), 'deposits.created_at', 'deposits.updated_at', DB::raw('journal_entries.id as journal_entry_id'), ]); $withdrawalsQuery = DB::table('withdrawals') ->leftJoin('journal_entries', function ($join) use ($tenantId) { $join->on('journal_entries.source_key', '=', DB::raw("CONCAT('withdrawal_', withdrawals.id)")) ->where('journal_entries.tenant_id', $tenantId) ->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION) ->whereNull('journal_entries.deleted_at'); }) ->where('withdrawals.tenant_id', $tenantId) ->where('withdrawals.payment_method', 'transfer') ->whereNull('withdrawals.deleted_at') ->select([ 'withdrawals.id', 'withdrawals.withdrawal_date as date', DB::raw("'withdrawal' as division"), 'withdrawals.amount', 'withdrawals.description', DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'), DB::raw('0 as deposit_amount'), 'withdrawals.amount as withdrawal_amount', DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'), DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'), DB::raw("'linked' as source"), 'withdrawals.created_at', 'withdrawals.updated_at', DB::raw('journal_entries.id as journal_entry_id'), ]); $manualQuery = DB::table('journal_entries') ->where('journal_entries.tenant_id', $tenantId) ->where('journal_entries.source_type', JournalEntry::SOURCE_MANUAL) ->whereNull('journal_entries.deleted_at') ->select([ 'journal_entries.id', 'journal_entries.entry_date as date', DB::raw("'transfer' as division"), 'journal_entries.total_debit as amount', 'journal_entries.description', 'journal_entries.description as journal_description', DB::raw('0 as deposit_amount'), DB::raw('0 as withdrawal_amount'), 'journal_entries.total_debit as debit_amount', 'journal_entries.total_credit as credit_amount', DB::raw("'manual' as source"), 'journal_entries.created_at', 'journal_entries.updated_at', DB::raw('journal_entries.id as journal_entry_id'), ]); // 날짜 필터 if ($startDate) { $depositsQuery->where('deposits.deposit_date', '>=', $startDate); $withdrawalsQuery->where('withdrawals.withdrawal_date', '>=', $startDate); $manualQuery->where('journal_entries.entry_date', '>=', $startDate); } if ($endDate) { $depositsQuery->where('deposits.deposit_date', '<=', $endDate); $withdrawalsQuery->where('withdrawals.withdrawal_date', '<=', $endDate); $manualQuery->where('journal_entries.entry_date', '<=', $endDate); } // 검색 필터 if ($search) { $depositsQuery->where(function ($q) use ($search) { $q->where('deposits.description', 'like', "%{$search}%") ->orWhere('deposits.client_name', 'like', "%{$search}%"); }); $withdrawalsQuery->where(function ($q) use ($search) { $q->where('withdrawals.description', 'like', "%{$search}%") ->orWhere('withdrawals.client_name', 'like', "%{$search}%"); }); $manualQuery->where('journal_entries.description', 'like', "%{$search}%"); } // UNION $unionQuery = $depositsQuery ->unionAll($withdrawalsQuery) ->unionAll($manualQuery); // 전체 건수 $totalCount = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table")) ->mergeBindings($unionQuery) ->count(); // 날짜순 정렬 + 페이지네이션 $items = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table")) ->mergeBindings($unionQuery) ->orderBy('date', 'desc') ->orderBy('created_at', 'desc') ->offset(($page - 1) * $perPage) ->limit($perPage) ->get(); // 누적잔액 계산 (해당 기간 전체 기준) $allForBalance = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table")) ->mergeBindings($unionQuery) ->orderBy('date', 'asc') ->orderBy('created_at', 'asc') ->get(['deposit_amount', 'withdrawal_amount']); $runningBalance = 0; $balanceMap = []; foreach ($allForBalance as $idx => $row) { $runningBalance += (int) $row->deposit_amount - (int) $row->withdrawal_amount; $balanceMap[$idx] = $runningBalance; } // 역순이므로 현재 페이지에 해당하는 잔액을 매핑 $totalItems = count($allForBalance); $items = $items->map(function ($item, $index) use ($balanceMap, $totalItems, $page, $perPage) { // 역순 인덱스 → 정순 인덱스 $reverseIdx = $totalItems - 1 - (($page - 1) * $perPage + $index); $item->balance = $reverseIdx >= 0 ? ($balanceMap[$reverseIdx] ?? 0) : 0; return $item; }); return [ 'data' => $items->toArray(), 'current_page' => $page, 'last_page' => (int) ceil($totalCount / $perPage), 'per_page' => $perPage, 'total' => $totalCount, ]; } /** * 요약 통계 */ public function summary(array $params): array { $tenantId = $this->tenantId(); $startDate = $params['start_date'] ?? null; $endDate = $params['end_date'] ?? null; $search = $params['search'] ?? null; // 입금 통계 $depositQuery = DB::table('deposits') ->where('tenant_id', $tenantId) ->where('payment_method', 'transfer') ->whereNull('deleted_at'); // 출금 통계 $withdrawalQuery = DB::table('withdrawals') ->where('tenant_id', $tenantId) ->where('payment_method', 'transfer') ->whereNull('deleted_at'); if ($startDate) { $depositQuery->where('deposit_date', '>=', $startDate); $withdrawalQuery->where('withdrawal_date', '>=', $startDate); } if ($endDate) { $depositQuery->where('deposit_date', '<=', $endDate); $withdrawalQuery->where('withdrawal_date', '<=', $endDate); } if ($search) { $depositQuery->where(function ($q) use ($search) { $q->where('description', 'like', "%{$search}%") ->orWhere('client_name', 'like', "%{$search}%"); }); $withdrawalQuery->where(function ($q) use ($search) { $q->where('description', 'like', "%{$search}%") ->orWhere('client_name', 'like', "%{$search}%"); }); } $depositCount = (clone $depositQuery)->count(); $depositAmount = (int) (clone $depositQuery)->sum('amount'); $withdrawalCount = (clone $withdrawalQuery)->count(); $withdrawalAmount = (int) (clone $withdrawalQuery)->sum('amount'); // 분개 완료/미완료 건수 (journal_entries가 연결된 입출금 수) $journalCompleteCount = DB::table('journal_entries') ->where('tenant_id', $tenantId) ->where('source_type', JournalEntry::SOURCE_BANK_TRANSACTION) ->whereNull('deleted_at') ->when($startDate, fn ($q) => $q->where('entry_date', '>=', $startDate)) ->when($endDate, fn ($q) => $q->where('entry_date', '<=', $endDate)) ->count(); $totalCount = $depositCount + $withdrawalCount; $journalIncompleteCount = max(0, $totalCount - $journalCompleteCount); return [ 'total_count' => $totalCount, 'deposit_count' => $depositCount, 'deposit_amount' => $depositAmount, 'withdrawal_count' => $withdrawalCount, 'withdrawal_amount' => $withdrawalAmount, 'journal_complete_count' => $journalCompleteCount, 'journal_incomplete_count' => $journalIncompleteCount, ]; } /** * 전표 상세 조회 (분개 수정 모달용) */ public function show(int $id): array { $tenantId = $this->tenantId(); $entry = JournalEntry::query() ->where('tenant_id', $tenantId) ->with('lines') ->findOrFail($id); // source_type에 따라 원본 거래 정보 조회 $sourceInfo = $this->getSourceInfo($entry); return [ 'id' => $entry->id, 'date' => $entry->entry_date->format('Y-m-d'), 'division' => $sourceInfo['division'], 'amount' => $sourceInfo['amount'], 'description' => $sourceInfo['description'] ?? $entry->description, 'bank_name' => $sourceInfo['bank_name'] ?? '', 'account_number' => $sourceInfo['account_number'] ?? '', 'journal_memo' => $entry->description, 'rows' => $entry->lines->map(function ($line) { return [ 'id' => $line->id, 'side' => $line->dc_type, 'account_subject_id' => $line->account_code, 'account_subject_name' => $line->account_name, 'vendor_id' => $line->trading_partner_id, 'vendor_name' => $line->trading_partner_name ?? '', 'debit_amount' => (int) $line->debit_amount, 'credit_amount' => (int) $line->credit_amount, 'memo' => $line->description ?? '', ]; })->toArray(), ]; } /** * 수기전표 등록 */ public function store(array $data): JournalEntry { $tenantId = $this->tenantId(); return DB::transaction(function () use ($data, $tenantId) { // 차대 균형 검증 $this->validateDebitCreditBalance($data['rows']); // 전표번호 생성 $entryNo = $this->generateEntryNo($tenantId, $data['journal_date']); // 합계 계산 $totalDebit = 0; $totalCredit = 0; foreach ($data['rows'] as $row) { $totalDebit += (int) ($row['debit_amount'] ?? 0); $totalCredit += (int) ($row['credit_amount'] ?? 0); } // 전표 생성 $entry = new JournalEntry; $entry->tenant_id = $tenantId; $entry->entry_no = $entryNo; $entry->entry_date = $data['journal_date']; $entry->entry_type = JournalEntry::TYPE_GENERAL; $entry->description = $data['description'] ?? null; $entry->total_debit = $totalDebit; $entry->total_credit = $totalCredit; $entry->status = JournalEntry::STATUS_CONFIRMED; $entry->source_type = JournalEntry::SOURCE_MANUAL; $entry->source_key = null; $entry->save(); // 분개 행 생성 $this->createLines($entry, $data['rows'], $tenantId); return $entry->load('lines'); }); } /** * 분개 수정 (lines 전체 교체) */ public function updateJournal(int $id, array $data): JournalEntry { $tenantId = $this->tenantId(); return DB::transaction(function () use ($id, $data, $tenantId) { $entry = JournalEntry::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 메모 업데이트 if (array_key_exists('journal_memo', $data)) { $entry->description = $data['journal_memo']; } // rows가 있으면 lines 교체 if (isset($data['rows']) && ! empty($data['rows'])) { $this->validateDebitCreditBalance($data['rows']); // 기존 lines 삭제 JournalEntryLine::query() ->where('journal_entry_id', $entry->id) ->delete(); // 새 lines 생성 $this->createLines($entry, $data['rows'], $tenantId); // 합계 재계산 $totalDebit = 0; $totalCredit = 0; foreach ($data['rows'] as $row) { $totalDebit += (int) ($row['debit_amount'] ?? 0); $totalCredit += (int) ($row['credit_amount'] ?? 0); } $entry->total_debit = $totalDebit; $entry->total_credit = $totalCredit; } $entry->save(); return $entry->load('lines'); }); } /** * 전표 삭제 (soft delete, lines는 FK CASCADE) */ public function destroyJournal(int $id): bool { $tenantId = $this->tenantId(); return DB::transaction(function () use ($id, $tenantId) { $entry = JournalEntry::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // lines 먼저 삭제 (soft delete가 아니므로 물리 삭제) JournalEntryLine::query() ->where('journal_entry_id', $entry->id) ->delete(); $entry->delete(); // soft delete return true; }); } /** * 전표번호 생성: JE-YYYYMMDD-NNN (동시성 안전) */ private function generateEntryNo(int $tenantId, string $date): string { $dateStr = str_replace('-', '', substr($date, 0, 10)); $prefix = "JE-{$dateStr}-"; // SELECT ... FOR UPDATE 락으로 동시성 안전 보장 $lastEntry = DB::table('journal_entries') ->where('tenant_id', $tenantId) ->where('entry_no', 'like', "{$prefix}%") ->lockForUpdate() ->orderBy('entry_no', 'desc') ->first(['entry_no']); if ($lastEntry) { $lastSeq = (int) substr($lastEntry->entry_no, -3); $nextSeq = $lastSeq + 1; } else { $nextSeq = 1; } return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT); } /** * 차대 균형 검증 */ private function validateDebitCreditBalance(array $rows): void { $totalDebit = 0; $totalCredit = 0; foreach ($rows as $row) { $totalDebit += (int) ($row['debit_amount'] ?? 0); $totalCredit += (int) ($row['credit_amount'] ?? 0); } if ($totalDebit !== $totalCredit) { throw new BadRequestHttpException(__('error.journal_entry.debit_credit_mismatch')); } } /** * 분개 행 생성 */ private function createLines(JournalEntry $entry, array $rows, int $tenantId): void { foreach ($rows as $index => $row) { $accountCode = $row['account_subject_id'] ?? ''; $accountName = $this->resolveAccountName($tenantId, $accountCode); $vendorName = $this->resolveVendorName($row['vendor_id'] ?? null); $line = new JournalEntryLine; $line->tenant_id = $tenantId; $line->journal_entry_id = $entry->id; $line->line_no = $index + 1; $line->dc_type = $row['side']; $line->account_code = $accountCode; $line->account_name = $accountName; $line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null; $line->trading_partner_name = $vendorName; $line->debit_amount = (int) ($row['debit_amount'] ?? 0); $line->credit_amount = (int) ($row['credit_amount'] ?? 0); $line->description = $row['memo'] ?? null; $line->save(); } } /** * 계정과목 코드 → 이름 조회 */ private function resolveAccountName(int $tenantId, string $code): string { if (empty($code)) { return ''; } $account = AccountCode::query() ->where('tenant_id', $tenantId) ->where('code', $code) ->first(['name']); return $account ? $account->name : $code; } /** * 거래처 ID → 이름 조회 */ private function resolveVendorName(?int $vendorId): string { if (! $vendorId) { return ''; } $vendor = DB::table('clients') ->where('id', $vendorId) ->first(['name']); return $vendor ? $vendor->name : ''; } /** * 원본 거래 정보 조회 (입금/출금) */ private function getSourceInfo(JournalEntry $entry): array { if ($entry->source_type === JournalEntry::SOURCE_MANUAL) { return [ 'division' => 'transfer', 'amount' => $entry->total_debit, 'description' => $entry->description, 'bank_name' => '', 'account_number' => '', ]; } // bank_transaction → deposit_123 / withdrawal_456 if ($entry->source_key && str_starts_with($entry->source_key, 'deposit_')) { $sourceId = (int) str_replace('deposit_', '', $entry->source_key); $deposit = DB::table('deposits') ->leftJoin('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id') ->where('deposits.id', $sourceId) ->first([ 'deposits.amount', 'deposits.description', 'bank_accounts.bank_name', 'bank_accounts.account_number', ]); if ($deposit) { return [ 'division' => 'deposit', 'amount' => (int) $deposit->amount, 'description' => $deposit->description, 'bank_name' => $deposit->bank_name ?? '', 'account_number' => $deposit->account_number ?? '', ]; } } if ($entry->source_key && str_starts_with($entry->source_key, 'withdrawal_')) { $sourceId = (int) str_replace('withdrawal_', '', $entry->source_key); $withdrawal = DB::table('withdrawals') ->leftJoin('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id') ->where('withdrawals.id', $sourceId) ->first([ 'withdrawals.amount', 'withdrawals.description', 'bank_accounts.bank_name', 'bank_accounts.account_number', ]); if ($withdrawal) { return [ 'division' => 'withdrawal', 'amount' => (int) $withdrawal->amount, 'description' => $withdrawal->description, 'bank_name' => $withdrawal->bank_name ?? '', 'account_number' => $withdrawal->account_number ?? '', ]; } } return [ 'division' => 'transfer', 'amount' => $entry->total_debit, 'description' => $entry->description, 'bank_name' => '', 'account_number' => '', ]; } }