diff --git a/backend/app/main.py b/backend/app/main.py index 99d34a0..abd15cb 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,10 +4,12 @@ from datetime import datetime from pathlib import Path import csv from io import BytesIO, StringIO -import re +import math import shutil import uuid +import ezdxf +from ezdxf import recover from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -15,7 +17,7 @@ from fastapi.staticfiles import StaticFiles from openpyxl import load_workbook from pydantic import BaseModel, Field -from .config import LEGACY_DIR, MOCK_LOGIN_ENABLED, SNAPSHOT_DIR, UPLOAD_DIR +from .config import LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR from .db import get_conn, init_db @@ -56,6 +58,36 @@ class MemberBulkPayload(BaseModel): items: list[MemberPayload] +class SeatMapPayload(BaseModel): + name: str = Field(min_length=1) + image_url: str = "" + source_type: str = "image" + source_url: str = "" + preview_svg: str = "" + view_box_min_x: float | None = None + view_box_min_y: float | None = None + view_box_width: float | None = None + view_box_height: float | None = None + image_width: int | None = None + image_height: int | None = None + grid_rows: int = Field(default=1, ge=1, le=200) + grid_cols: int = Field(default=1, ge=1, le=200) + cell_gap: int = Field(default=0, ge=0, le=24) + is_active: bool = True + + +class SeatPlacementPayload(BaseModel): + member_id: int + seat_slot_id: int | None = None + row_index: int = Field(default=0, ge=0) + col_index: int = Field(default=0, ge=0) + seat_label: str = "" + + +class SeatLayoutPayload(BaseModel): + placements: list[SeatPlacementPayload] + + LEGACY_HEADER_MAP = { "이름": "name", "name": "name", @@ -132,6 +164,408 @@ def fetch_members() -> list[dict[str, object]]: return cur.fetchall() +def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]: + return ( + payload.name.strip(), + payload.source_type.strip() or "image", + payload.source_url.strip(), + payload.preview_svg, + payload.view_box_min_x, + payload.view_box_min_y, + payload.view_box_width, + payload.view_box_height, + payload.image_url.strip(), + payload.image_width, + payload.image_height, + payload.grid_rows, + payload.grid_cols, + payload.cell_gap, + payload.is_active, + ) + + +def fetch_seat_map(seat_map_id: int) -> dict[str, object] | None: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + FROM seat_maps + WHERE id = %s + """, + (seat_map_id,), + ) + return cur.fetchone() + + +def fetch_active_seat_map() -> dict[str, object] | None: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + FROM seat_maps + WHERE is_active = TRUE + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """ + ) + return cur.fetchone() + + +def compute_seat_label(row_index: int, col_index: int) -> str: + quotient = row_index + row_label = "" + while True: + quotient, remainder = divmod(quotient, 26) + row_label = chr(65 + remainder) + row_label + if quotient == 0: + break + quotient -= 1 + return f"{row_label}-{col_index + 1:02d}" + + +def compute_slot_label(index: int) -> str: + return f"CHAIR-{index + 1:03d}" + + +def get_entity_points(entity: ezdxf.entities.DXFGraphic) -> list[tuple[float, float]]: + entity_type = entity.dxftype() + if entity_type == "LINE": + return [ + (float(entity.dxf.start.x), float(entity.dxf.start.y)), + (float(entity.dxf.end.x), float(entity.dxf.end.y)), + ] + if entity_type == "LWPOLYLINE": + return [(float(point[0]), float(point[1])) for point in entity.get_points("xy")] + if entity_type == "POLYLINE": + return [(float(vertex.dxf.location.x), float(vertex.dxf.location.y)) for vertex in entity.vertices] + if entity_type == "CIRCLE": + center = entity.dxf.center + radius = float(entity.dxf.radius) + return [ + (float(center.x - radius), float(center.y - radius)), + (float(center.x + radius), float(center.y + radius)), + ] + if entity_type == "ARC": + center = entity.dxf.center + radius = float(entity.dxf.radius) + return [ + (float(center.x - radius), float(center.y - radius)), + (float(center.x + radius), float(center.y + radius)), + ] + if entity_type == "POINT": + location = entity.dxf.location + return [(float(location.x), float(location.y))] + if entity_type == "INSERT": + insert = entity.dxf.insert + return [(float(insert.x), float(insert.y))] + return [] + + +def get_entity_center(entity: ezdxf.entities.DXFGraphic) -> tuple[float, float] | None: + points = get_entity_points(entity) + if not points: + return None + min_x = min(point[0] for point in points) + max_x = max(point[0] for point in points) + min_y = min(point[1] for point in points) + max_y = max(point[1] for point in points) + return ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + + +def line_svg(points: list[tuple[float, float]]) -> str: + if len(points) < 2: + return "" + coordinates = " ".join(f"{x:.2f},{-y:.2f}" for x, y in points) + return f'' + + +def circle_svg(center_x: float, center_y: float, radius: float, stroke: str = "#cbd5e1", fill: str = "none") -> str: + return ( + f'' + ) + + +def build_dxf_preview_svg( + entities: list[ezdxf.entities.DXFGraphic], + chair_slots: list[dict[str, object]], + bounds: tuple[float, float, float, float], +) -> str: + min_x, min_y, width, height = bounds + max_y = min_y + height + svg_parts: list[str] = [] + + for entity in entities: + layer_name = entity.dxf.layer.lower() + if layer_name == "chair": + continue + entity_type = entity.dxftype() + if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE"}: + svg = line_svg(get_entity_points(entity)) + if svg: + svg_parts.append(svg) + elif entity_type == "CIRCLE": + center = entity.dxf.center + svg_parts.append(circle_svg(float(center.x), float(center.y), float(entity.dxf.radius))) + elif entity_type == "ARC": + center = entity.dxf.center + radius = float(entity.dxf.radius) + start_angle = math.radians(float(entity.dxf.start_angle)) + end_angle = math.radians(float(entity.dxf.end_angle)) + start_x = float(center.x) + radius * math.cos(start_angle) + start_y = float(center.y) + radius * math.sin(start_angle) + end_x = float(center.x) + radius * math.cos(end_angle) + end_y = float(center.y) + radius * math.sin(end_angle) + large_arc = 1 if abs(float(entity.dxf.end_angle) - float(entity.dxf.start_angle)) > 180 else 0 + svg_parts.append( + f'' + ) + + for slot in chair_slots: + svg_parts.append( + circle_svg( + float(slot["x"]), + float(slot["y"]), + 10, + stroke="#0f766e", + fill="rgba(45, 212, 191, 0.22)", + ) + ) + + view_box = f"{min_x:.2f} {-max_y:.2f} {max(width, 1.0):.2f} {max(height, 1.0):.2f}" + return ( + f'' + '' + + "".join(svg_parts) + + "" + ) + + +def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, object]]]: + try: + document = ezdxf.readfile(file_path) + except OSError: + try: + document, _ = recover.readfile(file_path) + except Exception as exc: + with file_path.open("rb") as source: + header = source.read(64) + header_text = header.decode("latin-1", errors="ignore") + if header.startswith(b"AC10") or "AutoCAD Binary DXF" in header_text: + raise HTTPException( + status_code=400, + detail="DXF 파일을 해석하지 못했습니다. binary DXF 또는 손상된 DXF일 수 있습니다. 가능하면 ASCII DXF로 다시 저장해 업로드하세요.", + ) from exc + raise HTTPException( + status_code=400, + detail="업로드한 파일이 DXF 형식으로 읽히지 않습니다. DWG 파일이거나 확장자만 dxf로 바뀐 파일일 수 있습니다.", + ) from exc + modelspace = document.modelspace() + all_entities = [entity for entity in modelspace if entity.dxftype() in {"LINE", "LWPOLYLINE", "POLYLINE", "CIRCLE", "ARC", "POINT", "INSERT"}] + points: list[tuple[float, float]] = [] + chair_entities: list[ezdxf.entities.DXFGraphic] = [] + for entity in all_entities: + entity_points = get_entity_points(entity) + if entity_points: + points.extend(entity_points) + if entity.dxf.layer.lower() == "chair": + chair_entities.append(entity) + + if not chair_entities: + raise HTTPException(status_code=400, detail="DXF 파일에서 chair 레이어를 찾지 못했습니다.") + + if not points: + raise HTTPException(status_code=400, detail="DXF 좌표를 해석하지 못했습니다.") + + min_x = min(point[0] for point in points) + max_x = max(point[0] for point in points) + min_y = min(point[1] for point in points) + max_y = max(point[1] for point in points) + width = max(max_x - min_x, 1.0) + height = max(max_y - min_y, 1.0) + + slots: list[dict[str, object]] = [] + for index, entity in enumerate(sorted(chair_entities, key=lambda item: (-(get_entity_center(item) or (0.0, 0.0))[1], (get_entity_center(item) or (0.0, 0.0))[0]))): + center = get_entity_center(entity) + if center is None: + continue + slots.append( + { + "slot_key": entity.dxf.handle, + "label": compute_slot_label(index), + "x": round(float(center[0]), 3), + "y": round(float(center[1]), 3), + "rotation": float(getattr(entity.dxf, "rotation", 0.0) or 0.0), + "layer_name": entity.dxf.layer, + } + ) + + if not slots: + raise HTTPException(status_code=400, detail="chair 레이어에서 좌석 위치를 추출하지 못했습니다.") + + preview_svg = build_dxf_preview_svg(all_entities, slots, (min_x, min_y, width, height)) + metadata = { + "source_type": "dxf", + "view_box_min_x": round(min_x, 3), + "view_box_min_y": round(min_y, 3), + "view_box_width": round(width, 3), + "view_box_height": round(height, 3), + "preview_svg": preview_svg, + "grid_rows": 1, + "grid_cols": max(len(slots), 1), + "image_width": None, + "image_height": None, + "cell_gap": 0, + } + return metadata, slots + + +def fetch_seat_layout(seat_map_id: int) -> dict[str, object]: + seat_map = fetch_seat_map(seat_map_id) + if seat_map is None: + raise HTTPException(status_code=404, detail="Seat map not found.") + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.id, m.name, m.company, m.rank, m.role, m.department, m.grp, m.division, + m.team, m.cell, m.work_status, m.work_time, m.phone, m.email, + m.seat_label AS member_seat_label, m.photo_url, m.sort_order + FROM members m + ORDER BY m.sort_order ASC, m.id ASC + """ + ) + members = cur.fetchall() + cur.execute( + """ + SELECT id, slot_key, label, x, y, rotation, layer_name + FROM seat_slots + WHERE seat_map_id = %s + ORDER BY label ASC, id ASC + """, + (seat_map_id,), + ) + slots = cur.fetchall() + cur.execute( + """ + SELECT sp.member_id, sp.row_index, sp.col_index, sp.seat_label, + sp.seat_slot_id, + m.name, m.company, m.rank, m.role, m.department, m.grp, m.division, + m.team, m.cell, m.work_status, m.work_time, m.phone, m.email, + m.photo_url, m.sort_order + FROM seat_positions sp + JOIN members m ON m.id = sp.member_id + WHERE sp.seat_map_id = %s + ORDER BY sp.row_index ASC, sp.col_index ASC, m.sort_order ASC, m.id ASC + """, + (seat_map_id,), + ) + placements = cur.fetchall() + return { + "seat_map": seat_map, + "members": members, + "slots": slots, + "placements": placements, + } + + +def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[str, object]]: + seat_map = fetch_seat_map(seat_map_id) + if seat_map is None: + raise HTTPException(status_code=404, detail="Seat map not found.") + + member_ids: list[int] = [] + occupied_cells: set[tuple[int, int]] = set() + occupied_slots: set[int] = set() + is_dxf = seat_map["source_type"] == "dxf" + for item in payload.placements: + if is_dxf: + if item.seat_slot_id is None: + raise HTTPException(status_code=400, detail="DXF 자리배치도는 seat_slot_id가 필요합니다.") + if item.seat_slot_id in occupied_slots: + raise HTTPException(status_code=400, detail="같은 좌석에 둘 이상의 구성원을 배치할 수 없습니다.") + occupied_slots.add(item.seat_slot_id) + else: + if item.row_index >= int(seat_map["grid_rows"]) or item.col_index >= int(seat_map["grid_cols"]): + raise HTTPException(status_code=400, detail="좌표가 자리배치도 범위를 벗어났습니다.") + cell_key = (item.row_index, item.col_index) + if cell_key in occupied_cells: + raise HTTPException(status_code=400, detail="같은 칸에 둘 이상의 구성원을 배치할 수 없습니다.") + occupied_cells.add(cell_key) + member_ids.append(item.member_id) + + if len(member_ids) != len(set(member_ids)): + raise HTTPException(status_code=400, detail="같은 구성원을 중복 배치할 수 없습니다.") + + if member_ids: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("SELECT id FROM members WHERE id = ANY(%s)", (member_ids,)) + existing_ids = {int(row["id"]) for row in cur.fetchall()} + missing_ids = sorted(set(member_ids) - existing_ids) + if missing_ids: + raise HTTPException(status_code=400, detail=f"존재하지 않는 구성원 ID가 포함되어 있습니다: {missing_ids}") + + if is_dxf: + slot_ids = sorted(occupied_slots) + cur.execute("SELECT id FROM seat_slots WHERE seat_map_id = %s AND id = ANY(%s)", (seat_map_id, slot_ids)) + existing_slot_ids = {int(row["id"]) for row in cur.fetchall()} + missing_slot_ids = sorted(set(slot_ids) - existing_slot_ids) + if missing_slot_ids: + raise HTTPException(status_code=400, detail=f"존재하지 않는 좌석 슬롯 ID가 포함되어 있습니다: {missing_slot_ids}") + cur.execute("SELECT id, label FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,)) + slot_label_map = {int(row["id"]): row["label"] for row in cur.fetchall()} + else: + slot_label_map = {} + + cur.execute("DELETE FROM seat_positions WHERE seat_map_id = %s AND NOT (member_id = ANY(%s))", (seat_map_id, member_ids)) + for item in payload.placements: + seat_label = item.seat_label.strip() or ( + slot_label_map.get(int(item.seat_slot_id), f"SLOT-{item.seat_slot_id}") + if is_dxf and item.seat_slot_id is not None + else compute_seat_label(item.row_index, item.col_index) + ) + cur.execute( + """ + INSERT INTO seat_positions (member_id, seat_map_id, seat_slot_id, row_index, col_index, seat_label, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (member_id) DO UPDATE + SET seat_map_id = EXCLUDED.seat_map_id, + seat_slot_id = EXCLUDED.seat_slot_id, + row_index = EXCLUDED.row_index, + col_index = EXCLUDED.col_index, + seat_label = EXCLUDED.seat_label, + updated_at = NOW() + """, + ( + item.member_id, + seat_map_id, + item.seat_slot_id if is_dxf else None, + item.row_index, + item.col_index, + seat_label, + ), + ) + conn.commit() + else: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM seat_positions WHERE seat_map_id = %s", (seat_map_id,)) + conn.commit() + + return fetch_seat_layout(seat_map_id)["placements"] + + def get_member_count() -> int: with get_conn() as conn: with conn.cursor() as cur: @@ -139,13 +573,6 @@ def get_member_count() -> int: return int(cur.fetchone()["count"]) -def ensure_snapshot_month(snapshot_month: str) -> str: - value = snapshot_month.strip() - if not re.fullmatch(r"\d{4}-\d{2}", value): - raise HTTPException(status_code=400, detail="snapshot_month 형식은 YYYY-MM 이어야 합니다.") - return value - - def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: with get_conn() as conn: with conn.cursor() as cur: @@ -216,7 +643,6 @@ def parse_import_rows(file: UploadFile, content: bytes) -> list[MemberPayload]: @app.on_event("startup") def startup() -> None: UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) LEGACY_STATIC_DIR.mkdir(parents=True, exist_ok=True) init_db() @@ -228,7 +654,6 @@ app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=F def health() -> dict[str, object]: checks = { "upload_dir": UPLOAD_DIR.exists(), - "snapshot_dir": SNAPSHOT_DIR.exists(), } try: @@ -292,6 +717,11 @@ def create_member(payload: MemberPayload) -> dict[str, object]: return {"item": member} +@app.put("/api/members/bulk-sync") +def bulk_sync_members(payload: MemberBulkPayload) -> dict[str, list[dict[str, object]]]: + return {"items": replace_members(payload.items)} + + @app.put("/api/members/{member_id}") def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]: with get_conn() as conn: @@ -342,11 +772,6 @@ def delete_member(member_id: int) -> dict[str, bool]: return {"ok": True} -@app.put("/api/members/bulk-sync") -def bulk_sync_members(payload: MemberBulkPayload) -> dict[str, list[dict[str, object]]]: - return {"items": replace_members(payload.items)} - - @app.post("/api/members/import") async def import_members(file: UploadFile = File(...)) -> dict[str, list[dict[str, object]]]: content = await file.read() @@ -367,74 +792,192 @@ def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form(" return {"url": f"/uploads/{filename}"} -@app.post("/api/snapshots/monthly") -def create_monthly_snapshot(snapshot_month: str = Form(...)) -> dict[str, str]: - snapshot_month = ensure_snapshot_month(snapshot_month) - filename = f"organization-snapshot-{snapshot_month}.csv" - target = SNAPSHOT_DIR / filename +@app.post("/api/uploads/seat-map-image") +def upload_seat_map_image(file: UploadFile = File(...), seat_map_name: str = Form("")) -> dict[str, str]: + suffix = Path(file.filename or "").suffix.lower() + if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + raise HTTPException(status_code=400, detail="Only image files are allowed.") + stem = seat_map_name.strip().replace(" ", "-") or "seat-map" + filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}" + target = UPLOAD_DIR / filename + with target.open("wb") as out_file: + shutil.copyfileobj(file.file, out_file) + return {"url": f"/uploads/{filename}"} + + +@app.post("/api/seat-maps/dxf") +async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...)) -> dict[str, object]: + suffix = Path(file.filename or "").suffix.lower() + if suffix != ".dxf": + raise HTTPException(status_code=400, detail="DXF 파일만 업로드할 수 있습니다.") + + stem = name.strip().replace(" ", "-") or "seat-map" + filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}" + target = UPLOAD_DIR / filename + content = await file.read() + with target.open("wb") as out_file: + out_file.write(content) + + try: + metadata, slots = parse_dxf_layout(target) + except Exception: + if target.exists(): + target.unlink(missing_ok=True) + raise + + payload = SeatMapPayload( + name=name.strip(), + source_type="dxf", + source_url=f"/uploads/{filename}", + image_url="", + preview_svg=metadata["preview_svg"], + view_box_min_x=metadata["view_box_min_x"], + view_box_min_y=metadata["view_box_min_y"], + view_box_width=metadata["view_box_width"], + view_box_height=metadata["view_box_height"], + image_width=None, + image_height=None, + grid_rows=1, + grid_cols=max(len(slots), 1), + cell_gap=0, + is_active=True, + ) with get_conn() as conn: with conn.cursor() as cur: - cur.execute( - "SELECT id FROM snapshots WHERE snapshot_month = %s ORDER BY created_at DESC LIMIT 1", - (snapshot_month,), - ) - if cur.fetchone() is not None: - raise HTTPException(status_code=409, detail="해당 월의 스냅샷이 이미 존재합니다.") + cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") cur.execute( """ - SELECT name, company, rank, role, department, grp, division, team, cell, - work_status, work_time, phone, email, seat_label, photo_url, updated_at - FROM members - ORDER BY sort_order ASC, id ASC - """ - ) - rows = cur.fetchall() - - with target.open("w", newline="", encoding="utf-8-sig") as csv_file: - writer = csv.DictWriter( - csv_file, - fieldnames=[ - "name", - "company", - "rank", - "role", - "department", - "grp", - "division", - "team", - "cell", - "work_status", - "work_time", - "phone", - "email", - "seat_label", - "photo_url", - "updated_at", - ], - ) - writer.writeheader() - writer.writerows(rows) - - with conn.cursor() as cur: - cur.execute( - "INSERT INTO snapshots (snapshot_month, file_path) VALUES (%s, %s)", - (snapshot_month, str(target)), + INSERT INTO seat_maps ( + name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + """, + serialize_seat_map_payload(payload), ) + seat_map = cur.fetchone() + for slot in slots: + cur.execute( + """ + INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + seat_map["id"], + slot["slot_key"], + slot["label"], + slot["x"], + slot["y"], + slot["rotation"], + slot["layer_name"], + ), + ) conn.commit() - return {"file": f"/snapshots/{filename}"} + return fetch_seat_layout(int(seat_map["id"])) -@app.get("/api/snapshots") -def list_snapshots() -> dict[str, list[dict[str, object]]]: +@app.get("/api/seat-maps/active") +def get_active_seat_map() -> dict[str, dict[str, object]]: + seat_map = fetch_active_seat_map() + if seat_map is None: + raise HTTPException(status_code=404, detail="Active seat map not found.") + return {"item": seat_map} + + +@app.post("/api/seat-maps") +def create_seat_map(payload: SeatMapPayload) -> dict[str, dict[str, object]]: with get_conn() as conn: with conn.cursor() as cur: + if payload.is_active: + cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") cur.execute( - "SELECT id, snapshot_month, file_path, created_at FROM snapshots ORDER BY created_at DESC" + """ + INSERT INTO seat_maps ( + name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + """, + serialize_seat_map_payload(payload), ) - snapshots = cur.fetchall() - return {"items": snapshots} + seat_map = cur.fetchone() + conn.commit() + return {"item": seat_map} + + +@app.put("/api/seat-maps/{seat_map_id}") +def update_seat_map(seat_map_id: int, payload: SeatMapPayload) -> dict[str, dict[str, object]]: + with get_conn() as conn: + with conn.cursor() as cur: + if payload.source_type != "dxf": + cur.execute( + """ + SELECT COUNT(*) AS count + FROM seat_positions + WHERE seat_map_id = %s + AND (row_index >= %s OR col_index >= %s) + """, + (seat_map_id, payload.grid_rows, payload.grid_cols), + ) + out_of_bounds_count = int(cur.fetchone()["count"]) + if out_of_bounds_count > 0: + raise HTTPException(status_code=400, detail="현재 배치된 좌석이 새 그리드 범위를 벗어납니다. 먼저 좌석 배치를 정리하세요.") + if payload.is_active: + cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE AND id <> %s", (seat_map_id,)) + cur.execute( + """ + UPDATE seat_maps + SET name = %s, + source_type = %s, + source_url = %s, + preview_svg = %s, + view_box_min_x = %s, + view_box_min_y = %s, + view_box_width = %s, + view_box_height = %s, + image_url = %s, + image_width = %s, + image_height = %s, + grid_rows = %s, + grid_cols = %s, + cell_gap = %s, + is_active = %s, + updated_at = NOW() + WHERE id = %s + RETURNING id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + """, + (*serialize_seat_map_payload(payload), seat_map_id), + ) + seat_map = cur.fetchone() + if seat_map is None: + raise HTTPException(status_code=404, detail="Seat map not found.") + conn.commit() + return {"item": seat_map} + + +@app.get("/api/seat-maps/{seat_map_id}/layout") +def get_seat_layout(seat_map_id: int) -> dict[str, object]: + return fetch_seat_layout(seat_map_id) + + +@app.put("/api/seat-maps/{seat_map_id}/layout") +def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]: + return {"items": save_seat_layout(seat_map_id, payload)} @app.get("/legacy/organization") @@ -459,11 +1002,3 @@ def get_upload(filename: str) -> FileResponse: if not target.exists(): raise HTTPException(status_code=404, detail="Upload not found.") return FileResponse(target) - - -@app.get("/snapshots/{filename}") -def get_snapshot(filename: str) -> FileResponse: - target = SNAPSHOT_DIR / filename - if not target.exists(): - raise HTTPException(status_code=404, detail="Snapshot not found.") - return FileResponse(target, media_type="text/csv")