feat:카드 거래 숨김(삭제) 및 복원 기능 추가

- CardTransactionHide 모델 생성 (숨김 테이블 연동)
- EcardController에 hide/restore/hidden 메서드 추가
- 기존 transactions/getAllCardsTransactions에 숨김 키 필터링 적용
- 프론트엔드에 숨김 버튼, 삭제데이터 보기 토글, 복원 기능 추가
- web.php에 숨김 관련 라우트 3개 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-05 17:27:30 +09:00
parent 4a2abbd945
commit 3fb1777bad
4 changed files with 495 additions and 22 deletions

View File

@@ -8,6 +8,7 @@
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\CardTransaction;
use App\Models\Barobill\CardTransactionAmountLog;
use App\Models\Barobill\CardTransactionHide;
use App\Models\Barobill\CardTransactionSplit;
use App\Models\Tenants\Tenant;
use Illuminate\Http\JsonResponse;
@@ -365,6 +366,13 @@ public function transactions(Request $request): JsonResponse
// 데이터 파싱 (저장된 계정과목 병합)
$logs = $this->parseTransactionLogs($resultData, $savedData);
// 숨김 키 필터링
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate);
if ($hiddenKeys->isNotEmpty()) {
$hiddenSet = $hiddenKeys->flip();
$logs = $this->filterHiddenLogs($logs, $hiddenSet);
}
// 수동 입력 건 병합
$manualLogs = $this->convertManualToLogs($manualTransactions);
$mergedLogs = array_merge($logs['logs'], $manualLogs['logs']);
@@ -446,6 +454,11 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri
], $cardList),
]);
// 숨김 키 조회
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate);
$hiddenSet = $hiddenKeys->isNotEmpty() ? $hiddenKeys->flip() : collect();
$allLogs = [];
$totalAmount = 0;
$approvalCount = 0;
@@ -497,6 +510,12 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri
if (!$errorCode || in_array($errorCode, [-24005, -24001])) {
$parsed = $this->parseTransactionLogs($cardData, $savedData);
// 숨김 키 필터링
if ($hiddenSet->isNotEmpty()) {
$parsed = $this->filterHiddenLogs($parsed, $hiddenSet);
}
Log::info('[ECard] 카드별 파싱 결과', [
'cardNum' => $cardNum,
'logs_count' => count($parsed['logs']),
@@ -1577,6 +1596,195 @@ private function mergeSummaries(array $a, array $b): array
];
}
/**
* 숨김 키로 로그 필터링
*/
private function filterHiddenLogs(array $parsed, $hiddenSet): array
{
$filteredLogs = [];
$totalAmount = 0;
$approvalCount = 0;
$cancelCount = 0;
$totalTax = 0;
$deductibleAmount = 0;
$deductibleCount = 0;
$nonDeductibleAmount = 0;
$nonDeductibleCount = 0;
foreach ($parsed['logs'] as $log) {
$uniqueKey = implode('|', [
$log['cardNum'],
$log['useDt'],
$log['approvalNum'],
(int) $log['approvalAmount'],
]);
if ($hiddenSet->has($uniqueKey)) {
continue;
}
$filteredLogs[] = $log;
$amount = floatval($log['approvalAmount']);
$isApproval = $log['approvalType'] !== '2';
if ($isApproval) {
$totalAmount += $amount;
$approvalCount++;
} else {
$cancelCount++;
}
$deductionType = $log['deductionType'] ?? 'non_deductible';
$absAmount = abs($amount);
if ($deductionType === 'deductible') {
$deductibleAmount += $absAmount;
$deductibleCount++;
$totalTax += abs($log['effectiveTax'] ?? $log['tax'] ?? 0);
} else {
$nonDeductibleAmount += $absAmount;
$nonDeductibleCount++;
}
}
return [
'logs' => $filteredLogs,
'summary' => [
'totalAmount' => $totalAmount,
'count' => count($filteredLogs),
'approvalCount' => $approvalCount,
'cancelCount' => $cancelCount,
'totalTax' => $totalTax,
'deductibleAmount' => $deductibleAmount,
'deductibleCount' => $deductibleCount,
'nonDeductibleAmount' => $nonDeductibleAmount,
'nonDeductibleCount' => $nonDeductibleCount,
]
];
}
/**
* 거래 숨김 처리
*/
public function hideTransaction(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$uniqueKey = $request->input('uniqueKey');
$originalData = $request->input('originalData', []);
if (empty($uniqueKey)) {
return response()->json([
'success' => false,
'error' => '고유키가 없습니다.'
]);
}
// 이미 숨김 처리된 건인지 확인
$exists = CardTransactionHide::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->exists();
if ($exists) {
return response()->json([
'success' => false,
'error' => '이미 숨김 처리된 거래입니다.'
]);
}
CardTransactionHide::hideTransaction($tenantId, $uniqueKey, $originalData, auth()->id());
return response()->json([
'success' => true,
'message' => '거래가 숨김 처리되었습니다.'
]);
} catch (\Throwable $e) {
Log::error('거래 숨김 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '숨김 처리 오류: ' . $e->getMessage()
]);
}
}
/**
* 거래 복원 (숨김 해제)
*/
public function restoreTransaction(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 = CardTransactionHide::restoreTransaction($tenantId, $uniqueKey);
if ($deleted === 0) {
return response()->json([
'success' => false,
'error' => '숨김 데이터를 찾을 수 없습니다.'
]);
}
return response()->json([
'success' => true,
'message' => '거래가 복원되었습니다.'
]);
} catch (\Throwable $e) {
Log::error('거래 복원 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '복원 오류: ' . $e->getMessage()
]);
}
}
/**
* 숨김 처리된 거래 목록 조회
*/
public function hiddenTransactions(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'));
$hidden = CardTransactionHide::where('tenant_id', $tenantId)
->where('use_date', '>=', $startDate)
->where('use_date', '<=', $endDate)
->orderBy('use_date', 'desc')
->get()
->map(fn($h) => [
'id' => $h->id,
'uniqueKey' => $h->original_unique_key,
'cardNum' => $h->card_num,
'useDate' => $h->use_date,
'approvalNum' => $h->approval_num,
'originalAmount' => (float) $h->original_amount,
'originalAmountFormatted' => number_format((float) $h->original_amount),
'merchantName' => $h->merchant_name,
'hiddenAt' => $h->created_at?->format('Y-m-d H:i'),
]);
return response()->json([
'success' => true,
'data' => $hidden,
'count' => $hidden->count()
]);
} catch (\Throwable $e) {
Log::error('숨김 목록 조회 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '조회 오류: ' . $e->getMessage()
]);
}
}
/**
* SOAP 호출
*/

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class CardTransactionHide extends Model
{
protected $table = 'barobill_card_transaction_hides';
protected $fillable = [
'tenant_id',
'original_unique_key',
'card_num',
'use_date',
'approval_num',
'original_amount',
'merchant_name',
'hidden_by',
];
/**
* 기간 내 숨김된 키 목록 조회
*/
public static function getHiddenKeys(int $tenantId, string $startDate, string $endDate): Collection
{
return static::where('tenant_id', $tenantId)
->where('use_date', '>=', $startDate)
->where('use_date', '<=', $endDate)
->pluck('original_unique_key');
}
/**
* 거래 숨김 처리
*/
public static function hideTransaction(int $tenantId, string $uniqueKey, array $originalData, ?int $userId = null): static
{
return static::create([
'tenant_id' => $tenantId,
'original_unique_key' => $uniqueKey,
'card_num' => $originalData['cardNum'] ?? '',
'use_date' => $originalData['useDate'] ?? '',
'approval_num' => $originalData['approvalNum'] ?? '',
'original_amount' => floatval($originalData['approvalAmount'] ?? 0),
'merchant_name' => $originalData['merchantName'] ?? '',
'hidden_by' => $userId,
]);
}
/**
* 거래 복원 (숨김 해제)
*/
public static function restoreTransaction(int $tenantId, string $uniqueKey): int
{
return static::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->delete();
}
}

View File

@@ -78,6 +78,9 @@
manualStore: '{{ route("barobill.ecard.manual.store") }}',
manualUpdate: '{{ route("barobill.ecard.manual.update", ":id") }}',
manualDestroy: '{{ route("barobill.ecard.manual.destroy", ":id") }}',
hide: '{{ route("barobill.ecard.hide") }}',
restore: '{{ route("barobill.ecard.restore") }}',
hidden: '{{ route("barobill.ecard.hidden") }}',
};
const CSRF_TOKEN = '{{ csrf_token() }}';
@@ -1070,6 +1073,12 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
onManualNew,
onManualEdit,
onManualDelete,
onHide,
showHidden,
hiddenLogs,
onToggleHidden,
onRestore,
loadingHidden,
}) => {
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
@@ -1177,6 +1186,22 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
</svg>
엑셀 다운로드
</button>
<button
onClick={onToggleHidden}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
showHidden
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-red-100 text-red-700 hover:bg-red-200'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
{showHidden ? '삭제데이터 닫기' : '삭제데이터 보기'}
{showHidden && hiddenLogs.length > 0 && (
<span className="bg-white text-red-600 text-xs px-1.5 py-0.5 rounded-full font-bold">{hiddenLogs.length}</span>
)}
</button>
</div>
</div>
<div className="overflow-x-auto" style={ {minHeight: '500px', overflowY: 'auto'} }>
@@ -1407,28 +1432,39 @@ className="text-xs text-amber-600 hover:text-amber-700 underline"
)}
</td>
<td className="px-3 py-3 text-center">
{log.isManual && (
<div className="flex items-center gap-1 justify-center">
<button
onClick={() => onManualEdit(log)}
className="p-1 text-blue-500 hover:bg-blue-50 rounded transition-colors"
title="수정"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onManualDelete(log.dbId)}
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
<div className="flex items-center gap-1 justify-center">
{log.isManual && (
<>
<button
onClick={() => onManualEdit(log)}
className="p-1 text-blue-500 hover:bg-blue-50 rounded transition-colors"
title="수정"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onManualDelete(log.dbId)}
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
title="삭제"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</>
)}
<button
onClick={() => onHide(log, uniqueKey)}
className="p-1 text-stone-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="숨기기"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</td>
</tr>
{/* 분개 행들 */}
@@ -1494,6 +1530,66 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
</tbody>
</table>
</div>
{/* 숨김 데이터 영역 */}
{showHidden && (
<div className="border-t-2 border-red-200 bg-red-50/50">
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
<h3 className="text-sm font-bold text-red-700">숨김 처리된 거래 ({hiddenLogs.length})</h3>
</div>
{loadingHidden ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-red-500"></div>
</div>
) : hiddenLogs.length === 0 ? (
<p className="text-sm text-red-400 py-4 text-center">숨김 처리된 거래가 없습니다.</p>
) : (
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-red-100/50 text-xs uppercase font-medium text-red-500">
<tr>
<th className="px-4 py-2">사용일</th>
<th className="px-4 py-2">카드번호</th>
<th className="px-4 py-2">승인번호</th>
<th className="px-4 py-2">가맹점명</th>
<th className="px-4 py-2 text-right">금액</th>
<th className="px-4 py-2">숨김일시</th>
<th className="px-4 py-2 text-center">복원</th>
</tr>
</thead>
<tbody className="divide-y divide-red-100">
{hiddenLogs.map((h, i) => {
const dateStr = h.useDate
? `${h.useDate.substring(0,4)}-${h.useDate.substring(4,6)}-${h.useDate.substring(6,8)}`
: '-';
return (
<tr key={h.id || i} className="bg-red-50 hover:bg-red-100/50">
<td className="px-4 py-2 text-sm">{dateStr}</td>
<td className="px-4 py-2 text-sm font-mono">{h.cardNum}</td>
<td className="px-4 py-2 text-sm">{h.approvalNum || '-'}</td>
<td className="px-4 py-2 text-sm">{h.merchantName || '-'}</td>
<td className="px-4 py-2 text-sm text-right font-medium">{h.originalAmountFormatted}</td>
<td className="px-4 py-2 text-xs text-stone-400">{h.hiddenAt}</td>
<td className="px-4 py-2 text-center">
<button
onClick={() => onRestore(h.uniqueKey)}
className="px-3 py-1 bg-green-500 text-white text-xs rounded-lg hover:bg-green-600 transition-colors font-medium"
>
복원
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
);
};
@@ -1522,6 +1618,11 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
const [manualModalOpen, setManualModalOpen] = useState(false);
const [manualEditData, setManualEditData] = useState(null);
// 숨김 관련 상태
const [showHidden, setShowHidden] = useState(false);
const [hiddenLogs, setHiddenLogs] = useState([]);
const [loadingHidden, setLoadingHidden] = useState(false);
// 가맹점명 모달
const [merchantNameModal, setMerchantNameModal] = useState(null);
@@ -1879,6 +1980,100 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
}
};
// 거래 숨김 처리
const handleHide = async (log, uniqueKey) => {
if (!confirm('이 거래를 숨기시겠습니까?\n(삭제데이터 보기에서 복원 가능)')) return;
try {
const response = await fetch(API.hide, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify({
uniqueKey,
originalData: {
cardNum: log.cardNum,
useDate: log.useDate,
approvalNum: log.approvalNum,
approvalAmount: log.approvalAmount,
merchantName: log.merchantName,
}
})
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadTransactions();
if (showHidden) loadHidden();
} else {
notify(data.error || '숨김 처리 실패', 'error');
}
} catch (err) {
notify('숨김 처리 오류: ' + err.message, 'error');
}
};
// 거래 복원
const handleRestore = async (uniqueKey) => {
if (!confirm('이 거래를 복원하시겠습니까?')) return;
try {
const response = await fetch(API.restore, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: JSON.stringify({ uniqueKey })
});
const data = await response.json();
if (data.success) {
notify(data.message, 'success');
loadTransactions();
loadHidden();
} else {
notify(data.error || '복원 실패', 'error');
}
} catch (err) {
notify('복원 오류: ' + err.message, 'error');
}
};
// 숨김 데이터 로드
const loadHidden = async () => {
setLoadingHidden(true);
try {
const params = new URLSearchParams({
startDate: dateFrom.replace(/-/g, ''),
endDate: dateTo.replace(/-/g, '')
});
const response = await fetch(`${API.hidden}?${params}`);
const data = await response.json();
if (data.success) {
setHiddenLogs(data.data || []);
}
} catch (err) {
console.error('숨김 데이터 로드 오류:', err);
} finally {
setLoadingHidden(false);
}
};
// 삭제데이터 보기 토글
const handleToggleHidden = () => {
const newVal = !showHidden;
setShowHidden(newVal);
if (newVal) {
loadHidden();
}
};
// 계정과목 변경 핸들러
const handleAccountCodeChange = useCallback((index, code, name) => {
setLogs(prevLogs => {
@@ -2132,6 +2327,12 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
onManualNew={handleManualNew}
onManualEdit={handleManualEdit}
onManualDelete={handleManualDelete}
onHide={handleHide}
showHidden={showHidden}
hiddenLogs={hiddenLogs}
onToggleHidden={handleToggleHidden}
onRestore={handleRestore}
loadingHidden={loadingHidden}
/>
)}

View File

@@ -466,6 +466,10 @@
Route::post('/manual', [\App\Http\Controllers\Barobill\EcardController::class, 'storeManual'])->name('manual.store');
Route::put('/manual/{id}', [\App\Http\Controllers\Barobill\EcardController::class, 'updateManual'])->name('manual.update');
Route::delete('/manual/{id}', [\App\Http\Controllers\Barobill\EcardController::class, 'destroyManual'])->name('manual.destroy');
// 거래 숨김/복원 관련
Route::post('/hide', [\App\Http\Controllers\Barobill\EcardController::class, 'hideTransaction'])->name('hide');
Route::post('/restore', [\App\Http\Controllers\Barobill\EcardController::class, 'restoreTransaction'])->name('restore');
Route::get('/hidden', [\App\Http\Controllers\Barobill\EcardController::class, 'hiddenTransactions'])->name('hidden');
});
// 홈택스 매입/매출 (React 페이지)