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'"
+ )
+
+
+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")