From 77c412367ad351f7edcb1d8cbe2704cd21f57e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:09:44 +0900 Subject: [PATCH 01/42] =?UTF-8?q?fix:=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EC=A4=91=EC=A7=80=20=ED=9B=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=99=94=EC=9E=90=20=EB=B6=84=EB=A6=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 녹음 중지 시 오디오 업로드 → 자동 화자 분리(Google Cloud STT) → 자동 AI 요약 순서로 진행하도록 변경 기존에는 수동으로 "화자 분리" 버튼을 눌러야 했음 Co-Authored-By: Claude Opus 4.6 --- .../views/juil/meeting-minutes.blade.php | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/resources/views/juil/meeting-minutes.blade.php b/resources/views/juil/meeting-minutes.blade.php index 32956546..b47fbd8b 100644 --- a/resources/views/juil/meeting-minutes.blade.php +++ b/resources/views/juil/meeting-minutes.blade.php @@ -431,9 +431,9 @@ function MeetingDetail({ meetingId, onBack, showToast }) { const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); const duration = recordingTime; - // 1. 세그먼트 저장 setSaving(true); try { + // 1. 세그먼트 임시 저장 (Web Speech API 결과) const segmentsToSave = localSegments .filter(s => s.is_final && s.text && s.text.trim()) .map((s, i) => ({ @@ -453,6 +453,7 @@ function MeetingDetail({ meetingId, onBack, showToast }) { } // 2. 오디오 업로드 + let audioUploaded = false; if (audioBlob.size > 0 && duration > 0) { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); @@ -461,6 +462,7 @@ function MeetingDetail({ meetingId, onBack, showToast }) { method: 'POST', body: formData, }); + audioUploaded = true; } // 3. STT 사용량 로깅 @@ -473,25 +475,53 @@ function MeetingDetail({ meetingId, onBack, showToast }) { } showToast('녹음이 저장되었습니다.'); + setSaving(false); - // 4. 자동 요약 - if (segmentsToSave.length > 0) { - setSummarizing(true); + // 4. 자동 화자 분리 (오디오 업로드 완료 시) + if (audioUploaded) { + setDiarizing(true); try { - await apiFetch(API.summarize(meetingId), { method: 'POST' }); - showToast('AI 요약이 완료되었습니다.'); + const diarizeRes = await apiFetch(API.diarize(meetingId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ min_speakers: 2, max_speakers: Math.max(speakers.length, 2) }), + }); + if (diarizeRes.data && diarizeRes.data.segments && diarizeRes.data.segments.length > 0) { + setLocalSegments(diarizeRes.data.segments.map(s => ({ + speaker_name: s.speaker_name, + text: s.text, + start_time_ms: s.start_time_ms, + end_time_ms: s.end_time_ms, + is_final: true, + }))); + const uniqueSpeakers = [...new Set(diarizeRes.data.segments.map(s => s.speaker_name))]; + if (uniqueSpeakers.length > 0) { + setSpeakers(uniqueSpeakers.map(n => ({ name: n }))); + } + } + showToast(`화자 분리 완료 (${diarizeRes.speaker_count || 1}명 감지)`); } catch (e) { - showToast('AI 요약 실패: ' + e.message, 'warning'); + showToast('자동 화자 분리 실패: ' + e.message, 'warning'); } finally { - setSummarizing(false); + setDiarizing(false); } } + // 5. 자동 AI 요약 + setSummarizing(true); + try { + await apiFetch(API.summarize(meetingId), { method: 'POST' }); + showToast('AI 요약이 완료되었습니다.'); + } catch (e) { + showToast('AI 요약 실패: ' + e.message, 'warning'); + } finally { + setSummarizing(false); + } + // 새로고침 await loadMeeting(); } catch (e) { showToast('저장 실패: ' + e.message, 'error'); - } finally { setSaving(false); } }; From 13f2e1df7376fb464523f5c9c52426701ec48916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:25:00 +0900 Subject: [PATCH 02/42] =?UTF-8?q?fix:=EC=9E=85=EC=B6=9C=EA=B8=88=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=83=81=EB=8C=80=EA=B3=84=EC=A2=8C=EC=98=88?= =?UTF-8?q?=EA=B8=88=EC=A3=BC=EB=AA=85=20=EC=A0=80=EC=9E=A5=20=ED=9B=84=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B5=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인라인 편집으로 cast 저장 시 override 테이블의 modified_cast가 메인 테이블 값보다 우선하여 이전 값이 표시되는 문제. save() 후 override의 modified_cast를 동기화(제거)하여 충돌 방지. Co-Authored-By: Claude Opus 4.6 --- .../Barobill/EaccountController.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index a5e8b271..adb94227 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -901,6 +901,7 @@ public function save(Request $request): JsonResponse $saved = 0; $updated = 0; + $savedUniqueKeys = []; DB::beginTransaction(); @@ -927,6 +928,16 @@ public function save(Request $request): JsonResponse 'account_name' => $trans['accountName'] ?? null, ]; + // 고유 키 생성 (오버라이드 동기화용) + $uniqueKey = implode('|', [ + $data['bank_account_num'], + $transDt, + (int) $data['deposit'], + (int) $data['withdraw'], + (int) $data['balance'], + ]); + $savedUniqueKeys[] = $uniqueKey; + // Upsert: 있으면 업데이트, 없으면 생성 // balance 포함: 같은 금액이라도 잔액이 다르면 별도 거래로 구분 $existing = BankTransaction::where('tenant_id', $tenantId) @@ -953,6 +964,26 @@ public function save(Request $request): JsonResponse } } + // 오버라이드 동기화: 메인 테이블에 저장된 값이 최신이므로 + // override의 modified_cast를 제거하여 충돌 방지 + if (!empty($savedUniqueKeys)) { + $overrides = BankTransactionOverride::forTenant($tenantId) + ->whereIn('unique_key', $savedUniqueKeys) + ->get(); + + foreach ($overrides as $override) { + if ($override->modified_cast !== null) { + if (!empty($override->modified_summary)) { + // summary 오버라이드는 유지, cast 오버라이드만 제거 + $override->update(['modified_cast' => null]); + } else { + // summary도 없으면 오버라이드 레코드 삭제 + $override->delete(); + } + } + } + } + DB::commit(); return response()->json([ From f73e8a18a1aef6f7c19dfdec9d5bc0f4679de73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:35:04 +0900 Subject: [PATCH 03/42] =?UTF-8?q?fix:=EC=9E=85=EC=B6=9C=EA=B8=88=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20decimal:2=20cast?= =?UTF-8?q?=20dirty=20=EA=B0=90=EC=A7=80=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20u?= =?UTF-8?q?nique=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=9C=84?= =?UTF-8?q?=EB=B0=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eloquent 모델의 decimal:2 cast가 balance를 dirty로 잘못 감지하여 UPDATE 시 balance가 포함되면서 unique 제약조건 위반 발생. Query Builder(DB::table)로 변경하여 지정 필드만 업데이트. Co-Authored-By: Claude Opus 4.6 --- .../Barobill/EaccountController.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index adb94227..1b51e9f0 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -949,14 +949,18 @@ public function save(Request $request): JsonResponse ->first(); if ($existing) { - // 계정과목 + 적요/예금주명 업데이트 (balance는 키값이므로 제외) - $existing->update([ - 'summary' => $data['summary'], - 'cast' => $data['cast'], - 'trans_office' => $data['trans_office'], - 'account_code' => $data['account_code'], - 'account_name' => $data['account_name'], - ]); + // Query Builder 사용: Eloquent의 decimal:2 cast가 balance를 + // dirty로 잘못 감지하여 unique 제약조건 위반을 일으키는 문제 방지 + DB::table('barobill_bank_transactions') + ->where('id', $existing->id) + ->update([ + 'summary' => $data['summary'], + 'cast' => $data['cast'], + 'trans_office' => $data['trans_office'], + 'account_code' => $data['account_code'], + 'account_name' => $data['account_name'], + 'updated_at' => now(), + ]); $updated++; } else { BankTransaction::create($data); From d5606e71d689fec2fd628871fd0350f97b96a41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:38:32 +0900 Subject: [PATCH 04/42] =?UTF-8?q?fix:BankTransaction=20decimal:2=20cast=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=EB=A1=9C=20dirty=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eloquent decimal:2 cast가 deposit/withdraw/balance를 dirty로 잘못 감지하여 update 시 unique 제약조건 위반 발생. 모든 사용처에서 이미 (int)/(float) 명시 변환하므로 cast 불필요. Co-Authored-By: Claude Opus 4.6 --- app/Models/Barobill/BankTransaction.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/Models/Barobill/BankTransaction.php b/app/Models/Barobill/BankTransaction.php index 1430ff40..54d6ccd2 100644 --- a/app/Models/Barobill/BankTransaction.php +++ b/app/Models/Barobill/BankTransaction.php @@ -36,9 +36,6 @@ class BankTransaction extends Model ]; protected $casts = [ - 'deposit' => 'decimal:2', - 'withdraw' => 'decimal:2', - 'balance' => 'decimal:2', 'is_manual' => 'boolean', ]; From 1cab267ec66a4a07b3afa996e4e4a0615cd7cc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:42:13 +0900 Subject: [PATCH 05/42] =?UTF-8?q?fix:=EC=9E=85=EC=B6=9C=EA=B8=88=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20save()=EB=A5=BC=20=EC=88=9C=EC=88=98=20Query=20Buil?= =?UTF-8?q?der=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eloquent 모델의 decimal cast + opcache 캐시 문제로 인해 deposit/withdraw/balance가 dirty 감지되어 unique 제약조건 위반. - Eloquent 완전 우회: DB::table() 기반 lookup + update + insert - CAST(AS SIGNED) 제거: 정확한 decimal 비교로 변경 Co-Authored-By: Claude Opus 4.6 --- .../Barobill/EaccountController.php | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 1b51e9f0..943743c1 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -938,21 +938,20 @@ public function save(Request $request): JsonResponse ]); $savedUniqueKeys[] = $uniqueKey; - // Upsert: 있으면 업데이트, 없으면 생성 - // balance 포함: 같은 금액이라도 잔액이 다르면 별도 거래로 구분 - $existing = BankTransaction::where('tenant_id', $tenantId) + // 순수 Query Builder로 Upsert (Eloquent 모델 우회) + // Eloquent의 decimal cast가 dirty 감지하여 unique 제약조건 위반을 일으키므로 + $existingId = DB::table('barobill_bank_transactions') + ->where('tenant_id', $tenantId) ->where('bank_account_num', $data['bank_account_num']) ->where('trans_dt', $transDt) - ->whereRaw('CAST(deposit AS SIGNED) = ?', [(int) $data['deposit']]) - ->whereRaw('CAST(withdraw AS SIGNED) = ?', [(int) $data['withdraw']]) - ->whereRaw('CAST(balance AS SIGNED) = ?', [(int) $data['balance']]) - ->first(); + ->where('deposit', $data['deposit']) + ->where('withdraw', $data['withdraw']) + ->where('balance', $data['balance']) + ->value('id'); - if ($existing) { - // Query Builder 사용: Eloquent의 decimal:2 cast가 balance를 - // dirty로 잘못 감지하여 unique 제약조건 위반을 일으키는 문제 방지 + if ($existingId) { DB::table('barobill_bank_transactions') - ->where('id', $existing->id) + ->where('id', $existingId) ->update([ 'summary' => $data['summary'], 'cast' => $data['cast'], @@ -963,7 +962,10 @@ public function save(Request $request): JsonResponse ]); $updated++; } else { - BankTransaction::create($data); + DB::table('barobill_bank_transactions')->insert(array_merge($data, [ + 'created_at' => now(), + 'updated_at' => now(), + ])); $saved++; } } From 587c21fa11cfc5a50dc328d562a577e6103bc63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:48:28 +0900 Subject: [PATCH 06/42] =?UTF-8?q?fix:updateManual()=EB=8F=84=20Query=20Bui?= =?UTF-8?q?lder=EB=A1=9C=20=EC=A0=84=ED=99=98=20(numeric=20dirty=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EB=AC=B8=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 거래 수정 시 Eloquent가 deposit/withdraw/balance를 dirty로 오감지 (DB "515900.00" vs validation 515900 비교). DB::table()로 변경하여 지정 필드만 정확히 업데이트. Co-Authored-By: Claude Opus 4.6 --- .../Barobill/EaccountController.php | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 943743c1..ad952bab 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -1253,23 +1253,28 @@ public function updateManual(Request $request, int $id): JsonResponse $transTime = $validated['trans_time'] ?? '000000'; $transDt = $validated['trans_date'] . $transTime; - $transaction->update([ - 'bank_account_num' => $validated['bank_account_num'], - 'bank_code' => $validated['bank_code'] ?? '', - 'bank_name' => $validated['bank_name'] ?? '', - 'trans_date' => $validated['trans_date'], - 'trans_time' => $transTime, - 'trans_dt' => $transDt, - 'deposit' => $validated['deposit'], - 'withdraw' => $validated['withdraw'], - 'balance' => $validated['balance'] ?? 0, - 'summary' => $validated['summary'] ?? '', - 'cast' => $validated['cast'] ?? '', - 'memo' => $validated['memo'] ?? '', - 'trans_office' => $validated['trans_office'] ?? '', - 'account_code' => $validated['account_code'] ?? null, - 'account_name' => $validated['account_name'] ?? null, - ]); + // Query Builder 사용: Eloquent의 numeric dirty 감지 문제 방지 + // (DB의 "515900.00" 문자열 vs validation 후 515900 정수 비교 시 dirty 오감지) + DB::table('barobill_bank_transactions') + ->where('id', $transaction->id) + ->update([ + 'bank_account_num' => $validated['bank_account_num'], + 'bank_code' => $validated['bank_code'] ?? '', + 'bank_name' => $validated['bank_name'] ?? '', + 'trans_date' => $validated['trans_date'], + 'trans_time' => $transTime, + 'trans_dt' => $transDt, + 'deposit' => $validated['deposit'], + 'withdraw' => $validated['withdraw'], + 'balance' => $validated['balance'] ?? 0, + 'summary' => $validated['summary'] ?? '', + 'cast' => $validated['cast'] ?? '', + 'memo' => $validated['memo'] ?? '', + 'trans_office' => $validated['trans_office'] ?? '', + 'account_code' => $validated['account_code'] ?? null, + 'account_name' => $validated['account_name'] ?? null, + 'updated_at' => now(), + ]); return response()->json([ 'success' => true, From cc4cf64248c184d2be451f352c921dbda23d6743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:52:44 +0900 Subject: [PATCH 07/42] =?UTF-8?q?fix:=EC=88=98=EB=8F=99=EA=B1=B0=EB=9E=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20unique=20key=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC(deposit/withdraw/balance)=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 거래의 balance는 화면에서 재계산(recalcManualBalances)되어 표시되므로 DB의 원본값과 다름. 프론트에서 재계산된 balance를 보내면 다른 레코드와 unique key 충돌 발생. 수정 시 적요/예금주명 등 비-키 필드만 업데이트. Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Barobill/EaccountController.php | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index ad952bab..42140628 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -1253,20 +1253,12 @@ public function updateManual(Request $request, int $id): JsonResponse $transTime = $validated['trans_time'] ?? '000000'; $transDt = $validated['trans_date'] . $transTime; - // Query Builder 사용: Eloquent의 numeric dirty 감지 문제 방지 - // (DB의 "515900.00" 문자열 vs validation 후 515900 정수 비교 시 dirty 오감지) + // 수동 거래 수정: unique key 컬럼(deposit/withdraw/balance)은 제외 + // balance는 화면에서 재계산(recalcManualBalances)되므로 DB값 유지 필수 + // (프론트에서 재계산된 balance를 보내면 다른 레코드와 unique key 충돌) DB::table('barobill_bank_transactions') ->where('id', $transaction->id) ->update([ - 'bank_account_num' => $validated['bank_account_num'], - 'bank_code' => $validated['bank_code'] ?? '', - 'bank_name' => $validated['bank_name'] ?? '', - 'trans_date' => $validated['trans_date'], - 'trans_time' => $transTime, - 'trans_dt' => $transDt, - 'deposit' => $validated['deposit'], - 'withdraw' => $validated['withdraw'], - 'balance' => $validated['balance'] ?? 0, 'summary' => $validated['summary'] ?? '', 'cast' => $validated['cast'] ?? '', 'memo' => $validated['memo'] ?? '', From 2c3bccb9a00ffc9ea88868fd308aed167c612b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:55:48 +0900 Subject: [PATCH 08/42] =?UTF-8?q?fix:save()=EC=97=90=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EA=B1=B0=EB=9E=98=20=EC=8A=A4=ED=82=B5=20(?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=20balance=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 거래의 balance는 화면에서 재계산된 값이므로 DB 원본값과 다름. save()에서 처리하면 잘못된 레코드 매칭 또는 unique key 충돌 발생. 수동 거래는 updateManual()에서만 처리하도록 스킵. Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/Barobill/EaccountController.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 42140628..a5eab35e 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -906,6 +906,13 @@ public function save(Request $request): JsonResponse DB::beginTransaction(); foreach ($transactions as $trans) { + // 수동 입력 거래는 스킵 (updateManual에서 별도 처리) + // 수동 거래의 balance는 화면에서 재계산된 값이므로 save()에서 처리하면 + // 다른 레코드와 unique key 충돌 발생 + if (!empty($trans['isManual'])) { + continue; + } + // 거래일시 생성 $transDt = ($trans['transDate'] ?? '') . ($trans['transTime'] ?? ''); From d01253aa27d62757bae181e38de5b80ea292ec33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 16:57:48 +0900 Subject: [PATCH 09/42] =?UTF-8?q?fix:save()=EC=97=90=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EA=B1=B0=EB=9E=98=EB=A5=BC=20dbId=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 거래는 balance가 재계산되어 composite key 매칭 불가. dbId를 사용하여 직접 레코드를 찾고 비-키 필드만 업데이트. Co-Authored-By: Claude Opus 4.6 --- .../Barobill/EaccountController.php | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index a5eab35e..7b6f24be 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -906,10 +906,22 @@ public function save(Request $request): JsonResponse DB::beginTransaction(); foreach ($transactions as $trans) { - // 수동 입력 거래는 스킵 (updateManual에서 별도 처리) - // 수동 거래의 balance는 화면에서 재계산된 값이므로 save()에서 처리하면 - // 다른 레코드와 unique key 충돌 발생 - if (!empty($trans['isManual'])) { + // 수동 입력 거래: dbId로 직접 찾아서 비-키 필드만 업데이트 + // balance는 화면에서 재계산된 값이므로 composite key 매칭 불가 + if (!empty($trans['isManual']) && !empty($trans['dbId'])) { + DB::table('barobill_bank_transactions') + ->where('id', $trans['dbId']) + ->where('tenant_id', $tenantId) + ->update([ + 'summary' => $trans['summary'] ?? '', + 'cast' => $trans['cast'] ?? '', + 'memo' => $trans['memo'] ?? '', + 'trans_office' => $trans['transOffice'] ?? '', + 'account_code' => $trans['accountCode'] ?? null, + 'account_name' => $trans['accountName'] ?? null, + 'updated_at' => now(), + ]); + $updated++; continue; } @@ -946,7 +958,6 @@ public function save(Request $request): JsonResponse $savedUniqueKeys[] = $uniqueKey; // 순수 Query Builder로 Upsert (Eloquent 모델 우회) - // Eloquent의 decimal cast가 dirty 감지하여 unique 제약조건 위반을 일으키므로 $existingId = DB::table('barobill_bank_transactions') ->where('tenant_id', $tenantId) ->where('bank_account_num', $data['bank_account_num']) From 892778b7eb13806a7bcd18aa7d8803e8487a9a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 17:04:37 +0900 Subject: [PATCH 10/42] =?UTF-8?q?fix:save()=20=EB=94=94=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(=EC=88=98?= =?UTF-8?q?=EB=8F=99=EA=B1=B0=EB=9E=98=20=EC=A0=80=EC=9E=A5=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=B6=94=EC=A0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Barobill/EaccountController.php | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 7b6f24be..e6828386 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -892,6 +892,11 @@ public function save(Request $request): JsonResponse $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $transactions = $request->input('transactions', []); + Log::info('[Eaccount Save] 요청 수신', [ + 'tenant_id' => $tenantId, + 'transaction_count' => count($transactions), + ]); + if (empty($transactions)) { return response()->json([ 'success' => false, @@ -899,6 +904,23 @@ public function save(Request $request): JsonResponse ]); } + // 수동 거래 디버그 + $manualCount = 0; + $apiCount = 0; + foreach ($transactions as $t) { + if (!empty($t['isManual'])) { + $manualCount++; + Log::info('[Eaccount Save] 수동 거래 감지', [ + 'dbId' => $t['dbId'] ?? 'MISSING', + 'cast' => $t['cast'] ?? 'EMPTY', + 'isManual' => $t['isManual'], + ]); + } else { + $apiCount++; + } + } + Log::info('[Eaccount Save] 거래 분류', ['manual' => $manualCount, 'api' => $apiCount]); + $saved = 0; $updated = 0; $savedUniqueKeys = []; @@ -909,7 +931,7 @@ public function save(Request $request): JsonResponse // 수동 입력 거래: dbId로 직접 찾아서 비-키 필드만 업데이트 // balance는 화면에서 재계산된 값이므로 composite key 매칭 불가 if (!empty($trans['isManual']) && !empty($trans['dbId'])) { - DB::table('barobill_bank_transactions') + $affectedRows = DB::table('barobill_bank_transactions') ->where('id', $trans['dbId']) ->where('tenant_id', $tenantId) ->update([ @@ -921,6 +943,11 @@ public function save(Request $request): JsonResponse 'account_name' => $trans['accountName'] ?? null, 'updated_at' => now(), ]); + Log::info('[Eaccount Save] 수동 거래 업데이트', [ + 'dbId' => $trans['dbId'], + 'cast' => $trans['cast'] ?? '', + 'affected_rows' => $affectedRows, + ]); $updated++; continue; } From 6b22450e5b520afea5e984fd2b946d39c31d8099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 17:10:13 +0900 Subject: [PATCH 11/42] =?UTF-8?q?fix:save/cast=20=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C+=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/views/barobill/eaccount/index.blade.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 99fc44e0..fe641af2 100644 --- a/resources/views/barobill/eaccount/index.blade.php +++ b/resources/views/barobill/eaccount/index.blade.php @@ -1985,8 +1985,10 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" // 상대계좌예금주명 변경 핸들러 const handleCastChange = useCallback((index, value) => { + console.log('[CastChange Debug] index:', index, 'value:', value); setLogs(prevLogs => { const newLogs = [...prevLogs]; + console.log('[CastChange Debug] 기존 cast:', newLogs[index]?.cast, 'isManual:', newLogs[index]?.isManual, 'dbId:', newLogs[index]?.dbId); newLogs[index] = { ...newLogs[index], cast: value @@ -2022,8 +2024,16 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" const handleSave = async () => { if (logs.length === 0) return; + // 디버그: 저장할 수동 거래 확인 + const manualLogs = logs.filter(l => l.isManual); + console.log('[Save Debug] 전체 로그 수:', logs.length, '수동 거래 수:', manualLogs.length); + manualLogs.forEach(l => { + console.log('[Save Debug] 수동 거래:', {dbId: l.dbId, isManual: l.isManual, cast: l.cast, summary: l.summary}); + }); + setSaving(true); try { + console.log('[Save Debug] API.save URL:', API.save); const response = await fetch(API.save, { method: 'POST', headers: { @@ -2034,7 +2044,9 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" body: JSON.stringify({ transactions: logs }) }); + console.log('[Save Debug] 응답 status:', response.status); const data = await response.json(); + console.log('[Save Debug] 응답 data:', data); if (data.success) { notify(data.message, 'success'); setHasChanges(false); @@ -2044,6 +2056,7 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" notify(data.error || '저장 실패', 'error'); } } catch (err) { + console.error('[Save Debug] 오류:', err); notify('저장 오류: ' + err.message, 'error'); } finally { setSaving(false); From 352b521fcf444b939315ef239cd2e20d31d74d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 17:17:53 +0900 Subject: [PATCH 12/42] =?UTF-8?q?fix:=EC=88=98=EB=8F=99/API=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EA=B1=B0=EB=9E=98=20=EC=A0=9C=EA=B1=B0=20(mergeWit?= =?UTF-8?q?hDedup)=20+=20=EB=94=94=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Barobill/EaccountController.php | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index e6828386..76acee2e 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -395,9 +395,9 @@ public function transactions(Request $request): JsonResponse // 데이터 파싱 (저장된 계정과목 + 오버라이드 병합) $logs = $this->parseTransactionLogs($resultData, '', $savedData, $tenantId); - // 수동 입력 건 병합 + // 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외) $manualLogs = $this->convertManualToLogs($manualTransactions); - $mergedLogs = array_merge($logs['logs'], $manualLogs['logs']); + $mergedLogs = $this->mergeWithDedup($logs['logs'], $manualLogs['logs']); // 날짜/시간 기준 정렬 (최신순) usort($mergedLogs, function ($a, $b) { @@ -410,11 +410,11 @@ public function transactions(Request $request): JsonResponse $baseBalance = $this->findBaseBalance($tenantId, $startDate, $bankAccountNum); $mergedLogs = $this->recalcManualBalances($mergedLogs, $baseBalance); - // summary 합산 + // summary 합산 (중복 제거 후 count 재계산) $mergedSummary = [ 'totalDeposit' => $logs['summary']['totalDeposit'] + $manualLogs['summary']['totalDeposit'], 'totalWithdraw' => $logs['summary']['totalWithdraw'] + $manualLogs['summary']['totalWithdraw'], - 'count' => $logs['summary']['count'] + $manualLogs['summary']['count'], + 'count' => count($mergedLogs), ]; return response()->json([ @@ -499,12 +499,10 @@ private function getAllAccountsTransactions(string $userId, string $startDate, s } } - // 수동 입력 건 병합 + // 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외) if ($manualTransactions && $manualTransactions->isNotEmpty()) { $manualLogs = $this->convertManualToLogs($manualTransactions); - foreach ($manualLogs['logs'] as $mLog) { - $allLogs[] = $mLog; - } + $allLogs = $this->mergeWithDedup($allLogs, $manualLogs['logs']); $totalDeposit += $manualLogs['summary']['totalDeposit']; $totalWithdraw += $manualLogs['summary']['totalWithdraw']; } @@ -1516,6 +1514,53 @@ private function findBaseBalance(int $tenantId, string $startDate, ?string $bank return $balance; } + /** + * API 로그와 수동 로그 병합 (중복 제거) + * 수동 거래와 동일한 API 거래가 있으면 API 거래를 제외하고 수동 거래를 유지 + * 매칭 기준: 계좌번호 + 거래일시 + 입금액 + 출금액 (잔액 제외 - 수동 거래는 재계산됨) + */ + private function mergeWithDedup(array $apiLogs, array $manualLogs): array + { + if (empty($manualLogs)) { + return $apiLogs; + } + + // 수동 거래의 매칭 키 생성 (잔액 제외) + $manualKeys = []; + foreach ($manualLogs as $mLog) { + $key = implode('|', [ + $mLog['bankAccountNum'] ?? '', + ($mLog['transDate'] ?? '') . ($mLog['transTime'] ?? ''), + (int) ($mLog['deposit'] ?? 0), + (int) ($mLog['withdraw'] ?? 0), + ]); + $manualKeys[$key] = true; + } + + // API 로그에서 수동 거래와 중복되는 것 제외 + $dedupedApiLogs = []; + $removedCount = 0; + foreach ($apiLogs as $aLog) { + $key = implode('|', [ + $aLog['bankAccountNum'] ?? '', + ($aLog['transDate'] ?? '') . ($aLog['transTime'] ?? ''), + (int) ($aLog['deposit'] ?? 0), + (int) ($aLog['withdraw'] ?? 0), + ]); + if (isset($manualKeys[$key])) { + $removedCount++; + continue; // 수동 거래가 우선, API 거래 스킵 + } + $dedupedApiLogs[] = $aLog; + } + + if ($removedCount > 0) { + Log::info('[Eaccount] 중복 거래 제거', ['removed' => $removedCount]); + } + + return array_merge($dedupedApiLogs, $manualLogs); + } + private function recalcManualBalances(array $logs, ?float $baseBalance = null): array { if (empty($logs)) return $logs; From 5596ed0e7624cf137ad8cd292527aca43561d47c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 17:19:29 +0900 Subject: [PATCH 13/42] =?UTF-8?q?fix:mergeWithDedup=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=20=ED=98=95=EC=8B=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=EA=B8=88=EC=95=A1=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=B0=A8=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Barobill/EaccountController.php | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 76acee2e..fdd83eb7 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -397,7 +397,8 @@ public function transactions(Request $request): JsonResponse // 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외) $manualLogs = $this->convertManualToLogs($manualTransactions); - $mergedLogs = $this->mergeWithDedup($logs['logs'], $manualLogs['logs']); + $mergeResult = $this->mergeWithDedup($logs['logs'], $manualLogs['logs']); + $mergedLogs = $mergeResult['logs']; // 날짜/시간 기준 정렬 (최신순) usort($mergedLogs, function ($a, $b) { @@ -410,10 +411,10 @@ public function transactions(Request $request): JsonResponse $baseBalance = $this->findBaseBalance($tenantId, $startDate, $bankAccountNum); $mergedLogs = $this->recalcManualBalances($mergedLogs, $baseBalance); - // summary 합산 (중복 제거 후 count 재계산) + // summary 합산 (중복 제거된 API 거래 금액 차감) $mergedSummary = [ - 'totalDeposit' => $logs['summary']['totalDeposit'] + $manualLogs['summary']['totalDeposit'], - 'totalWithdraw' => $logs['summary']['totalWithdraw'] + $manualLogs['summary']['totalWithdraw'], + 'totalDeposit' => $logs['summary']['totalDeposit'] + $manualLogs['summary']['totalDeposit'] - $mergeResult['removedDeposit'], + 'totalWithdraw' => $logs['summary']['totalWithdraw'] + $manualLogs['summary']['totalWithdraw'] - $mergeResult['removedWithdraw'], 'count' => count($mergedLogs), ]; @@ -502,9 +503,10 @@ private function getAllAccountsTransactions(string $userId, string $startDate, s // 수동 입력 건 병합 (중복 제거: 수동 거래와 동일한 API 거래는 제외) if ($manualTransactions && $manualTransactions->isNotEmpty()) { $manualLogs = $this->convertManualToLogs($manualTransactions); - $allLogs = $this->mergeWithDedup($allLogs, $manualLogs['logs']); - $totalDeposit += $manualLogs['summary']['totalDeposit']; - $totalWithdraw += $manualLogs['summary']['totalWithdraw']; + $mergeResult = $this->mergeWithDedup($allLogs, $manualLogs['logs']); + $allLogs = $mergeResult['logs']; + $totalDeposit += $manualLogs['summary']['totalDeposit'] - $mergeResult['removedDeposit']; + $totalWithdraw += $manualLogs['summary']['totalWithdraw'] - $mergeResult['removedWithdraw']; } // 날짜/시간 기준 정렬 (최신순) @@ -1518,11 +1520,13 @@ private function findBaseBalance(int $tenantId, string $startDate, ?string $bank * API 로그와 수동 로그 병합 (중복 제거) * 수동 거래와 동일한 API 거래가 있으면 API 거래를 제외하고 수동 거래를 유지 * 매칭 기준: 계좌번호 + 거래일시 + 입금액 + 출금액 (잔액 제외 - 수동 거래는 재계산됨) + * + * @return array ['logs' => array, 'removedDeposit' => float, 'removedWithdraw' => float] */ private function mergeWithDedup(array $apiLogs, array $manualLogs): array { if (empty($manualLogs)) { - return $apiLogs; + return ['logs' => $apiLogs, 'removedDeposit' => 0, 'removedWithdraw' => 0]; } // 수동 거래의 매칭 키 생성 (잔액 제외) @@ -1539,7 +1543,8 @@ private function mergeWithDedup(array $apiLogs, array $manualLogs): array // API 로그에서 수동 거래와 중복되는 것 제외 $dedupedApiLogs = []; - $removedCount = 0; + $removedDeposit = 0; + $removedWithdraw = 0; foreach ($apiLogs as $aLog) { $key = implode('|', [ $aLog['bankAccountNum'] ?? '', @@ -1548,17 +1553,26 @@ private function mergeWithDedup(array $apiLogs, array $manualLogs): array (int) ($aLog['withdraw'] ?? 0), ]); if (isset($manualKeys[$key])) { - $removedCount++; + $removedDeposit += (float) ($aLog['deposit'] ?? 0); + $removedWithdraw += (float) ($aLog['withdraw'] ?? 0); continue; // 수동 거래가 우선, API 거래 스킵 } $dedupedApiLogs[] = $aLog; } - if ($removedCount > 0) { - Log::info('[Eaccount] 중복 거래 제거', ['removed' => $removedCount]); + if ($removedDeposit > 0 || $removedWithdraw > 0) { + Log::info('[Eaccount] 중복 거래 제거', [ + 'count' => count($manualLogs) - count($dedupedApiLogs) + count($apiLogs) - count($manualLogs), + 'removedDeposit' => $removedDeposit, + 'removedWithdraw' => $removedWithdraw, + ]); } - return array_merge($dedupedApiLogs, $manualLogs); + return [ + 'logs' => array_merge($dedupedApiLogs, $manualLogs), + 'removedDeposit' => $removedDeposit, + 'removedWithdraw' => $removedWithdraw, + ]; } private function recalcManualBalances(array $logs, ?float $baseBalance = null): array From 510b25ff1d7891d6e0c3400713a776e180c3c8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 17:22:50 +0900 Subject: [PATCH 14/42] =?UTF-8?q?fix:=EC=A0=84=ED=91=9C=EB=B2=88=ED=98=B8?= =?UTF-8?q?=20=EC=B1=84=EB=B2=88=20=EC=8B=9C=20soft-deleted=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20(withTrashed)=20+=20store=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Finance/JournalEntryController.php | 75 ++++++++++--------- app/Models/Finance/JournalEntry.php | 4 +- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/Finance/JournalEntryController.php b/app/Http/Controllers/Finance/JournalEntryController.php index 8464bd1c..ca83dacf 100644 --- a/app/Http/Controllers/Finance/JournalEntryController.php +++ b/app/Http/Controllers/Finance/JournalEntryController.php @@ -161,45 +161,52 @@ public function store(Request $request): JsonResponse ], 422); } - $entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) { - $entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date); + 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([ + $entry = JournalEntry::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, + '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, ]); - } - return $entry; - }); + 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' => '전표가 저장되었습니다.', - 'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no], - ]); + 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); + } } /** diff --git a/app/Models/Finance/JournalEntry.php b/app/Models/Finance/JournalEntry.php index 49750433..3821101e 100644 --- a/app/Models/Finance/JournalEntry.php +++ b/app/Models/Finance/JournalEntry.php @@ -49,7 +49,9 @@ public static function generateEntryNo($tenantId, $date) $dateStr = date('Ymd', strtotime($date)); $prefix = "JE-{$dateStr}-"; - $last = static::where('tenant_id', $tenantId) + // withTrashed: soft-deleted 레코드도 포함하여 채번 (DB unique 제약 충돌 방지) + $last = static::withTrashed() + ->where('tenant_id', $tenantId) ->where('entry_no', 'like', $prefix . '%') ->lockForUpdate() ->orderByDesc('entry_no') From 34788e854c84f4e255fd7aacd454415c7c70314c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 17:58:33 +0900 Subject: [PATCH 15/42] =?UTF-8?q?feat:=EC=9D=BC=EB=B0=98=EC=A0=84=ED=91=9C?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B0=9C=ED=8E=B8=20-=20=EA=B3=84=EC=A2=8C?= =?UTF-8?q?=EC=9E=85=EC=B6=9C=EA=B8=88=20=EA=B8=B0=EB=B0=98=20=EB=B6=84?= =?UTF-8?q?=EA=B0=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3탭 구조로 전면 재작성 (은행거래분개, 수동전표, 전표목록) - JournalEntryController에 bankTransactions, storeFromBank, bankJournals, deleteBankJournal API 추가 - JournalEntry 모델에 source_type, source_key fillable 및 헬퍼 메서드 추가 - 은행거래 목록에서 분개 모달로 복식부기 전표 생성 - 입금/출금에 따라 보통예금(103) 자동 세팅 - 분개 완료/미분개 상태 표시 및 필터링 - 기존 수동전표, 전표목록 기능 그대로 유지 Co-Authored-By: Claude Opus 4.6 --- .../Finance/JournalEntryController.php | 235 +++ app/Models/Finance/JournalEntry.php | 29 + .../views/finance/journal-entries.blade.php | 1655 ++++++++++++----- routes/web.php | 7 + 4 files changed, 1465 insertions(+), 461 deletions(-) diff --git a/app/Http/Controllers/Finance/JournalEntryController.php b/app/Http/Controllers/Finance/JournalEntryController.php index ca83dacf..3c55342e 100644 --- a/app/Http/Controllers/Finance/JournalEntryController.php +++ b/app/Http/Controllers/Finance/JournalEntryController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Finance; use App\Http\Controllers\Controller; +use App\Http\Controllers\Barobill\EaccountController; use App\Models\Finance\JournalEntry; use App\Models\Finance\JournalEntryLine; use App\Models\Finance\TradingPartner; @@ -10,6 +11,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class JournalEntryController extends Controller { @@ -359,4 +361,237 @@ public function tradingPartners(Request $request): JsonResponse }), ]); } + + // ================================================================ + // 은행거래 기반 분개 API + // ================================================================ + + /** + * 은행거래 목록 조회 (EaccountController 재사용 + 분개상태 병합) + */ + public function bankTransactions(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', 1); + + // EaccountController의 transactions 메서드 호출하여 은행거래 조회 + $eaccountController = app(EaccountController::class); + $transResponse = $eaccountController->transactions($request); + $transData = json_decode($transResponse->getContent(), true); + + if (!($transData['success'] ?? false)) { + return response()->json($transData); + } + + $logs = $transData['data']['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)) { + $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); + + // 분개 통계 + $journaledCount = count($journaledKeys); + $totalCount = count($logs); + + $transData['data']['logs'] = $logs; + $transData['data']['journalStats'] = [ + 'journaledCount' => $journaledCount, + 'unjournaledCount' => $totalCount - $journaledCount, + ]; + + return response()->json($transData); + } 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' => '분개가 삭제되었습니다.', + ]); + } } diff --git a/app/Models/Finance/JournalEntry.php b/app/Models/Finance/JournalEntry.php index 3821101e..c6367de9 100644 --- a/app/Models/Finance/JournalEntry.php +++ b/app/Models/Finance/JournalEntry.php @@ -21,6 +21,8 @@ class JournalEntry extends Model 'total_debit', 'total_credit', 'status', + 'source_type', + 'source_key', 'created_by_name', 'attachment_note', ]; @@ -41,6 +43,33 @@ public function scopeForTenant($query, $tenantId) return $query->where('tenant_id', $tenantId); } + /** + * 분개 완료된 source_key 일괄 조회 + */ + public static function getJournaledSourceKeys(int $tenantId, string $sourceType, array $sourceKeys): array + { + if (empty($sourceKeys)) { + return []; + } + + return static::where('tenant_id', $tenantId) + ->where('source_type', $sourceType) + ->whereIn('source_key', $sourceKeys) + ->pluck('source_key') + ->toArray(); + } + + /** + * source_key로 분개 전표 조회 (ID 포함) + */ + public static function getJournalBySourceKey(int $tenantId, string $sourceType, string $sourceKey) + { + return static::where('tenant_id', $tenantId) + ->where('source_type', $sourceType) + ->where('source_key', $sourceKey) + ->first(); + } + /** * 전표번호 자동채번: JE-YYYYMMDD-NNN */ diff --git a/resources/views/finance/journal-entries.blade.php b/resources/views/finance/journal-entries.blade.php index 9c056e8b..7affe0eb 100644 --- a/resources/views/finance/journal-entries.blade.php +++ b/resources/views/finance/journal-entries.blade.php @@ -22,6 +22,9 @@ +@endpush diff --git a/resources/views/dashboard/partials/calendar.blade.php b/resources/views/dashboard/partials/calendar.blade.php new file mode 100644 index 00000000..bc643b62 --- /dev/null +++ b/resources/views/dashboard/partials/calendar.blade.php @@ -0,0 +1,152 @@ +{{-- 대시보드 달력 그리드 (HTMX로 로드) --}} +@php + use Carbon\Carbon; + use App\Models\System\Schedule; + + $firstDay = Carbon::create($year, $month, 1); + $lastDay = $firstDay->copy()->endOfMonth(); + $startOfWeek = $firstDay->copy()->startOfWeek(Carbon::SUNDAY); + $endOfWeek = $lastDay->copy()->endOfWeek(Carbon::SATURDAY); + + $today = Carbon::today(); + $prevMonth = $firstDay->copy()->subMonth(); + $nextMonth = $firstDay->copy()->addMonth(); +@endphp + +{{-- 달력 헤더 --}} +
+
+ + +

{{ $year }}년 {{ $month }}월

+ + + + @if(!$today->isSameMonth($firstDay)) + + @endif +
+ + +
+ +{{-- 달력 그리드 --}} +
+ + + + + + + + + + + + + + @php + $currentDate = $startOfWeek->copy(); + @endphp + + @while($currentDate <= $endOfWeek) + + @for($i = 0; $i < 7; $i++) + @php + $dateKey = $currentDate->format('Y-m-d'); + $isCurrentMonth = $currentDate->month === $month; + $isToday = $currentDate->isSameDay($today); + $isSunday = $currentDate->dayOfWeek === Carbon::SUNDAY; + $isSaturday = $currentDate->dayOfWeek === Carbon::SATURDAY; + $daySchedules = $calendarData[$dateKey] ?? collect(); + $holidayName = $holidayMap[$dateKey] ?? null; + $isHoliday = $holidayName !== null; + @endphp + + + + @php + $currentDate->addDay(); + @endphp + @endfor + + @endwhile + +
+
+ {{-- 날짜 헤더 --}} +
+
+ + {{ $currentDate->day }} + + @if($isHoliday && $isCurrentMonth) + {{ $holidayName }} + @endif +
+ + @if($isCurrentMonth) + + @endif +
+ + {{-- 일정 목록 --}} +
+ @foreach($daySchedules as $schedule) + @php + $colors = $schedule->type_colors; + @endphp + + @endforeach +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 1efca4a2..798f13c7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -618,9 +618,19 @@ // 대시보드 Route::get('/dashboard', function () { + if (request()->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('dashboard')); + } return view('dashboard.index'); })->name('dashboard'); + // 대시보드 달력 + Route::get('/dashboard/calendar', [\App\Http\Controllers\DashboardCalendarController::class, 'calendar'])->name('dashboard.calendar'); + Route::post('/dashboard/schedules', [\App\Http\Controllers\DashboardCalendarController::class, 'store'])->name('dashboard.schedules.store'); + Route::get('/dashboard/schedules/{id}', [\App\Http\Controllers\DashboardCalendarController::class, 'show'])->name('dashboard.schedules.show'); + Route::put('/dashboard/schedules/{id}', [\App\Http\Controllers\DashboardCalendarController::class, 'update'])->name('dashboard.schedules.update'); + Route::delete('/dashboard/schedules/{id}', [\App\Http\Controllers\DashboardCalendarController::class, 'destroy'])->name('dashboard.schedules.destroy'); + // 루트 리다이렉트 Route::get('/', function () { return redirect()->route('dashboard'); From 376a7b904d3c9eaaf0a8a578e52b358ee08bd5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 20:13:45 +0900 Subject: [PATCH 24/42] =?UTF-8?q?fix:=EB=8B=AC=EB=A0=A5=20=EC=85=80=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=ED=99=95=EB=8C=80=20(7rem=20=E2=86=92=201?= =?UTF-8?q?0rem,=203=ED=96=89=20=EC=9D=BC=EC=A0=95=20=EC=88=98=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- resources/views/dashboard/partials/calendar.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/dashboard/partials/calendar.blade.php b/resources/views/dashboard/partials/calendar.blade.php index bc643b62..5a986c1a 100644 --- a/resources/views/dashboard/partials/calendar.blade.php +++ b/resources/views/dashboard/partials/calendar.blade.php @@ -92,7 +92,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald- @endphp -
+
{{-- 날짜 헤더 --}}
From 9bf93f0a99c9f5d0aa63baa503686ca9d5f8d0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 20:20:28 +0900 Subject: [PATCH 25/42] =?UTF-8?q?fix:=EB=8B=AC=EB=A0=A5=20=EC=85=80=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=203=EB=B0=B0=20=ED=99=95=EB=8C=80=20(10rem?= =?UTF-8?q?=20=E2=86=92=2030rem)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- resources/views/dashboard/partials/calendar.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/dashboard/partials/calendar.blade.php b/resources/views/dashboard/partials/calendar.blade.php index 5a986c1a..9bc731d4 100644 --- a/resources/views/dashboard/partials/calendar.blade.php +++ b/resources/views/dashboard/partials/calendar.blade.php @@ -92,7 +92,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald- @endphp -
+
{{-- 날짜 헤더 --}}
From b5673e2b517576d96d4ef6678af69557793c8742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 20:23:43 +0900 Subject: [PATCH 26/42] =?UTF-8?q?fix:=EB=8B=AC=EB=A0=A5=20=EC=85=80=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=EB=A5=BC=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A1=9C=20=EC=A0=81=EC=9A=A9=20(Ta?= =?UTF-8?q?ilwind=20JIT=20=EB=AF=B8=EB=B9=8C=EB=93=9C=20=EB=8C=80=EC=9D=91?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- resources/views/dashboard/partials/calendar.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/dashboard/partials/calendar.blade.php b/resources/views/dashboard/partials/calendar.blade.php index 9bc731d4..16348f31 100644 --- a/resources/views/dashboard/partials/calendar.blade.php +++ b/resources/views/dashboard/partials/calendar.blade.php @@ -92,7 +92,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald- @endphp -
+
{{-- 날짜 헤더 --}}
From a4d6f1de7441a7d4cc6b1d31c852da223ae34cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 20:29:05 +0900 Subject: [PATCH 27/42] =?UTF-8?q?fix:=EB=8B=AC=EB=A0=A5=20=EC=85=80=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=95=20(10rem=20=E2=86=92=20?= =?UTF-8?q?7.5rem)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- resources/views/dashboard/partials/calendar.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/dashboard/partials/calendar.blade.php b/resources/views/dashboard/partials/calendar.blade.php index 16348f31..cac3b39f 100644 --- a/resources/views/dashboard/partials/calendar.blade.php +++ b/resources/views/dashboard/partials/calendar.blade.php @@ -92,7 +92,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald- @endphp -
+
{{-- 날짜 헤더 --}}
From 489a491415fc3dce69668b6e87015aba3eaf9fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 20:36:22 +0900 Subject: [PATCH 28/42] =?UTF-8?q?feat:=EC=9D=BC=EC=A0=95=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=ED=8C=8C=EC=9D=BC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=8B=A4=EC=A4=91=20=EC=97=85=EB=A1=9C=EB=93=9C,?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=EC=95=A4=EB=93=9C=EB=A1=AD,=20GC?= =?UTF-8?q?S)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardCalendarController에 uploadFiles/deleteFile/downloadFile 추가 - 파일 업로드 라우트 3개 추가 (POST/DELETE/GET) - 모달에 드래그앤드롭 파일 업로드 영역 추가 - XHR 진행률 표시, 파일 목록 렌더링, 개별 삭제 - Google Cloud Storage 연동 (가용시 자동 업로드) - files 테이블 document_type='schedule' 활용 Co-Authored-By: Claude Opus 4.6 --- .../DashboardCalendarController.php | 135 ++++++ resources/views/dashboard/index.blade.php | 397 ++++++++++++++---- routes/web.php | 5 + 3 files changed, 450 insertions(+), 87 deletions(-) diff --git a/app/Http/Controllers/DashboardCalendarController.php b/app/Http/Controllers/DashboardCalendarController.php index 93ac94a9..be121e0e 100644 --- a/app/Http/Controllers/DashboardCalendarController.php +++ b/app/Http/Controllers/DashboardCalendarController.php @@ -2,12 +2,17 @@ namespace App\Http\Controllers; +use App\Models\Boards\File; use App\Models\System\Holiday; use App\Models\System\Schedule; +use App\Services\GoogleCloudStorageService; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Contracts\View\View; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class DashboardCalendarController extends Controller { @@ -85,9 +90,16 @@ public function show(int $id): JsonResponse $schedule = Schedule::forTenant($tenantId)->findOrFail($id); + // 첨부파일 목록 + $files = File::where('document_type', 'schedule') + ->where('document_id', $id) + ->whereNull('deleted_at') + ->get(['id', 'display_name', 'original_name', 'file_size', 'mime_type', 'created_at']); + return response()->json([ 'success' => true, 'data' => $schedule, + 'files' => $files, ]); } @@ -144,6 +156,129 @@ public function destroy(int $id): JsonResponse ]); } + /** + * 파일 업로드 (다중) + */ + public function uploadFiles(Request $request, int $scheduleId, GoogleCloudStorageService $gcs): JsonResponse + { + $request->validate([ + 'files' => 'required|array|min:1', + 'files.*' => 'file|max:20480', // 20MB + ]); + + $tenantId = session('selected_tenant_id', 1); + $schedule = Schedule::forTenant($tenantId)->findOrFail($scheduleId); + + $uploaded = []; + + foreach ($request->file('files') as $file) { + $originalName = $file->getClientOriginalName(); + $storedName = Str::random(40) . '.' . $file->getClientOriginalExtension(); + $storagePath = "schedules/{$tenantId}/{$schedule->id}/{$storedName}"; + + // 로컬(tenant 디스크) 저장 + Storage::disk('tenant')->put($storagePath, file_get_contents($file)); + + // GCS 업로드 (가능한 경우) + $gcsUri = null; + if ($gcs->isAvailable()) { + $gcsObjectName = "schedules/{$tenantId}/{$schedule->id}/{$storedName}"; + $gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName); + } + + // DB 레코드 + $fileRecord = File::create([ + 'tenant_id' => $tenantId, + 'document_type' => 'schedule', + 'document_id' => $schedule->id, + 'file_path' => $storagePath, + 'display_name' => $originalName, + 'stored_name' => $storedName, + 'original_name' => $originalName, + 'file_name' => $originalName, + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + 'file_type' => $this->determineFileType($file->getMimeType()), + 'is_temp' => false, + 'uploaded_by' => auth()->id(), + 'created_by' => auth()->id(), + ]); + + $uploaded[] = [ + 'id' => $fileRecord->id, + 'name' => $originalName, + 'size' => $fileRecord->getFormattedSize(), + 'gcs' => $gcsUri ? true : false, + ]; + } + + return response()->json([ + 'success' => true, + 'message' => count($uploaded) . '개 파일이 업로드되었습니다.', + 'files' => $uploaded, + ]); + } + + /** + * 파일 삭제 + */ + public function deleteFile(int $scheduleId, int $fileId, GoogleCloudStorageService $gcs): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + Schedule::forTenant($tenantId)->findOrFail($scheduleId); + + $file = File::where('document_type', 'schedule') + ->where('document_id', $scheduleId) + ->where('id', $fileId) + ->firstOrFail(); + + // GCS 삭제 + if ($gcs->isAvailable() && $file->file_path) { + $gcs->delete($file->file_path); + } + + // 로컬 삭제 + if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) { + Storage::disk('tenant')->delete($file->file_path); + } + + $file->deleted_by = auth()->id(); + $file->save(); + $file->delete(); + + return response()->json([ + 'success' => true, + 'message' => '파일이 삭제되었습니다.', + ]); + } + + /** + * 파일 다운로드 + */ + public function downloadFile(int $scheduleId, int $fileId) + { + $tenantId = session('selected_tenant_id', 1); + Schedule::forTenant($tenantId)->findOrFail($scheduleId); + + $file = File::where('document_type', 'schedule') + ->where('document_id', $scheduleId) + ->where('id', $fileId) + ->firstOrFail(); + + return $file->download(); + } + + /** + * MIME 타입으로 파일 유형 결정 + */ + private function determineFileType(string $mimeType): string + { + if (str_starts_with($mimeType, 'image/')) return 'image'; + if (str_contains($mimeType, 'spreadsheet') || str_contains($mimeType, 'excel')) return 'excel'; + if (str_contains($mimeType, 'zip') || str_contains($mimeType, 'rar') || str_contains($mimeType, 'archive')) return 'archive'; + return 'document'; + } + /** * 해당 월의 휴일 맵 생성 (날짜 => 휴일명) */ diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php index 8ec7eac9..619e4315 100644 --- a/resources/views/dashboard/index.blade.php +++ b/resources/views/dashboard/index.blade.php @@ -86,9 +86,9 @@