diff --git a/app/modules/expense/db.py b/app/modules/expense/db.py
index 39af638..5453a02 100644
--- a/app/modules/expense/db.py
+++ b/app/modules/expense/db.py
@@ -398,7 +398,7 @@ class ExpenseDBStore:
"""
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
a.stored_path, a.content_type, a.uploaded_at,
- i.spent_at
+ i.spent_at, i.category, i.method, i.amount, i.merchant
FROM expense_attachments a
JOIN expense_items i ON i.id = a.item_id
WHERE i.status = ANY(%s)
diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py
index 1dcc350..fd70b6b 100644
--- a/app/modules/expense/router.py
+++ b/app/modules/expense/router.py
@@ -105,15 +105,47 @@ def _display_name(email: str, names: dict[str, str]) -> str:
return names.get(email) or (email.split("@")[0] if email else "")
-def _att_download_name(uploaded_at: Any, name: str, original: str) -> str:
- """첨부 다운로드 파일명: `yy년 mm월 dd일(요일)_이름{ext}`. 날짜=업로드일(KST)."""
- if isinstance(uploaded_at, datetime):
- d = uploaded_at.astimezone(KST)
- else:
- d = now_kst()
- wd = _WEEKDAYS_KR[d.weekday()]
- base = f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})_{name}"
- ext = os.path.splitext(original or "")[1].lower()
+def _safe_part(value: Any) -> str:
+ """파일명 구성요소 정리 — 경로/금지문자 제거, 공백 압축."""
+ s = str(value or "").strip()
+ s = re.sub(r"[\\/:*?\"<>|\r\n\t]", " ", s)
+ s = re.sub(r"\s+", " ", s).strip()
+ return s
+
+
+def _fmt_date_kr(value: Any) -> str:
+ """날짜 → `yy년 mm월 dd일(요일)`. date/datetime/문자열(YYYY-MM-DD) 허용."""
+ d: Any = value
+ if isinstance(value, str):
+ try:
+ d = datetime.strptime(value[:10], "%Y-%m-%d").date()
+ except ValueError:
+ return _safe_part(value)
+ if isinstance(d, datetime):
+ d = d.astimezone(KST)
+ if hasattr(d, "weekday"):
+ wd = _WEEKDAYS_KR[d.weekday()]
+ return f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})"
+ return _safe_part(value)
+
+
+def _att_download_name(att: dict[str, Any], name: str) -> str:
+ """첨부 다운로드 파일명: `날짜_이름_분류_결제수단_금액_가맹점{ext}`.
+
+ 날짜=사용일(spent_at). 금액=쉼표 표기. 빈 가맹점은 생략.
+ """
+ parts = [
+ _fmt_date_kr(att.get("spent_at")),
+ _safe_part(name),
+ _safe_part(att.get("category")),
+ _safe_part(att.get("method")),
+ f"{int(att.get('amount', 0) or 0):,}원",
+ ]
+ merchant = _safe_part(att.get("merchant"))
+ if merchant:
+ parts.append(merchant)
+ base = "_".join(p for p in parts if p)
+ ext = os.path.splitext(att.get("filename") or "")[1].lower()
return f"{base}{ext}"
@@ -559,7 +591,7 @@ async def approved_attachments_zip(
missing += 1
continue
nm = _display_name(a.get("owner", ""), names)
- fname = _att_download_name(a.get("uploaded_at"), nm, a.get("filename", ""))
+ fname = _att_download_name(a, nm)
# 동일명 충돌 → 접미사
if fname in used:
used[fname] += 1
diff --git a/app/modules/expense/templates/expense/index.html b/app/modules/expense/templates/expense/index.html
index 64309bd..962f90d 100644
--- a/app/modules/expense/templates/expense/index.html
+++ b/app/modules/expense/templates/expense/index.html
@@ -69,7 +69,7 @@