feat: add member history list snapshot and compare views

This commit is contained in:
hyunho
2026-03-30 09:46:48 +09:00
parent b735a4cdd1
commit 33f157cb08
3 changed files with 481 additions and 47 deletions

View File

@@ -620,9 +620,9 @@ def sync_seat_assignment_versions(cur, member_ids: list[int], change_reason: str
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
cur.execute(
"""
SELECT m.id,
SELECT mv.member_id AS id,
mv.name,
m.employee_id,
COALESCE(m.employee_id, '') AS employee_id,
mv.company,
mv.rank,
mv.role,
@@ -637,25 +637,137 @@ def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
mv.email,
COALESCE(sav.seat_label, '') AS seat_label,
mv.photo_url,
m.sort_order,
COALESCE(m.sort_order, 2147483647) AS 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)
FROM member_versions mv
LEFT JOIN members m
ON m.id = mv.member_id
LEFT JOIN seat_assignment_versions sav
ON sav.member_id = m.id
ON sav.member_id = mv.member_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
WHERE mv.valid_from <= %s
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
""",
(as_of, as_of, as_of, as_of),
)
return cur.fetchall()
def build_member_compare_items(from_items: list[dict[str, object]], to_items: list[dict[str, object]]) -> list[dict[str, object]]:
tracked_fields = (
("company", "소속회사", "기본"),
("rank", "직급", "기본"),
("role", "직책", "기본"),
("department", "부서", "조직"),
("grp", "그룹", "조직"),
("division", "디비전", "조직"),
("team", "", "조직"),
("cell", "", "조직"),
("work_status", "근무상태", "기본"),
("work_time", "근무시간", "기본"),
("phone", "전화번호", "기본"),
("email", "이메일", "기본"),
)
def build_summary(item: dict[str, object] | None) -> list[str]:
if not item:
return []
summary_fields = (
("rank", "직급"),
("role", "직책"),
("department", "부서"),
("grp", "그룹"),
("division", "디비전"),
("team", ""),
("cell", ""),
)
lines: list[str] = []
for field, label in summary_fields:
value = str(item.get(field) or "").strip()
if value:
lines.append(f"{label}: {value}")
return lines
from_map = {int(item["id"]): item for item in from_items}
to_map = {int(item["id"]): item for item in to_items}
all_ids = sorted(set(from_map) | set(to_map))
items: list[dict[str, object]] = []
for member_id in all_ids:
before = from_map.get(member_id)
after = to_map.get(member_id)
if before is None and after is not None:
items.append(
{
"member_id": member_id,
"name": str(after.get("name") or "-"),
"status": "added",
"status_label": "신규",
"categories": ["신규"],
"changes": [],
"before_lines": [],
"after_lines": build_summary(after),
}
)
continue
if before is not None and after is None:
items.append(
{
"member_id": member_id,
"name": str(before.get("name") or "-"),
"status": "removed",
"status_label": "삭제",
"categories": ["삭제"],
"changes": [],
"before_lines": build_summary(before),
"after_lines": [],
}
)
continue
if before is None or after is None:
continue
changes: list[dict[str, str]] = []
categories: set[str] = set()
for field, label, category in tracked_fields:
before_value = str(before.get(field) or "").strip()
after_value = str(after.get(field) or "").strip()
if before_value == after_value:
continue
changes.append(
{
"field": field,
"label": label,
"before": before_value,
"after": after_value,
}
)
categories.add(category)
if not changes:
continue
items.append(
{
"member_id": member_id,
"name": str(after.get("name") or before.get("name") or "-"),
"status": "updated",
"status_label": "변경",
"categories": sorted(categories),
"changes": changes,
"before_lines": [f"{change['label']}: {change['before'] or '-'}" for change in changes],
"after_lines": [f"{change['label']}: {change['after'] or '-'}" for change in changes],
}
)
order_map = {"added": 0, "updated": 1, "removed": 2}
items.sort(key=lambda item: (order_map.get(str(item.get("status") or ""), 9), str(item.get("name") or ""), int(item.get("member_id") or 0)))
return items
def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]:
return (
payload.name.strip(),
@@ -3959,6 +4071,19 @@ def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]
return {"items": fetch_members_as_of(cur, parsed_as_of)}
@app.get("/api/history/members/compare")
def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]:
parsed_from = parse_as_of(from_date)
parsed_to = parse_as_of(to_date)
if parsed_from is None or parsed_to is None:
raise HTTPException(status_code=400, detail="from_date and to_date are required.")
with get_conn() as conn:
with conn.cursor() as cur:
from_items = fetch_members_as_of(cur, parsed_from)
to_items = fetch_members_as_of(cur, parsed_to)
return {"items": build_member_compare_items(from_items, to_items)}
@app.post("/api/members")
def create_member(payload: MemberPayload) -> dict[str, object]:
with get_conn() as conn: