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 ( +
| 분개 | 사용일시 | 카드정보 | 가맹점 | @@ -410,69 +600,141 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l|||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + | 해당 기간에 조회된 카드 사용내역이 없습니다. | |||||||||||||||||
|
- {log.useDateTime || '-'}
- |
-
- {log.cardBrand}
-
- {log.cardNumMasked || '-'}
-
- |
-
- {log.merchantName || '-'}
- {log.merchantBizNum && (
- {log.merchantBizNum}
- )}
- |
- - - {log.approvalAmountFormatted}원 - - | -- {log.taxFormatted}원 - | -- - {log.paymentPlanName} - - | -- - {log.approvalTypeName} - - | -- {log.approvalNum || '-'} - | -
- {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 && (
+ <>
+ {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} + + ) : ( + 미지정 + )} + | ++ | |||||||||||||