feat:분개 모달 공급가액/부가세 필드 추가

- SplitModal: 금액 단일필드 → 공급가액+부가세 2필드로 변경
- 행별 합계금액 자동계산 표시
- 분개 리스트 행에 공급가액/부가세 개별 표시
- 분개 기반 요약 재계산 로직 추가 (recalculateSummary)
- 모델: split_supply_amount, split_tax 필드 추가
- 컨트롤러: 분개 합계 검증 및 CSV 내보내기 반영
- 레거시 데이터(supply/tax 없는 기존 분개) 호환성 유지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-05 15:42:42 +09:00
parent befa4273a8
commit fbfedf03d7
3 changed files with 172 additions and 32 deletions

View File

@@ -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([

View File

@@ -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,

View File

@@ -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 ${
<span className="text-stone-500">사용일시</span>
<span className="font-medium">{log.useDateTime}</span>
</div>
<div className="flex justify-between mb-1">
<span className="text-stone-500">공급가액</span>
<span className="font-medium">{formatCurrency(log.effectiveSupplyAmount ?? ((log.approvalAmount || 0) - (log.tax || 0)))}</span>
</div>
<div className="flex justify-between mb-1">
<span className="text-stone-500">부가세</span>
<span className="font-medium">{formatCurrency(log.effectiveTax ?? (log.tax || 0))}</span>
</div>
<div className="flex justify-between">
<span className="text-stone-500">원본 금액</span>
<span className="text-stone-500">합계금액</span>
<span className="font-bold text-purple-600">{formatCurrency(originalAmount)}</span>
</div>
</div>
@@ -467,19 +484,37 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${
<div className="p-6 overflow-y-auto" style={ {maxHeight: '400px'} }>
<div className="space-y-3">
{splits.map((split, index) => (
{splits.map((split, index) => {
const rowTotal = (parseFloat(split.supplyAmount) || 0) + (parseFloat(split.tax) || 0);
return (
<div key={index} className="flex items-start gap-3 p-3 bg-stone-50 rounded-lg">
<div className="flex-1 grid grid-cols-2 gap-3">
<div className="flex-1 grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-stone-500 mb-1"></label>
<label className="block text-xs text-stone-500 mb-1">공급가</label>
<input
type="text"
value={formatAmountInput(split.amount)}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">부가세</label>
<input
type="text"
value={formatAmountInput(split.tax)}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">합계금액</label>
<div className="px-3 py-2 bg-stone-100 rounded-lg text-sm text-right font-medium text-purple-700">
{formatCurrency(rowTotal)}
</div>
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">계정과목</label>
<AccountCodeSelect
@@ -544,7 +579,8 @@ className="mt-6 p-2 text-red-500 hover:bg-red-50 rounded-lg disabled:opacity-30
</svg>
</button>
</div>
))}
);
})}
</div>
<button
@@ -1396,7 +1432,15 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
</td>
</tr>
{/* 분개 행들 */}
{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 (
<tr key={`${index}-split-${splitIdx}`} className="bg-amber-50/30 hover:bg-amber-100/30">
<td className="px-3 py-2 text-center">
<div className="w-4 h-4 border-l-2 border-b-2 border-amber-300 ml-2"></div>
@@ -1422,10 +1466,14 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
{split.description || '-'}
</td>
<td className="px-4 py-2 text-right font-medium text-amber-700 text-sm">
{new Intl.NumberFormat('ko-KR').format(split.split_amount || split.amount || 0)}
{new Intl.NumberFormat('ko-KR').format(splitTotal)}
</td>
<td className="px-4 py-2 text-right text-xs text-amber-600">
{new Intl.NumberFormat('ko-KR').format(splitSupply)}
</td>
<td className="px-4 py-2 text-right text-xs text-amber-600">
{new Intl.NumberFormat('ko-KR').format(splitTax)}
</td>
<td></td>
<td></td>
<td className="px-4 py-2 text-xs">
{split.account_code || split.accountCode ? (
<span className="text-purple-600">
@@ -1437,7 +1485,8 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
</td>
<td></td>
</tr>
))}
);
})}
</React.Fragment>
);
})
@@ -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);