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:
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user