feat(expense): 영수증 파일명 규칙 변경 + 등록 폼 필수 검증
- zip 첨부 파일명: 날짜_이름_분류_결제수단_금액_가맹점.확장자 (날짜=사용일) - 가맹점 없으면 생략, 금액 쉼표 표기, 금지문자 정리 - db.approved_attachments 에 category/method/amount/merchant 추가 - 등록 폼: 사용일/분류/결제수단/금액/가맹점 필수 — 누락 시 경고창 + 포커스 (가맹점 input required, 금액 1원 이상) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -398,7 +398,7 @@ class ExpenseDBStore:
|
|||||||
"""
|
"""
|
||||||
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
|
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
|
||||||
a.stored_path, a.content_type, a.uploaded_at,
|
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
|
FROM expense_attachments a
|
||||||
JOIN expense_items i ON i.id = a.item_id
|
JOIN expense_items i ON i.id = a.item_id
|
||||||
WHERE i.status = ANY(%s)
|
WHERE i.status = ANY(%s)
|
||||||
|
|||||||
@@ -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 "")
|
return names.get(email) or (email.split("@")[0] if email else "")
|
||||||
|
|
||||||
|
|
||||||
def _att_download_name(uploaded_at: Any, name: str, original: str) -> str:
|
def _safe_part(value: Any) -> str:
|
||||||
"""첨부 다운로드 파일명: `yy년 mm월 dd일(요일)_이름{ext}`. 날짜=업로드일(KST)."""
|
"""파일명 구성요소 정리 — 경로/금지문자 제거, 공백 압축."""
|
||||||
if isinstance(uploaded_at, datetime):
|
s = str(value or "").strip()
|
||||||
d = uploaded_at.astimezone(KST)
|
s = re.sub(r"[\\/:*?\"<>|\r\n\t]", " ", s)
|
||||||
else:
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
d = now_kst()
|
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()]
|
wd = _WEEKDAYS_KR[d.weekday()]
|
||||||
base = f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})_{name}"
|
return f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})"
|
||||||
ext = os.path.splitext(original or "")[1].lower()
|
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}"
|
return f"{base}{ext}"
|
||||||
|
|
||||||
|
|
||||||
@@ -559,7 +591,7 @@ async def approved_attachments_zip(
|
|||||||
missing += 1
|
missing += 1
|
||||||
continue
|
continue
|
||||||
nm = _display_name(a.get("owner", ""), names)
|
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:
|
if fname in used:
|
||||||
used[fname] += 1
|
used[fname] += 1
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
||||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" required />
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field erp-field-wide"><span>메모</span>
|
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||||
<input type="text" name="memo" placeholder="비고" />
|
<input type="text" name="memo" placeholder="비고" />
|
||||||
@@ -258,8 +258,37 @@
|
|||||||
}
|
}
|
||||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||||
|
|
||||||
|
// 필수 입력 검증 — 누락 시 경고창
|
||||||
|
function validateRequired() {
|
||||||
|
const required = [
|
||||||
|
["spent_at", "사용일"],
|
||||||
|
["category", "분류"],
|
||||||
|
["method", "결제수단"],
|
||||||
|
["amount", "금액"],
|
||||||
|
["merchant", "가맹점/사용처"],
|
||||||
|
];
|
||||||
|
const missing = [];
|
||||||
|
for (const [field, label] of required) {
|
||||||
|
const val = (form[field].value || "").trim();
|
||||||
|
if (!val) missing.push([form[field], label]);
|
||||||
|
}
|
||||||
|
// 금액은 0 이하도 미입력 취급
|
||||||
|
if (!missing.some(([f]) => f === form.amount)) {
|
||||||
|
if (!(parseInt(form.amount.value, 10) > 0)) {
|
||||||
|
missing.push([form.amount, "금액(1원 이상)"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length) {
|
||||||
|
alert("다음 항목을 입력하세요:\n- " + missing.map(([, l]) => l).join("\n- "));
|
||||||
|
missing[0][0].focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!validateRequired()) return;
|
||||||
const id = form.id.value.trim();
|
const id = form.id.value.trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
spent_at: form.spent_at.value,
|
spent_at: form.spent_at.value,
|
||||||
|
|||||||
Reference in New Issue
Block a user