Update WEHAGO comparison data and tools

This commit is contained in:
b17301
2026-05-09 02:49:34 +09:00
parent b0b4647bb4
commit 7ceee2f897
44 changed files with 6296 additions and 135 deletions
+142 -6
View File
@@ -23,10 +23,12 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from openpyxl import load_workbook
from sqlalchemy import bindparam, create_engine, event, text
from sqlalchemy.exc import OperationalError
from datasette.app import Datasette
from wehago_compare import (
enqueue_default_pair_recommend_precompute,
get_erp_filtered_rows,
get_compare_snapshot_status,
get_individual_pair_recommendations,
get_last_action_summary,
get_status_field_suggestions,
@@ -36,6 +38,7 @@ from wehago_compare import (
get_wehago_filtered_rows,
import_uploaded_erp_voucher_file,
init_wehago_compare_db,
request_compare_snapshot_rebuild,
recommend_pair_matches,
save_recommended_pair_matches,
save_manual_pair_matches,
@@ -54,8 +57,13 @@ _DB_BACKUP_STATE: dict[str, Any] = {
"last_run_at": 0.0,
}
_DB_BACKUP_LOCK = threading.Lock()
_DB_INIT_LOCK = threading.Lock()
_DB_INIT_DONE = False
_DB_ANALYZE_LOCK = threading.Lock()
_DB_ANALYZE_LAST_ATTEMPT_AT = 0.0
DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0
DB_BACKUP_KEEP_COUNT = 24
DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 60 * 60
PROCESS_COST_CACHE_TTL_SECONDS = 120.0
_PROCESS_COST_RUNTIME_CACHE_LOCK = threading.Lock()
_PROCESS_COST_PROJECT_OPTIONS_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {}
@@ -395,8 +403,30 @@ DEFAULT_APP_KEYWORD_RULES = {
def ensure_default_app_config(conn: Any) -> None:
def _clean_text(value: Any) -> str:
return "" if value is None else str(value).strip()
existing_option_rows = conn.execute(
text(
"""
SELECT group_key, item_key, label, value_text, sort_order
FROM app_option_items
"""
)
).mappings().all()
existing_options = {
(_clean_text(row.get("group_key")), _clean_text(row.get("item_key"))): (
_clean_text(row.get("label")),
_clean_text(row.get("value_text")),
int(row.get("sort_order") or 0),
)
for row in existing_option_rows
}
for group_key, items in DEFAULT_APP_OPTION_ITEMS.items():
for sort_order, (item_key, label, value_text) in enumerate(items):
existing_value = existing_options.get((group_key, item_key))
if existing_value == (label, value_text, sort_order):
continue
conn.execute(
text(
"""
@@ -420,8 +450,22 @@ def ensure_default_app_config(conn: Any) -> None:
},
)
existing_rule_rows = conn.execute(
text(
"""
SELECT rule_group, category_key, keyword, sort_order
FROM app_keyword_rules
"""
)
).mappings().all()
existing_rules = {
(_clean_text(row.get("rule_group")), _clean_text(row.get("category_key")), _clean_text(row.get("keyword"))): int(row.get("sort_order") or 0)
for row in existing_rule_rows
}
for rule_group, items in DEFAULT_APP_KEYWORD_RULES.items():
for sort_order, (category_key, keyword) in enumerate(items):
if existing_rules.get((rule_group, category_key, keyword)) == sort_order:
continue
conn.execute(
text(
"""
@@ -445,12 +489,38 @@ def ensure_default_app_config(conn: Any) -> None:
load_app_config.cache_clear()
def _maybe_run_db_analyze() -> None:
global _DB_ANALYZE_LAST_ATTEMPT_AT
now = time.time()
if now - _DB_ANALYZE_LAST_ATTEMPT_AT < DB_ANALYZE_MIN_INTERVAL_SECONDS:
return
with _DB_ANALYZE_LOCK:
now = time.time()
if now - _DB_ANALYZE_LAST_ATTEMPT_AT < DB_ANALYZE_MIN_INTERVAL_SECONDS:
return
_DB_ANALYZE_LAST_ATTEMPT_AT = now
try:
with engine.begin() as conn:
conn.execute(text("ANALYZE"))
except OperationalError as exc:
logger.warning("ANALYZE skipped due to database lock: %s", exc)
except Exception as exc:
logger.warning("ANALYZE skipped due to unexpected error: %s", exc)
def init_db() -> None:
with engine.begin() as conn:
global _DB_INIT_DONE
if _DB_INIT_DONE:
return
with _DB_INIT_LOCK:
if _DB_INIT_DONE:
return
conn = engine.connect()
trans = conn.begin()
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS transactions (
text(
"""
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
approval_status TEXT,
voucher_number TEXT,
@@ -1333,8 +1403,11 @@ def init_db() -> None:
migrate_project_status_entries(conn)
migrate_project_basic_info(conn)
ensure_default_app_config(conn)
conn.execute(text("ANALYZE"))
init_wehago_compare_db(engine)
trans.commit()
conn.close()
init_wehago_compare_db(engine)
_DB_INIT_DONE = True
_maybe_run_db_analyze()
@lru_cache(maxsize=1)
@@ -8459,11 +8532,36 @@ def render_wehago_compare_page(
return templates.TemplateResponse(request, "wehago_compare.html", context)
def build_wehago_compare_health_payload() -> dict[str, Any]:
init_db()
payload = get_wehago_compare_dashboard(
engine,
include_metric_counts=False,
warm_caches=False,
)
return {
"status": "ok",
"selected_start_year": payload.get("selected_start_year"),
"selected_end_year": payload.get("selected_end_year"),
"available_year_count": len(payload.get("available_years") or []),
"metric_section_count": len(payload.get("metric_sections") or []),
}
@app.get("/health")
async def health() -> dict[str, str]:
return build_health_payload()
@app.get("/health/wehago-compare")
async def health_wehago_compare() -> JSONResponse:
try:
return JSONResponse(content=jsonable_encoder(build_wehago_compare_health_payload()))
except Exception as exc:
logger.exception("전표비교 readiness 에러: %s", exc)
return JSONResponse(content={"status": "error", "error": str(exc)}, status_code=500)
@app.get("/")
async def home(request: Request, edit_id: int | None = None, overview_year: int | None = None):
try:
@@ -9114,6 +9212,44 @@ async def wehago_compare_summary(
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.get("/wehago-compare/api/snapshot-status")
async def wehago_compare_snapshot_status(
start_year: int | None = None,
end_year: int | None = None,
):
try:
payload = get_compare_snapshot_status(
engine,
start_year=start_year,
end_year=end_year,
)
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/snapshot-rebuild")
async def wehago_compare_snapshot_rebuild(request: Request):
try:
payload = await request.json()
if not isinstance(payload, dict):
payload = {}
start_year = payload.get("start_year")
end_year = payload.get("end_year")
include_metric_counts = normalize_text(payload.get("include_metric_counts")) not in {"0", "false", "n", "no", "off"}
response = request_compare_snapshot_rebuild(
engine,
start_year=int(start_year) if start_year is not None else None,
end_year=int(end_year) if end_year is not None else None,
include_metric_counts=include_metric_counts,
)
return JSONResponse(content=jsonable_encoder({"ok": True, **response}))
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,