feat: add as-of history read and write paths

This commit is contained in:
hyunho
2026-03-30 09:26:04 +09:00
parent cbae8769bf
commit 6e55b99e9a

View File

@@ -64,6 +64,7 @@ _fixed_office_cache: dict[str, dict[str, object]] = {}
AUTH_DEFAULT_PASSWORD = "1111" AUTH_DEFAULT_PASSWORD = "1111"
AUTH_PASSWORD_ITERATIONS = 390000 AUTH_PASSWORD_ITERATIONS = 390000
AUTH_SESSION_HOURS = 12 AUTH_SESSION_HOURS = 12
APP_TIMEZONE = timezone(timedelta(hours=9))
PAYMENT_HEADER_ORDER = [ PAYMENT_HEADER_ORDER = [
"상신회사", "청구일", "발행일", "발행월", "계정코드", "관리계정코드", "각사 계정명", "프로젝트코드", "상신회사", "청구일", "발행일", "발행월", "계정코드", "관리계정코드", "각사 계정명", "프로젝트코드",
"사업명", "사업명(표출PJT)", "사업명(인트라넷기준)", "사업분야", "세부분야", "기획/개발/영업", "사업명", "사업명(표출PJT)", "사업명(인트라넷기준)", "사업분야", "세부분야", "기획/개발/영업",
@@ -423,6 +424,238 @@ def fetch_members() -> list[dict[str, object]]:
return cur.fetchall() 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, ...]: def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]:
return ( return (
payload.name.strip(), payload.name.strip(),
@@ -1165,13 +1398,14 @@ def parse_dxf_layout(file_path: Path) -> tuple[dict[str, object], list[dict[str,
return metadata, slots 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) seat_map = fetch_seat_map(seat_map_id)
if seat_map is None: if seat_map is None:
raise HTTPException(status_code=404, detail="Seat map not found.") raise HTTPException(status_code=404, detail="Seat map not found.")
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
if as_of is None:
cur.execute( cur.execute(
""" """
SELECT m.id, m.name, m.company, m.rank, m.role, m.department, m.grp, m.division, SELECT m.id, m.name, m.company, m.rank, m.role, m.department, m.grp, m.division,
@@ -1182,6 +1416,8 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
""" """
) )
members = cur.fetchall() members = cur.fetchall()
else:
members = fetch_members_as_of(cur, as_of)
cur.execute( cur.execute(
""" """
SELECT id, slot_key, label, x, y, rotation, layer_name SELECT id, slot_key, label, x, y, rotation, layer_name
@@ -1192,6 +1428,7 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
(seat_map_id,), (seat_map_id,),
) )
slots = cur.fetchall() slots = cur.fetchall()
if as_of is None:
cur.execute( cur.execute(
""" """
SELECT sp.member_id, sp.row_index, sp.col_index, sp.seat_label, SELECT sp.member_id, sp.row_index, sp.col_index, sp.seat_label,
@@ -1207,6 +1444,28 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]:
(seat_map_id,), (seat_map_id,),
) )
placements = cur.fetchall() 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 viewer_data: dict[str, object] | None = None
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY) office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
fixed_office = FIXED_OFFICE_CONFIGS.get(office_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 get_conn() as conn:
with conn.cursor() as cur: 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: if member_ids:
cur.execute("SELECT id FROM members WHERE id = ANY(%s)", (member_ids,)) cur.execute("SELECT id FROM members WHERE id = ANY(%s)", (member_ids,))
existing_ids = {int(row["id"]) for row in cur.fetchall()} 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 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() conn.commit()
return fetch_seat_layout(seat_map_id)["placements"] 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: if stale_ids:
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,)) cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
sync_auth_users_from_members(cur) 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() conn.commit()
return fetch_members() return fetch_members()
@@ -3677,8 +3950,13 @@ def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str
@app.get("/api/members") @app.get("/api/members")
def list_members() -> dict[str, list[dict[str, object]]]: 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()} 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") @app.post("/api/members")
@@ -3702,6 +3980,8 @@ def create_member(payload: MemberPayload) -> dict[str, object]:
) )
member = cur.fetchone() member = cur.fetchone()
sync_auth_users_from_members(cur) 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() conn.commit()
return {"item": member} return {"item": member}
@@ -3747,6 +4027,9 @@ def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]:
if member is None: if member is None:
raise HTTPException(status_code=404, detail="Member not found.") raise HTTPException(status_code=404, detail="Member not found.")
sync_auth_users_from_members(cur) 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() conn.commit()
return {"item": member} return {"item": member}
@@ -3759,6 +4042,9 @@ def delete_member(member_id: int) -> dict[str, bool]:
deleted = cur.rowcount > 0 deleted = cur.rowcount > 0
if deleted: if deleted:
sync_auth_users_from_members(cur) 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() conn.commit()
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Member not found.") 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") @app.get("/api/seat-maps/{seat_map_id}/layout")
def get_seat_layout(seat_map_id: int) -> dict[str, object]: def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]:
return fetch_seat_layout(seat_map_id) return fetch_seat_layout(seat_map_id, parse_as_of(as_of))
@app.get("/api/seat-maps/{seat_map_id}/viewer") @app.get("/api/seat-maps/{seat_map_id}/viewer")