"""측점 설계를 **여러 행 한 번에** 쓰는 자리. 왜 (2026-09-06 실측) — [저장]이 측점마다 `update_cross_section_design` · `merge_cross_section_design_patch` 를 불렀고, 그 하나가 `SELECT` + `UPDATE` 두 왕복이다. DB 가 원격(`dsm.chemifactory.com`)이라 왕복 하나가 **약 12ms** 다. 22행이면 왕복 44번, 곧 **670ms**. 측점이 많은 프로젝트일수록 선형으로 늘어난다. 여기서는 세 문장으로 끝낸다 — ① 노선의 측점 행을 **한 번에** 읽고 ② 파이썬에서 chainage 를 맞춰 JSON 을 합치고 ③ `UPDATE … SET data = CASE id …` 한 문장으로 되돌려 쓴다(없는 행은 다중 INSERT). `B06_Section_Repository` 가 685줄이라 700줄 한계에 걸려 파일을 나눴다. 한 행짜리 함수는 그쪽에 그대로 두고, 여러 행을 쓸 때만 이쪽을 쓴다. """ from __future__ import annotations import json from typing import Any from uuid import UUID import aiomysql # 측점을 같은 자리로 볼 허용 오차(m) — 한 행짜리 함수와 같은 값을 쓴다. _CHAINAGE_TOLERANCE_M = 0.01 async def merge_cross_section_designs( connection: aiomysql.Connection, *, route_id: int, entries: list[tuple[float, dict[str, Any]]], replace: bool, project_id: UUID | None = None, ) -> int: """측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다. `replace=True` 면 design 을 통째로 갈아 끼우고(`update_cross_section_design` 과 같은 뜻), `False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻). 행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이 파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙). """ if not entries: return 0 async with connection.cursor() as cursor: await cursor.execute( "SELECT id, chainage_m, data FROM cross_sections WHERE route_id = %s ORDER BY id", (route_id,), ) rows = await cursor.fetchall() existing: dict[int, dict[str, Any]] = {} for row_id, _chainage, raw in rows: data = json.loads(raw) if isinstance(raw, str) else raw existing[int(row_id)] = data if isinstance(data, dict) else {} # 같은 측점이 여러 행이면 뒤에 온 것(=큰 id)을 쓴다 — 한 행짜리 함수의 `ORDER BY id DESC`. ordered = [(float(chainage), int(row_id)) for row_id, chainage, _ in rows] def find(chainage_m: float) -> int | None: best: int | None = None for value, row_id in ordered: if abs(value - chainage_m) < _CHAINAGE_TOLERANCE_M and (best is None or row_id > best): best = row_id return best updates: list[tuple[int, str]] = [] inserts: list[tuple[str, int, float, str]] = [] for chainage_m, payload in entries: if not payload and not replace: continue row_id = find(chainage_m) if row_id is None: if project_id is None: continue inserts.append( ( str(project_id), route_id, chainage_m, json.dumps({"design": payload}, ensure_ascii=False), ) ) continue data = dict(existing[row_id]) if replace: data["design"] = payload else: design = data.get("design") design = dict(design) if isinstance(design, dict) else {} design.update(payload) data["design"] = design updates.append((row_id, json.dumps(data, ensure_ascii=False))) written = 0 async with connection.cursor() as cursor: if updates: # 한 문장 — `CASE id WHEN … THEN …` 이라 왕복이 한 번이다. cases = " ".join("WHEN %s THEN %s" for _ in updates) params: list[Any] = [] for row_id, blob in updates: params.extend((row_id, blob)) params.extend(row_id for row_id, _ in updates) placeholders = ", ".join("%s" for _ in updates) await cursor.execute( f"UPDATE cross_sections SET data = CASE id {cases} END " f"WHERE id IN ({placeholders})", params, ) written += len(updates) if inserts: values = ", ".join("(%s, %s, %s, %s, 'DRAFT')" for _ in inserts) flat: list[Any] = [] for item in inserts: flat.extend(item) await cursor.execute( "INSERT INTO cross_sections (project_id, route_id, chainage_m, data, status) " f"VALUES {values}", flat, ) written += len(inserts) return written