diff --git a/app/Http/Controllers/Barobill/EcardController.php b/app/Http/Controllers/Barobill/EcardController.php index 5812885a..f8eb6f9b 100644 --- a/app/Http/Controllers/Barobill/EcardController.php +++ b/app/Http/Controllers/Barobill/EcardController.php @@ -10,6 +10,8 @@ use App\Models\Barobill\CardTransactionAmountLog; use App\Models\Barobill\CardTransactionHide; use App\Models\Barobill\CardTransactionSplit; +use App\Models\Finance\JournalEntry; +use App\Models\Finance\JournalEntryLine; use App\Models\Tenants\Tenant; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -28,9 +30,13 @@ class EcardController extends Controller * 바로빌 SOAP 설정 */ private ?string $certKey = null; + private ?string $corpNum = null; + private bool $isTestMode = false; + private ?string $soapUrl = null; + private ?\SoapClient $soapClient = null; // 바로빌 파트너사 (본사) 테넌트 ID @@ -49,7 +55,7 @@ public function __construct() $baseUrl = $this->isTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'; - $this->soapUrl = $baseUrl . '/CARD.asmx?WSDL'; + $this->soapUrl = $baseUrl.'/CARD.asmx?WSDL'; } else { $this->isTestMode = config('services.barobill.test_mode', true); $this->certKey = $this->isTestMode @@ -69,14 +75,14 @@ public function __construct() */ private function initSoapClient(): void { - if (!empty($this->certKey) || $this->isTestMode) { + if (! empty($this->certKey) || $this->isTestMode) { try { $context = stream_context_create([ 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, - 'allow_self_signed' => true - ] + 'allow_self_signed' => true, + ], ]); $this->soapClient = new \SoapClient($this->soapUrl, [ @@ -85,10 +91,10 @@ private function initSoapClient(): void 'exceptions' => true, 'connection_timeout' => 30, 'stream_context' => $context, - 'cache_wsdl' => WSDL_CACHE_NONE + 'cache_wsdl' => WSDL_CACHE_NONE, ]); } catch (\Throwable $e) { - Log::error('바로빌 카드 SOAP 클라이언트 생성 실패: ' . $e->getMessage()); + Log::error('바로빌 카드 SOAP 클라이언트 생성 실패: '.$e->getMessage()); } } } @@ -145,14 +151,14 @@ private function applyMemberServerMode(BarobillMember $member): void $baseUrl = $config->base_url ?: ($memberTestMode ? 'https://testws.baroservice.com' : 'https://ws.baroservice.com'); - $this->soapUrl = $baseUrl . '/CARD.asmx?WSDL'; + $this->soapUrl = $baseUrl.'/CARD.asmx?WSDL'; // SOAP 클라이언트 재초기화 $this->initSoapClient(); Log::info('[Ecard] 서버 모드 적용', [ 'targetEnv' => $targetEnv, - 'certKey' => substr($this->certKey ?? '', 0, 10) . '...', + 'certKey' => substr($this->certKey ?? '', 0, 10).'...', 'corpNum' => $this->corpNum, 'soapUrl' => $this->soapUrl, ]); @@ -178,14 +184,14 @@ public function cards(Request $request): JsonResponse $availOnly = $request->input('availOnly', 0); $result = $this->callSoap('GetCardEx2', [ - 'AvailOnly' => (int)$availOnly + 'AvailOnly' => (int) $availOnly, ]); - if (!$result['success']) { + if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], - 'error_code' => $result['error_code'] ?? null + 'error_code' => $result['error_code'] ?? null, ]); } @@ -199,7 +205,9 @@ public function cards(Request $request): JsonResponse } foreach ($cardList as $card) { - if (!is_object($card)) continue; + if (! is_object($card)) { + continue; + } $cardNum = $card->CardNum ?? ''; // 에러 체크: CardNum이 음수면 에러 코드 @@ -208,7 +216,7 @@ public function cards(Request $request): JsonResponse } $cardCompanyCode = $card->CardCompanyCode ?? $card->CardCompany ?? ''; - $cardCompanyName = !empty($card->CardCompanyName) + $cardCompanyName = ! empty($card->CardCompanyName) ? $card->CardCompanyName : $this->getCardCompanyName($cardCompanyCode); @@ -228,13 +236,14 @@ public function cards(Request $request): JsonResponse return response()->json([ 'success' => true, 'cards' => $cards, - 'count' => count($cards) + 'count' => count($cards), ]); } catch (\Throwable $e) { - Log::error('카드 목록 조회 오류: ' . $e->getMessage()); + Log::error('카드 목록 조회 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '서버 오류: ' . $e->getMessage() + 'error' => '서버 오류: '.$e->getMessage(), ]); } } @@ -249,8 +258,9 @@ private function getCardStatusName(string $status): string '1' => '정상', '2' => '해지', '3' => '수집오류', - '4' => '일시중지' + '4' => '일시중지', ]; + return $statuses[$status] ?? $status; } @@ -263,8 +273,8 @@ public function transactions(Request $request): JsonResponse $startDate = $request->input('startDate', date('Ymd')); $endDate = $request->input('endDate', date('Ymd')); $cardNum = str_replace('-', '', $request->input('cardNum', '')); - $page = (int)$request->input('page', 1); - $limit = (int)$request->input('limit', 50); + $page = (int) $request->input('page', 1); + $limit = (int) $request->input('limit', 50); // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); @@ -294,7 +304,7 @@ public function transactions(Request $request): JsonResponse $manualTransactions = CardTransaction::where('tenant_id', $tenantId) ->where('is_manual', true) ->whereBetween('use_date', [$startDate, $endDate]) - ->when($cardNum, fn($q) => $q->where('card_num', $cardNum)) + ->when($cardNum, fn ($q) => $q->where('card_num', $cardNum)) ->orderBy('use_date', 'desc') ->orderBy('use_time', 'desc') ->get(); @@ -312,7 +322,7 @@ public function transactions(Request $request): JsonResponse 'EndDate' => $endDate, 'CountPerPage' => 10000, 'CurrentPage' => 1, - 'OrderDirection' => 2 // 2:내림차순 + 'OrderDirection' => 2, // 2:내림차순 ]; Log::info('[ECard] GetPeriodCardApprovalLog 호출', $params); @@ -326,11 +336,11 @@ public function transactions(Request $request): JsonResponse 'data_keys' => $result['success'] && isset($result['data']) ? (is_object($result['data']) ? get_object_vars($result['data']) : 'not_object') : null, ]); - if (!$result['success']) { + if (! $result['success']) { return response()->json([ 'success' => false, 'error' => $result['error'], - 'error_code' => $result['error_code'] ?? null + 'error_code' => $result['error_code'] ?? null, ]); } @@ -340,11 +350,11 @@ public function transactions(Request $request): JsonResponse $errorCode = $this->checkErrorCode($resultData); Log::info('[ECard] 에러 코드 체크', ['errorCode' => $errorCode]); - if ($errorCode && !in_array($errorCode, [-24005, -24001])) { + if ($errorCode && ! in_array($errorCode, [-24005, -24001])) { return response()->json([ 'success' => false, 'error' => $this->getErrorMessage($errorCode), - 'error_code' => $errorCode + 'error_code' => $errorCode, ]); } @@ -353,13 +363,14 @@ public function transactions(Request $request): JsonResponse Log::info('[ECard] 데이터 없음 (에러코드로 판단)'); // API 데이터 없어도 수동 건은 표시 $manualLogs = $this->convertManualToLogs($manualTransactions); + return response()->json([ 'success' => true, 'data' => [ 'logs' => $manualLogs['logs'], 'summary' => $manualLogs['summary'], - 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1] - ] + 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1], + ], ]); } @@ -378,14 +389,14 @@ public function transactions(Request $request): JsonResponse $allLogs = array_merge($logs['logs'], $manualLogs['logs']); // 날짜/시간 기준 정렬 (최신순) - usort($allLogs, fn($a, $b) => strcmp($b['useDt'] ?? '', $a['useDt'] ?? '')); + usort($allLogs, fn ($a, $b) => strcmp($b['useDt'] ?? '', $a['useDt'] ?? '')); // 전체 데이터에서 통계 계산 (공제/불공제 포함) $mergedSummary = $this->mergeSummaries($logs['summary'], $manualLogs['summary']); // 로컬 페이지네이션 $totalCount = count($allLogs); - $maxPageNum = (int)ceil($totalCount / $limit); + $maxPageNum = (int) ceil($totalCount / $limit); $startIndex = ($page - 1) * $limit; $paginatedLogs = array_slice($allLogs, $startIndex, $limit); @@ -404,18 +415,19 @@ public function transactions(Request $request): JsonResponse 'currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => $maxPageNum, - 'maxIndex' => $totalCount + 'maxIndex' => $totalCount, ], - 'summary' => $mergedSummary - ] + 'summary' => $mergedSummary, + ], ]); } catch (\Throwable $e) { - Log::error('카드 사용내역 조회 오류: ' . $e->getMessage(), [ - 'trace' => $e->getTraceAsString() + Log::error('카드 사용내역 조회 오류: '.$e->getMessage(), [ + 'trace' => $e->getTraceAsString(), ]); + return response()->json([ 'success' => false, - 'error' => '서버 오류: ' . $e->getMessage() + 'error' => '서버 오류: '.$e->getMessage(), ]); } } @@ -439,10 +451,10 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri 'error' => $cardResult['error'] ?? null, ]); - if (!$cardResult['success']) { + if (! $cardResult['success']) { return response()->json([ 'success' => false, - 'error' => $cardResult['error'] + 'error' => $cardResult['error'], ]); } @@ -454,7 +466,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri Log::info('[ECard] 카드 목록', [ 'count' => count($cardList), - 'cards' => array_map(fn($c) => [ + 'cards' => array_map(fn ($c) => [ 'CardNum' => $c->CardNum ?? 'N/A', 'Alias' => $c->Alias ?? 'N/A', 'Status' => $c->Status ?? 'N/A', @@ -473,10 +485,14 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri $totalTax = 0; foreach ($cardList as $card) { - if (!is_object($card)) continue; + if (! is_object($card)) { + continue; + } $cardNum = $card->CardNum ?? ''; - if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) continue; + if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) { + continue; + } $params = [ 'ID' => $userId, @@ -485,7 +501,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri 'EndDate' => $endDate, 'CountPerPage' => 1000, 'CurrentPage' => 1, - 'OrderDirection' => 2 + 'OrderDirection' => 2, ]; Log::info('[ECard] 카드별 사용내역 조회', ['cardNum' => $cardNum, 'params' => $params]); @@ -515,7 +531,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri 'hasCardApprovalLog' => isset($cardData->CardLogList) && isset($cardData->CardLogList->CardApprovalLog), ]); - if (!$errorCode || in_array($errorCode, [-24005, -24001])) { + if (! $errorCode || in_array($errorCode, [-24005, -24001])) { $parsed = $this->parseTransactionLogs($cardData, $savedData); // 숨김 키 필터링 @@ -577,7 +593,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri // 페이지네이션 $totalCount = count($allLogs); - $maxPageNum = (int)ceil($totalCount / $limit); + $maxPageNum = (int) ceil($totalCount / $limit); $startIndex = ($page - 1) * $limit; $paginatedLogs = array_slice($allLogs, $startIndex, $limit); @@ -589,7 +605,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri 'currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => $maxPageNum, - 'maxIndex' => $totalCount + 'maxIndex' => $totalCount, ], 'summary' => [ 'totalAmount' => $totalAmount, @@ -601,8 +617,8 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, - ] - ] + ], + ], ]); } @@ -631,11 +647,11 @@ private function parseTransactionLogs($resultData, $savedData = null): array foreach ($rawLogs as $log) { $amount = floatval($log->ApprovalAmount ?? 0); $rawApprovalType = $log->ApprovalType ?? null; - $approvalType = (string)($rawApprovalType ?? '1'); + $approvalType = (string) ($rawApprovalType ?? '1'); // ApprovalType: 1=승인 or "승인", 2=취소 or "취소" // API에서 한글 텍스트로 반환될 수 있으므로 취소가 아닌 경우 모두 승인으로 처리 - $isApproval = !in_array($approvalType, ['2', '취소'], true); + $isApproval = ! in_array($approvalType, ['2', '취소'], true); // 디버깅: ApprovalType 값 확인 (첫 번째 로그만) if (count($logs) === 0) { @@ -662,14 +678,14 @@ private function parseTransactionLogs($resultData, $savedData = null): array $useTime = ''; $dateTime = ''; - if (!empty($useDT) && strlen($useDT) >= 8) { + if (! empty($useDT) && strlen($useDT) >= 8) { $useDate = substr($useDT, 0, 8); if (strlen($useDT) >= 14) { $useTime = substr($useDT, 8, 6); - $dateTime = substr($useDT, 0, 4) . '-' . substr($useDT, 4, 2) . '-' . substr($useDT, 6, 2) . ' ' . - substr($useDT, 8, 2) . ':' . substr($useDT, 10, 2) . ':' . substr($useDT, 12, 2); + $dateTime = substr($useDT, 0, 4).'-'.substr($useDT, 4, 2).'-'.substr($useDT, 6, 2).' '. + substr($useDT, 8, 2).':'.substr($useDT, 10, 2).':'.substr($useDT, 12, 2); } else { - $dateTime = substr($useDT, 0, 4) . '-' . substr($useDT, 4, 2) . '-' . substr($useDT, 6, 2); + $dateTime = substr($useDT, 0, 4).'-'.substr($useDT, 4, 2).'-'.substr($useDT, 6, 2); } } @@ -757,7 +773,7 @@ private function parseTransactionLogs($resultData, $savedData = null): array 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, - ] + ], ]; } @@ -767,11 +783,12 @@ private function parseTransactionLogs($resultData, $savedData = null): array private function checkErrorCode($data): ?int { if (isset($data->CurrentPage) && is_numeric($data->CurrentPage) && $data->CurrentPage < 0) { - return (int)$data->CurrentPage; + return (int) $data->CurrentPage; } if (isset($data->CardNum) && is_numeric($data->CardNum) && $data->CardNum < 0) { - return (int)$data->CardNum; + return (int) $data->CardNum; } + return null; } @@ -788,7 +805,8 @@ private function getErrorMessage(int $errorCode): string -25006 => '카드번호가 잘못되었습니다 (-25006).', -25007 => '조회 기간이 잘못되었습니다 (-25007).', ]; - return $messages[$errorCode] ?? '바로빌 API 오류: ' . $errorCode; + + return $messages[$errorCode] ?? '바로빌 API 오류: '.$errorCode; } /** @@ -796,8 +814,11 @@ private function getErrorMessage(int $errorCode): string */ private function maskCardNumber(string $cardNum): string { - if (strlen($cardNum) < 8) return $cardNum; - return substr($cardNum, 0, 4) . '-****-****-' . substr($cardNum, -4); + if (strlen($cardNum) < 8) { + return $cardNum; + } + + return substr($cardNum, 0, 4).'-****-****-'.substr($cardNum, -4); } /** @@ -830,8 +851,9 @@ private function getCardCompanyName(string $code): string '30' => '우체국', '31' => '카카오뱅크', '32' => 'K뱅크', - '33' => '토스뱅크' + '33' => '토스뱅크', ]; + return $companies[$code] ?? $code; } @@ -843,7 +865,8 @@ private function getPaymentPlanName(string $plan): string if (empty($plan) || $plan === '0' || $plan === '00') { return '일시불'; } - return $plan . '개월'; + + return $plan.'개월'; } /** @@ -856,12 +879,12 @@ public function accountCodes(): JsonResponse return response()->json([ 'success' => true, - 'data' => $codes->map(fn($c) => [ + 'data' => $codes->map(fn ($c) => [ 'id' => $c->id, 'code' => $c->code, 'name' => $c->name, 'category' => $c->category, - ]) + ]), ]); } @@ -877,7 +900,7 @@ public function save(Request $request): JsonResponse if (empty($transactions)) { return response()->json([ 'success' => false, - 'error' => '저장할 데이터가 없습니다.' + 'error' => '저장할 데이터가 없습니다.', ]); } @@ -996,14 +1019,15 @@ public function save(Request $request): JsonResponse 'success' => true, 'message' => "저장 완료: 신규 {$saved}건, 수정 {$updated}건", 'saved' => $saved, - 'updated' => $updated + 'updated' => $updated, ]); } catch (\Throwable $e) { DB::rollBack(); - Log::error('카드 사용내역 저장 오류: ' . $e->getMessage()); + Log::error('카드 사용내역 저장 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '저장 오류: ' . $e->getMessage() + 'error' => '저장 오류: '.$e->getMessage(), ]); } } @@ -1023,7 +1047,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse if (empty($logs)) { return response()->json([ 'success' => false, - 'error' => '내보낼 데이터가 없습니다.' + 'error' => '내보낼 데이터가 없습니다.', ]); } @@ -1033,7 +1057,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse $handle = fopen('php://output', 'w'); // UTF-8 BOM for Excel - fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF)); // 헤더 fputcsv($handle, [ @@ -1052,7 +1076,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse '승인번호', '계정과목코드', '계정과목명', - '메모' + '메모', ]); // 데이터 @@ -1109,8 +1133,8 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse number_format($tax), $approvalNum, '-', - '분개됨 (' . count($splits) . '건)', - '' + '분개됨 ('.count($splits).'건)', + '', ]); // 각 분개 행 출력 @@ -1127,7 +1151,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse $splitMemo = $split['memo'] ?? ''; fputcsv($handle, [ - '└ 분개 #' . ($index + 1), + '└ 분개 #'.($index + 1), '', '', '', @@ -1142,7 +1166,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse '', $splitAccountCode, $splitAccountName, - $splitMemo + $splitMemo, ]); } } else { @@ -1163,7 +1187,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse $approvalNum, $log['accountCode'] ?? '', $log['accountName'] ?? '', - '' + '', ]); } } @@ -1171,13 +1195,14 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse fclose($handle); }, $filename, [ 'Content-Type' => 'text/csv; charset=utf-8', - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ]); } catch (\Throwable $e) { - Log::error('엑셀 다운로드 오류: ' . $e->getMessage()); + Log::error('엑셀 다운로드 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '다운로드 오류: ' . $e->getMessage() + 'error' => '다운로드 오류: '.$e->getMessage(), ]); } } @@ -1196,13 +1221,14 @@ public function splits(Request $request): JsonResponse return response()->json([ 'success' => true, - 'data' => $splits + 'data' => $splits, ]); } catch (\Throwable $e) { - Log::error('분개 내역 조회 오류: ' . $e->getMessage()); + Log::error('분개 내역 조회 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '조회 오류: ' . $e->getMessage() + 'error' => '조회 오류: '.$e->getMessage(), ]); } } @@ -1221,7 +1247,7 @@ public function saveSplits(Request $request): JsonResponse if (empty($uniqueKey)) { return response()->json([ 'success' => false, - 'error' => '고유키가 없습니다.' + 'error' => '고유키가 없습니다.', ]); } @@ -1231,13 +1257,14 @@ public function saveSplits(Request $request): JsonResponse if (isset($s['supplyAmount']) && isset($s['tax'])) { return floatval($s['supplyAmount']) + floatval($s['tax']); } + return floatval($s['amount'] ?? 0); }, $splits)); if (abs($originalAmount - $splitTotal) > 0.01) { return response()->json([ 'success' => false, - 'error' => "분개 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다." + 'error' => "분개 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다.", ]); } @@ -1250,14 +1277,15 @@ public function saveSplits(Request $request): JsonResponse return response()->json([ 'success' => true, 'message' => '분개가 저장되었습니다.', - 'splitCount' => count($splits) + 'splitCount' => count($splits), ]); } catch (\Throwable $e) { DB::rollBack(); - Log::error('분개 저장 오류: ' . $e->getMessage()); + Log::error('분개 저장 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '저장 오류: ' . $e->getMessage() + 'error' => '저장 오류: '.$e->getMessage(), ]); } } @@ -1274,7 +1302,7 @@ public function deleteSplits(Request $request): JsonResponse if (empty($uniqueKey)) { return response()->json([ 'success' => false, - 'error' => '고유키가 없습니다.' + 'error' => '고유키가 없습니다.', ]); } @@ -1283,13 +1311,14 @@ public function deleteSplits(Request $request): JsonResponse return response()->json([ 'success' => true, 'message' => '분개가 삭제되었습니다.', - 'deleted' => $deleted + 'deleted' => $deleted, ]); } catch (\Throwable $e) { - Log::error('분개 삭제 오류: ' . $e->getMessage()); + Log::error('분개 삭제 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '삭제 오류: ' . $e->getMessage() + 'error' => '삭제 오류: '.$e->getMessage(), ]); } } @@ -1322,7 +1351,7 @@ public function storeManual(Request $request): JsonResponse ]); $useTime = $validated['use_time'] ?? '000000'; - $useDt = $validated['use_date'] . $useTime; + $useDt = $validated['use_date'].$useTime; $transaction = CardTransaction::create([ 'tenant_id' => $tenantId, @@ -1354,19 +1383,20 @@ public function storeManual(Request $request): JsonResponse return response()->json([ 'success' => true, 'message' => '수동 거래가 등록되었습니다.', - 'data' => ['id' => $transaction->id] + 'data' => ['id' => $transaction->id], ]); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'success' => false, 'error' => '입력 데이터가 올바르지 않습니다.', - 'errors' => $e->errors() + 'errors' => $e->errors(), ], 422); } catch (\Throwable $e) { - Log::error('수동 거래 등록 오류: ' . $e->getMessage()); + Log::error('수동 거래 등록 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '등록 오류: ' . $e->getMessage() + 'error' => '등록 오류: '.$e->getMessage(), ]); } } @@ -1404,7 +1434,7 @@ public function updateManual(Request $request, int $id): JsonResponse ]); $useTime = $validated['use_time'] ?? '000000'; - $useDt = $validated['use_date'] . $useTime; + $useDt = $validated['use_date'].$useTime; $transaction->update([ 'card_num' => $validated['card_num'], @@ -1434,19 +1464,20 @@ public function updateManual(Request $request, int $id): JsonResponse } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { return response()->json([ 'success' => false, - 'error' => '해당 거래를 찾을 수 없거나 수동 입력 건이 아닙니다.' + 'error' => '해당 거래를 찾을 수 없거나 수동 입력 건이 아닙니다.', ], 404); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'success' => false, 'error' => '입력 데이터가 올바르지 않습니다.', - 'errors' => $e->errors() + 'errors' => $e->errors(), ], 422); } catch (\Throwable $e) { - Log::error('수동 거래 수정 오류: ' . $e->getMessage()); + Log::error('수동 거래 수정 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '수정 오류: ' . $e->getMessage() + 'error' => '수정 오류: '.$e->getMessage(), ]); } } @@ -1473,13 +1504,14 @@ public function destroyManual(int $id): JsonResponse } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { return response()->json([ 'success' => false, - 'error' => '해당 거래를 찾을 수 없거나 수동 입력 건이 아닙니다.' + 'error' => '해당 거래를 찾을 수 없거나 수동 입력 건이 아닙니다.', ], 404); } catch (\Throwable $e) { - Log::error('수동 거래 삭제 오류: ' . $e->getMessage()); + Log::error('수동 거래 삭제 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '삭제 오류: ' . $e->getMessage() + 'error' => '삭제 오류: '.$e->getMessage(), ]); } } @@ -1499,14 +1531,14 @@ private function convertManualToLogs($manualTransactions): array $nonDeductibleAmount = 0; $nonDeductibleCount = 0; - if (!$manualTransactions || $manualTransactions->isEmpty()) { + if (! $manualTransactions || $manualTransactions->isEmpty()) { return [ 'logs' => [], 'summary' => [ 'totalAmount' => 0, 'count' => 0, 'approvalCount' => 0, 'cancelCount' => 0, 'totalTax' => 0, 'deductibleAmount' => 0, 'deductibleCount' => 0, 'nonDeductibleAmount' => 0, 'nonDeductibleCount' => 0, - ] + ], ]; } @@ -1526,10 +1558,10 @@ private function convertManualToLogs($manualTransactions): array } $dateTime = ''; - if (!empty($useDt) && strlen($useDt) >= 8) { - $dateTime = substr($useDt, 0, 4) . '-' . substr($useDt, 4, 2) . '-' . substr($useDt, 6, 2); + if (! empty($useDt) && strlen($useDt) >= 8) { + $dateTime = substr($useDt, 0, 4).'-'.substr($useDt, 4, 2).'-'.substr($useDt, 6, 2); if (strlen($useDt) >= 14) { - $dateTime .= ' ' . substr($useDt, 8, 2) . ':' . substr($useDt, 10, 2) . ':' . substr($useDt, 12, 2); + $dateTime .= ' '.substr($useDt, 8, 2).':'.substr($useDt, 10, 2).':'.substr($useDt, 12, 2); } } @@ -1600,7 +1632,7 @@ private function convertManualToLogs($manualTransactions): array 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, - ] + ], ]; } @@ -1684,7 +1716,7 @@ private function filterHiddenLogs(array $parsed, $hiddenSet): array 'deductibleCount' => $deductibleCount, 'nonDeductibleAmount' => $nonDeductibleAmount, 'nonDeductibleCount' => $nonDeductibleCount, - ] + ], ]; } @@ -1701,7 +1733,7 @@ public function hideTransaction(Request $request): JsonResponse if (empty($uniqueKey)) { return response()->json([ 'success' => false, - 'error' => '고유키가 없습니다.' + 'error' => '고유키가 없습니다.', ]); } @@ -1713,7 +1745,7 @@ public function hideTransaction(Request $request): JsonResponse if ($exists) { return response()->json([ 'success' => false, - 'error' => '이미 숨김 처리된 거래입니다.' + 'error' => '이미 숨김 처리된 거래입니다.', ]); } @@ -1721,13 +1753,14 @@ public function hideTransaction(Request $request): JsonResponse return response()->json([ 'success' => true, - 'message' => '거래가 숨김 처리되었습니다.' + 'message' => '거래가 숨김 처리되었습니다.', ]); } catch (\Throwable $e) { - Log::error('거래 숨김 오류: ' . $e->getMessage()); + Log::error('거래 숨김 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '숨김 처리 오류: ' . $e->getMessage() + 'error' => '숨김 처리 오류: '.$e->getMessage(), ]); } } @@ -1744,7 +1777,7 @@ public function restoreTransaction(Request $request): JsonResponse if (empty($uniqueKey)) { return response()->json([ 'success' => false, - 'error' => '고유키가 없습니다.' + 'error' => '고유키가 없습니다.', ]); } @@ -1753,19 +1786,20 @@ public function restoreTransaction(Request $request): JsonResponse if ($deleted === 0) { return response()->json([ 'success' => false, - 'error' => '숨김 데이터를 찾을 수 없습니다.' + 'error' => '숨김 데이터를 찾을 수 없습니다.', ]); } return response()->json([ 'success' => true, - 'message' => '거래가 복원되었습니다.' + 'message' => '거래가 복원되었습니다.', ]); } catch (\Throwable $e) { - Log::error('거래 복원 오류: ' . $e->getMessage()); + Log::error('거래 복원 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '복원 오류: ' . $e->getMessage() + 'error' => '복원 오류: '.$e->getMessage(), ]); } } @@ -1785,7 +1819,7 @@ public function hiddenTransactions(Request $request): JsonResponse ->where('use_date', '<=', $endDate) ->orderBy('use_date', 'desc') ->get() - ->map(fn($h) => [ + ->map(fn ($h) => [ 'id' => $h->id, 'uniqueKey' => $h->original_unique_key, 'cardNum' => $h->card_num, @@ -1800,48 +1834,269 @@ public function hiddenTransactions(Request $request): JsonResponse return response()->json([ 'success' => true, 'data' => $hidden, - 'count' => $hidden->count() + 'count' => $hidden->count(), ]); } catch (\Throwable $e) { - Log::error('숨김 목록 조회 오류: ' . $e->getMessage()); + Log::error('숨김 목록 조회 오류: '.$e->getMessage()); + return response()->json([ 'success' => false, - 'error' => '조회 오류: ' . $e->getMessage() + 'error' => '조회 오류: '.$e->getMessage(), ]); } } + // ================================================================ + // 카드거래 복식부기 분개 API (journal_entries 통합) + // ================================================================ + + /** + * 카드 거래 분개 생성/수정 + */ + public function storeJournal(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.*.debit_amount' => 'required|integer|min:0', + 'lines.*.credit_amount' => 'required|integer|min:0', + 'lines.*.description' => 'nullable|string|max:300', + ]); + + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $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); + + $maxRetries = 3; + $lastError = null; + + for ($attempt = 0; $attempt < $maxRetries; $attempt++) { + try { + $entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit, $existing) { + if ($existing) { + // 수정 모드: 기존 lines 삭제 후 재생성 (전표번호 유지) + $existing->update([ + 'entry_date' => $request->entry_date, + 'description' => $request->description, + 'total_debit' => $totalDebit, + 'total_credit' => $totalCredit, + ]); + $existing->lines()->delete(); + $entry = $existing; + } else { + // 신규 생성 + $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'], + 'debit_amount' => $line['debit_amount'], + 'credit_amount' => $line['credit_amount'], + 'description' => $line['description'] ?? null, + ]); + } + + // 동일 uniqueKey의 구버전 splits 자동 삭제 + CardTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $request->source_key) + ->delete(); + + return $entry; + }); + + return response()->json([ + 'success' => true, + 'message' => $existing ? '분개가 수정되었습니다.' : '분개가 저장되었습니다.', + '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 getJournal(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $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(fn ($line) => [ + 'id' => $line->id, + 'line_no' => $line->line_no, + 'dc_type' => $line->dc_type, + 'account_code' => $line->account_code, + 'account_name' => $line->account_name, + 'debit_amount' => $line->debit_amount, + 'credit_amount' => $line->credit_amount, + 'description' => $line->description, + ]), + ], + ]); + } + + /** + * 카드 거래 분개 삭제 (soft delete) + */ + public function deleteJournal(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $entry = JournalEntry::forTenant($tenantId) + ->where('source_type', 'ecard_transaction') + ->findOrFail($id); + + $entry->delete(); + + return response()->json([ + 'success' => true, + 'message' => '분개가 삭제되었습니다.', + ]); + } + + /** + * 기간 내 카드 거래 분개 상태 일괄 조회 + */ + public function getJournalStatuses(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $startDate = $request->input('startDate'); + $endDate = $request->input('endDate'); + + if (! $startDate || ! $endDate) { + return response()->json(['success' => false, 'message' => 'startDate, endDate가 필요합니다.'], 422); + } + + // YYYYMMDD → YYYY-MM-DD 변환 + $startFormatted = substr($startDate, 0, 4).'-'.substr($startDate, 4, 2).'-'.substr($startDate, 6, 2); + $endFormatted = substr($endDate, 0, 4).'-'.substr($endDate, 4, 2).'-'.substr($endDate, 6, 2); + + $journals = JournalEntry::where('tenant_id', $tenantId) + ->where('source_type', 'ecard_transaction') + ->whereBetween('entry_date', [$startFormatted, $endFormatted]) + ->select('id', 'source_key', 'entry_no') + ->get(); + + $map = []; + foreach ($journals as $j) { + $map[$j->source_key] = [ + 'id' => $j->id, + 'entry_no' => $j->entry_no, + 'hasJournal' => true, + ]; + } + + return response()->json([ + 'success' => true, + 'data' => $map, + ]); + } + /** * SOAP 호출 */ private function callSoap(string $method, array $params = []): array { - if (!$this->soapClient) { + if (! $this->soapClient) { return [ 'success' => false, - 'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다.' + 'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다.', ]; } - if (empty($this->certKey) && !$this->isTestMode) { + if (empty($this->certKey) && ! $this->isTestMode) { return [ 'success' => false, - 'error' => 'CERTKEY가 설정되지 않았습니다.' + 'error' => 'CERTKEY가 설정되지 않았습니다.', ]; } if (empty($this->corpNum)) { return [ 'success' => false, - 'error' => '사업자번호가 설정되지 않았습니다.' + 'error' => '사업자번호가 설정되지 않았습니다.', ]; } // CERTKEY와 CorpNum 자동 추가 - if (!isset($params['CERTKEY'])) { + if (! isset($params['CERTKEY'])) { $params['CERTKEY'] = $this->certKey ?? ''; } - if (!isset($params['CorpNum'])) { + if (! isset($params['CorpNum'])) { $params['CorpNum'] = $this->corpNum; } @@ -1849,7 +2104,7 @@ private function callSoap(string $method, array $params = []): array Log::info("바로빌 카드 API 호출 - Method: {$method}, CorpNum: {$this->corpNum}"); $result = $this->soapClient->$method($params); - $resultProperty = $method . 'Result'; + $resultProperty = $method.'Result'; if (isset($result->$resultProperty)) { $resultData = $result->$resultProperty; @@ -1858,33 +2113,35 @@ private function callSoap(string $method, array $params = []): array if (is_numeric($resultData) && $resultData < 0) { return [ 'success' => false, - 'error' => $this->getErrorMessage((int)$resultData), - 'error_code' => (int)$resultData + 'error' => $this->getErrorMessage((int) $resultData), + 'error_code' => (int) $resultData, ]; } return [ 'success' => true, - 'data' => $resultData + 'data' => $resultData, ]; } return [ 'success' => true, - 'data' => $result + 'data' => $result, ]; } catch (\SoapFault $e) { - Log::error('바로빌 SOAP 오류: ' . $e->getMessage()); + Log::error('바로빌 SOAP 오류: '.$e->getMessage()); + return [ 'success' => false, - 'error' => 'SOAP 오류: ' . $e->getMessage(), - 'error_code' => $e->getCode() + 'error' => 'SOAP 오류: '.$e->getMessage(), + 'error_code' => $e->getCode(), ]; } catch (\Throwable $e) { - Log::error('바로빌 API 호출 오류: ' . $e->getMessage()); + Log::error('바로빌 API 호출 오류: '.$e->getMessage()); + return [ 'success' => false, - 'error' => 'API 호출 오류: ' . $e->getMessage() + 'error' => 'API 호출 오류: '.$e->getMessage(), ]; } } diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index c5d01b50..81767554 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -30,6 +30,10 @@ hide: '{{ route("barobill.ecard.hide") }}', restore: '{{ route("barobill.ecard.restore") }}', hidden: '{{ route("barobill.ecard.hidden") }}', + journalStore: '{{ route("barobill.ecard.journal.store") }}', + journalShow: '{{ route("barobill.ecard.journal.show") }}', + journalDelete: '/barobill/ecard/journal/', + journalStatuses: '{{ route("barobill.ecard.journal.statuses") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -552,6 +556,309 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t ); }; + // ============================================ + // CardJournalModal - 복식부기 분개 모달 + // ============================================ + const CardJournalModal = ({ isOpen, onClose, onSave, onDelete, log, accountCodes = [] }) => { + const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); + const formatAmountInput = (val) => { const n = String(val).replace(/[^\d]/g, ''); return n ? Number(n).toLocaleString() : ''; }; + const parseAmountInput = (val) => parseInt(String(val).replace(/[^\d]/g, ''), 10) || 0; + + if (!log) return null; + + const supplyAmount = Math.round(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0))); + const taxAmount = Math.round(log.effectiveTax ?? (log.tax || 0)); + const totalAmount = supplyAmount + taxAmount; + const isDeductible = (log.deductionType || 'non_deductible') === 'deductible'; + + const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`; + + // 기본 분개 라인 + const getDefaultLines = () => { + const expenseCode = log.accountCode || '826'; + const expenseName = log.accountName || '잡비'; + + if (isDeductible) { + return [ + { dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: supplyAmount, credit_amount: 0, description: '' }, + { dc_type: 'debit', account_code: '135', account_name: '부가세대급금', debit_amount: taxAmount, credit_amount: 0, description: '' }, + { dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, description: '' }, + ]; + } else { + return [ + { dc_type: 'debit', account_code: expenseCode, account_name: expenseName, debit_amount: totalAmount, credit_amount: 0, description: '' }, + { dc_type: 'credit', account_code: '205', account_name: '미지급비용', debit_amount: 0, credit_amount: totalAmount, description: '' }, + ]; + } + }; + + const [lines, setLines] = useState(getDefaultLines()); + const [saving, setSaving] = useState(false); + const [loadingJournal, setLoadingJournal] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [journalId, setJournalId] = useState(null); + + // 기존 분개 로드 + useEffect(() => { + if (!isOpen || !log) return; + + if (log._journalData) { + // 이미 로드된 분개 데이터가 있으면 사용 + setLines(log._journalData.lines.map(l => ({ + dc_type: l.dc_type, + account_code: l.account_code, + account_name: l.account_name, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + description: l.description || '', + }))); + setIsEditMode(true); + setJournalId(log._journalData.id); + } else if (log._hasJournal) { + // 분개가 있는 것으로 알고 있으면 API 조회 + setLoadingJournal(true); + fetch(`${API.journalShow}?source_key=${encodeURIComponent(uniqueKey)}`) + .then(res => res.json()) + .then(data => { + if (data.success && data.data) { + setLines(data.data.lines.map(l => ({ + dc_type: l.dc_type, + account_code: l.account_code, + account_name: l.account_name, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + description: l.description || '', + }))); + setIsEditMode(true); + setJournalId(data.data.id); + } else { + setLines(getDefaultLines()); + setIsEditMode(false); + setJournalId(null); + } + }) + .catch(err => console.error('분개 로드 오류:', err)) + .finally(() => setLoadingJournal(false)); + } else { + setLines(getDefaultLines()); + setIsEditMode(false); + setJournalId(null); + } + }, [isOpen, log]); + + const updateLine = (idx, field, value) => { + setLines(prev => prev.map((l, i) => i === idx ? { ...l, [field]: value } : l)); + }; + + const addLine = () => { + setLines(prev => [...prev, { dc_type: 'debit', account_code: '', account_name: '', debit_amount: 0, credit_amount: 0, description: '' }]); + }; + + const removeLine = (idx) => { + if (lines.length <= 2) return; + setLines(prev => prev.filter((_, i) => i !== idx)); + }; + + const totalDebit = lines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0); + const totalCredit = lines.reduce((sum, l) => sum + (parseInt(l.credit_amount) || 0), 0); + const isBalanced = totalDebit === totalCredit && totalDebit > 0; + + const toggleDcType = (idx) => { + setLines(prev => prev.map((l, i) => { + if (i !== idx) return l; + const newType = l.dc_type === 'debit' ? 'credit' : 'debit'; + return { ...l, dc_type: newType, debit_amount: l.credit_amount, credit_amount: l.debit_amount }; + })); + }; + + const handleSubmit = async () => { + const emptyLine = lines.find(l => !l.account_code || !l.account_name); + if (emptyLine) { + notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning'); + return; + } + if (!isBalanced) { + notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning'); + return; + } + setSaving(true); + + // entry_date: useDt에서 YYYY-MM-DD 추출 + const useDt = log.useDt || ''; + const entryDate = useDt.length >= 8 + ? `${useDt.substring(0,4)}-${useDt.substring(4,6)}-${useDt.substring(6,8)}` + : new Date().toISOString().substring(0,10); + + await onSave({ + source_key: uniqueKey, + entry_date: entryDate, + description: `${log.merchantName || ''} 카드결제`, + lines, + }); + setSaving(false); + }; + + const handleDelete = async () => { + if (!journalId) return; + if (!confirm('분개를 삭제하시겠습니까?')) return; + setSaving(true); + await onDelete(journalId); + setSaving(false); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ {isEditMode ? '분개 수정' : '분개 생성'} +

+ +
+
+ {/* 카드 거래 정보 */} +
+

카드 거래 정보

+
+
가맹점: {log.merchantName || '-'}
+
사용일시: {log.useDateTime || '-'}
+
공급가액: {formatCurrency(supplyAmount)}
+
세액: {formatCurrency(taxAmount)}
+
+
+ + {loadingJournal ? ( +
+
+ 분개 데이터 로딩중... +
+ ) : ( +
+

분개 내역

+ + + + + + + + + + + + {lines.map((line, idx) => ( + + + + + + + + ))} + {/* 합계 */} + + + + + + + +
차/대계정과목차변금액대변금액
+ + + { + setLines(prev => prev.map((l, i) => i === idx ? { ...l, account_code: code, account_name: name } : l)); + }} + accountCodes={accountCodes} + /> + + updateLine(idx, 'debit_amount', parseAmountInput(e.target.value))} + className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-purple-500 outline-none" + /> + + updateLine(idx, 'credit_amount', parseAmountInput(e.target.value))} + className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-right focus:ring-1 focus:ring-purple-500 outline-none" + /> + + +
합계{formatCurrency(totalDebit)}{formatCurrency(totalCredit)}
+ {!isBalanced && ( +

차변과 대변의 합계가 일치하지 않습니다. (차이: {formatCurrency(Math.abs(totalDebit - totalCredit))})

+ )} + + {/* 행 추가 버튼 */} + +
+ )} +
+
+
+ {isEditMode && journalId && ( + + )} +
+
+ + +
+
+
+
+ ); + }; + // 카드사 코드 목록 const CARD_COMPANIES = [ { code: '01', name: '비씨' }, @@ -979,6 +1286,8 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t hiddenLogs, onRestore, loadingHidden, + journalMap, + onOpenJournalModal, }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원'; @@ -1029,27 +1338,45 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t {/* 원본 거래 행 */} - {hasSplits ? ( - - ) : ( - - )} + {(() => { + const jInfo = journalMap[uniqueKey]; + if (jInfo) { + // 복식부기 분개 완료 + return ( + + ); + } else if (hasSplits) { + // 구버전 splits만 존재 + return ( + + ); + } else { + // 분개 없음 + return ( + + ); + } + })()}
{log.useDateTime || '-'}
@@ -1133,8 +1460,11 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:outlin ); })()} - {hasSplits && ( -
분개됨 ({logSplits.length}건)
+ {journalMap[uniqueKey] && ( +
{journalMap[uniqueKey].entry_no}
+ )} + {!journalMap[uniqueKey] && hasSplits && ( +
구버전({logSplits.length}건)
)} @@ -1395,7 +1725,12 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6 const [accountCodes, setAccountCodes] = useState([]); const [hasChanges, setHasChanges] = useState(false); - // 분개 관련 상태 + // 복식부기 분개 관련 상태 + const [journalMap, setJournalMap] = useState({}); + const [journalModalOpen, setJournalModalOpen] = useState(false); + const [journalModalLog, setJournalModalLog] = useState(null); + + // 구버전 분개 관련 상태 const [splits, setSplits] = useState({}); const [splitModalOpen, setSplitModalOpen] = useState(false); const [splitModalLog, setSplitModalLog] = useState(null); @@ -1501,6 +1836,7 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6 // 분개 데이터 로드 loadSplits(); + loadJournalStatuses(); }; // 분개 데이터 로드 @@ -1522,6 +1858,88 @@ className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-6 } }; + // 복식부기 분개 상태 로드 + const loadJournalStatuses = async () => { + try { + const params = new URLSearchParams({ + startDate: dateFrom.replace(/-/g, ''), + endDate: dateTo.replace(/-/g, '') + }); + const response = await fetch(`${API.journalStatuses}?${params}`); + const data = await response.json(); + if (data.success) { + setJournalMap(data.data || {}); + } + } catch (err) { + console.error('분개 상태 로드 오류:', err); + } + }; + + // 복식부기 분개 모달 열기 + const handleOpenJournalModal = (log, uniqueKey, hasJournal) => { + const logWithJournalInfo = { + ...log, + uniqueKey, + _hasJournal: hasJournal, + _journalData: null, + }; + setJournalModalLog(logWithJournalInfo); + setJournalModalOpen(true); + }; + + // 복식부기 분개 저장 + const handleSaveJournal = async (payload) => { + try { + const response = await fetch(API.journalStore, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN, + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + if (data.success) { + notify(data.message, 'success'); + setJournalModalOpen(false); + setJournalModalLog(null); + loadJournalStatuses(); + loadSplits(); // splits 자동 삭제 반영 + } else { + notify(data.message || '분개 저장 실패', 'error'); + } + } catch (err) { + notify('분개 저장 오류: ' + err.message, 'error'); + } + }; + + // 복식부기 분개 삭제 + const handleDeleteJournal = async (journalId) => { + try { + const response = await fetch(`${API.journalDelete}${journalId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': CSRF_TOKEN, + 'Accept': 'application/json' + } + }); + + const data = await response.json(); + if (data.success) { + notify(data.message, 'success'); + setJournalModalOpen(false); + setJournalModalLog(null); + loadJournalStatuses(); + } else { + notify(data.message || '분개 삭제 실패', 'error'); + } + } catch (err) { + notify('분개 삭제 오류: ' + err.message, 'error'); + } + }; + // 요약 재계산: 분개가 있는 거래는 원본 대신 분개별 통계로 대체 // 수정된 공급가액/세액이 있으면 해당 값으로 합계 반영 const recalculateSummary = (currentLogs, allSplits) => { @@ -2245,6 +2663,8 @@ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium tra hiddenLogs={hiddenLogs} onRestore={handleRestore} loadingHidden={loadingHidden} + journalMap={journalMap} + onOpenJournalModal={handleOpenJournalModal} /> )} @@ -2259,6 +2679,16 @@ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium tra splits={splitModalExisting} /> + {/* Card Journal Modal (복식부기) */} + { setJournalModalOpen(false); setJournalModalLog(null); }} + onSave={handleSaveJournal} + onDelete={handleDeleteJournal} + log={journalModalLog} + accountCodes={accountCodes} + /> + {/* Manual Entry Modal */} name('hide'); Route::post('/restore', [\App\Http\Controllers\Barobill\EcardController::class, 'restoreTransaction'])->name('restore'); Route::get('/hidden', [\App\Http\Controllers\Barobill\EcardController::class, 'hiddenTransactions'])->name('hidden'); + // 복식부기 분개 (journal_entries 통합) + Route::post('/journal', [\App\Http\Controllers\Barobill\EcardController::class, 'storeJournal'])->name('journal.store'); + Route::get('/journal', [\App\Http\Controllers\Barobill\EcardController::class, 'getJournal'])->name('journal.show'); + Route::delete('/journal/{id}', [\App\Http\Controllers\Barobill\EcardController::class, 'deleteJournal'])->name('journal.delete'); + Route::get('/journal-statuses', [\App\Http\Controllers\Barobill\EcardController::class, 'getJournalStatuses'])->name('journal.statuses'); }); // 홈택스 매출/매입 (React 페이지)