Refine voucher compare UI/layout and fix suggestion behavior

This commit is contained in:
b17301
2026-04-27 16:22:23 +09:00
parent 080f7cf112
commit 354d149245
8 changed files with 5977 additions and 18 deletions
BIN
View File
Binary file not shown.
+356
View File
@@ -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("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", 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("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", 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:
+4 -4
View File
@@ -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 {
+16
View File
@@ -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 @@
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
<div class="nav-spacer"></div>
<aside
class="sync-status"
+5 -5
View File
@@ -158,12 +158,12 @@
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 10px;
padding: 6px 10px;
background: rgba(255,255,255,0.92);
border: 1px solid var(--line);
border-radius: 0;
padding: 0;
background: transparent;
border: 0;
color: #363b44;
font-size: 12px;
font-size: 13px;
font-weight: 700;
}
+37 -9
View File
@@ -227,14 +227,15 @@
gap: 8px;
min-width: 0;
max-width: 190px;
padding: 8px 12px;
min-height: 27px;
padding: 3px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
font-size: 12px;
font-weight: 700;
line-height: 1.3;
line-height: 1.05;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
@@ -263,6 +264,22 @@
justify-content: center;
cursor: pointer;
flex: 0 0 auto;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
border-radius: 999px;
aspect-ratio: 1 / 1;
background: rgba(15, 23, 42, 0.08);
box-shadow: none !important;
transform: none !important;
line-height: 1;
}
.project-quick-link-remove:hover {
background: rgba(15, 23, 42, 0.14);
}
.project-quick-link-remove svg {
@@ -506,10 +523,11 @@
background: #f7fbfe;
color: #29485f;
border-radius: 999px;
padding: 5px 10px;
min-height: 18px;
padding: 1px 10px;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
line-height: 1.05;
}
.related-project-tag.is-muted {
@@ -517,15 +535,25 @@
}
.related-project-tag button {
width: 18px;
height: 18px;
min-width: 18px;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
padding: 0;
border-radius: 999px;
border-radius: 50%;
aspect-ratio: 1 / 1;
border: 0;
background: rgba(41, 72, 95, 0.08);
color: #29485f;
box-shadow: none;
box-shadow: none !important;
transform: none !important;
font-size: 11px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.related-project-tag button:hover {
File diff suppressed because it is too large Load Diff
+3475
View File
File diff suppressed because it is too large Load Diff