Refine voucher compare UI/layout and fix suggestion behavior
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user