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, '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); } 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 (\Throwable $e) { return response()->json([ 'success' => false, 'message' => '전표 저장 실패: ' . $e->getMessage(), ], 500); } } /** * 전표 수정 */ public function update(Request $request, int $id): 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); } DB::transaction(function () use ($tenantId, $id, $request, $lines, $totalDebit, $totalCredit) { $entry = JournalEntry::forTenant($tenantId)->findOrFail($id); $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, ]; }), ]); } // ================================================================ // 은행거래 기반 분개 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 테이블에서 직접 조회 // 같은 거래가 잔액(balance)만 다르게 중복 저장된 경우 최신 건만 사용 $dedupQuery = BankTransaction::where('tenant_id', $tenantId) ->whereBetween('trans_date', [$startDate, $endDate]); if (!empty($accountNum)) { $dedupQuery->where('bank_account_num', str_replace('-', '', $accountNum)); } $latestIds = $dedupQuery ->selectRaw('MAX(id) as id') ->groupBy('bank_account_num', 'trans_dt', 'deposit', 'withdraw') ->pluck('id'); $transactions = BankTransaction::whereIn('id', $latestIds) ->orderByDesc('trans_date') ->orderByDesc('trans_time') ->get(); // 로그 데이터 변환 $logs = $transactions->map(function ($tx) { return [ 'uniqueKey' => $tx->unique_key, 'transDate' => $tx->trans_date, 'transTime' => $tx->trans_time, 'bankAccountNum' => $tx->bank_account_num, 'bankName' => $tx->bank_name, 'deposit' => (int) $tx->deposit, 'withdraw' => (int) $tx->withdraw, 'balance' => (int) $tx->balance, '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, ]; })->toArray(); // 각 거래의 uniqueKey 수집 $uniqueKeys = array_column($logs, 'uniqueKey'); // 분개 완료된 source_key 조회 $journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'bank_transaction', $uniqueKeys); $journaledKeysMap = array_flip($journaledKeys); // 분개된 전표 ID도 조회 $journalMap = []; if (!empty($journaledKeys)) { $journals = JournalEntry::where('tenant_id', $tenantId) ->where('source_type', 'bank_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); $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); } 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 (\Throwable $e) { Log::error('은행거래 분개 저장 오류: ' . $e->getMessage()); return response()->json([ 'success' => false, 'message' => '분개 저장 실패: ' . $e->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); } $entry = JournalEntry::forTenant($tenantId) ->where('source_type', 'bank_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 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' => '분개가 삭제되었습니다.', ]); } /** * 계정과목 전체 목록 (활성/비활성 포함) */ 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' => '계정과목이 삭제되었습니다.', ]); } }