From fbfedf03d7e0642ad60eaa73e0456b5c8ddbb418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Feb 2026 15:42:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EB=B6=84=EA=B0=9C=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EA=B0=80=EC=95=A1/=EB=B6=80=EA=B0=80?= =?UTF-8?q?=EC=84=B8=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SplitModal: 금액 단일필드 → 공급가액+부가세 2필드로 변경 - 행별 합계금액 자동계산 표시 - 분개 리스트 행에 공급가액/부가세 개별 표시 - 분개 기반 요약 재계산 로직 추가 (recalculateSummary) - 모델: split_supply_amount, split_tax 필드 추가 - 컨트롤러: 분개 합계 검증 및 CSV 내보내기 반영 - 레거시 데이터(supply/tax 없는 기존 분개) 호환성 유지 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Barobill/EcardController.php | 17 +- app/Models/Barobill/CardTransactionSplit.php | 14 +- .../views/barobill/ecard/index.blade.php | 173 +++++++++++++++--- 3 files changed, 172 insertions(+), 32 deletions(-) diff --git a/app/Http/Controllers/Barobill/EcardController.php b/app/Http/Controllers/Barobill/EcardController.php index 34408ac5..8299d64c 100644 --- a/app/Http/Controllers/Barobill/EcardController.php +++ b/app/Http/Controllers/Barobill/EcardController.php @@ -1076,7 +1076,11 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse foreach ($splits as $index => $split) { $splitDeductionType = $split['deduction_type'] ?? $split['deductionType'] ?? 'deductible'; $splitDeductionText = ($splitDeductionType === 'non_deductible') ? '불공' : '공제'; - $splitAmount = $split['split_amount'] ?? $split['amount'] ?? 0; + $splitSupplyAmount = $split['split_supply_amount'] ?? $split['supplyAmount'] ?? null; + $splitTax = $split['split_tax'] ?? $split['tax'] ?? null; + $splitAmount = ($splitSupplyAmount !== null && $splitTax !== null) + ? floatval($splitSupplyAmount) + floatval($splitTax) + : ($split['split_amount'] ?? $split['amount'] ?? 0); $splitEvidenceName = $split['evidence_name'] ?? $split['evidenceName'] ?? ''; $splitDescription = $split['description'] ?? ''; $splitAccountCode = $split['account_code'] ?? $split['accountCode'] ?? ''; @@ -1092,7 +1096,7 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse $splitEvidenceName, $splitDescription, number_format($splitAmount), - '', // 부가세 (분개에서는 생략) + $splitTax !== null ? number_format(floatval($splitTax)) : '', '', // 승인번호 $splitAccountCode, $splitAccountName, @@ -1176,9 +1180,14 @@ public function saveSplits(Request $request): JsonResponse ]); } - // 분개 금액 합계 검증 + // 분개 금액 합계 검증 (공급가액 + 부가세 합계) $originalAmount = floatval($originalData['originalAmount'] ?? 0); - $splitTotal = array_sum(array_map(fn($s) => floatval($s['amount'] ?? 0), $splits)); + $splitTotal = array_sum(array_map(function ($s) { + 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([ diff --git a/app/Models/Barobill/CardTransactionSplit.php b/app/Models/Barobill/CardTransactionSplit.php index 78d0b971..0e4654a2 100644 --- a/app/Models/Barobill/CardTransactionSplit.php +++ b/app/Models/Barobill/CardTransactionSplit.php @@ -18,6 +18,8 @@ class CardTransactionSplit extends Model 'tenant_id', 'original_unique_key', 'split_amount', + 'split_supply_amount', + 'split_tax', 'account_code', 'account_name', 'deduction_type', @@ -35,6 +37,8 @@ class CardTransactionSplit extends Model protected $casts = [ 'split_amount' => 'decimal:2', + 'split_supply_amount' => 'decimal:2', + 'split_tax' => 'decimal:2', 'original_amount' => 'decimal:2', 'sort_order' => 'integer', ]; @@ -95,10 +99,18 @@ public static function saveSplits(int $tenantId, string $uniqueKey, array $origi // 새 분개 저장 foreach ($splits as $index => $split) { + $supplyAmount = $split['supplyAmount'] ?? null; + $tax = $split['tax'] ?? null; + $splitAmount = ($supplyAmount !== null && $tax !== null) + ? (float) $supplyAmount + (float) $tax + : ($split['amount'] ?? 0); + self::create([ 'tenant_id' => $tenantId, 'original_unique_key' => $uniqueKey, - 'split_amount' => $split['amount'] ?? 0, + 'split_amount' => $splitAmount, + 'split_supply_amount' => $supplyAmount, + 'split_tax' => $tax, 'account_code' => $split['accountCode'] ?? null, 'account_name' => $split['accountName'] ?? null, 'deduction_type' => $split['deductionType'] ?? null, diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index b725dbd0..97c9a231 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -347,19 +347,26 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${ const defaultDeductionType = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible'); 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 || '', - deductionType: s.deduction_type || s.deductionType || defaultDeductionType, - evidenceName: s.evidence_name || s.evidenceName || log.evidenceName || log.merchantName || '', - description: s.description || log.description || log.merchantBizType || log.memo || '', - memo: s.memo || '' - }))); + setSplits(existingSplits.map(s => { + const hasSupplyTax = s.split_supply_amount !== null && s.split_supply_amount !== undefined; + return { + supplyAmount: hasSupplyTax ? parseFloat(s.split_supply_amount) : parseFloat(s.split_amount || s.amount || 0), + tax: hasSupplyTax ? parseFloat(s.split_tax || 0) : 0, + accountCode: s.account_code || s.accountCode || '', + accountName: s.account_name || s.accountName || '', + deductionType: s.deduction_type || s.deductionType || defaultDeductionType, + evidenceName: s.evidence_name || s.evidenceName || log.evidenceName || log.merchantName || '', + description: s.description || log.description || log.merchantBizType || log.memo || '', + memo: s.memo || '' + }; + })); } else { - // 새 분개: 원본 금액으로 1개 행 생성 + // 새 분개: 원본의 공급가액/부가세로 1개 행 생성 + const origSupply = log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)); + const origTax = log.effectiveTax ?? (log.tax || 0); setSplits([{ - amount: log.approvalAmount || 0, + supplyAmount: origSupply, + tax: origTax, accountCode: log.accountCode || '', accountName: log.accountName || '', deductionType: defaultDeductionType, @@ -374,14 +381,16 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${ if (!isOpen || !log) return null; const originalAmount = log.approvalAmount || 0; - const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0); + // 합계금액 = sum(공급가액 + 부가세) + const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.supplyAmount) || 0) + (parseFloat(s.tax) || 0), 0); const isValid = Math.abs(originalAmount - splitTotal) < 0.01; const addSplit = () => { const remaining = originalAmount - splitTotal; const defaultDeductionType = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible'); setSplits([...splits, { - amount: remaining > 0 ? remaining : 0, + supplyAmount: remaining > 0 ? remaining : 0, + tax: 0, accountCode: '', accountName: '', deductionType: defaultDeductionType, @@ -404,7 +413,7 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${ const handleSave = async () => { if (!isValid) { - notify('분개 금액 합계가 원본 금액과 일치하지 않습니다.', 'error'); + notify('분개 합계금액이 원본 금액과 일치하지 않습니다.', 'error'); return; } setSaving(true); @@ -458,8 +467,16 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${ 사용일시 {log.useDateTime} +
+ 공급가액 + {formatCurrency(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)))}원 +
+
+ 부가세 + {formatCurrency(log.effectiveTax ?? (log.tax || 0))}원 +
- 원본 금액 + 합계금액 {formatCurrency(originalAmount)}원
@@ -467,19 +484,37 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
- {splits.map((split, index) => ( + {splits.map((split, index) => { + const rowTotal = (parseFloat(split.supplyAmount) || 0) + (parseFloat(split.tax) || 0); + return (
-
+
- + updateSplit(index, { amount: parseAmountInput(e.target.value) })} + value={formatAmountInput(split.supplyAmount)} + onChange={(e) => updateSplit(index, { supplyAmount: parseAmountInput(e.target.value) })} className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-purple-500 outline-none" placeholder="0" />
+
+ + updateSplit(index, { tax: parseAmountInput(e.target.value) })} + className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-purple-500 outline-none" + placeholder="0" + /> +
+
+ +
+ {formatCurrency(rowTotal)}원 +
+
{/* 분개 행들 */} - {hasSplits && logSplits.map((split, splitIdx) => ( + {hasSplits && logSplits.map((split, splitIdx) => { + const splitSupply = split.split_supply_amount !== null && split.split_supply_amount !== undefined + ? parseFloat(split.split_supply_amount) + : parseFloat(split.split_amount || split.amount || 0); + const splitTax = split.split_tax !== null && split.split_tax !== undefined + ? parseFloat(split.split_tax) + : 0; + const splitTotal = splitSupply + splitTax; + return (
@@ -1422,10 +1466,14 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" {split.description || '-'} - {new Intl.NumberFormat('ko-KR').format(split.split_amount || split.amount || 0)}원 + {new Intl.NumberFormat('ko-KR').format(splitTotal)}원 + + + {new Intl.NumberFormat('ko-KR').format(splitSupply)}원 + + + {new Intl.NumberFormat('ko-KR').format(splitTax)}원 - - {split.account_code || split.accountCode ? ( @@ -1437,7 +1485,8 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" - ))} + ); + })} ); }) @@ -1575,13 +1624,83 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" const response = await fetch(`${API.splits}?${params}`); const data = await response.json(); if (data.success) { - setSplits(data.data || {}); + const newSplits = data.data || {}; + setSplits(newSplits); + // 분개 데이터 기반으로 요약 재계산 + recalculateSummary(logs, newSplits); } } catch (err) { console.error('분개 데이터 로드 오류:', err); } }; + // 요약 재계산: 분개가 있는 거래는 원본 대신 분개별 통계로 대체 + const recalculateSummary = (currentLogs, allSplits) => { + if (!currentLogs || currentLogs.length === 0) return; + + let deductibleAmount = 0; + let deductibleCount = 0; + let nonDeductibleAmount = 0; + let nonDeductibleCount = 0; + let totalTax = 0; + + currentLogs.forEach(log => { + const uniqueKey = log.uniqueKey || `${log.cardNum}|${log.useDt}|${log.approvalNum}|${Math.floor(log.approvalAmount)}`; + const logSplits = allSplits[uniqueKey] || []; + + if (logSplits.length > 0) { + // 분개가 있는 거래: 각 분개별로 계산 + logSplits.forEach(split => { + const splitSupply = split.split_supply_amount !== null && split.split_supply_amount !== undefined + ? parseFloat(split.split_supply_amount) : parseFloat(split.split_amount || 0); + const splitTax = split.split_tax !== null && split.split_tax !== undefined + ? parseFloat(split.split_tax) : 0; + const splitTotal = Math.abs(splitSupply + splitTax); + const splitType = split.deduction_type || split.deductionType || 'non_deductible'; + + if (splitType === 'deductible') { + deductibleAmount += splitTotal; + deductibleCount++; + totalTax += Math.abs(splitTax); + } else { + nonDeductibleAmount += splitTotal; + nonDeductibleCount++; + } + }); + } else { + // 분개가 없는 거래: 기존 방식 + const type = log.deductionType || 'non_deductible'; + const amount = Math.abs(log.approvalAmount || 0); + const effTax = Math.abs(log.effectiveTax ?? (log.tax || 0)); + + if (type === 'deductible') { + deductibleAmount += amount; + deductibleCount++; + totalTax += effTax; + } else { + nonDeductibleAmount += amount; + nonDeductibleCount++; + } + } + }); + + setSummary(prev => ({ + ...prev, + deductibleAmount, + deductibleCount, + nonDeductibleAmount, + nonDeductibleCount, + totalTax, + })); + }; + + // splits 또는 logs 변경 시 요약 재계산 + useEffect(() => { + if (logs.length > 0 && Object.keys(splits).length > 0) { + recalculateSummary(logs, splits); + } + }, [splits]); + // 분개 모달 열기 const handleOpenSplitModal = (log, uniqueKey, existingSplits = []) => { setSplitModalLog(log);