Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 216 additions & 4 deletions src/features/asset/asset.api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,222 @@
/**
* 자산 관련 API 함수들
* 자산/거래 분석 및 계좌 관련 API
* Swagger: https://api.valuedi.site/swagger-ui/index.html → Ledger (거래내역)
*/

import { apiGet, ApiResponse } from '@/utils/api';
import { apiGet, apiPost, ApiResponse } from '@/utils/api';

// ========== 타입 정의 ==========
// ========== POST /api/transactions/rematch-categories (카테고리 재매칭) ==========

/** rematchCategories 요청 (Swagger 스펙에 맞게 조정) */
export interface RematchCategoriesRequest {
yearMonth?: string; // YYYY-MM
[key: string]: unknown;
}

/** rematchCategories 응답 */
export interface RematchCategoriesResult {
matchedCount?: number;
message?: string;
[key: string]: unknown;
}

/**
* 거래 내역 카테고리 재매칭 (키워드 등으로 재분류)
* POST /api/transactions/rematch-categories
* Swagger: Ledger (거래내역) → rematchCategories
*/
export const rematchCategoriesApi = async (
params?: RematchCategoriesRequest
): Promise<ApiResponse<RematchCategoriesResult | null>> => {
return apiPost<RematchCategoriesResult | null>(
'/api/transactions/rematch-categories',
params ?? {}
);
};

// ========== GET /api/transactions/by-category (getCategoryStats) ==========

/** 카테고리별 소비 집계 응답 한 항목 */
export interface TransactionByCategoryItem {
categoryCode: string;
categoryName: string;
totalAmount: number;
percentage: number; // 0~100 또는 0~1 (백엔드 스펙에 맞춰 매핑 시 처리)
}

/**
* 카테고리별 소비 집계
* GET /api/transactions/by-category?yearMonth=YYYY-MM
* Swagger: Ledger (거래내역) → getCategoryStats
*/
export const getTransactionsByCategoryApi = async (
yearMonth: string
): Promise<ApiResponse<TransactionByCategoryItem[]>> => {
return apiGet<TransactionByCategoryItem[]>(
`/api/transactions/by-category?yearMonth=${encodeURIComponent(yearMonth)}`
);
};

// ========== GET /api/transactions (거래 내역 조회) ==========

/** API가 내려주는 카테고리 객체 (중첩 시, camel/snake 둘 다 허용) */
export interface LedgerCategoryRef {
code?: string;
name?: string;
id?: number;
category_code?: string;
category_name?: string;
category_id?: number;
[key: string]: unknown;
}

export interface LedgerTransactionItem {
transactionId?: number;
date?: string;
transactionAt?: string; // API가 ISO 날짜로 내려줄 수 있음
amount?: number;
title?: string;
categoryCode?: string;
categoryName?: string;
categoryId?: number;
/** snake_case 응답 대응 */
category_code?: string;
category_name?: string;
category_id?: number;
/** 문자열이면 코드, 객체면 { code, name, id } */
category?: string | LedgerCategoryRef;
type?: string;
[key: string]: unknown;
}

export interface LedgerListResponse {
content?: LedgerTransactionItem[];
totalElements?: number;
totalPages?: number;
[key: string]: unknown;
}

// ========== POST /api/transactions/sync (거래 동기화·목록) ==========

/** sync 요청 (Swagger 스펙에 맞게 조정) */
export interface SyncTransactionsRequest {
yearMonth?: string; // YYYY-MM
[key: string]: unknown;
}

/** sync 응답 - 동기화 후 거래 목록을 내려줄 수 있음 */
export interface SyncTransactionsResult {
content?: LedgerTransactionItem[];
totalElements?: number;
totalPages?: number;
syncedCount?: number;
[key: string]: unknown;
}

/**
* 거래 내역 동기화 (외부 연동 후 최신 거래 반영, 응답에 목록 포함 시 사용)
* POST /api/transactions/sync
* Swagger: Ledger (거래내역) → sync
*/
export const syncTransactionsApi = async (
params?: SyncTransactionsRequest
): Promise<ApiResponse<SyncTransactionsResult | null>> => {
return apiPost<SyncTransactionsResult | null>('/api/transactions/sync', params ?? {});
};

/**
* 거래 내역 조회 (월별/일별, 페이징)
* GET /api/transactions?yearMonth=YYYY-MM&date=YYYY-MM-DD&page=0&size=20&sort=LATEST
* Swagger: Ledger (거래내역) → getTransactions
*/
export const getTransactionsApi = async (params: {
yearMonth: string;
date?: string;
page?: number;
size?: number;
sort?: 'LATEST' | 'OLDEST' | 'AMOUNT_DESC' | 'AMOUNT_ASC';
}): Promise<ApiResponse<LedgerListResponse>> => {
const search = new URLSearchParams({ yearMonth: params.yearMonth });
if (params.date) search.set('date', params.date);
if (params.page != null) search.set('page', String(params.page));
if (params.size != null) search.set('size', String(params.size));
if (params.sort) search.set('sort', params.sort);
return apiGet<LedgerListResponse>(`/api/transactions?${search.toString()}`);
};

// ========== GET /api/transactions/summary (월 소비 내역 요약) ==========

export interface LedgerSummaryResult {
totalIncome?: number;
totalExpense?: number;
previousMonthExpense?: number;
expenseDiff?: number; // 전월 대비 증감 (음수: 덜 씀, 양수: 더 씀)
[key: string]: unknown;
}

/**
* 월 소비 내역 요약 (총 수입/지출, 전월 대비)
* GET /api/transactions/summary?yearMonth=YYYY-MM
* Swagger: Ledger (거래내역) → getMonthlySummary
*/
export const getMonthlySummaryApi = async (
yearMonth: string
): Promise<ApiResponse<LedgerSummaryResult>> => {
return apiGet<LedgerSummaryResult>(
`/api/transactions/summary?yearMonth=${encodeURIComponent(yearMonth)}`
);
};

// ========== GET /api/transactions/trend (월별 지출 추이) ==========

export interface TrendItem {
yearMonth?: string; // "YYYY-MM"
amount?: number;
totalExpense?: number;
[key: string]: unknown;
}

/**
* 월별 지출 추이 (차트용)
* GET /api/transactions/trend?fromYearMonth=YYYY-MM&toYearMonth=YYYY-MM
* Swagger: Ledger (거래내역) → getTrend
*/
export const getTrendApi = async (params: {
fromYearMonth: string;
toYearMonth: string;
}): Promise<ApiResponse<TrendItem[]>> => {
const search = new URLSearchParams({
fromYearMonth: params.fromYearMonth,
toYearMonth: params.toYearMonth,
});
return apiGet<TrendItem[]>(`/api/transactions/trend?${search.toString()}`);
};

// ========== GET /api/transactions/top-category (최다 소비 항목) ==========

export interface TopCategoryItem {
categoryCode?: string;
categoryName?: string;
totalAmount?: number;
percentage?: number;
[key: string]: unknown;
}

/**
* 최다 소비 항목 (상위 N개)
* GET /api/transactions/top-category?yearMonth=YYYY-MM&limit=3
* Swagger: Ledger (거래내역) → getTopCategories
*/
export const getTopCategoriesApi = async (params: {
yearMonth: string;
limit?: number;
}): Promise<ApiResponse<TopCategoryItem[]>> => {
const search = new URLSearchParams({ yearMonth: params.yearMonth });
if (params.limit != null) search.set('limit', String(params.limit));
return apiGet<TopCategoryItem[]>(`/api/transactions/top-category?${search.toString()}`);
};

// ========== 자산(계좌) 관련 타입 ==========

export interface Account {
accountId: number;
Expand All @@ -23,7 +235,7 @@ export interface AccountListResponse {
totalCount: number;
}

// ========== API 함수들 ==========
// ========== 자산(계좌) API ==========

/**
* 전체 계좌 목록 조회
Expand Down
152 changes: 152 additions & 0 deletions src/features/asset/constants/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,158 @@ export interface CategoryStyle {
icon: string;
}

/** 프론트에서 쓰는 카테고리 키 목록 (아이콘/스타일 매칭용) */
const FRONTEND_KEYS = [
'transfer', 'traffic', 'shopping', 'food', 'leisure', 'medical', 'market', 'living', 'cafe', 'others',
] as const;

/** API에서 오는 카테고리 코드 → 프론트 키 (백엔드 DB code 컬럼 기준) */
const API_CATEGORY_CODE_MAP: Record<string, string> = {
// 백엔드 DB code (소문자 매칭)
transfer: 'transfer',
food: 'food',
hobby_leisure: 'leisure',
mart_etc: 'market',
transport: 'traffic',
shopping: 'shopping',
housing_comm: 'living',
cafe_snack: 'cafe',
medical_life: 'medical',
etc: 'others',
// 레거시/축약 코드
fd: 'food',
f: 'food',
sh: 'shopping',
tr: 'traffic',
tf: 'traffic',
traffic: 'traffic',
lv: 'living',
living: 'living',
md: 'medical',
medical: 'medical',
mr: 'market',
market: 'market',
cf: 'cafe',
cafe: 'cafe',
lr: 'leisure',
leisure: 'leisure',
other: 'others',
others: 'others',
unknown: 'others',
'': 'others',
};

/** 백엔드 category.id 또는 sort_order (1~10) → 프론트 키 */
const API_CATEGORY_ID_MAP: Record<number, string> = {
1: 'transfer',
2: 'food',
3: 'leisure',
4: 'market',
5: 'traffic',
6: 'shopping',
7: 'living',
8: 'cafe',
9: 'medical',
10: 'others',
};

/** API가 categoryName(한글)만 줄 때: 한글 이름 → 프론트 키 (백엔드 DB name 기준) */
const CATEGORY_NAME_TO_KEY: Record<string, string> = {
// 백엔드 DB name (슬래시 구분)
이체: 'transfer',
식비: 'food',
'취미/여가': 'leisure',
'편의점/마트/잡화': 'market',
'교통/자동차': 'traffic',
쇼핑: 'shopping',
'주거/통신': 'living',
'카페/간식': 'cafe',
'의료/생활': 'medical',
'카테고리 없음(기타)': 'others',
// 단일 한글 (API가 일부만 보낼 때)
교통: 'traffic',
자동차: 'traffic',
// 점/공백 구분 변형
'교통 · 자동차': 'traffic',
취미: 'leisure',
여가: 'leisure',
'취미 · 여가': 'leisure',
의료: 'medical',
건강: 'medical',
'의료 · 건강': 'medical',
편의점: 'market',
마트: 'market',
잡화: 'market',
'편의점 · 마트 · 잡화': 'market',
주거: 'living',
통신: 'living',
'주거 · 통신': 'living',
카페: 'cafe',
디저트: 'cafe',
'카페 · 디저트': 'cafe',
그외: 'others',
기타: 'others',
음식: 'food',
};

/**
* API categoryCode / categoryName / categoryId를 프론트 카테고리 키로 통일
* - categoryId가 있으면 ID 매핑 우선 (DB id 1~10)
* - 그다음 categoryCode 매핑 (문자열 코드 또는 숫자 문자열=ID)
* - 없거나 매칭 안 되면 categoryName(한글)으로 매칭
*/
export function normalizeCategoryCode(
apiCode: string | null | undefined,
categoryName?: string | null,
categoryId?: number | string | null
): string {
const id =
categoryId != null
? typeof categoryId === 'string'
? parseInt(categoryId, 10)
: categoryId
: undefined;
if (id != null && !Number.isNaN(id) && API_CATEGORY_ID_MAP[id]) return API_CATEGORY_ID_MAP[id];

const codeRaw = (apiCode ?? '').toString().trim().toLowerCase();
if (codeRaw && API_CATEGORY_CODE_MAP[codeRaw]) return API_CATEGORY_CODE_MAP[codeRaw];
if (codeRaw && FRONTEND_KEYS.includes(codeRaw as (typeof FRONTEND_KEYS)[number])) return codeRaw;
// API가 code 대신 category id를 문자열로 보낼 수 있음 (예: "2" → food)
const codeAsId = /^\d+$/.test(codeRaw) ? parseInt(codeRaw, 10) : NaN;
if (!Number.isNaN(codeAsId) && API_CATEGORY_ID_MAP[codeAsId]) return API_CATEGORY_ID_MAP[codeAsId];

const nameRaw = (categoryName ?? '').toString().trim();
if (nameRaw && CATEGORY_NAME_TO_KEY[nameRaw]) return CATEGORY_NAME_TO_KEY[nameRaw];
for (const [name, key] of Object.entries(CATEGORY_NAME_TO_KEY)) {
if (name.length > 0 && nameRaw.includes(name)) return key;
}

return codeRaw || 'others';
}

/** API가 그외(ETC)로 내려줄 때, 거래 제목(가맹점명)으로 카테고리 추정 (백엔드 keyword 미적용 월 보정용) */
const TITLE_KEYWORDS_TO_CATEGORY: { keywords: string[]; key: string }[] = [
{ keywords: ['티머니', '지하철', '후불교통', '교통', '택시', '버스', '카카오택시', '주차'], key: 'traffic' },
{ keywords: ['이체', '펌뱅킹', '토스', '오픈뱅킹'], key: 'transfer' },
{ keywords: ['커피', '카페', '스타벅스', '이디야', '빽다방', '디저트', '제과'], key: 'cafe' },
{ keywords: ['편의점', '세븐일레븐', 'gs25', 'cu', '이마트24', '다이소'], key: 'market' },
{ keywords: ['쿠팡', '올리브영', '백화점', '쇼핑'], key: 'shopping' },
{ keywords: ['식비', '한식', '중식', '일식', '맛집', '배달', '요식'], key: 'food' },
{ keywords: ['의료', '약국', '병원', '치과', '한의원'], key: 'medical' },
{ keywords: ['주거', '통신', '전기', '가스', '관리비'], key: 'living' },
{ keywords: ['영화', '레저', '숙박', '취미', '여가'], key: 'leisure' },
];

export function inferCategoryFromTitle(title: string | null | undefined): string | null {
const t = (title ?? '').toString().trim();
if (!t) return null;
const lower = t.toLowerCase();
for (const { keywords, key } of TITLE_KEYWORDS_TO_CATEGORY) {
if (keywords.some((kw) => lower.includes(kw.toLowerCase()))) return key;
}
return null;
}

// 💡 새로 추가할 한글 라벨 정의
export const CATEGORY_LABELS: Record<string, string> = {
transfer: '이체',
Expand Down
Loading