diff --git a/backend/app/main.py b/backend/app/main.py index 95c81c6..b786a5d 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -64,6 +64,7 @@ _fixed_office_cache: dict[str, dict[str, object]] = {} AUTH_DEFAULT_PASSWORD = "1111" AUTH_PASSWORD_ITERATIONS = 390000 AUTH_SESSION_HOURS = 12 +APP_TIMEZONE = timezone(timedelta(hours=9)) PAYMENT_HEADER_ORDER = [ "상신회사", "청구일", "발행일", "발행월", "계정코드", "관리계정코드", "각사 계정명", "프로젝트코드", "사업명", "사업명(표출PJT)", "사업명(인트라넷기준)", "사업분야", "세부분야", "기획/개발/영업", @@ -423,6 +424,238 @@ def fetch_members() -> list[dict[str, object]]: return cur.fetchall() +def parse_as_of(as_of: str | None) -> datetime | None: + raw = str(as_of or "").strip() + if not raw: + return None + try: + if "T" in raw: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=APP_TIMEZONE) + return parsed + parsed_date = date.fromisoformat(raw) + return datetime.combine(parsed_date, time.max, tzinfo=APP_TIMEZONE) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid as_of format. Use YYYY-MM-DD or ISO datetime.") from exc + + +def create_history_revision(cur, label_prefix: str, note: str) -> int: + cur.execute( + """ + INSERT INTO history_revisions (scope, revision_label, note) + VALUES ('organization', %s, %s) + RETURNING id + """, + (f"{label_prefix}-{datetime.now(APP_TIMEZONE).strftime('%Y%m%d-%H%M%S-%f')}", note), + ) + return int(cur.fetchone()["id"]) + + +def fetch_current_member_state(cur) -> dict[int, dict[str, object]]: + cur.execute( + """ + 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 + """ + ) + return {int(row["id"]): row for row in cur.fetchall()} + + +def sync_member_versions(cur, member_ids: list[int], change_reason: str, revision_no: int) -> None: + if not member_ids: + return + unique_ids = sorted(set(int(member_id) for member_id in member_ids)) + current_members = fetch_current_member_state(cur) + cur.execute( + """ + SELECT id, member_id, name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, photo_url, valid_from, valid_to + FROM member_versions + WHERE member_id = ANY(%s) + AND valid_to IS NULL + """, + (unique_ids,), + ) + active_versions = {int(row["member_id"]): row for row in cur.fetchall()} + + for member_id in unique_ids: + current = current_members.get(member_id) + active = active_versions.get(member_id) + if current is None: + if active is not None: + cur.execute( + "UPDATE member_versions SET valid_to = NOW() WHERE id = %s AND valid_to IS NULL", + (int(active["id"]),), + ) + continue + + current_tuple = ( + str(current.get("name") or ""), + str(current.get("company") or ""), + str(current.get("rank") or ""), + str(current.get("role") or ""), + str(current.get("department") or ""), + str(current.get("grp") or ""), + str(current.get("division") or ""), + str(current.get("team") or ""), + str(current.get("cell") or ""), + str(current.get("work_status") or ""), + str(current.get("work_time") or ""), + str(current.get("phone") or ""), + str(current.get("email") or ""), + str(current.get("photo_url") or ""), + ) + active_tuple = None + if active is not None: + active_tuple = ( + str(active.get("name") or ""), + str(active.get("company") or ""), + str(active.get("rank") or ""), + str(active.get("role") or ""), + str(active.get("department") or ""), + str(active.get("grp") or ""), + str(active.get("division") or ""), + str(active.get("team") or ""), + str(active.get("cell") or ""), + str(active.get("work_status") or ""), + str(active.get("work_time") or ""), + str(active.get("phone") or ""), + str(active.get("email") or ""), + str(active.get("photo_url") or ""), + ) + if active_tuple == current_tuple: + continue + if active is not None: + cur.execute( + "UPDATE member_versions SET valid_to = NOW() WHERE id = %s AND valid_to IS NULL", + (int(active["id"]),), + ) + cur.execute( + """ + INSERT INTO member_versions ( + member_id, name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, photo_url, + valid_from, valid_to, revision_no, changed_by_user_id, change_reason + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NULL, %s, NULL, %s) + """, + (member_id, *current_tuple, revision_no, change_reason), + ) + + +def fetch_current_seat_assignments(cur) -> dict[int, dict[str, object]]: + cur.execute( + """ + SELECT member_id, seat_map_id, seat_slot_id, seat_label, updated_at + FROM seat_positions + """ + ) + return {int(row["member_id"]): row for row in cur.fetchall()} + + +def sync_seat_assignment_versions(cur, member_ids: list[int], change_reason: str, revision_no: int) -> None: + if not member_ids: + return + unique_ids = sorted(set(int(member_id) for member_id in member_ids)) + current_assignments = fetch_current_seat_assignments(cur) + cur.execute( + """ + SELECT id, member_id, seat_map_id, seat_slot_id, seat_label + FROM seat_assignment_versions + WHERE member_id = ANY(%s) + AND valid_to IS NULL + """, + (unique_ids,), + ) + active_versions = {int(row["member_id"]): row for row in cur.fetchall()} + + for member_id in unique_ids: + current = current_assignments.get(member_id) + active = active_versions.get(member_id) + current_tuple = None + if current is not None: + current_tuple = ( + current.get("seat_map_id"), + current.get("seat_slot_id"), + str(current.get("seat_label") or ""), + ) + active_tuple = None + if active is not None: + active_tuple = ( + active.get("seat_map_id"), + active.get("seat_slot_id"), + str(active.get("seat_label") or ""), + ) + if active_tuple == current_tuple: + continue + if active is not None: + cur.execute( + "UPDATE seat_assignment_versions SET valid_to = NOW() WHERE id = %s AND valid_to IS NULL", + (int(active["id"]),), + ) + if current is None: + continue + cur.execute( + """ + INSERT INTO seat_assignment_versions ( + member_id, seat_map_id, seat_slot_id, seat_label, + valid_from, valid_to, revision_no, changed_by_user_id, change_reason + ) + VALUES (%s, %s, %s, %s, NOW(), NULL, %s, NULL, %s) + """, + ( + member_id, + current.get("seat_map_id"), + current.get("seat_slot_id"), + str(current.get("seat_label") or ""), + revision_no, + change_reason, + ), + ) + + +def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]: + cur.execute( + """ + SELECT m.id, + mv.name, + m.employee_id, + mv.company, + mv.rank, + mv.role, + mv.department, + mv.grp, + mv.division, + mv.team, + mv.cell, + mv.work_status, + mv.work_time, + mv.phone, + mv.email, + COALESCE(sav.seat_label, '') AS seat_label, + mv.photo_url, + m.sort_order, + mv.created_at, + mv.valid_from AS updated_at + FROM members m + JOIN member_versions mv + ON mv.member_id = m.id + AND mv.valid_from <= %s + AND (mv.valid_to IS NULL OR mv.valid_to > %s) + LEFT JOIN seat_assignment_versions sav + ON sav.member_id = m.id + AND sav.valid_from <= %s + AND (sav.valid_to IS NULL OR sav.valid_to > %s) + ORDER BY m.sort_order ASC, m.id ASC + """, + (as_of, as_of, as_of, as_of), + ) + return cur.fetchall() + + def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]: return ( payload.name.strip(), @@ -1165,23 +1398,26 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str, return metadata, slots -def fetch_seat_layout(seat_map_id: int) -> dict[str, object]: +def fetch_seat_layout(seat_map_id: int, as_of: datetime | None = None) -> 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() + if as_of is None: + 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() + else: + members = fetch_members_as_of(cur, as_of) cur.execute( """ SELECT id, slot_key, label, x, y, rotation, layer_name @@ -1192,21 +1428,44 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]: (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() + if as_of is None: + 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() + else: + cur.execute( + """ + SELECT sav.member_id, 0 AS row_index, 0 AS col_index, sav.seat_label, + sav.seat_slot_id, + mv.name, mv.company, mv.rank, mv.role, mv.department, mv.grp, mv.division, + mv.team, mv.cell, mv.work_status, mv.work_time, mv.phone, mv.email, + mv.photo_url, m.sort_order + FROM seat_assignment_versions sav + JOIN members m ON m.id = sav.member_id + JOIN member_versions mv + ON mv.member_id = sav.member_id + AND mv.valid_from <= %s + AND (mv.valid_to IS NULL OR mv.valid_to > %s) + WHERE sav.seat_map_id = %s + AND sav.valid_from <= %s + AND (sav.valid_to IS NULL OR sav.valid_to > %s) + ORDER BY m.sort_order ASC, m.id ASC + """, + (as_of, as_of, seat_map_id, as_of, as_of), + ) + placements = cur.fetchall() viewer_data: dict[str, object] | None = None office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY) fixed_office = FIXED_OFFICE_CONFIGS.get(office_key) @@ -1680,6 +1939,8 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[ with get_conn() as conn: with conn.cursor() as cur: + cur.execute("SELECT DISTINCT member_id FROM seat_positions WHERE seat_map_id = %s", (seat_map_id,)) + previous_member_ids = [int(row["member_id"]) for row in cur.fetchall()] if member_ids: cur.execute("SELECT id FROM members WHERE id = ANY(%s)", (member_ids,)) existing_ids = {int(row["id"]) for row in cur.fetchall()} @@ -1758,6 +2019,11 @@ def save_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> list[dict[ WHERE sp.member_id = m.id """ ) + affected_member_ids = sorted(set(previous_member_ids + member_ids)) + if affected_member_ids: + revision_no = create_history_revision(cur, "seat-layout", f"Seat layout saved for seat_map_id={seat_map_id}") + sync_seat_assignment_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no) + sync_member_versions(cur, affected_member_ids, f"seat-layout:{seat_map_id}", revision_no) conn.commit() return fetch_seat_layout(seat_map_id)["placements"] @@ -1879,6 +2145,13 @@ def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: if stale_ids: cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,)) sync_auth_users_from_members(cur) + cur.execute("SELECT id FROM members") + current_ids = [int(row["id"]) for row in cur.fetchall()] + affected_member_ids = sorted(set(current_ids + [int(member["id"]) for member in existing_members])) + if affected_member_ids: + revision_no = create_history_revision(cur, "members-bulk-sync", "Bulk member sync applied") + sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no) + sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no) conn.commit() return fetch_members() @@ -3677,8 +3950,13 @@ def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str @app.get("/api/members") -def list_members() -> dict[str, list[dict[str, object]]]: - return {"items": fetch_members()} +def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]: + parsed_as_of = parse_as_of(as_of) + if parsed_as_of is None: + return {"items": fetch_members()} + with get_conn() as conn: + with conn.cursor() as cur: + return {"items": fetch_members_as_of(cur, parsed_as_of)} @app.post("/api/members") @@ -3702,6 +3980,8 @@ def create_member(payload: MemberPayload) -> dict[str, object]: ) member = cur.fetchone() sync_auth_users_from_members(cur) + revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}") + sync_member_versions(cur, [int(member["id"])], "member-create", revision_no) conn.commit() return {"item": member} @@ -3747,6 +4027,9 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]: if member is None: raise HTTPException(status_code=404, detail="Member not found.") sync_auth_users_from_members(cur) + revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}") + sync_member_versions(cur, [member_id], "member-update", revision_no) + sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no) conn.commit() return {"item": member} @@ -3759,6 +4042,9 @@ def delete_member(member_id: int) -> dict[str, bool]: deleted = cur.rowcount > 0 if deleted: sync_auth_users_from_members(cur) + revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}") + sync_member_versions(cur, [member_id], "member-delete", revision_no) + sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no) conn.commit() if not deleted: raise HTTPException(status_code=404, detail="Member not found.") @@ -4017,8 +4303,8 @@ def update_seat_map(seat_map_id: int, payload: SeatMapPayload) -> dict[str, dict @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) +def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]: + return fetch_seat_layout(seat_map_id, parse_as_of(as_of)) @app.get("/api/seat-maps/{seat_map_id}/viewer")