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 json
import re import re
import time import time
import tempfile
import zipfile import zipfile
from datetime import date, datetime from datetime import date, datetime
from functools import lru_cache from functools import lru_cache
@@ -18,6 +19,23 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from openpyxl import load_workbook from openpyxl import load_workbook
from sqlalchemy import create_engine, event, text 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) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1078,6 +1096,7 @@ def init_db() -> None:
migrate_project_basic_info(conn) migrate_project_basic_info(conn)
ensure_default_app_config(conn) ensure_default_app_config(conn)
conn.execute(text("ANALYZE")) conn.execute(text("ANALYZE"))
init_wehago_compare_db(engine)
@lru_cache(maxsize=1) @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) 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") @app.get("/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return build_health_payload() return build_health_payload()
@@ -6620,6 +6653,329 @@ async def annual_summary(request: Request):
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500) 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") @app.post("/upload")
async def upload_excel(request: Request, excel_file: UploadFile = File(...)): async def upload_excel(request: Request, excel_file: UploadFile = File(...)):
try: try:
+4 -4
View File
@@ -41,10 +41,10 @@
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
letter-spacing: -0.01em; letter-spacing: -0.01em;
background: rgba(255,255,255,0.92); background: transparent;
border: 1px solid var(--line); border: 0;
border-radius: 12px; border-radius: 0;
padding: 8px 12px; padding: 0;
} }
.legend-swatch { .legend-swatch {
+16
View File
@@ -249,6 +249,17 @@
padding: 10px 12px; padding: 10px 12px;
color: var(--ink); color: var(--ink);
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03); 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 { select option {
@@ -301,6 +312,10 @@
text-decoration: none; text-decoration: none;
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease; 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); 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, button:hover,
@@ -513,6 +528,7 @@
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a> <a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
<a href="/projects" class="{% if request.url.path == '/projects' %}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="/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> <div class="nav-spacer"></div>
<aside <aside
class="sync-status" class="sync-status"
+5 -5
View File
@@ -158,12 +158,12 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
border-radius: 10px; border-radius: 0;
padding: 6px 10px; padding: 0;
background: rgba(255,255,255,0.92); background: transparent;
border: 1px solid var(--line); border: 0;
color: #363b44; color: #363b44;
font-size: 12px; font-size: 13px;
font-weight: 700; font-weight: 700;
} }
+37 -9
View File
@@ -227,14 +227,15 @@
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
max-width: 190px; max-width: 190px;
padding: 8px 12px; min-height: 27px;
padding: 3px 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
color: var(--ink); color: var(--ink);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.05;
cursor: pointer; cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
} }
@@ -263,6 +264,22 @@
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
flex: 0 0 auto; 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 { .project-quick-link-remove svg {
@@ -506,10 +523,11 @@
background: #f7fbfe; background: #f7fbfe;
color: #29485f; color: #29485f;
border-radius: 999px; border-radius: 999px;
padding: 5px 10px; min-height: 18px;
padding: 1px 10px;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.05;
} }
.related-project-tag.is-muted { .related-project-tag.is-muted {
@@ -517,15 +535,25 @@
} }
.related-project-tag button { .related-project-tag button {
width: 18px; width: 14px;
height: 18px; height: 14px;
min-width: 18px; min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
padding: 0; padding: 0;
border-radius: 999px; border-radius: 50%;
aspect-ratio: 1 / 1;
border: 0; border: 0;
background: rgba(41, 72, 95, 0.08); background: rgba(41, 72, 95, 0.08);
color: #29485f; 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 { .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