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 @@
대시보드
프로젝트 정보
연도별 수익/비용
+ 전표비교