Refactor import and workflow services plus adapter seams
This commit is contained in:
@@ -274,220 +274,13 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru
|
||||
applied += 1
|
||||
return ok(applied_count=applied, preview=preview)
|
||||
|
||||
|
||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
|
||||
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
|
||||
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
|
||||
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
|
||||
"""Recommend a category for one transaction using deterministic, explainable signals."""
|
||||
for rule in rules:
|
||||
try:
|
||||
if _rule_matches(rule, tx):
|
||||
return {
|
||||
"category": rule.get("category", ""),
|
||||
"subcategory": rule.get("subcategory", ""),
|
||||
"confidence": round(float(rule.get("confidence") or 1.0), 2),
|
||||
"source": "rule",
|
||||
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
|
||||
"rule_id": rule.get("id", ""),
|
||||
}
|
||||
except re.error:
|
||||
continue
|
||||
|
||||
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
|
||||
candidates = history.get(key, [])
|
||||
if candidates:
|
||||
best = candidates[0]
|
||||
count = int(best.get("count") or 0)
|
||||
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
|
||||
return {
|
||||
"category": best.get("category", ""),
|
||||
"subcategory": best.get("subcategory", ""),
|
||||
"confidence": round(confidence, 2),
|
||||
"source": "history",
|
||||
"reason": f"같은 사용처의 기존 분류 {count}건",
|
||||
"rule_id": "",
|
||||
}
|
||||
|
||||
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
|
||||
|
||||
|
||||
def get_uncategorized_workbench(
|
||||
limit: int = 100,
|
||||
min_amount: int = 0,
|
||||
sort_by: str = "amount_desc",
|
||||
db_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row_limit = max(1, min(int(limit), 1000))
|
||||
minimum_amount = int(min_amount)
|
||||
order_by_map = {
|
||||
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
|
||||
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
|
||||
}
|
||||
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
|
||||
with db(db_path) as conn:
|
||||
tx_rows = [row_to_dict(r) for r in conn.execute(
|
||||
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
|
||||
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
|
||||
ORDER BY {order_by}
|
||||
LIMIT ?""",
|
||||
(minimum_amount, row_limit),
|
||||
).fetchall()]
|
||||
total_row = conn.execute(
|
||||
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
|
||||
(minimum_amount,),
|
||||
).fetchone()
|
||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
|
||||
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||
GROUP BY merchant, type, category, subcategory
|
||||
ORDER BY merchant, type, count DESC, total_amount DESC"""
|
||||
).fetchall()]
|
||||
rules = [row_to_dict(r) for r in conn.execute(
|
||||
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
|
||||
).fetchall()]
|
||||
|
||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for row in history_rows:
|
||||
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
|
||||
|
||||
txs: list[dict[str, Any]] = []
|
||||
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
for tx in tx_rows:
|
||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||
public_tx = {
|
||||
**tx,
|
||||
"recommendation": rec,
|
||||
"recommended_category": rec.get("category", ""),
|
||||
"recommended_subcategory": rec.get("subcategory", ""),
|
||||
"recommendation_confidence": rec.get("confidence", 0.0),
|
||||
"recommendation_reason": rec.get("reason", ""),
|
||||
"recommendation_source": rec.get("source", "none"),
|
||||
}
|
||||
txs.append(public_tx)
|
||||
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
|
||||
group = groups.setdefault(gkey, {
|
||||
"merchant": gkey[0],
|
||||
"raw_summary": gkey[1],
|
||||
"type": gkey[2],
|
||||
"count": 0,
|
||||
"total_amount": 0,
|
||||
"max_amount": 0,
|
||||
"latest_date": "",
|
||||
"sample_transaction_id": tx.get("id", ""),
|
||||
"recommendation": rec,
|
||||
})
|
||||
group["count"] += 1
|
||||
group["total_amount"] += int(tx.get("amount") or 0)
|
||||
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
|
||||
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
||||
if date_value > (group.get("latest_date") or ""):
|
||||
group["latest_date"] = date_value
|
||||
if not group["recommendation"].get("category") and rec.get("category"):
|
||||
group["recommendation"] = rec
|
||||
|
||||
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
||||
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
||||
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
||||
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
||||
return ok(
|
||||
summary={
|
||||
"uncategorized_count": int(total.get("count") or 0),
|
||||
"uncategorized_total_amount": int(total.get("total_amount") or 0),
|
||||
"merchant_group_count": int(total.get("merchant_group_count") or 0),
|
||||
"recommendable_count": recommendable_count,
|
||||
"repeated_group_count": repeated_group_count,
|
||||
},
|
||||
groups=group_items,
|
||||
transactions=txs,
|
||||
count=len(txs),
|
||||
)
|
||||
|
||||
|
||||
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not transaction_id:
|
||||
return fail("validation_error", "transaction_id is required")
|
||||
with db(db_path) as conn:
|
||||
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
|
||||
if not row:
|
||||
return fail("not_found", "transaction not found")
|
||||
tx = row_to_dict(row)
|
||||
if tx.get("category"):
|
||||
return fail("validation_error", "transaction is already categorized")
|
||||
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||
GROUP BY merchant, type, category, subcategory
|
||||
ORDER BY count DESC, total_amount DESC"""
|
||||
).fetchall()]
|
||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for h in history_rows:
|
||||
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||
if not rec.get("category"):
|
||||
return fail("no_recommendation", "추천 분류가 없습니다")
|
||||
cur = conn.execute(
|
||||
"""UPDATE transactions
|
||||
SET category=?, subcategory=?, updated_at=?
|
||||
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
||||
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
|
||||
)
|
||||
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
|
||||
|
||||
|
||||
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
sample = conn.execute(
|
||||
"""SELECT * FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
|
||||
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
|
||||
(merchant, raw_summary, transaction_type),
|
||||
).fetchone()
|
||||
if not sample:
|
||||
return fail("not_found", "uncategorized group not found")
|
||||
tx = row_to_dict(sample)
|
||||
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||
GROUP BY merchant, type, category, subcategory
|
||||
ORDER BY count DESC, total_amount DESC"""
|
||||
).fetchall()]
|
||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for h in history_rows:
|
||||
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||
if not rec.get("category"):
|
||||
return fail("no_recommendation", "추천 분류가 없습니다")
|
||||
cur = conn.execute(
|
||||
"""UPDATE transactions
|
||||
SET category=?, subcategory=?, updated_at=?
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
|
||||
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
|
||||
)
|
||||
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
|
||||
|
||||
from .uncategorized_workbench_service import (
|
||||
apply_uncategorized_group_recommendation,
|
||||
apply_uncategorized_recommendation,
|
||||
get_uncategorized_merchant_summary,
|
||||
get_uncategorized_workbench,
|
||||
list_uncategorized_transactions,
|
||||
)
|
||||
|
||||
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
|
||||
{"category": "수입", "subcategory": "급여", "sort_order": 10},
|
||||
@@ -599,6 +392,3 @@ def list_ledger_categories(active_only: bool = True, include_usage: bool = True,
|
||||
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
|
||||
grouped[item["category"]].append(item["subcategory"])
|
||||
return ok(categories=items, grouped=grouped, count=len(items))
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user