diff --git a/app/Http/Controllers/Barobill/EcardController.php b/app/Http/Controllers/Barobill/EcardController.php index 4c63620c..9a95e2cb 100644 --- a/app/Http/Controllers/Barobill/EcardController.php +++ b/app/Http/Controllers/Barobill/EcardController.php @@ -7,6 +7,7 @@ use App\Models\Barobill\BarobillConfig; use App\Models\Barobill\BarobillMember; use App\Models\Barobill\CardTransaction; +use App\Models\Barobill\CardTransactionSplit; use App\Models\Tenants\Tenant; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -855,6 +856,113 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse } } + /** + * 분개 내역 조회 + */ + public function splits(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $startDate = $request->input('startDate', date('Ymd')); + $endDate = $request->input('endDate', date('Ymd')); + + $splits = CardTransactionSplit::getByDateRange($tenantId, $startDate, $endDate); + + return response()->json([ + 'success' => true, + 'data' => $splits + ]); + } catch (\Throwable $e) { + Log::error('분개 내역 조회 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '조회 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 분개 저장 + */ + public function saveSplits(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $uniqueKey = $request->input('uniqueKey'); + $originalData = $request->input('originalData', []); + $splits = $request->input('splits', []); + + if (empty($uniqueKey)) { + return response()->json([ + 'success' => false, + 'error' => '고유키가 없습니다.' + ]); + } + + // 분개 금액 합계 검증 + $originalAmount = floatval($originalData['originalAmount'] ?? 0); + $splitTotal = array_sum(array_map(fn($s) => floatval($s['amount'] ?? 0), $splits)); + + if (abs($originalAmount - $splitTotal) > 0.01) { + return response()->json([ + 'success' => false, + 'error' => "분개 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다." + ]); + } + + DB::beginTransaction(); + + CardTransactionSplit::saveSplits($tenantId, $uniqueKey, $originalData, $splits); + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => '분개가 저장되었습니다.', + 'splitCount' => count($splits) + ]); + } catch (\Throwable $e) { + DB::rollBack(); + Log::error('분개 저장 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '저장 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 분개 삭제 (원본으로 복원) + */ + public function deleteSplits(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $uniqueKey = $request->input('uniqueKey'); + + if (empty($uniqueKey)) { + return response()->json([ + 'success' => false, + 'error' => '고유키가 없습니다.' + ]); + } + + $deleted = CardTransactionSplit::deleteSplits($tenantId, $uniqueKey); + + return response()->json([ + 'success' => true, + 'message' => '분개가 삭제되었습니다.', + 'deleted' => $deleted + ]); + } catch (\Throwable $e) { + Log::error('분개 삭제 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '삭제 오류: ' . $e->getMessage() + ]); + } + } + /** * SOAP 호출 */ diff --git a/app/Models/Barobill/CardTransactionSplit.php b/app/Models/Barobill/CardTransactionSplit.php new file mode 100644 index 00000000..293381d2 --- /dev/null +++ b/app/Models/Barobill/CardTransactionSplit.php @@ -0,0 +1,122 @@ + 'decimal:2', + 'original_amount' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 테넌트별 분개 내역 조회 (기간별) + * 고유키를 기준으로 그룹핑하여 반환 + */ + public static function getByDateRange(int $tenantId, string $startDate, string $endDate): array + { + $splits = self::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get(); + + // 고유키별로 그룹핑 + $grouped = []; + foreach ($splits as $split) { + $key = $split->original_unique_key; + if (!isset($grouped[$key])) { + $grouped[$key] = []; + } + $grouped[$key][] = $split; + } + + return $grouped; + } + + /** + * 특정 거래의 분개 내역 조회 + */ + public static function getByUniqueKey(int $tenantId, string $uniqueKey): \Illuminate\Database\Eloquent\Collection + { + return self::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } + + /** + * 특정 거래의 분개 내역 저장 (기존 분개 삭제 후 재생성) + */ + public static function saveSplits(int $tenantId, string $uniqueKey, array $originalData, array $splits): void + { + // 기존 분개 삭제 + self::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + // 새 분개 저장 + foreach ($splits as $index => $split) { + self::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'split_amount' => $split['amount'] ?? 0, + 'account_code' => $split['accountCode'] ?? null, + 'account_name' => $split['accountName'] ?? null, + 'memo' => $split['memo'] ?? null, + 'sort_order' => $index, + 'card_num' => $originalData['cardNum'] ?? '', + 'use_dt' => $originalData['useDt'] ?? '', + 'use_date' => $originalData['useDate'] ?? '', + 'approval_num' => $originalData['approvalNum'] ?? '', + 'original_amount' => $originalData['originalAmount'] ?? 0, + 'merchant_name' => $originalData['merchantName'] ?? '', + ]); + } + } + + /** + * 분개 내역 삭제 (원본으로 복원) + */ + public static function deleteSplits(int $tenantId, string $uniqueKey): int + { + return self::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + } +} diff --git a/database/migrations/2026_01_23_160000_create_barobill_card_transaction_splits_table.php b/database/migrations/2026_01_23_160000_create_barobill_card_transaction_splits_table.php new file mode 100644 index 00000000..d1829e0c --- /dev/null +++ b/database/migrations/2026_01_23_160000_create_barobill_card_transaction_splits_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + + // 원본 거래 고유키 (바로빌에서 가져온 원본 데이터 식별) + $table->string('original_unique_key', 200)->comment('원본 거래 고유키 (cardNum|useDt|approvalNum|amount)'); + + // 분개 정보 + $table->decimal('split_amount', 18, 2)->comment('분개 금액'); + $table->string('account_code', 50)->nullable()->comment('계정과목 코드'); + $table->string('account_name', 100)->nullable()->comment('계정과목명'); + $table->string('memo', 255)->nullable()->comment('분개 메모'); + $table->integer('sort_order')->default(0)->comment('정렬 순서'); + + // 원본 거래 정보 (조회 편의를 위해 저장) + $table->string('card_num', 50)->comment('카드번호'); + $table->string('use_dt', 20)->comment('사용일시 (YYYYMMDDHHMMSS)'); + $table->string('use_date', 8)->comment('사용일 (YYYYMMDD)'); + $table->string('approval_num', 50)->nullable()->comment('승인번호'); + $table->decimal('original_amount', 18, 2)->comment('원본 거래 총액'); + $table->string('merchant_name', 255)->nullable()->comment('가맹점명'); + + $table->timestamps(); + + // 인덱스 + $table->index(['tenant_id', 'original_unique_key'], 'bb_card_split_tenant_key_idx'); + $table->index(['tenant_id', 'use_date'], 'bb_card_split_tenant_date_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('barobill_card_transaction_splits'); + } +}; diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index ba1f18cd..6eba3c9e 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -72,6 +72,9 @@ accountCodes: '{{ route("barobill.ecard.account-codes") }}', save: '{{ route("barobill.ecard.save") }}', export: '{{ route("barobill.ecard.export") }}', + splits: '{{ route("barobill.ecard.splits") }}', + saveSplits: '{{ route("barobill.ecard.splits.save") }}', + deleteSplits: '{{ route("barobill.ecard.splits.delete") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -274,6 +277,189 @@ className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-purple-50 ${ ); }; + // SplitModal Component - 분개 모달 + const SplitModal = ({ isOpen, onClose, log, accountCodes, onSave, splits: existingSplits }) => { + const [splits, setSplits] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (isOpen && log) { + if (existingSplits && existingSplits.length > 0) { + // 기존 분개 로드 + setSplits(existingSplits.map(s => ({ + amount: parseFloat(s.split_amount || s.amount || 0), + accountCode: s.account_code || s.accountCode || '', + accountName: s.account_name || s.accountName || '', + memo: s.memo || '' + }))); + } else { + // 새 분개: 원본 금액으로 1개 행 생성 + setSplits([{ + amount: log.approvalAmount || 0, + accountCode: log.accountCode || '', + accountName: log.accountName || '', + memo: '' + }]); + } + } + }, [isOpen, log, existingSplits]); + + if (!isOpen || !log) return null; + + const originalAmount = log.approvalAmount || 0; + const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0); + const isValid = Math.abs(originalAmount - splitTotal) < 0.01; + + const addSplit = () => { + const remaining = originalAmount - splitTotal; + setSplits([...splits, { amount: remaining > 0 ? remaining : 0, accountCode: '', accountName: '', memo: '' }]); + }; + + const removeSplit = (index) => { + if (splits.length <= 1) return; + setSplits(splits.filter((_, i) => i !== index)); + }; + + const updateSplit = (index, field, value) => { + const newSplits = [...splits]; + newSplits[index] = { ...newSplits[index], [field]: value }; + setSplits(newSplits); + }; + + const handleSave = async () => { + if (!isValid) { + notify('분개 금액 합계가 원본 금액과 일치하지 않습니다.', 'error'); + return; + } + setSaving(true); + await onSave(log, splits); + setSaving(false); + onClose(); + }; + + const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); + + return ( +
+
e.stopPropagation()}> +
+
+

거래 분개

+ +
+
+
+ 가맹점 + {log.merchantName} +
+
+ 사용일시 + {log.useDateTime} +
+
+ 원본 금액 + {formatCurrency(originalAmount)}원 +
+
+
+ +
+
+ {splits.map((split, index) => ( +
+
+
+ + updateSplit(index, 'amount', parseFloat(e.target.value) || 0)} + className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none" + /> +
+
+ + { + updateSplit(index, 'accountCode', code); + updateSplit(index, 'accountName', name); + }} + accountCodes={accountCodes} + /> +
+
+ + updateSplit(index, 'memo', e.target.value)} + placeholder="분개 메모 (선택)" + className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 outline-none" + /> +
+
+ +
+ ))} +
+ + +
+ +
+
+ 분개 합계 + + {formatCurrency(splitTotal)}원 + {!isValid && ( + + (차이: {formatCurrency(originalAmount - splitTotal)}원) + + )} + +
+
+ + +
+
+
+
+ ); + }; + // TransactionTable Component const TransactionTable = ({ logs, @@ -291,7 +477,10 @@ className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-purple-50 ${ onSave, onExport, saving, - hasChanges + hasChanges, + splits, + onOpenSplitModal, + onDeleteSplits }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원'; @@ -396,6 +585,7 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l + @@ -410,69 +600,141 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l {logs.length === 0 ? ( - ) : ( - logs.map((log, index) => ( - - - - - - - - - - - - )) + logs.map((log, index) => { + const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`; + const logSplits = splits[uniqueKey] || []; + const hasSplits = logSplits.length > 0; + + return ( + + {/* 원본 거래 행 */} + + + + + + + + + + + + + {/* 분개 행들 */} + {hasSplits && logSplits.map((split, splitIdx) => ( + + + + + + + + + ))} + + ); + }) )}
분개 사용일시 카드정보 가맹점
+ 해당 기간에 조회된 카드 사용내역이 없습니다.
-
{log.useDateTime || '-'}
-
-
{log.cardBrand}
-
- {log.cardNumMasked || '-'} -
-
-
{log.merchantName || '-'}
- {log.merchantBizNum && ( -
{log.merchantBizNum}
- )} -
- - {log.approvalAmountFormatted}원 - - - {log.taxFormatted}원 - - - {log.paymentPlanName} - - - - {log.approvalTypeName} - - - {log.approvalNum || '-'} - - onAccountCodeChange(index, code, name)} - accountCodes={accountCodes} - /> - {log.accountName && ( -
{log.accountName}
- )} -
+ {hasSplits ? ( + + ) : ( + + )} + +
{log.useDateTime || '-'}
+
+
{log.cardBrand}
+
+ {log.cardNumMasked || '-'} +
+
+
{log.merchantName || '-'}
+ {log.merchantBizNum && ( +
{log.merchantBizNum}
+ )} +
+ + {log.approvalAmountFormatted}원 + + {hasSplits && ( +
분개됨 ({logSplits.length}건)
+ )} +
+ {log.taxFormatted}원 + + + {log.paymentPlanName} + + + + {log.approvalTypeName} + + + {log.approvalNum || '-'} + + {!hasSplits && ( + <> + onAccountCodeChange(index, code, name)} + accountCodes={accountCodes} + /> + {log.accountName && ( +
{log.accountName}
+ )} + + )} + {hasSplits && ( + + )} +
+
+
+ └ 분개 #{splitIdx + 1} {split.memo && `- ${split.memo}`} + + {new Intl.NumberFormat('ko-KR').format(split.split_amount || split.amount || 0)}원 + + {split.account_code || split.accountCode ? ( + + {split.account_code || split.accountCode} {split.account_name || split.accountName} + + ) : ( + 미지정 + )} +
@@ -494,6 +756,13 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l const [accountCodes, setAccountCodes] = useState([]); const [hasChanges, setHasChanges] = useState(false); + // 분개 관련 상태 + const [splits, setSplits] = useState({}); + const [splitModalOpen, setSplitModalOpen] = useState(false); + const [splitModalLog, setSplitModalLog] = useState(null); + const [splitModalKey, setSplitModalKey] = useState(''); + const [splitModalExisting, setSplitModalExisting] = useState([]); + // 날짜 필터 상태 (기본: 현재 월) const currentMonth = getMonthDates(0); const [dateFrom, setDateFrom] = useState(currentMonth.from); @@ -567,6 +836,98 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l } finally { setLoading(false); } + + // 분개 데이터 로드 + loadSplits(); + }; + + // 분개 데이터 로드 + const loadSplits = async () => { + try { + const params = new URLSearchParams({ + startDate: dateFrom.replace(/-/g, ''), + endDate: dateTo.replace(/-/g, '') + }); + const response = await fetch(`${API.splits}?${params}`); + const data = await response.json(); + if (data.success) { + setSplits(data.data || {}); + } + } catch (err) { + console.error('분개 데이터 로드 오류:', err); + } + }; + + // 분개 모달 열기 + const handleOpenSplitModal = (log, uniqueKey, existingSplits = []) => { + setSplitModalLog(log); + setSplitModalKey(uniqueKey); + setSplitModalExisting(existingSplits); + setSplitModalOpen(true); + }; + + // 분개 저장 + const handleSaveSplits = async (log, splitData) => { + try { + const uniqueKey = splitModalKey; + const response = await fetch(API.saveSplits, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN, + 'Accept': 'application/json' + }, + body: JSON.stringify({ + uniqueKey: uniqueKey, + originalData: { + cardNum: log.cardNum, + useDt: log.useDt, + useDate: log.useDate, + approvalNum: log.approvalNum, + originalAmount: log.approvalAmount, + merchantName: log.merchantName + }, + splits: splitData + }) + }); + + const data = await response.json(); + if (data.success) { + notify(data.message, 'success'); + loadSplits(); // 분개 데이터 새로고침 + } else { + notify(data.error || '분개 저장 실패', 'error'); + } + } catch (err) { + notify('분개 저장 오류: ' + err.message, 'error'); + } + }; + + // 분개 삭제 + const handleDeleteSplits = async (uniqueKey) => { + if (!confirm('분개를 삭제하시겠습니까? 원본 거래로 복원됩니다.')) return; + + try { + const response = await fetch(API.deleteSplits, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN, + 'Accept': 'application/json' + }, + body: JSON.stringify({ uniqueKey }) + }); + + const data = await response.json(); + if (data.success) { + notify(data.message, 'success'); + loadSplits(); // 분개 데이터 새로고침 + } else { + notify(data.error || '분개 삭제 실패', 'error'); + } + } catch (err) { + notify('분개 삭제 오류: ' + err.message, 'error'); + } }; // 계정과목 변경 핸들러 @@ -747,9 +1108,22 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l onExport={handleExport} saving={saving} hasChanges={hasChanges} + splits={splits} + onOpenSplitModal={handleOpenSplitModal} + onDeleteSplits={handleDeleteSplits} /> )} + {/* Split Modal */} + setSplitModalOpen(false)} + log={splitModalLog} + accountCodes={accountCodes} + onSave={handleSaveSplits} + splits={splitModalExisting} + /> + {/* Pagination */} {!error && pagination.maxPageNum > 1 && (
diff --git a/routes/web.php b/routes/web.php index 8b04165b..6ead76d1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -309,6 +309,10 @@ Route::get('/account-codes', [\App\Http\Controllers\Barobill\EcardController::class, 'accountCodes'])->name('account-codes'); Route::post('/save', [\App\Http\Controllers\Barobill\EcardController::class, 'save'])->name('save'); Route::get('/export', [\App\Http\Controllers\Barobill\EcardController::class, 'exportExcel'])->name('export'); + // 분개 관련 + Route::get('/splits', [\App\Http\Controllers\Barobill\EcardController::class, 'splits'])->name('splits'); + Route::post('/splits', [\App\Http\Controllers\Barobill\EcardController::class, 'saveSplits'])->name('splits.save'); + Route::delete('/splits', [\App\Http\Controllers\Barobill\EcardController::class, 'deleteSplits'])->name('splits.delete'); }); });