feat:바로빌 계좌 거래내역 적요/내용 수정 기능 추가

- BankTransactionOverride 모델 추가 (오버라이드 데이터 관리)
- EaccountController에 saveOverride 엔드포인트 추가
- parseTransactionLogs에서 오버라이드 데이터 병합 로직 추가
- 프론트엔드에 TransactionEditModal 컴포넌트 추가
- 적요 셀 클릭 시 수정 모달 표시
- 오버라이드된 항목 시각적 표시 (배경색, 수정 배지)
- 원본 복원 기능 포함

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-06 09:57:55 +09:00
parent 5bd0b288a7
commit fd0b8d8536
4 changed files with 415 additions and 14 deletions

View File

@@ -7,6 +7,7 @@
use App\Models\Barobill\BarobillConfig;
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\BankTransaction;
use App\Models\Barobill\BankTransactionOverride;
use App\Models\Tenants\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -309,9 +310,12 @@ public function transactions(Request $request): JsonResponse
// DB에서 저장된 계정과목 데이터 조회
$savedData = BankTransaction::getByDateRange($tenantId, $startDate, $endDate, $bankAccountNum ?: null);
// 오버라이드 데이터 (수정된 적요/내용) 조회
$overrideData = null;
// 전체 계좌 조회: 빈 값이면 모든 계좌의 거래 내역 조회
if (empty($bankAccountNum)) {
return $this->getAllAccountsTransactions($userId, $startDate, $endDate, $page, $limit, $savedData);
return $this->getAllAccountsTransactions($userId, $startDate, $endDate, $page, $limit, $savedData, $overrideData, $tenantId);
}
// 단일 계좌 조회
@@ -358,8 +362,8 @@ public function transactions(Request $request): JsonResponse
]);
}
// 데이터 파싱 (저장된 계정과목 병합)
$logs = $this->parseTransactionLogs($resultData, '', $savedData);
// 데이터 파싱 (저장된 계정과목 + 오버라이드 병합)
$logs = $this->parseTransactionLogs($resultData, '', $savedData, $tenantId);
return response()->json([
'success' => true,
@@ -386,7 +390,7 @@ public function transactions(Request $request): JsonResponse
/**
* 전체 계좌의 거래 내역 조회
*/
private function getAllAccountsTransactions(string $userId, string $startDate, string $endDate, int $page, int $limit, $savedData = null): JsonResponse
private function getAllAccountsTransactions(string $userId, string $startDate, string $endDate, int $page, int $limit, $savedData = null, $overrideData = null, int $tenantId = 1): JsonResponse
{
// 먼저 계좌 목록 조회
$accountResult = $this->callSoap('GetBankAccountEx', ['AvailOnly' => 0]);
@@ -432,7 +436,7 @@ private function getAllAccountsTransactions(string $userId, string $startDate, s
$errorCode = $this->checkErrorCode($accData);
if (!$errorCode || in_array($errorCode, [-25005, -25001])) {
$parsed = $this->parseTransactionLogs($accData, $acc->BankName ?? '', $savedData);
$parsed = $this->parseTransactionLogs($accData, $acc->BankName ?? '', $savedData, $tenantId);
foreach ($parsed['logs'] as $log) {
$log['bankName'] = $acc->BankName ?? $this->getBankName($acc->BankCode ?? '');
$allLogs[] = $log;
@@ -476,11 +480,12 @@ private function getAllAccountsTransactions(string $userId, string $startDate, s
}
/**
* 거래 내역 파싱 (저장된 계정과목 병합)
* 거래 내역 파싱 (저장된 계정과목 + 오버라이드 병합)
*/
private function parseTransactionLogs($resultData, string $defaultBankName = '', $savedData = null): array
private function parseTransactionLogs($resultData, string $defaultBankName = '', $savedData = null, int $tenantId = 1): array
{
$logs = [];
$uniqueKeys = [];
$totalDeposit = 0;
$totalWithdraw = 0;
@@ -491,6 +496,22 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
: [$resultData->BankAccountLogList->BankAccountTransLog];
}
// 1단계: 모든 고유 키 수집
foreach ($rawLogs as $log) {
$bankAccountNum = $log->BankAccountNum ?? '';
$transDT = $log->TransDT ?? '';
$deposit = (int) floatval($log->Deposit ?? 0);
$withdraw = (int) floatval($log->Withdraw ?? 0);
$balance = (int) floatval($log->Balance ?? 0);
$uniqueKey = implode('|', [$bankAccountNum, $transDT, $deposit, $withdraw, $balance]);
$uniqueKeys[] = $uniqueKey;
}
// 2단계: 오버라이드 데이터 일괄 조회
$overrides = BankTransactionOverride::getByUniqueKeys($tenantId, $uniqueKeys);
// 3단계: 각 로그 처리
foreach ($rawLogs as $log) {
$deposit = floatval($log->Deposit ?? 0);
$withdraw = floatval($log->Withdraw ?? 0);
@@ -525,6 +546,15 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
// 고유 키 생성하여 저장된 데이터와 매칭 (숫자는 정수로 변환하여 형식 통일)
$uniqueKey = implode('|', [$bankAccountNum, $transDT, (int) $deposit, (int) $withdraw, (int) $balance]);
$savedItem = $savedData?->get($uniqueKey);
$override = $overrides->get($uniqueKey);
// 원본 적요/내용
$originalSummary = $fullSummary;
$originalCast = $savedItem?->cast ?? '';
// 오버라이드 적용 (수정된 값이 있으면 사용)
$displaySummary = $override?->modified_summary ?? $originalSummary;
$displayCast = $override?->modified_cast ?? $originalCast;
$logItem = [
'transDate' => $transDate,
@@ -538,15 +568,18 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
'withdrawFormatted' => number_format($withdraw),
'balance' => $balance,
'balanceFormatted' => number_format($balance),
'summary' => $fullSummary,
// 저장된 상대계좌예금주명 우선 사용 (직접 입력 가능)
'cast' => $savedItem?->cast ?? '',
'summary' => $displaySummary,
'originalSummary' => $originalSummary,
'cast' => $displayCast,
'originalCast' => $originalCast,
'memo' => $log->Memo ?? '',
'transOffice' => $log->TransOffice ?? '',
// 저장된 계정과목 정보 병합
'accountCode' => $savedItem?->account_code ?? '',
'accountName' => $savedItem?->account_name ?? '',
'isSaved' => $savedItem !== null,
'isOverridden' => $override !== null,
'uniqueKey' => $uniqueKey,
];
$logs[] = $logItem;
@@ -965,6 +998,53 @@ public function exportExcel(Request $request): StreamedResponse|JsonResponse
}
}
/**
* 거래내역 적요/내용 오버라이드 저장
*/
public function saveOverride(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$validated = $request->validate([
'uniqueKey' => 'required|string|max:100',
'modifiedSummary' => 'nullable|string|max:200',
'modifiedCast' => 'nullable|string|max:200',
]);
$result = BankTransactionOverride::saveOverride(
$tenantId,
$validated['uniqueKey'],
$validated['modifiedSummary'] ?? null,
$validated['modifiedCast'] ?? null
);
if ($result === null) {
return response()->json([
'success' => true,
'message' => '오버라이드가 삭제되었습니다.',
'deleted' => true
]);
}
return response()->json([
'success' => true,
'message' => '오버라이드가 저장되었습니다.',
'data' => [
'id' => $result->id,
'modifiedSummary' => $result->modified_summary,
'modifiedCast' => $result->modified_cast,
]
]);
} catch (\Throwable $e) {
Log::error('오버라이드 저장 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '저장 오류: ' . $e->getMessage()
], 500);
}
}
/**
* SOAP 호출
*/

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* 바로빌 계좌 거래내역 적요/내용 수정 오버라이드 모델
*/
class BankTransactionOverride extends Model
{
protected $table = 'barobill_bank_transaction_overrides';
protected $fillable = [
'tenant_id',
'unique_key',
'modified_summary',
'modified_cast',
];
/**
* 테넌트별 조회 스코프
*/
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
/**
* 고유키로 조회
*/
public function scopeByUniqueKey($query, string $uniqueKey)
{
return $query->where('unique_key', $uniqueKey);
}
/**
* 여러 고유키에 대한 오버라이드 조회
* @return Collection<string, self> key가 unique_key인 컬렉션
*/
public static function getByUniqueKeys(int $tenantId, array $uniqueKeys): Collection
{
if (empty($uniqueKeys)) {
return collect();
}
return static::forTenant($tenantId)
->whereIn('unique_key', $uniqueKeys)
->get()
->keyBy('unique_key');
}
/**
* 오버라이드 저장 또는 업데이트
*/
public static function saveOverride(
int $tenantId,
string $uniqueKey,
?string $modifiedSummary,
?string $modifiedCast
): ?self {
// 둘 다 null이거나 빈 문자열이면 기존 레코드 삭제
if (empty($modifiedSummary) && empty($modifiedCast)) {
static::forTenant($tenantId)->byUniqueKey($uniqueKey)->delete();
return null;
}
return static::updateOrCreate(
[
'tenant_id' => $tenantId,
'unique_key' => $uniqueKey,
],
[
'modified_summary' => $modifiedSummary ?: null,
'modified_cast' => $modifiedCast ?: null,
]
);
}
}

View File

@@ -76,6 +76,7 @@
accountCodesDestroy: (id) => `/barobill/eaccount/account-codes/${id}`,
save: '{{ route("barobill.eaccount.save") }}',
export: '{{ route("barobill.eaccount.export") }}',
saveOverride: '{{ route("barobill.eaccount.save-override") }}',
};
const CSRF_TOKEN = '{{ csrf_token() }}';
@@ -596,6 +597,197 @@ className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font-
);
};
// 적요/내용 수정 모달 컴포넌트
const TransactionEditModal = ({ isOpen, onClose, log, onSave }) => {
const [modifiedSummary, setModifiedSummary] = useState('');
const [modifiedCast, setModifiedCast] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen && log) {
// 현재 표시되는 값으로 초기화 (수정된 값이 있으면 그 값, 없으면 원본)
setModifiedSummary(log.summary || '');
setModifiedCast(log.cast || '');
}
}, [isOpen, log]);
const handleSave = async () => {
if (!log?.uniqueKey) {
notify('고유 키가 없습니다.', 'error');
return;
}
setSaving(true);
try {
const res = await fetch(API.saveOverride, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
body: JSON.stringify({
uniqueKey: log.uniqueKey,
modifiedSummary: modifiedSummary !== log.originalSummary ? modifiedSummary : null,
modifiedCast: modifiedCast !== log.originalCast ? modifiedCast : null,
}),
});
const data = await res.json();
if (data.success) {
notify(data.message, 'success');
onSave(modifiedSummary, modifiedCast);
onClose();
} else {
notify(data.error || '저장 실패', 'error');
}
} catch (err) {
notify('저장 오류: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm('원본 값으로 되돌리시겠습니까?')) return;
setSaving(true);
try {
const res = await fetch(API.saveOverride, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
body: JSON.stringify({
uniqueKey: log.uniqueKey,
modifiedSummary: null,
modifiedCast: null,
}),
});
const data = await res.json();
if (data.success) {
notify('원본으로 복원되었습니다.', 'success');
onSave(log.originalSummary, log.originalCast);
onClose();
} else {
notify(data.error || '복원 실패', 'error');
}
} catch (err) {
notify('복원 오류: ' + err.message, 'error');
} finally {
setSaving(false);
}
};
if (!isOpen || !log) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-200 bg-stone-50">
<h2 className="text-lg font-bold text-stone-900">적요/내용 수정</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600 text-2xl">&times;</button>
</div>
{/* 거래 정보 */}
<div className="px-6 py-4 bg-emerald-50/50 border-b border-stone-100">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-stone-500">거래일시:</span>
<span className="ml-2 font-medium">{log.transDateTime}</span>
</div>
<div>
<span className="text-stone-500">계좌:</span>
<span className="ml-2 font-medium">{log.bankName}</span>
</div>
<div>
<span className="text-stone-500">입금:</span>
<span className="ml-2 font-medium text-blue-600">{log.deposit > 0 ? log.depositFormatted + '원' : '-'}</span>
</div>
<div>
<span className="text-stone-500">출금:</span>
<span className="ml-2 font-medium text-red-600">{log.withdraw > 0 ? log.withdrawFormatted + '원' : '-'}</span>
</div>
</div>
</div>
{/* 수정 폼 */}
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
적요
{log.isOverridden && log.originalSummary !== modifiedSummary && (
<span className="ml-2 text-xs text-amber-600">(수정됨)</span>
)}
</label>
<input
type="text"
value={modifiedSummary}
onChange={(e) => setModifiedSummary(e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="적요 입력"
/>
{log.originalSummary && modifiedSummary !== log.originalSummary && (
<p className="mt-1 text-xs text-stone-400">원본: {log.originalSummary}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
내용 (상대계좌예금주명)
{log.isOverridden && log.originalCast !== modifiedCast && (
<span className="ml-2 text-xs text-amber-600">(수정됨)</span>
)}
</label>
<input
type="text"
value={modifiedCast}
onChange={(e) => setModifiedCast(e.target.value)}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="내용 입력"
/>
{log.originalCast && modifiedCast !== log.originalCast && (
<p className="mt-1 text-xs text-stone-400">원본: {log.originalCast}</p>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-stone-200 bg-stone-50 flex justify-between">
<div>
{log.isOverridden && (
<button
onClick={handleReset}
disabled={saving}
className="px-4 py-2 text-amber-600 hover:text-amber-700 text-sm font-medium disabled:opacity-50"
>
원본으로 복원
</button>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-stone-200 text-stone-700 rounded-lg hover:bg-stone-300 text-sm font-medium"
>
취소
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium disabled:opacity-50"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
</div>
);
};
// TransactionTable Component
const TransactionTable = ({
logs,
@@ -615,7 +807,8 @@ className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font-
onExport,
onOpenSettings,
saving,
hasChanges
hasChanges,
onEditTransaction
}) => {
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
@@ -750,7 +943,7 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded
</tr>
) : (
logs.map((log, index) => (
<tr key={index} className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-green-50/30' : ''}`}>
<tr key={index} className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-green-50/30' : ''} ${log.isOverridden ? 'bg-amber-50/50' : ''}`}>
<td className="px-4 py-3 whitespace-nowrap">
<div className="font-medium text-stone-900">{log.transDateTime || '-'}</div>
</td>
@@ -760,8 +953,19 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded
{log.bankAccountNum ? '****' + log.bankAccountNum.slice(-4) : '-'}
</div>
</td>
<td className="px-4 py-3">
<div className="font-medium text-stone-900">{log.summary || '-'}</div>
<td
className="px-4 py-3 cursor-pointer hover:bg-emerald-50 group"
onClick={() => onEditTransaction(index)}
>
<div className="flex items-center gap-1">
<div className="font-medium text-stone-900">{log.summary || '-'}</div>
{log.isOverridden && (
<span className="px-1 py-0.5 bg-amber-100 text-amber-700 text-xs rounded">수정</span>
)}
<svg className="w-3 h-3 text-stone-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
{log.memo && <div className="text-xs text-stone-400">{log.memo}</div>}
</td>
<td className="px-4 py-3 text-right font-medium text-blue-600">
@@ -818,6 +1022,8 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
const [accountCodes, setAccountCodes] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingLogIndex, setEditingLogIndex] = useState(null);
// 날짜 필터 상태 (기본: 현재 월)
const currentMonth = getMonthDates(0);
@@ -915,6 +1121,28 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
setHasChanges(true);
}, []);
// 거래 수정 모달 열기
const handleEditTransaction = useCallback((index) => {
setEditingLogIndex(index);
setEditModalOpen(true);
}, []);
// 오버라이드 저장 후 로그 업데이트
const handleSaveOverride = useCallback((newSummary, newCast) => {
if (editingLogIndex !== null) {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[editingLogIndex] = {
...newLogs[editingLogIndex],
summary: newSummary,
cast: newCast,
isOverridden: true
};
return newLogs;
});
}
}, [editingLogIndex]);
// 저장 핸들러
const handleSave = async () => {
if (logs.length === 0) return;
@@ -1072,9 +1300,21 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
onOpenSettings={() => setShowSettingsModal(true)}
saving={saving}
hasChanges={hasChanges}
onEditTransaction={handleEditTransaction}
/>
)}
{/* Transaction Edit Modal */}
<TransactionEditModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setEditingLogIndex(null);
}}
log={editingLogIndex !== null ? logs[editingLogIndex] : null}
onSave={handleSaveOverride}
/>
{/* Account Code Settings Modal */}
<AccountCodeSettingsModal
isOpen={showSettingsModal}

View File

@@ -462,6 +462,7 @@
Route::delete('/account-codes/{id}', [\App\Http\Controllers\Barobill\EaccountController::class, 'accountCodeDestroy'])->name('account-codes.destroy');
Route::post('/save', [\App\Http\Controllers\Barobill\EaccountController::class, 'save'])->name('save');
Route::get('/export', [\App\Http\Controllers\Barobill\EaccountController::class, 'exportExcel'])->name('export');
Route::post('/save-override', [\App\Http\Controllers\Barobill\EaccountController::class, 'saveOverride'])->name('save-override');
});
// 카드 사용내역 (React 페이지)