611 lines
28 KiB
Python
611 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
from .shared import (
|
|
Any,
|
|
CROSS_TRANSFER_MATCH_TAG,
|
|
CROSS_TRANSFER_REVIEW_TAG,
|
|
_append_memo_tag,
|
|
_memo_has_tag,
|
|
_normalize_counterparty_for_match,
|
|
_party_match_score,
|
|
current_kst_date,
|
|
date,
|
|
datetime,
|
|
db,
|
|
fail,
|
|
normalize_date,
|
|
now_iso,
|
|
ok,
|
|
re,
|
|
row_to_dict,
|
|
)
|
|
|
|
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
|
|
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
|
|
for tx in transactions:
|
|
if tx.get("status") != "active":
|
|
continue
|
|
key = (tx["transaction_date"], int(tx["amount"]), tx["type"])
|
|
index.setdefault(key, []).append(tx)
|
|
return index
|
|
|
|
|
|
def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]:
|
|
rows = db_conn.execute(
|
|
"""
|
|
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
|
|
t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status,
|
|
a.holder_name, t.merchant
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON a.id = t.account_id
|
|
WHERE t.source=?
|
|
AND t.type IN ('income','expense')
|
|
AND (
|
|
t.category='이체'
|
|
OR t.subcategory IN ('확인필요', '본인계좌이동', '투자이동')
|
|
OR t.payment_method IN ('내계좌간자동이전', '내계좌간자동이체', '오픈뱅킹', '펌뱅킹출금', '펌뱅킹입금', '입금', '출금', 'ATM출금', '적금개설')
|
|
OR t.merchant LIKE '%이체%'
|
|
)
|
|
""",
|
|
(source,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def _parse_tx_datetime(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
text = value.strip()
|
|
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
|
try:
|
|
return datetime.strptime(text, fmt)
|
|
except ValueError:
|
|
continue
|
|
try:
|
|
return datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int:
|
|
if source_type == "expense":
|
|
if candidate_type == "income":
|
|
return 3
|
|
if candidate_type == "transfer":
|
|
return 2
|
|
elif source_type == "income":
|
|
if candidate_type == "expense":
|
|
return 3
|
|
if candidate_type == "transfer":
|
|
return 2
|
|
elif source_type == "transfer":
|
|
if candidate_type in {"income", "expense"}:
|
|
return 2
|
|
if candidate_type == "transfer":
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _normalize_cancel_lookup_text(value: str) -> str:
|
|
if not value:
|
|
return ""
|
|
text = value.lower().strip()
|
|
replacements = [
|
|
"kb체크취소", "승인취소", "취소", "환불", "환급", "refund", "reversal",
|
|
"카드입금", "카드환급", "매출취소", "입금", "출금", "체크카드", "신용카드",
|
|
]
|
|
for token in replacements:
|
|
text = text.replace(token, " ")
|
|
text = re.sub(r"\s+", " ", text)
|
|
return _normalize_counterparty_for_match(text)
|
|
|
|
|
|
def _cancel_channel(value: str) -> str:
|
|
text = (value or "").lower()
|
|
if any(token in text for token in ("카드", "check", "credit")):
|
|
return "card"
|
|
if any(token in text for token in ("이체", "오픈뱅킹", "출금", "입금", "bank")):
|
|
return "bank"
|
|
return "other"
|
|
|
|
|
|
def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool:
|
|
if str(tx.get("type") or "") == "refund":
|
|
return True
|
|
combined = " ".join(
|
|
str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description")
|
|
).lower()
|
|
keywords = ("취소", "환불", "환급", "refund", "reversal", "카드입금", "매출취소")
|
|
return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords)
|
|
|
|
|
|
def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]:
|
|
limit = max(1, min(int(limit), 50))
|
|
lookback_days = max(1, min(int(lookback_days), 730))
|
|
with db(db_path) as conn:
|
|
source_row = conn.execute(
|
|
"""
|
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON a.id = t.account_id
|
|
WHERE t.id=?
|
|
""",
|
|
(transaction_id,),
|
|
).fetchone()
|
|
if not source_row:
|
|
return fail("not_found", f"transaction not found: {transaction_id}")
|
|
source = row_to_dict(source_row)
|
|
explicit_cancel_like = _is_cancel_like_transaction(source)
|
|
source_type = str(source.get("type") or "")
|
|
if source_type not in {"income", "refund"} and not explicit_cancel_like:
|
|
return fail("not_cancel_like", "cancel/original lookup is intended for 취소·환급성 거래")
|
|
|
|
source_date = normalize_date(source.get("transaction_date"), default_today=False)
|
|
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
|
|
source_day = date.fromisoformat(source_date)
|
|
start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat()
|
|
source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
|
|
source_norm_text = _normalize_cancel_lookup_text(source_text)
|
|
source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or ""))
|
|
candidate_rows = conn.execute(
|
|
"""
|
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON a.id = t.account_id
|
|
WHERE t.status='active'
|
|
AND t.id<>?
|
|
AND t.amount=?
|
|
AND t.transaction_date BETWEEN ? AND ?
|
|
AND t.type IN ('expense', 'adjustment')
|
|
ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC
|
|
""",
|
|
(transaction_id, int(source.get("amount") or 0), start_date, source_date),
|
|
).fetchall()
|
|
|
|
candidates: list[dict[str, Any]] = []
|
|
for row in candidate_rows:
|
|
candidate = row_to_dict(row)
|
|
candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False)
|
|
candidate_day = date.fromisoformat(candidate_date)
|
|
day_delta = (source_day - candidate_day).days
|
|
if day_delta < 0:
|
|
continue
|
|
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
|
|
time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None
|
|
same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id"))
|
|
source_same = bool(source.get("source") and source.get("source") == candidate.get("source"))
|
|
candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
|
|
candidate_norm_text = _normalize_cancel_lookup_text(candidate_text)
|
|
merchant_score = 0
|
|
if source_norm_text and candidate_norm_text:
|
|
if source_norm_text == candidate_norm_text:
|
|
merchant_score = 4
|
|
elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text:
|
|
merchant_score = 2
|
|
channel_score = 0
|
|
candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or ""))
|
|
if source_channel != "other" and source_channel == candidate_channel:
|
|
channel_score = 2
|
|
if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1):
|
|
continue
|
|
recency_score = 0
|
|
if day_delta == 0:
|
|
recency_score = 5
|
|
elif day_delta <= 7:
|
|
recency_score = 4
|
|
elif day_delta <= 30:
|
|
recency_score = 3
|
|
elif day_delta <= 90:
|
|
recency_score = 2
|
|
else:
|
|
recency_score = 1
|
|
time_score = 0
|
|
if time_delta_seconds is not None:
|
|
if time_delta_seconds <= 3600:
|
|
time_score = 2
|
|
elif time_delta_seconds <= 86400:
|
|
time_score = 1
|
|
score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0)
|
|
reason_bits = [
|
|
f"same_account={int(same_account)}",
|
|
f"merchant={merchant_score}",
|
|
f"channel={channel_score}",
|
|
f"days={day_delta}",
|
|
]
|
|
if time_delta_seconds is not None:
|
|
reason_bits.append(f"time_delta={time_delta_seconds}s")
|
|
candidates.append({
|
|
"id": candidate.get("id"),
|
|
"transaction_date": candidate.get("transaction_date"),
|
|
"transaction_datetime": candidate.get("transaction_datetime"),
|
|
"source": candidate.get("source"),
|
|
"type": candidate.get("type"),
|
|
"amount": candidate.get("amount"),
|
|
"category": candidate.get("category"),
|
|
"subcategory": candidate.get("subcategory"),
|
|
"merchant": candidate.get("merchant"),
|
|
"raw_summary": candidate.get("raw_summary"),
|
|
"counterparty": candidate.get("counterparty"),
|
|
"account_id": candidate.get("account_id"),
|
|
"account_name": candidate.get("account_name"),
|
|
"institution": candidate.get("institution"),
|
|
"payment_method": candidate.get("payment_method"),
|
|
"memo": candidate.get("memo"),
|
|
"score": score,
|
|
"day_delta": day_delta,
|
|
"time_delta_seconds": time_delta_seconds,
|
|
"reason": ", ".join(reason_bits),
|
|
})
|
|
|
|
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or "")))
|
|
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search")
|
|
|
|
|
|
def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
|
limit = max(1, min(int(limit), 50))
|
|
with db(db_path) as conn:
|
|
source_row = conn.execute(
|
|
"""
|
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON a.id = t.account_id
|
|
WHERE t.id=?
|
|
""",
|
|
(transaction_id,),
|
|
).fetchone()
|
|
if not source_row:
|
|
return fail("not_found", f"transaction not found: {transaction_id}")
|
|
|
|
source = row_to_dict(source_row)
|
|
source_date = normalize_date(source.get("transaction_date"), default_today=False)
|
|
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
|
|
source_holder = _normalize_counterparty_for_match(source.get("holder_name", ""))
|
|
anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date()
|
|
window_start = anchor_date.toordinal() - 1
|
|
window_end = anchor_date.toordinal() + 1
|
|
candidate_rows = conn.execute(
|
|
"""
|
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
|
FROM transactions t
|
|
LEFT JOIN accounts a ON a.id = t.account_id
|
|
WHERE t.status='active'
|
|
AND t.id<>?
|
|
AND COALESCE(t.account_id, '')<>COALESCE(?, '')
|
|
AND t.amount=?
|
|
AND t.transaction_date BETWEEN ? AND ?
|
|
ORDER BY t.transaction_date, t.transaction_datetime, t.created_at
|
|
""",
|
|
(
|
|
transaction_id,
|
|
source.get("account_id", ""),
|
|
int(source.get("amount") or 0),
|
|
date.fromordinal(window_start).isoformat(),
|
|
date.fromordinal(window_end).isoformat(),
|
|
),
|
|
).fetchall()
|
|
|
|
candidates: list[dict[str, Any]] = []
|
|
for row in candidate_rows:
|
|
candidate = row_to_dict(row)
|
|
direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", "")))
|
|
if direction_score == 0:
|
|
continue
|
|
|
|
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
|
|
day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days)
|
|
time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None
|
|
same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", "")))
|
|
party_score = max(
|
|
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))),
|
|
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))),
|
|
_party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))),
|
|
_party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))),
|
|
)
|
|
source_gap_score = 1 if source.get("source") != candidate.get("source") else 0
|
|
day_score = 3 if day_delta == 0 else 1
|
|
time_score = 0
|
|
if time_delta_seconds is not None:
|
|
if time_delta_seconds <= 120:
|
|
time_score = 3
|
|
elif time_delta_seconds <= 3600:
|
|
time_score = 2
|
|
elif time_delta_seconds <= 86400:
|
|
time_score = 1
|
|
score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0)
|
|
reason_bits = [
|
|
f"direction={direction_score}",
|
|
f"party={party_score}",
|
|
f"same_owner={int(same_owner)}",
|
|
f"same_source={int(source.get('source') == candidate.get('source'))}",
|
|
f"day_delta={day_delta}",
|
|
]
|
|
if time_delta_seconds is not None:
|
|
reason_bits.append(f"time_delta={time_delta_seconds}s")
|
|
candidates.append(
|
|
{
|
|
"id": candidate.get("id"),
|
|
"transaction_date": candidate.get("transaction_date"),
|
|
"transaction_datetime": candidate.get("transaction_datetime"),
|
|
"source": candidate.get("source"),
|
|
"type": candidate.get("type"),
|
|
"amount": candidate.get("amount"),
|
|
"category": candidate.get("category"),
|
|
"subcategory": candidate.get("subcategory"),
|
|
"merchant": candidate.get("merchant"),
|
|
"counterparty": candidate.get("counterparty"),
|
|
"account_id": candidate.get("account_id"),
|
|
"account_name": candidate.get("account_name"),
|
|
"institution": candidate.get("institution"),
|
|
"holder_name": candidate.get("holder_name"),
|
|
"memo": candidate.get("memo"),
|
|
"score": score,
|
|
"same_owner": same_owner,
|
|
"day_delta": day_delta,
|
|
"time_delta_seconds": time_delta_seconds,
|
|
"reason": ", ".join(reason_bits),
|
|
}
|
|
)
|
|
|
|
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or "")))
|
|
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search")
|
|
|
|
|
|
def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
|
if not source_transaction_id or not candidate_transaction_id:
|
|
return fail("validation_error", "both transaction ids are required")
|
|
if source_transaction_id == candidate_transaction_id:
|
|
return fail("validation_error", "source and candidate transaction must differ")
|
|
|
|
preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path)
|
|
if not preview.get("success"):
|
|
return preview
|
|
candidates = preview.get("candidates") or []
|
|
candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None)
|
|
if candidate is None:
|
|
return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}")
|
|
|
|
link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}"
|
|
ts = now_iso()
|
|
with db(db_path) as conn:
|
|
source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
|
|
candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
|
|
if not source_row or not candidate_row:
|
|
return fail("not_found", "source or candidate transaction not found")
|
|
source_memo = _append_memo_tag(source_row["memo"], link_tag)
|
|
candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag)
|
|
conn.execute(
|
|
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
|
|
(source_memo, ts, source_transaction_id),
|
|
)
|
|
conn.execute(
|
|
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
|
|
(candidate_memo, ts, candidate_transaction_id),
|
|
)
|
|
updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
|
|
updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
|
|
|
|
return ok(
|
|
source_transaction=row_to_dict(updated_source),
|
|
candidate_transaction=row_to_dict(updated_candidate),
|
|
link_tag=link_tag,
|
|
score=candidate.get("score"),
|
|
reason=candidate.get("reason"),
|
|
source="manual_self_transfer_link",
|
|
)
|
|
|
|
|
|
def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
|
if not cancel_transaction_id or not original_transaction_id:
|
|
return fail("validation_error", "both transaction ids are required")
|
|
if cancel_transaction_id == original_transaction_id:
|
|
return fail("validation_error", "cancel and original transaction must differ")
|
|
|
|
preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path)
|
|
if not preview.get("success"):
|
|
return preview
|
|
candidates = preview.get("candidates") or []
|
|
original = next((item for item in candidates if item.get("id") == original_transaction_id), None)
|
|
if original is None:
|
|
return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}")
|
|
|
|
link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}"
|
|
ts = now_iso()
|
|
with db(db_path) as conn:
|
|
cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
|
|
original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
|
|
if not cancel_row or not original_row:
|
|
return fail("not_found", "cancel or original transaction not found")
|
|
|
|
cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag)
|
|
original_memo = _append_memo_tag(original_row["memo"], link_tag)
|
|
original_category = original_row["category"] or "수입"
|
|
original_subcategory = original_row["subcategory"] or "환급"
|
|
original_person = original_row["person"] or ""
|
|
conn.execute(
|
|
"UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?",
|
|
(original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id),
|
|
)
|
|
conn.execute(
|
|
"UPDATE transactions SET memo=?, updated_at=? WHERE id=?",
|
|
(original_memo, ts, original_transaction_id),
|
|
)
|
|
updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
|
|
updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
|
|
|
|
return ok(
|
|
cancel_transaction=row_to_dict(updated_cancel),
|
|
original_transaction=row_to_dict(updated_original),
|
|
link_tag=link_tag,
|
|
score=original.get("score"),
|
|
reason=original.get("reason"),
|
|
source="manual_cancel_link",
|
|
)
|
|
|
|
|
|
def _tag_memos(conn: Any, tx_id: str, tag: str) -> None:
|
|
existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone()
|
|
if not existing:
|
|
return
|
|
memo = _append_memo_tag(existing["memo"], tag)
|
|
conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id))
|
|
|
|
|
|
def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
|
limit = max(1, min(int(limit), 1000))
|
|
matched_pairs: list[dict[str, Any]] = []
|
|
review_items: list[dict[str, Any]] = []
|
|
matched_tx_ids: set[str] = set()
|
|
|
|
with db(db_path) as conn:
|
|
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
|
|
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
|
|
toss_index = _build_cross_transfer_index(toss_rows)
|
|
|
|
for kb in kb_rows:
|
|
if kb["status"] != "active":
|
|
continue
|
|
if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG):
|
|
continue
|
|
required_type = "income" if kb["type"] == "expense" else "expense"
|
|
candidates = [
|
|
t
|
|
for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), [])
|
|
if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG)
|
|
]
|
|
|
|
if not candidates:
|
|
review_items.append(
|
|
{
|
|
"source_tx_id": kb["id"],
|
|
"source": "kb_bank_pdf",
|
|
"target_tx_id": "",
|
|
"reason": "no_candidate_found",
|
|
"type": kb["type"],
|
|
"amount": kb["amount"],
|
|
"transaction_date": kb["transaction_date"],
|
|
"counterparty": kb["counterparty"],
|
|
}
|
|
)
|
|
continue
|
|
|
|
scored = []
|
|
for candidate in candidates:
|
|
score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", ""))
|
|
scored.append((score, candidate["transaction_datetime"], candidate))
|
|
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
|
best_score = scored[0][0]
|
|
top = [c for c in scored if c[0] == best_score]
|
|
|
|
if best_score == 0 or len(top) != 1:
|
|
review_items.append(
|
|
{
|
|
"source_tx_id": kb["id"],
|
|
"source": "kb_bank_pdf",
|
|
"target_tx_id": "",
|
|
"reason": "ambiguous_or_name_mismatch",
|
|
"candidate_count": len(candidates),
|
|
"best_name_score": best_score,
|
|
"type": kb["type"],
|
|
"amount": kb["amount"],
|
|
"transaction_date": kb["transaction_date"],
|
|
"counterparty": kb["counterparty"],
|
|
}
|
|
)
|
|
continue
|
|
|
|
toss = top[0][2]
|
|
if toss["id"] in matched_tx_ids:
|
|
review_items.append(
|
|
{
|
|
"source_tx_id": kb["id"],
|
|
"source": "kb_bank_pdf",
|
|
"target_tx_id": toss["id"],
|
|
"reason": "target_already_matched",
|
|
"type": kb["type"],
|
|
"amount": kb["amount"],
|
|
"transaction_date": kb["transaction_date"],
|
|
"counterparty": kb["counterparty"],
|
|
}
|
|
)
|
|
continue
|
|
|
|
kb_party = _normalize_counterparty_for_match(kb.get("counterparty", ""))
|
|
toss_party = _normalize_counterparty_for_match(toss.get("counterparty", ""))
|
|
owner_candidates = {
|
|
_normalize_counterparty_for_match(kb.get("holder_name", "")),
|
|
_normalize_counterparty_for_match(toss.get("holder_name", "")),
|
|
}
|
|
if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates):
|
|
subcategory = "투자이동"
|
|
else:
|
|
subcategory = "확인필요"
|
|
|
|
matched_pairs.append(
|
|
{
|
|
"kb_tx_id": kb["id"],
|
|
"toss_tx_id": toss["id"],
|
|
"transaction_date": kb["transaction_date"],
|
|
"amount": kb["amount"],
|
|
"counterparty_kb": kb["counterparty"],
|
|
"counterparty_toss": toss["counterparty"],
|
|
"subcategory": subcategory,
|
|
"name_score": best_score,
|
|
"reason": "paired_by_date_amount_party",
|
|
}
|
|
)
|
|
if not dry_run:
|
|
review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}"
|
|
conn.execute(
|
|
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
|
|
(subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]),
|
|
)
|
|
conn.execute(
|
|
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
|
|
(subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]),
|
|
)
|
|
|
|
if not dry_run:
|
|
_tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
|
|
_tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
|
|
matched_tx_ids.add(kb["id"])
|
|
matched_tx_ids.add(toss["id"])
|
|
|
|
if len(matched_pairs) >= limit:
|
|
break
|
|
|
|
for review in review_items:
|
|
if review["source_tx_id"] in matched_tx_ids:
|
|
continue
|
|
if not dry_run:
|
|
_tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}")
|
|
|
|
|
|
return ok(
|
|
dry_run=dry_run,
|
|
matched_count=len(matched_pairs),
|
|
review_count=len(review_items),
|
|
matched_pairs=matched_pairs[:limit],
|
|
review_items=review_items[:limit],
|
|
source="kb_toss_cross_transfer_match",
|
|
)
|
|
|
|
|
|
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
|
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
|
|
|
|
|
|
def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
|
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
|
|
|
|
|
|
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
|
|
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
|
|
clauses.append("t.memo LIKE '%cross_transfer_review:%'")
|
|
where = " WHERE " + " AND ".join(clauses)
|
|
with db(db_path) as conn:
|
|
rows = conn.execute(
|
|
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
|
|
(max(1, min(int(limit), 1000)),),
|
|
).fetchall()
|
|
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
|