fix(expense): zip 버튼 라벨 변경 + 경로 폴백/진단 헤더

- "첨부 일괄(zip)" → "전부 다운로드(zip)"
- stored_path 깨진 경우 업로드루트/item_id/파일명 으로 폴백 탐색
- X-Attachment-Stats 헤더(total/added/missing)로 빈 zip 원인 진단

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:50:49 +09:00
parent a107b06826
commit 09e2f06a14
2 changed files with 29 additions and 4 deletions
+28 -3
View File
@@ -472,13 +472,33 @@ async def approved_attachments_zip(
year, mon, raw = _parse_month(request) year, mon, raw = _parse_month(request)
atts = store.approved_attachments(year=year, month=mon) atts = store.approved_attachments(year=year, month=mon)
names = _name_map(request) names = _name_map(request)
upload_root = _upload_dir(request)
def _resolve(a: dict[str, Any]) -> Path | None:
"""stored_path 우선, 없으면 현재 업로드 루트/item_id/파일명 으로 폴백."""
raw_path = a.get("stored_path") or ""
p = Path(raw_path)
if p.exists():
return p
# DATA_DIR 변경 등으로 절대경로가 깨진 경우 — basename 으로 재구성
item_id = a.get("item_id", "")
base = os.path.basename(raw_path) or a.get("filename", "")
if item_id and base:
alt = upload_root / item_id / base
if alt.exists():
return alt
return None
total = len(atts)
added = 0
missing = 0
buf = io.BytesIO() buf = io.BytesIO()
used: dict[str, int] = {} used: dict[str, int] = {}
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for a in atts: for a in atts:
path = Path(a.get("stored_path", "")) path = _resolve(a)
if not path.exists(): if path is None:
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.get("uploaded_at"), nm, a.get("filename", ""))
@@ -491,12 +511,17 @@ async def approved_attachments_zip(
used[fname] = 1 used[fname] = 1
arcname = fname arcname = fname
zf.write(str(path), arcname=arcname) zf.write(str(path), arcname=arcname)
added += 1
buf.seek(0) buf.seek(0)
zipname = f"expense_approved_{raw}.zip" zipname = f"expense_approved_{raw}.zip"
return StreamingResponse( return StreamingResponse(
buf, buf,
media_type="application/zip", media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zipname}"'}, headers={
"Content-Disposition": f'attachment; filename="{zipname}"',
# 진단용: 첨부 총개수 / 담긴 개수 / 디스크에서 못 찾은 개수
"X-Attachment-Stats": f"total={total};added={added};missing={missing}",
},
) )
@@ -11,7 +11,7 @@
<button type="submit" class="erp-btn erp-btn-outline">조회</button> <button type="submit" class="erp-btn erp-btn-outline">조회</button>
</form> </form>
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/export.xlsx?month={{ month }}">엑셀 다운</a> <a class="erp-btn erp-btn-primary" href="/expense/api/approved/export.xlsx?month={{ month }}">엑셀 다운</a>
<a class="erp-btn erp-btn-outline" href="/expense/api/approved/attachments.zip?month={{ month }}">일괄(zip)</a> <a class="erp-btn erp-btn-outline" href="/expense/api/approved/attachments.zip?month={{ month }}">다운로드(zip)</a>
</div> </div>
<!-- ── 직원별 합계 ── --> <!-- ── 직원별 합계 ── -->