- account_entry pending/notif_key 컬럼 추가(멱등 마이그레이션)
- CardNotificationParser: 카드사·금액·가맹점·승인/취소 파싱
- POST /entries/notification(미확인 지출 생성, 중복방지, 카드 자동매칭)
- POST /entries/{id}/confirm(확정), GET /entries/pending/count
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import com.sb.web.account.dto.AccountSummary;
|
|||||||
import com.sb.web.account.dto.AccountTagRequest;
|
import com.sb.web.account.dto.AccountTagRequest;
|
||||||
import com.sb.web.account.dto.AccountTagResponse;
|
import com.sb.web.account.dto.AccountTagResponse;
|
||||||
import com.sb.web.account.dto.CategoryStat;
|
import com.sb.web.account.dto.CategoryStat;
|
||||||
|
import com.sb.web.account.dto.ConfirmRequest;
|
||||||
|
import com.sb.web.account.dto.NotificationRequest;
|
||||||
import com.sb.web.account.dto.NetWorthPoint;
|
import com.sb.web.account.dto.NetWorthPoint;
|
||||||
import com.sb.web.account.dto.NetWorthResponse;
|
import com.sb.web.account.dto.NetWorthResponse;
|
||||||
import com.sb.web.account.dto.PeriodStat;
|
import com.sb.web.account.dto.PeriodStat;
|
||||||
@@ -23,6 +25,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근)
|
* 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근)
|
||||||
@@ -202,4 +205,34 @@ public class AccountController {
|
|||||||
accountService.delete(id, current.getId());
|
accountService.delete(id, current.getId());
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 카드 결제 알림 자동인식 ===== */
|
||||||
|
|
||||||
|
/** 미확인(확인 필요) 내역 개수 */
|
||||||
|
@GetMapping("/entries/pending/count")
|
||||||
|
public Map<String, Integer> pendingCount(
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return Map.of("count", accountService.countPending(current.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드 결제 푸시 알림 → 미확인 지출 내역 생성 (네이티브 알림 리스너가 호출). 생성 안 되면 204 */
|
||||||
|
@PostMapping("/entries/notification")
|
||||||
|
public ResponseEntity<AccountEntryResponse> fromNotification(
|
||||||
|
@RequestBody NotificationRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
AccountEntryResponse created = accountService.createFromNotification(req, current.getId());
|
||||||
|
if (created == null) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미확인 내역 확정 (분류/계좌 보정) */
|
||||||
|
@PostMapping("/entries/{id}/confirm")
|
||||||
|
public AccountEntryResponse confirm(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody(required = false) ConfirmRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return accountService.confirm(id, req, current.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public class AccountEntry implements Serializable {
|
|||||||
private Long toWalletId; // 이체 입금 계좌
|
private Long toWalletId; // 이체 입금 계좌
|
||||||
private String toWalletName;
|
private String toWalletName;
|
||||||
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
||||||
|
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
||||||
|
private String notifKey; // 알림 자동인식 중복방지 키
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class AccountEntryResponse {
|
|||||||
private Long toWalletId;
|
private Long toWalletId;
|
||||||
private String toWalletName;
|
private String toWalletName;
|
||||||
private Integer installmentMonths;
|
private Integer installmentMonths;
|
||||||
|
private Boolean pending;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
|
|
||||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||||
@@ -40,6 +41,7 @@ public class AccountEntryResponse {
|
|||||||
.toWalletId(e.getToWalletId())
|
.toWalletId(e.getToWalletId())
|
||||||
.toWalletName(e.getToWalletName())
|
.toWalletName(e.getToWalletName())
|
||||||
.installmentMonths(e.getInstallmentMonths())
|
.installmentMonths(e.getInstallmentMonths())
|
||||||
|
.pending(Boolean.TRUE.equals(e.getPending()))
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.sb.web.account.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미확인(알림 자동인식) 내역 확정 요청 — 분류·계좌 보정(선택).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ConfirmRequest {
|
||||||
|
|
||||||
|
private String category;
|
||||||
|
private Long walletId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sb.web.account.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NotificationRequest {
|
||||||
|
|
||||||
|
/** 알림을 띄운 앱 패키지명 (예: 카드사 앱) */
|
||||||
|
private String app;
|
||||||
|
|
||||||
|
/** 알림 제목 */
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** 알림 본문 */
|
||||||
|
private String text;
|
||||||
|
}
|
||||||
@@ -59,6 +59,17 @@ public interface AccountEntryMapper {
|
|||||||
|
|
||||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/* ----- 알림 자동인식(미확인) ----- */
|
||||||
|
/** 중복방지 키로 기존 알림 내역 조회 */
|
||||||
|
AccountEntry findByMemberAndNotifKey(@Param("memberId") Long memberId, @Param("notifKey") String notifKey);
|
||||||
|
|
||||||
|
/** 미확인(확인 필요) 내역 개수 */
|
||||||
|
int countPending(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
|
/** 미확인 내역 확정 (pending=0, 분류/계좌 보정) */
|
||||||
|
int confirm(@Param("id") Long id, @Param("memberId") Long memberId,
|
||||||
|
@Param("category") String category, @Param("walletId") Long walletId);
|
||||||
|
|
||||||
/** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */
|
/** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */
|
||||||
List<String> findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type);
|
List<String> findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.sb.web.account.domain.Wallet;
|
|||||||
import com.sb.web.account.dto.AccountEntryRequest;
|
import com.sb.web.account.dto.AccountEntryRequest;
|
||||||
import com.sb.web.account.dto.AccountEntryResponse;
|
import com.sb.web.account.dto.AccountEntryResponse;
|
||||||
import com.sb.web.account.dto.AccountSummary;
|
import com.sb.web.account.dto.AccountSummary;
|
||||||
|
import com.sb.web.account.dto.ConfirmRequest;
|
||||||
|
import com.sb.web.account.dto.NotificationRequest;
|
||||||
import com.sb.web.account.dto.AccountTagRequest;
|
import com.sb.web.account.dto.AccountTagRequest;
|
||||||
import com.sb.web.account.dto.AccountTagResponse;
|
import com.sb.web.account.dto.AccountTagResponse;
|
||||||
import com.sb.web.account.dto.CategoryStat;
|
import com.sb.web.account.dto.CategoryStat;
|
||||||
@@ -43,6 +45,7 @@ public class AccountService {
|
|||||||
private final AccountTagMapper tagMapper;
|
private final AccountTagMapper tagMapper;
|
||||||
private final WalletMapper walletMapper;
|
private final WalletMapper walletMapper;
|
||||||
private final InvestService investService;
|
private final InvestService investService;
|
||||||
|
private final CardNotificationParser notificationParser;
|
||||||
|
|
||||||
/* ===================== 항목 ===================== */
|
/* ===================== 항목 ===================== */
|
||||||
|
|
||||||
@@ -202,6 +205,73 @@ public class AccountService {
|
|||||||
mapper.delete(id, memberId);
|
mapper.delete(id, memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== 카드 결제 알림 자동인식 ===================== */
|
||||||
|
|
||||||
|
/** 미확인(확인 필요) 내역 개수 */
|
||||||
|
public int countPending(Long memberId) {
|
||||||
|
return mapper.countPending(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
||||||
|
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
|
||||||
|
CardNotificationParser.Parsed p =
|
||||||
|
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
||||||
|
if (!p.isCard() || p.isCanceled() || p.getAmount() <= 0) {
|
||||||
|
return null; // 결제 승인 알림 아님 / 취소 → 무시
|
||||||
|
}
|
||||||
|
// 중복방지
|
||||||
|
if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 카드사 → 등록된 카드(CARD) 매칭
|
||||||
|
Long walletId = matchCardWalletId(p.getIssuer(), memberId);
|
||||||
|
|
||||||
|
AccountEntry entry = AccountEntry.builder()
|
||||||
|
.memberId(memberId)
|
||||||
|
.entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now())
|
||||||
|
.type("EXPENSE")
|
||||||
|
.category(null)
|
||||||
|
.amount(p.getAmount())
|
||||||
|
.memo(p.getMerchant())
|
||||||
|
.walletId(walletId)
|
||||||
|
.toWalletId(null)
|
||||||
|
.installmentMonths(null)
|
||||||
|
.pending(true)
|
||||||
|
.notifKey(p.getNotifKey())
|
||||||
|
.build();
|
||||||
|
mapper.insert(entry);
|
||||||
|
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미확인 내역 확정 (분류/계좌 보정 후 pending 해제) */
|
||||||
|
@Transactional
|
||||||
|
public AccountEntryResponse confirm(Long id, ConfirmRequest req, Long memberId) {
|
||||||
|
AccountEntry e = mustFind(id, memberId);
|
||||||
|
if (!Boolean.TRUE.equals(e.getPending())) {
|
||||||
|
return toResponse(e); // 이미 확정된 건
|
||||||
|
}
|
||||||
|
String category = req != null ? req.getCategory() : null;
|
||||||
|
Long walletId = req != null ? req.getWalletId() : null;
|
||||||
|
mapper.confirm(id, memberId, category, walletId);
|
||||||
|
return toResponse(mapper.findByIdAndMember(id, memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 부분일치) */
|
||||||
|
private Long matchCardWalletId(String issuer, Long memberId) {
|
||||||
|
if (issuer == null) return null;
|
||||||
|
return walletMapper.findByMember(memberId).stream()
|
||||||
|
.filter(w -> "CARD".equals(w.getType()))
|
||||||
|
.filter(w -> (w.getIssuer() != null && w.getIssuer().contains(issuer))
|
||||||
|
|| (w.getName() != null && w.getName().contains(issuer)))
|
||||||
|
.map(Wallet::getId)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== 상환/납부 ===================== */
|
/* ===================== 상환/납부 ===================== */
|
||||||
|
|
||||||
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */
|
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.sb.web.account.service;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드사 결제 푸시 알림 텍스트 파싱.
|
||||||
|
* 카드사마다 문구가 달라 휴리스틱으로 카드사·금액·가맹점·승인/취소를 추출한다.
|
||||||
|
* (서버에 두어 APK 재빌드 없이 규칙 보강 가능)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CardNotificationParser {
|
||||||
|
|
||||||
|
// 감지할 카드사 (긴 이름 우선)
|
||||||
|
private static final List<String> ISSUERS = List.of(
|
||||||
|
"KB국민", "국민", "삼성", "신한", "현대", "롯데", "하나", "우리", "비씨", "BC",
|
||||||
|
"농협", "NH", "씨티", "카카오뱅크", "카카오", "토스", "수협", "광주", "전북", "제주"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
||||||
|
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public static class Parsed {
|
||||||
|
private boolean card; // 카드 결제 알림으로 인식되었는가
|
||||||
|
private boolean canceled; // 취소/취소승인
|
||||||
|
private String issuer; // 감지된 카드사 (없으면 null)
|
||||||
|
private long amount; // 금액(원)
|
||||||
|
private String merchant; // 가맹점(추정)
|
||||||
|
private LocalDate date; // 거래일(추정, 없으면 null)
|
||||||
|
private String notifKey; // 중복방지 키
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드 결제 알림 파싱. card=false 면 결제 알림이 아님(무시 대상). */
|
||||||
|
public Parsed parse(String title, String text, LocalDate today) {
|
||||||
|
String raw = ((title == null ? "" : title) + "\n" + (text == null ? "" : text)).trim();
|
||||||
|
if (raw.isBlank()) return notCard();
|
||||||
|
|
||||||
|
boolean approved = raw.contains("승인") || raw.contains("결제") || raw.contains("출금");
|
||||||
|
boolean canceled = raw.contains("취소") || raw.contains("환불");
|
||||||
|
Matcher am = AMOUNT.matcher(raw);
|
||||||
|
if (!am.find() || (!approved && !canceled)) {
|
||||||
|
return notCard();
|
||||||
|
}
|
||||||
|
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
||||||
|
|
||||||
|
String issuer = detectIssuer(raw);
|
||||||
|
String merchant = guessMerchant(raw);
|
||||||
|
LocalDate date = guessDate(raw, today);
|
||||||
|
|
||||||
|
String notifKey = (issuer == null ? "?" : issuer) + "|" + amount + "|"
|
||||||
|
+ (merchant == null ? "" : merchant) + "|" + date + "|" + (canceled ? "C" : "A");
|
||||||
|
|
||||||
|
return Parsed.builder()
|
||||||
|
.card(true)
|
||||||
|
.canceled(canceled)
|
||||||
|
.issuer(issuer)
|
||||||
|
.amount(amount)
|
||||||
|
.merchant(merchant)
|
||||||
|
.date(date)
|
||||||
|
.notifKey(notifKey)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Parsed notCard() {
|
||||||
|
return Parsed.builder().card(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String detectIssuer(String raw) {
|
||||||
|
for (String c : ISSUERS) {
|
||||||
|
if (raw.contains(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate guessDate(String raw, LocalDate today) {
|
||||||
|
Matcher m = DATE_MD.matcher(raw);
|
||||||
|
while (m.find()) {
|
||||||
|
int mo = Integer.parseInt(m.group(1));
|
||||||
|
int d = Integer.parseInt(m.group(2));
|
||||||
|
if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
|
||||||
|
try {
|
||||||
|
LocalDate cand = LocalDate.of(today.getYear(), mo, d);
|
||||||
|
// 미래 날짜(연말연초 경계)면 작년으로
|
||||||
|
return cand.isAfter(today.plusDays(1)) ? cand.minusYears(1) : cand;
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// 다음 후보
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가맹점 추정: 잡음(카드사·승인문구·금액·날짜·시간·마스킹 이름 등)을 제거한 뒤
|
||||||
|
* 남은 한글/영문 토큰 중 가장 그럴듯한 것을 고른다. (완벽하지 않음 — 사용자가 확정 시 수정)
|
||||||
|
*/
|
||||||
|
private String guessMerchant(String raw) {
|
||||||
|
String s = raw
|
||||||
|
.replaceAll("\\[?\\s*Web발신\\s*]?", " ")
|
||||||
|
.replaceAll("[0-9]{1,3}(?:,[0-9]{3})+\\s*원|[0-9]{4,}\\s*원", " ") // 금액
|
||||||
|
.replaceAll("\\d{1,2}[:시]\\d{2}분?", " ") // 시간
|
||||||
|
.replaceAll("\\d{1,2}[./]\\d{1,2}", " ") // 날짜
|
||||||
|
.replaceAll("\\([^)]*\\)", " ") // 괄호(카드 끝번호 등)
|
||||||
|
.replaceAll("승인취소|취소승인|승인|결제|취소|환불|일시불|할부|누적|잔액|출금|입금|체크|신용", " ")
|
||||||
|
.replaceAll("[가-힣]\\*+[가-힣]?", " "); // 마스킹 이름(홍*동)
|
||||||
|
for (String c : ISSUERS) {
|
||||||
|
s = s.replace(c, " ");
|
||||||
|
}
|
||||||
|
s = s.replace("카드", " ");
|
||||||
|
String[] tokens = s.trim().split("\\s+");
|
||||||
|
String best = null;
|
||||||
|
for (String t : tokens) {
|
||||||
|
String tok = t.replaceAll("[^0-9A-Za-z가-힣]", "").trim();
|
||||||
|
if (tok.length() < 2 || tok.length() > 20) continue;
|
||||||
|
if (tok.matches("\\d+")) continue;
|
||||||
|
if (!tok.matches(".*[가-힣A-Za-z].*")) continue;
|
||||||
|
// 더 긴(구체적인) 토큰을 가맹점으로 선호
|
||||||
|
if (best == null || tok.length() > best.length()) best = tok;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS account_entry (
|
|||||||
wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)',
|
wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)',
|
||||||
to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)',
|
to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)',
|
||||||
installment_months INT NULL COMMENT '카드 할부 개월수(2~24, 일시불은 NULL)',
|
installment_months INT NULL COMMENT '카드 할부 개월수(2~24, 일시불은 NULL)',
|
||||||
|
pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부',
|
||||||
|
notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
@@ -50,6 +52,10 @@ CREATE TABLE IF NOT EXISTS account_entry (
|
|||||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS wallet_id BIGINT NULL;
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS wallet_id BIGINT NULL;
|
||||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS to_wallet_id BIGINT NULL;
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS to_wallet_id BIGINT NULL;
|
||||||
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL;
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL;
|
||||||
|
-- 카드 결제 알림 자동인식: 미확인(확인 필요) 플래그 + 중복방지 키
|
||||||
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부';
|
||||||
|
ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키';
|
||||||
|
ALTER TABLE account_entry ADD INDEX IF NOT EXISTS idx_entry_notif (member_id, notif_key);
|
||||||
|
|
||||||
-- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리)
|
-- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리)
|
||||||
CREATE TABLE IF NOT EXISTS account_tag (
|
CREATE TABLE IF NOT EXISTS account_tag (
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<result property="toWalletId" column="to_wallet_id"/>
|
<result property="toWalletId" column="to_wallet_id"/>
|
||||||
<result property="toWalletName" column="to_wallet_name"/>
|
<result property="toWalletName" column="to_wallet_name"/>
|
||||||
<result property="installmentMonths" column="installment_months"/>
|
<result property="installmentMonths" column="installment_months"/>
|
||||||
|
<result property="pending" column="pending"/>
|
||||||
|
<result property="notifKey" column="notif_key"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo,
|
e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo,
|
||||||
e.wallet_id, w.name AS wallet_name,
|
e.wallet_id, w.name AS wallet_name,
|
||||||
e.to_wallet_id, tw.name AS to_wallet_name,
|
e.to_wallet_id, tw.name AS to_wallet_name,
|
||||||
e.installment_months,
|
e.installment_months, e.pending, e.notif_key,
|
||||||
e.created_at, e.updated_at
|
e.created_at, e.updated_at
|
||||||
</sql>
|
</sql>
|
||||||
<sql id="entryJoins">
|
<sql id="entryJoins">
|
||||||
@@ -127,9 +129,11 @@
|
|||||||
<insert id="insert" parameterType="com.sb.web.account.domain.AccountEntry"
|
<insert id="insert" parameterType="com.sb.web.account.domain.AccountEntry"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
|
||||||
wallet_id, to_wallet_id, installment_months, created_at, updated_at)
|
wallet_id, to_wallet_id, installment_months, pending, notif_key,
|
||||||
|
created_at, updated_at)
|
||||||
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
|
||||||
#{walletId}, #{toWalletId}, #{installmentMonths}, NOW(), NOW())
|
#{walletId}, #{toWalletId}, #{installmentMonths},
|
||||||
|
COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
|
||||||
@@ -144,6 +148,27 @@
|
|||||||
DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId}
|
DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
|
<!-- ===== 알림 자동인식(미확인) ===== -->
|
||||||
|
<select id="findByMemberAndNotifKey" resultMap="entryResultMap">
|
||||||
|
SELECT <include refid="entryCols"/>
|
||||||
|
<include refid="entryJoins"/>
|
||||||
|
WHERE e.member_id = #{memberId} AND e.notif_key = #{notifKey}
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countPending" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM account_entry WHERE member_id = #{memberId} AND pending = 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="confirm">
|
||||||
|
UPDATE account_entry
|
||||||
|
SET pending = 0,
|
||||||
|
<if test="category != null">category = #{category},</if>
|
||||||
|
<if test="walletId != null">wallet_id = #{walletId},</if>
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = #{id} AND member_id = #{memberId} AND pending = 1
|
||||||
|
</update>
|
||||||
|
|
||||||
<select id="findDistinctCategories" resultType="string">
|
<select id="findDistinctCategories" resultType="string">
|
||||||
SELECT DISTINCT category FROM account_entry
|
SELECT DISTINCT category FROM account_entry
|
||||||
WHERE member_id = #{memberId} AND type = #{type}
|
WHERE member_id = #{memberId} AND type = #{type}
|
||||||
|
|||||||
Reference in New Issue
Block a user