with('lines') ->orderByDesc('entry_date') ->orderByDesc('entry_no'); if ($request->filled('start_date')) { $query->where('entry_date', '>=', $request->start_date); } if ($request->filled('end_date')) { $query->where('entry_date', '<=', $request->end_date); } if ($request->filled('status') && $request->status !== 'all') { $query->where('status', $request->status); } if ($request->filled('search')) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('entry_no', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%"); }); } $entries = $query->get(); $data = $entries->map(function ($entry) { return [ '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, 'source_type' => $entry->source_type, 'created_by_name' => $entry->created_by_name, '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, ]; }), ]; }); $stats = [ 'totalCount' => $entries->count(), 'totalDebit' => $entries->sum('total_debit'), 'totalCredit' => $entries->sum('total_credit'), 'draftCount' => $entries->where('status', 'draft')->count(), 'confirmedCount' => $entries->where('status', 'confirmed')->count(), ]; return response()->json([ 'success' => true, 'data' => $data, 'stats' => $stats, ]); } /** * 전표 상세 조회 */ public function show(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $entry = JournalEntry::forTenant($tenantId) ->with('lines') ->findOrFail($id); return response()->json([ 'success' => true, 'data' => [ 'id' => $entry->id, 'entry_no' => $entry->entry_no, 'entry_date' => $entry->entry_date->format('Y-m-d'), 'entry_type' => $entry->entry_type, 'description' => $entry->description, 'total_debit' => $entry->total_debit, 'total_credit' => $entry->total_credit, 'status' => $entry->status, 'source_type' => $entry->source_type, 'created_by_name' => $entry->created_by_name, 'attachment_note' => $entry->attachment_note, '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, ]; }), ], ]); } /** * 전표 저장 */ public function store(Request $request): JsonResponse { $request->validate([ 'entry_date' => 'required|date', 'description' => 'nullable|string|max:500', 'attachment_note' => 'nullable|string', '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); } $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', 'created_by_name' => auth()->user()?->name ?? '시스템', 'attachment_note' => $request->attachment_note, ]); 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('전표 저장 실패', ['error' => $lastError->getMessage()]); return response()->json([ 'success' => false, 'message' => '전표 저장 실패: '.$lastError->getMessage(), ], 500); } /** * 전표 수정 */ public function update(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $entry = JournalEntry::forTenant($tenantId)->findOrFail($id); // 출처 연결 전표는 수정 불가 (카드/은행/홈택스 등) if ($entry->source_type && $entry->source_type !== 'manual') { return response()->json([ 'success' => false, 'message' => '카드/은행/홈택스 출처 전표는 직접 수정할 수 없습니다. 원본 거래에서 분개를 수정해주세요.', ], 403); } $request->validate([ 'entry_date' => 'required|date', 'description' => 'nullable|string|max:500', 'attachment_note' => 'nullable|string', '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', ]); $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); } DB::transaction(function () use ($tenantId, $entry, $request, $lines, $totalDebit, $totalCredit) { $entry->update([ 'entry_date' => $request->entry_date, 'description' => $request->description, 'total_debit' => $totalDebit, 'total_credit' => $totalCredit, 'attachment_note' => $request->attachment_note, ]); // 기존 lines 삭제 후 재생성 $entry->lines()->delete(); 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 response()->json([ 'success' => true, 'message' => '전표가 수정되었습니다.', ]); } /** * 전표 삭제 (soft delete) */ public function destroy(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $entry = JournalEntry::forTenant($tenantId)->findOrFail($id); $entry->delete(); return response()->json([ 'success' => true, 'message' => '전표가 삭제되었습니다.', ]); } /** * 다음 전표번호 미리보기 */ public function nextEntryNo(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $date = $request->get('date', date('Y-m-d')); $entryNo = JournalEntry::generateEntryNo($tenantId, $date); return response()->json([ 'success' => true, 'entry_no' => $entryNo, ]); } /** * 계정과목 목록 */ public function accountCodes(): JsonResponse { $codes = AccountCode::getActive(); return response()->json([ 'success' => true, 'data' => $codes->map(function ($code) { return [ 'code' => $code->code, 'name' => $code->name, 'category' => $code->category, ]; }), ]); } /** * 거래처 목록 */ public function tradingPartners(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $query = TradingPartner::forTenant($tenantId)->active(); if ($request->filled('search')) { $search = $request->search; $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('biz_no', 'like', "%{$search}%"); }); } $partners = $query->orderBy('name')->limit(50)->get(); return response()->json([ 'success' => true, 'data' => $partners->map(function ($p) { return [ 'id' => $p->id, 'name' => $p->name, 'biz_no' => $p->biz_no, 'type' => $p->type, 'category' => $p->category, 'ceo' => $p->ceo, 'address' => $p->address, 'email' => $p->email, ]; }), ]); } // ================================================================ // 은행거래 기반 분개 API // ================================================================ /** * 은행거래 목록 조회 (DB 직접 조회 + 분개상태 병합) */ public function bankTransactions(Request $request): JsonResponse { try { $tenantId = session('selected_tenant_id', 1); $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $accountNum = $request->input('accountNum', ''); // barobill_bank_transactions 테이블에서 직접 조회 (중복 제거) $normalizedAccNum = ! empty($accountNum) ? str_replace('-', '', $accountNum) : null; $dedupQuery = BankTransaction::where('tenant_id', $tenantId) ->whereBetween('trans_date', [$startDate, $endDate]); if ($normalizedAccNum) { $dedupQuery->where('bank_account_num', $normalizedAccNum); } $latestIds = $dedupQuery ->selectRaw('MAX(id) as id') ->groupBy('bank_account_num', 'trans_dt', 'deposit', 'withdraw', 'balance', 'summary') ->pluck('id'); $transactions = BankTransaction::whereIn('id', $latestIds) ->orderBy('trans_date') ->orderBy('trans_time') ->get(); // 계좌별 이전 기간 잔액 조회 후 running balance 재계산 $prevBalances = $this->getPreviousBalances($tenantId, $startDate, $normalizedAccNum); // 로그 데이터 변환 (시간순 ASC로 running balance 계산) $logs = []; $accountBalances = $prevBalances; // 계좌별 현재 잔액 추적 foreach ($transactions as $tx) { $accNum = $tx->bank_account_num; $deposit = (int) $tx->deposit; $withdraw = (int) $tx->withdraw; // running balance 계산 $prevBal = $accountBalances[$accNum] ?? 0; $newBal = $prevBal + $deposit - $withdraw; $accountBalances[$accNum] = $newBal; $logs[] = [ 'uniqueKey' => $tx->unique_key, 'transDate' => $tx->trans_date, 'transTime' => $tx->trans_time, 'bankAccountNum' => $accNum, 'bankName' => $tx->bank_name, 'deposit' => $deposit, 'withdraw' => $withdraw, 'balance' => $newBal, 'summary' => $tx->summary, 'cast' => $tx->cast, 'memo' => $tx->memo, 'transOffice' => $tx->trans_office, 'accountCode' => $tx->account_code, 'accountName' => $tx->account_name, 'isManual' => $tx->is_manual, ]; } // 최신순 정렬 (DESC) $logs = array_reverse($logs); // 각 거래의 uniqueKey 수집 $uniqueKeys = array_column($logs, 'uniqueKey'); // 분개 완료된 source_key 조회 $journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'bank_transaction', $uniqueKeys); $journaledKeysMap = array_flip($journaledKeys); // 분개된 전표 ID도 조회 (레거시 키 포함) $journalMap = []; if (! empty($journaledKeys)) { // 새 키 + 레거시 키 모두 검색 $allSearchKeys = $journaledKeys; $legacyMap = []; foreach ($journaledKeys as $key) { $parts = explode('|', $key); if (count($parts) === 6) { $legacyKey = implode('|', array_slice($parts, 0, 5)); $allSearchKeys[] = $legacyKey; $legacyMap[$legacyKey] = $key; } } $journals = JournalEntry::where('tenant_id', $tenantId) ->where('source_type', 'bank_transaction') ->whereIn('source_key', array_unique($allSearchKeys)) ->select('id', 'source_key', 'entry_no') ->get(); foreach ($journals as $j) { $mappedKey = $legacyMap[$j->source_key] ?? $j->source_key; $journalMap[$mappedKey] = ['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); $depositSum = array_sum(array_column($logs, 'deposit')); $withdrawSum = array_sum(array_column($logs, 'withdraw')); $journaledCount = count($journaledKeys); // 계좌 목록 (드롭다운용) $accounts = BankTransaction::where('tenant_id', $tenantId) ->select('bank_account_num', 'bank_name') ->distinct() ->get() ->toArray(); return response()->json([ 'success' => true, 'data' => [ 'logs' => $logs, 'accounts' => $accounts, 'summary' => [ 'totalCount' => $totalCount, 'depositSum' => $depositSum, 'withdrawSum' => $withdrawSum, ], 'journalStats' => [ 'journaledCount' => $journaledCount, 'unjournaledCount' => $totalCount - $journaledCount, ], ], ]); } catch (\Throwable $e) { Log::error('은행거래 목록 조회 오류: '.$e->getMessage()); return response()->json([ 'success' => false, 'message' => '은행거래 목록 조회 실패: '.$e->getMessage(), ], 500); } } /** * 은행거래 기반 전표 생성 */ public function storeFromBank(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, 'bank_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' => 'bank_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 bankJournals(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); } // 레거시(summary 미포함) 형식과 신규(summary 포함) 형식 모두 매칭 $keys = [$sourceKey]; $parts = explode('|', $sourceKey); if (count($parts) === 6) { $keys[] = implode('|', array_slice($parts, 0, 5)); } $entry = JournalEntry::forTenant($tenantId) ->where('source_type', 'bank_transaction') ->whereIn('source_key', $keys) ->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 deleteBankJournal(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $entry = JournalEntry::forTenant($tenantId) ->where('source_type', 'bank_transaction') ->findOrFail($id); $entry->delete(); return response()->json([ 'success' => true, 'message' => '분개가 삭제되었습니다.', ]); } /** * 조회기간 이전의 계좌별 잔액 계산 * EaccountController::findBaseBalance() 패턴을 계좌별로 확장 */ private function getPreviousBalances(int $tenantId, string $startDate, ?string $accountNum): array { $query = BankTransaction::where('tenant_id', $tenantId) ->where('trans_date', '<', $startDate); if ($accountNum) { $query->where('bank_account_num', $accountNum); } // 중복 제거 (bankTransactions와 동일 기준) $latestIds = (clone $query) ->selectRaw('MAX(id) as id') ->groupBy('bank_account_num', 'trans_dt', 'deposit', 'withdraw') ->pluck('id'); if ($latestIds->isEmpty()) { return []; } $transactions = BankTransaction::whereIn('id', $latestIds) ->orderBy('trans_date') ->orderBy('trans_time') ->get(); $balances = []; foreach ($transactions as $tx) { $accNum = $tx->bank_account_num; if (! $tx->is_manual && (float) $tx->balance != 0) { // API 데이터: 바로빌이 제공한 잔액을 앵커로 사용 $balances[$accNum] = (float) $tx->balance; } else { // 수동입력 또는 잔액 0: 이전 잔액에서 입출금 계산 $prev = $balances[$accNum] ?? 0; $balances[$accNum] = $prev + (float) $tx->deposit - (float) $tx->withdraw; } } return $balances; } /** * 계정과목 전체 목록 (활성/비활성 포함) */ public function accountCodesAll(): JsonResponse { $codes = AccountCode::getAll(); return response()->json([ 'success' => true, 'data' => $codes, ]); } /** * 계정과목 추가 */ public function accountCodeStore(Request $request): JsonResponse { $validated = $request->validate([ 'code' => 'required|string|max:10', 'name' => 'required|string|max:100', 'category' => 'nullable|string|max:50', ]); if (AccountCode::where('code', $validated['code'])->exists()) { return response()->json([ 'success' => false, 'error' => '이미 존재하는 계정과목 코드입니다.', ], 422); } $maxSort = AccountCode::max('sort_order') ?? 0; $accountCode = AccountCode::create([ 'tenant_id' => 1, 'code' => $validated['code'], 'name' => $validated['name'], 'category' => $validated['category'] ?? null, 'sort_order' => $maxSort + 1, 'is_active' => true, ]); return response()->json([ 'success' => true, 'message' => '계정과목이 추가되었습니다.', 'data' => $accountCode, ]); } /** * 계정과목 수정 */ public function accountCodeUpdate(Request $request, int $id): JsonResponse { $accountCode = AccountCode::find($id); if (! $accountCode) { return response()->json(['success' => false, 'error' => '계정과목을 찾을 수 없습니다.'], 404); } $validated = $request->validate([ 'code' => 'sometimes|string|max:10', 'name' => 'sometimes|string|max:100', 'category' => 'nullable|string|max:50', 'is_active' => 'sometimes|boolean', ]); if (isset($validated['code']) && $validated['code'] !== $accountCode->code) { if (AccountCode::where('code', $validated['code'])->where('id', '!=', $id)->exists()) { return response()->json(['success' => false, 'error' => '이미 존재하는 계정과목 코드입니다.'], 422); } } $accountCode->update($validated); return response()->json([ 'success' => true, 'message' => '계정과목이 수정되었습니다.', 'data' => $accountCode, ]); } /** * 계정과목 삭제 */ public function accountCodeDestroy(int $id): JsonResponse { $accountCode = AccountCode::find($id); if (! $accountCode) { return response()->json(['success' => false, 'error' => '계정과목을 찾을 수 없습니다.'], 404); } $accountCode->delete(); return response()->json([ 'success' => true, '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' => '분개가 삭제되었습니다.', ]); } }