사번 필드 및 상세 정보 반영

This commit is contained in:
hyunho
2026-03-25 17:34:37 +09:00
parent 485a581089
commit 8ac6aa6b72
3 changed files with 347 additions and 71 deletions

View File

@@ -37,6 +37,7 @@ LEGACY_STATIC_DIR = LEGACY_DIR / "static"
class MemberPayload(BaseModel):
id: int | None = None
name: str = Field(min_length=1)
employee_id: str = ""
company: str = ""
rank: str = ""
role: str = ""
@@ -91,6 +92,8 @@ class SeatLayoutPayload(BaseModel):
LEGACY_HEADER_MAP = {
"이름": "name",
"name": "name",
"tag": "employee_id",
"employee_id": "employee_id",
"소속회사": "company",
"co": "company",
"company": "company",
@@ -131,6 +134,7 @@ LEGACY_HEADER_MAP = {
def serialize_member_payload(item: MemberPayload, sort_order: int) -> tuple[object, ...]:
return (
item.name.strip(),
item.employee_id.strip(),
item.company.strip(),
item.rank.strip(),
item.role.strip(),
@@ -154,7 +158,7 @@ def fetch_members() -> list[dict[str, object]]:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, company, rank, role, department, grp, division, team, cell,
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
FROM members
@@ -235,6 +239,48 @@ def compute_slot_label(index: int) -> str:
return f"CHAIR-{index + 1:03d}"
def is_chair_layer(layer_name: str) -> bool:
raw = layer_name.strip().lower()
compact = raw.replace("-", "").replace("_", "").replace(" ", "")
return raw in {"chair", "_chair", "-chair"} or compact.endswith("chair")
def inspect_dxf_header(file_path: Path) -> tuple[str, str]:
with file_path.open("rb") as source:
header = source.read(128)
header_text = header.decode("latin-1", errors="ignore").replace("\x00", "")
preview = header[:32].hex(" ")
if header_text.startswith("AutoCAD Binary DXF"):
return ("binary_dxf", preview)
if header_text.startswith("0\nSECTION") or header_text.startswith("0\r\nSECTION"):
return ("ascii_dxf", preview)
if header.startswith(b"AC10"):
return ("dwg_or_dwg_like", preview)
return ("unknown", preview)
def iter_render_entities(entity: ezdxf.entities.DXFGraphic, inherited_layer: str | None = None, depth: int = 0) -> list[ezdxf.entities.DXFGraphic]:
if depth > 6:
return []
entity_type = entity.dxftype()
current_layer = inherited_layer or entity.dxf.layer
if entity_type == "INSERT":
expanded: list[ezdxf.entities.DXFGraphic] = []
try:
for child in entity.virtual_entities():
child_layer = child.dxf.layer
if child_layer == "0":
child.dxf.layer = current_layer
expanded.extend(iter_render_entities(child, inherited_layer=current_layer, depth=depth + 1))
except Exception:
return []
return expanded
if inherited_layer and entity.dxf.layer == "0":
entity.dxf.layer = inherited_layer
return [entity]
def get_entity_points(entity: ezdxf.entities.DXFGraphic) -> list[tuple[float, float]]:
entity_type = entity.dxftype()
if entity_type == "LINE":
@@ -263,6 +309,24 @@ def get_entity_points(entity: ezdxf.entities.DXFGraphic) -> list[tuple[float, fl
if entity_type == "POINT":
location = entity.dxf.location
return [(float(location.x), float(location.y))]
if entity_type == "SPLINE":
try:
return [(float(point[0]), float(point[1])) for point in entity.flattening(2)]
except Exception:
return []
if entity_type == "ELLIPSE":
try:
return [(float(point[0]), float(point[1])) for point in entity.flattening(2)]
except Exception:
center = entity.dxf.center
major_axis = entity.dxf.major_axis
ratio = float(entity.dxf.ratio)
radius_x = math.hypot(float(major_axis.x), float(major_axis.y))
radius_y = radius_x * ratio
return [
(float(center.x - radius_x), float(center.y - radius_y)),
(float(center.x + radius_x), float(center.y + radius_y)),
]
if entity_type == "INSERT":
insert = entity.dxf.insert
return [(float(insert.x), float(insert.y))]
@@ -280,23 +344,84 @@ def get_entity_center(entity: ezdxf.entities.DXFGraphic) -> tuple[float, float]
return ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
def line_svg(points: list[tuple[float, float]]) -> str:
def get_entity_bounds(entity: ezdxf.entities.DXFGraphic) -> tuple[float, float, 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, min_y, max_x, max_y)
def compute_bounds_from_points(points: list[tuple[float, float]]) -> tuple[float, float, float, float]:
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, min_y, max(max_x - min_x, 1.0), max(max_y - min_y, 1.0))
def percentile(values: list[float], ratio: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, round((len(ordered) - 1) * ratio)))
return float(ordered[index])
def compute_focus_bounds(slot_points: list[tuple[float, float]]) -> tuple[float, float, float, float]:
x_values = [point[0] for point in slot_points]
y_values = [point[1] for point in slot_points]
min_x = percentile(x_values, 0.02)
max_x = percentile(x_values, 0.98)
min_y = percentile(y_values, 0.02)
max_y = percentile(y_values, 0.98)
width = max(max_x - min_x, 1.0)
height = max(max_y - min_y, 1.0)
pad_x = max(width * 0.08, 500.0)
pad_y = max(height * 0.08, 500.0)
return (min_x - pad_x, min_y - pad_y, max_x + pad_x, max_y + pad_y)
def bounds_intersect(bounds: tuple[float, float, float, float], focus_bounds: tuple[float, float, float, float]) -> bool:
min_x, min_y, max_x, max_y = bounds
focus_min_x, focus_min_y, focus_max_x, focus_max_y = focus_bounds
return not (
max_x < focus_min_x
or min_x > focus_max_x
or max_y < focus_min_y
or min_y > focus_max_y
)
def line_svg(points: list[tuple[float, float]], css_class: str = "seatmap-dxf-entity") -> str:
if len(points) < 2:
return ""
coordinates = " ".join(f"{x:.2f},{-y:.2f}" for x, y in points)
return f'<polyline points="{coordinates}" fill="none" stroke="#94a3b8" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" />'
def circle_svg(center_x: float, center_y: float, radius: float, stroke: str = "#cbd5e1", fill: str = "none") -> str:
return (
f'<circle cx="{center_x:.2f}" cy="{-center_y:.2f}" r="{radius:.2f}" '
f'stroke="{stroke}" stroke-width="1.1" fill="{fill}" />'
f'<polyline class="{css_class}" points="{coordinates}" fill="none" '
'stroke-linejoin="round" stroke-linecap="round" />'
)
def circle_svg(
center_x: float,
center_y: float,
radius: float,
stroke: str = "#475569",
fill: str = "none",
css_class: str = "seatmap-dxf-entity",
) -> str:
return (
f'<circle class="{css_class}" cx="{center_x:.2f}" cy="{-center_y:.2f}" r="{radius:.2f}" '
f'stroke="{stroke}" fill="{fill}" />'
)
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
@@ -304,17 +429,25 @@ def build_dxf_preview_svg(
svg_parts: list[str] = []
for entity in entities:
layer_name = entity.dxf.layer.lower()
if layer_name == "chair":
continue
layer_name = entity.dxf.layer
is_chair = is_chair_layer(layer_name)
css_class = "seatmap-dxf-chair-entity" if is_chair else "seatmap-dxf-entity"
entity_type = entity.dxftype()
if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE"}:
svg = line_svg(get_entity_points(entity))
if entity_type in {"LINE", "LWPOLYLINE", "POLYLINE", "SPLINE", "ELLIPSE"}:
svg = line_svg(get_entity_points(entity), css_class=css_class)
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)))
svg_parts.append(
circle_svg(
float(center.x),
float(center.y),
float(entity.dxf.radius),
fill="none",
css_class=css_class,
)
)
elif entity_type == "ARC":
center = entity.dxf.center
radius = float(entity.dxf.radius)
@@ -326,25 +459,14 @@ def build_dxf_preview_svg(
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'<path d="M {start_x:.2f} {-start_y:.2f} A {radius:.2f} {radius:.2f} 0 {large_arc} 0 {end_x:.2f} {-end_y:.2f}" '
'fill="none" stroke="#94a3b8" stroke-width="1.2" />'
f'<path class="{css_class}" d="M {start_x:.2f} {-start_y:.2f} '
f'A {radius:.2f} {radius:.2f} 0 {large_arc} 0 {end_x:.2f} {-end_y:.2f}" fill="none" />'
)
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'<svg class="seatmap-preview-svg" viewBox="{view_box}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">'
'<rect width="100%" height="100%" fill="#f8fafc" />'
'<rect width="100%" height="100%" fill="#ffffff" />'
+ "".join(svg_parts)
+ "</svg>"
)
@@ -357,42 +479,44 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
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:
kind, preview = inspect_dxf_header(file_path)
if kind == "binary_dxf":
raise HTTPException(
status_code=400,
detail="DXF 파일을 해석하지 못했습니다. binary DXF 또는 손상된 DXF일 수 있습니다. 가능하면 ASCII DXF로 다시 저장해 업로드하세요.",
detail=f"Binary DXF로 보이지만 해석에 실패했습니다. 가능하면 ASCII DXF로 다시 저장해 업로드하세요. 헤더={preview}",
) from exc
if kind == "dwg_or_dwg_like":
raise HTTPException(
status_code=400,
detail=f"업로드한 파일은 DWG 계열 헤더(AC10xx)로 보입니다. DWG가 아니라 ASCII DXF로 다시 저장해 업로드하세요. 헤더={preview}",
) from exc
if kind == "ascii_dxf":
raise HTTPException(
status_code=400,
detail=f"ASCII DXF로 보이지만 구조를 해석하지 못했습니다. 도면을 다른 DXF 버전으로 다시 저장해보세요. 헤더={preview}",
) from exc
raise HTTPException(
status_code=400,
detail="업로드한 파일이 DXF 형식으로 읽히지 않습니다. DWG 파일이거나 확장자만 dxf로 바뀐 파일일 수 있습니다.",
detail=f"업로드한 파일 형식을 판별하지 못했습니다. 확장자만 dxf 파일일 수 있습니다. 헤더={preview}",
) 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]] = []
base_entities = [entity for entity in modelspace if entity.dxftype() in {"LINE", "LWPOLYLINE", "POLYLINE", "CIRCLE", "ARC", "INSERT", "SPLINE", "ELLIPSE"}]
all_entities: list[ezdxf.entities.DXFGraphic] = []
for entity in base_entities:
all_entities.extend(iter_render_entities(entity))
chair_entities: list[ezdxf.entities.DXFGraphic] = []
chair_points: list[tuple[float, float]] = []
for entity in all_entities:
entity_points = get_entity_points(entity)
if entity_points:
points.extend(entity_points)
if entity.dxf.layer.lower() == "chair":
if is_chair_layer(entity.dxf.layer):
chair_entities.append(entity)
chair_points.extend(get_entity_points(entity))
if not chair_entities:
raise HTTPException(status_code=400, detail="DXF 파일에서 chair 레이어를 찾지 못했습니다.")
raise HTTPException(status_code=400, detail="DXF 파일에서 chair 계열 레이어를 찾지 못했습니다.")
if not points:
if not chair_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)
@@ -412,7 +536,29 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
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))
slot_points = [(float(slot["x"]), float(slot["y"])) for slot in slots]
focus_bounds = compute_focus_bounds(slot_points)
visible_entities: list[ezdxf.entities.DXFGraphic] = []
visible_points: list[tuple[float, float]] = []
for entity in all_entities:
entity_bounds = get_entity_bounds(entity)
if entity_bounds is None:
continue
if bounds_intersect(entity_bounds, focus_bounds):
visible_entities.append(entity)
visible_points.extend(get_entity_points(entity))
if not visible_entities or not visible_points:
visible_entities = all_entities
visible_points = chair_points
focus_min_x, focus_min_y, focus_max_x, focus_max_y = focus_bounds
min_x = focus_min_x
min_y = focus_min_y
width = max(focus_max_x - focus_min_x, 1.0)
height = max(focus_max_y - focus_min_y, 1.0)
preview_svg = build_dxf_preview_svg(visible_entities, (min_x, min_y, width, height))
metadata = {
"source_type": "dxf",
"view_box_min_x": round(min_x, 3),
@@ -421,7 +567,7 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
"view_box_height": round(height, 3),
"preview_svg": preview_svg,
"grid_rows": 1,
"grid_cols": max(len(slots), 1),
"grid_cols": 1,
"image_width": None,
"image_height": None,
"cell_gap": 0,
@@ -581,10 +727,10 @@ def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]:
cur.execute(
"""
INSERT INTO members (
name, company, rank, role, department, grp, division, team, cell,
name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
serialize_member_payload(item, index),
)
@@ -702,11 +848,11 @@ def create_member(payload: MemberPayload) -> dict[str, object]:
cur.execute(
"""
INSERT INTO members (
name, company, rank, role, department, grp, division, team, cell,
name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, company, rank, role, department, grp, division, team, cell,
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
""",
@@ -730,6 +876,7 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]:
"""
UPDATE members
SET name = %s,
employee_id = %s,
company = %s,
rank = %s,
role = %s,
@@ -747,7 +894,7 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]:
sort_order = COALESCE(%s, sort_order),
updated_at = NOW()
WHERE id = %s
RETURNING id, name, company, rank, role, department, grp, division, team, cell,
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
""",
@@ -821,8 +968,6 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...
try:
metadata, slots = parse_dxf_layout(target)
except Exception:
if target.exists():
target.unlink(missing_ok=True)
raise
payload = SeatMapPayload(
@@ -838,7 +983,7 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...
image_width=None,
image_height=None,
grid_rows=1,
grid_cols=max(len(slots), 1),
grid_cols=1,
cell_gap=0,
is_active=True,
)