diff --git a/data.db b/data.db index 15220b9..8104a66 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index faba1fe..d101263 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ import logging import json import re import time +import tempfile import zipfile from datetime import date, datetime from functools import lru_cache @@ -18,6 +19,23 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from openpyxl import load_workbook from sqlalchemy import create_engine, event, text +from wehago_compare import ( + get_erp_filtered_rows, + get_individual_pair_recommendations, + get_last_action_summary, + get_status_field_suggestions, + get_status_detail_rows, + get_wehago_compare_dashboard, + get_wehago_filtered_rows, + import_uploaded_erp_voucher_file, + init_wehago_compare_db, + recommend_pair_matches, + refresh_wehago_compare_data, + save_recommended_pair_matches, + save_manual_pair_matches, + save_recheck_review_rows, + undo_last_action, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -1078,6 +1096,7 @@ def init_db() -> None: migrate_project_basic_info(conn) ensure_default_app_config(conn) conn.execute(text("ANALYZE")) + init_wehago_compare_db(engine) @lru_cache(maxsize=1) @@ -6135,6 +6154,20 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo return templates.TemplateResponse(request, "annual_summary.html", context) +def render_wehago_compare_page( + request: Request, + start_year: int | None = None, + end_year: int | None = None, + message: str = "", +) -> HTMLResponse: + init_db() + context = { + **base_context(request, message), + "wehago_compare": get_wehago_compare_dashboard(engine, start_year=start_year, end_year=end_year), + } + return templates.TemplateResponse(request, "wehago_compare.html", context) + + @app.get("/health") async def health() -> dict[str, str]: return build_health_payload() @@ -6620,6 +6653,329 @@ async def annual_summary(request: Request): return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) +@app.get("/wehago-compare") +async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): + try: + return render_wehago_compare_page( + request, + start_year=parse_optional_year(start_year), + end_year=parse_optional_year(end_year), + ) + except Exception as exc: + logger.exception("전표비교 페이지 에러: %s", exc) + return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) + + +@app.post("/wehago-compare/refresh") +async def refresh_wehago_compare( + request: Request, + start_year: str | None = None, + end_year: str | None = None, +): + try: + summary = refresh_wehago_compare_data(engine) + message = "WEHAGO DB 갱신 완료: " + ", ".join(f"{key}={value}" for key, value in summary.items()) + return render_wehago_compare_page( + request, + start_year=parse_optional_year(start_year), + end_year=parse_optional_year(end_year), + message=message, + ) + except Exception as exc: + logger.exception("전표비교 DB 갱신 에러: %s", exc) + return render_wehago_compare_page( + request, + start_year=parse_optional_year(start_year), + end_year=parse_optional_year(end_year), + message=f"WEHAGO DB 갱신 중 오류가 발생했습니다: {exc}", + ) + + +@app.post("/wehago-compare/upload-erp") +async def upload_wehago_erp_file( + request: Request, + start_year: str | None = None, + end_year: str | None = None, + erp_file: UploadFile = File(...), +): + temp_path: Path | None = None + try: + if not erp_file.filename: + raise ValueError("업로드할 ERP 파일을 선택해주세요.") + suffix = Path(erp_file.filename).suffix or ".xlsx" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: + temp_path = Path(handle.name) + while True: + chunk = await erp_file.read(1024 * 1024) + if not chunk: + break + handle.write(chunk) + + summary = import_uploaded_erp_voucher_file(engine, temp_path, erp_file.filename) + message = ( + f"ERP 파일 반영 완료: {summary['file_name']} " + f"(추가 {summary['inserted_rows']}건, 중복 제외 {summary['duplicate_rows']}건)" + ) + return render_wehago_compare_page( + request, + start_year=parse_optional_year(start_year), + end_year=parse_optional_year(end_year), + message=message, + ) + except Exception as exc: + logger.exception("전표비교 ERP 업로드 에러: %s", exc) + return render_wehago_compare_page( + request, + start_year=parse_optional_year(start_year), + end_year=parse_optional_year(end_year), + message=f"ERP 파일 업로드 중 오류가 발생했습니다: {exc}", + ) + finally: + await erp_file.close() + if temp_path and temp_path.exists(): + temp_path.unlink(missing_ok=True) + + +@app.get("/wehago-compare/api/status-rows") +async def wehago_compare_status_rows( + start_year: int | None = None, + end_year: int | None = None, + status: str = "", + voucher_no: str = "", + draft_no: str = "", + wehago_account: str = "", + erp_account: str = "", + wehago_amount: str = "", + erp_amount: str = "", + wehago_vendor: str = "", + erp_vendor: str = "", + desc_keyword: str = "", + review_reason: str = "", + offset: int = 0, + limit: int = 200, +): + try: + payload = get_status_detail_rows( + engine, + start_year=start_year, + end_year=end_year, + status=status, + voucher_no=voucher_no, + draft_no=draft_no, + wehago_account=wehago_account, + erp_account=erp_account, + wehago_amount=wehago_amount, + erp_amount=erp_amount, + wehago_vendor=wehago_vendor, + erp_vendor=erp_vendor, + desc_keyword=desc_keyword, + review_reason=review_reason, + offset=offset, + limit=limit, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 상태 상세 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/status-suggestions") +async def wehago_compare_status_suggestions( + start_year: int | None = None, + end_year: int | None = None, + status: str = "", + field: str = "voucher_no", + keyword: str = "", + offset: int = 0, + limit: int = 10, +): + try: + payload = get_status_field_suggestions( + engine, + start_year=start_year, + end_year=end_year, + status=status, + field=field, + keyword=keyword, + offset=offset, + limit=limit, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 자동완성 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-compare/api/recheck-review-save") +async def wehago_compare_recheck_review_save(request: Request): + try: + payload = await request.json() + rows = payload.get("rows") if isinstance(payload, dict) else None + if not isinstance(rows, list): + raise ValueError("저장할 검토 항목 형식이 올바르지 않습니다.") + saved = save_recheck_review_rows(engine, rows) + return JSONResponse(content={"saved_count": saved}) + except Exception as exc: + logger.exception("전표비교 검토 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-compare/api/pair-match-save") +async def wehago_compare_pair_match_save(request: Request): + try: + payload = await request.json() + ledger_rows = payload.get("ledger_rows") if isinstance(payload, dict) else None + voucher_rows = payload.get("voucher_rows") if isinstance(payload, dict) else None + if not isinstance(ledger_rows, list) or not isinstance(voucher_rows, list): + raise ValueError("저장할 쌍비교 항목 형식이 올바르지 않습니다.") + saved = save_manual_pair_matches(engine, ledger_rows, voucher_rows) + return JSONResponse(content={"saved_count": saved}) + except Exception as exc: + logger.exception("전표비교 쌍매칭 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/pair-recommendations") +async def wehago_compare_pair_recommendations( + start_year: int | None = None, + end_year: int | None = None, + ledger_voucher_no: str = "", + ledger_review_reason: str = "", + voucher_voucher_no: str = "", + voucher_review_reason: str = "", + limit: int = 300, +): + try: + payload = recommend_pair_matches( + engine, + start_year=start_year, + end_year=end_year, + ledger_voucher_no=ledger_voucher_no, + ledger_review_reason=ledger_review_reason, + voucher_voucher_no=voucher_voucher_no, + voucher_review_reason=voucher_review_reason, + limit=limit, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 추천 매칭 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-compare/api/pair-recommendations/apply") +async def wehago_compare_pair_recommendations_apply(request: Request): + try: + payload = await request.json() + pair_keys = payload.get("pair_keys") if isinstance(payload, dict) else None + auto_only = bool(payload.get("auto_only")) if isinstance(payload, dict) else False + saved = save_recommended_pair_matches( + engine, + start_year=parse_optional_year(payload.get("start_year")) if isinstance(payload, dict) else None, + end_year=parse_optional_year(payload.get("end_year")) if isinstance(payload, dict) else None, + pair_keys=pair_keys if isinstance(pair_keys, list) else None, + auto_only=auto_only, + ledger_voucher_no=payload.get("ledger_voucher_no", "") if isinstance(payload, dict) else "", + ledger_review_reason=payload.get("ledger_review_reason", "") if isinstance(payload, dict) else "", + voucher_voucher_no=payload.get("voucher_voucher_no", "") if isinstance(payload, dict) else "", + voucher_review_reason=payload.get("voucher_review_reason", "") if isinstance(payload, dict) else "", + ) + return JSONResponse(content=jsonable_encoder(saved)) + except Exception as exc: + logger.exception("전표비교 추천 매칭 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/last-action") +async def wehago_compare_last_action(): + try: + payload = get_last_action_summary(engine=engine) + return JSONResponse(content=jsonable_encoder(payload or {})) + except Exception as exc: + logger.exception("전표비교 최근 작업 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-compare/api/undo-last-action") +async def wehago_compare_undo_last_action(): + try: + payload = undo_last_action(engine) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 되돌리기 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/pair-individual-recommendations") +async def wehago_compare_pair_individual_recommendations( + start_year: int | None = None, + end_year: int | None = None, + source_status: str = "", + source_row_key: str = "", + offset: int = 0, + limit: int = 10, +): + try: + payload = get_individual_pair_recommendations( + engine, + start_year=start_year, + end_year=end_year, + source_status=source_status, + source_row_key=source_row_key, + offset=offset, + limit=limit, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 개별 추천 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/wehago-rows") +async def wehago_compare_wehago_rows( + start_year: int | None = None, + end_year: int | None = None, + voucher_no: str = "", + account_code: str = "", + vendor_name: str = "", +): + try: + payload = get_wehago_filtered_rows( + engine, + start_year=start_year, + end_year=end_year, + voucher_no=voucher_no, + account_code=account_code, + vendor_name=vendor_name, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 WEHAGO 상세 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/erp-rows") +async def wehago_compare_erp_rows( + start_year: int | None = None, + end_year: int | None = None, + voucher_no: str = "", + account_code: str = "", + vendor_name: str = "", +): + try: + payload = get_erp_filtered_rows( + engine, + start_year=start_year, + end_year=end_year, + voucher_no=voucher_no, + account_code=account_code, + vendor_name=vendor_name, + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 ERP 상세 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.post("/upload") async def upload_excel(request: Request, excel_file: UploadFile = File(...)): try: diff --git a/templates/annual_summary.html b/templates/annual_summary.html index c549ce9..2f51337 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -41,10 +41,10 @@ font-size: 13px; font-weight: 800; letter-spacing: -0.01em; - background: rgba(255,255,255,0.92); - border: 1px solid var(--line); - border-radius: 12px; - padding: 8px 12px; + background: transparent; + border: 0; + border-radius: 0; + padding: 0; } .legend-swatch { diff --git a/templates/base.html b/templates/base.html index aaab3c0..40a1b98 100644 --- a/templates/base.html +++ b/templates/base.html @@ -249,6 +249,17 @@ padding: 10px 12px; color: var(--ink); box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03); + box-sizing: border-box; + } + + input[type="text"], + input[type="number"], + input[type="date"], + select { + min-height: 38px; + line-height: 1.2; + padding-top: 8px; + padding-bottom: 8px; } select option { @@ -301,6 +312,10 @@ text-decoration: none; transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease; box-shadow: 0 8px 18px rgba(17, 17, 17, 0.14); + min-height: 38px; + box-sizing: border-box; + white-space: nowrap; + line-height: 1.2; } button:hover, @@ -513,6 +528,7 @@ 대시보드 프로젝트 정보 연도별 수익/비용 + 전표비교