feat: add as-of history read and write paths
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user