diff --git a/backend/app/main.py b/backend/app/main.py index e10739a..dec5847 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ import hmac from io import BytesIO, StringIO import json import math +from decimal import Decimal, ROUND_HALF_UP from pathlib import Path import re import secrets @@ -42,9 +43,24 @@ app.add_middleware( LEGACY_STATIC_DIR = LEGACY_DIR / "static" INCOMING_FILES_DIR = BASE_DIR / "incoming-files" FIXED_OFFICE_SOURCE_KEY = "technical-development-center" -FIXED_OFFICE_NAME = "기술개발센터" -FIXED_OFFICE_TEMPLATE_PATH = Path(__file__).with_name("center_chair_viewer_template.html") -_fixed_office_cache: dict[str, object] | None = None +FIXED_OFFICE_CONFIGS = { + "technical-development-center": { + "name": "기술개발센터", + "html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map.html", + "payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload.js", + }, + "hanmac-building-6f": { + "name": "한맥빌딩 6층", + "html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map_6f.html", + "payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload_6f.js", + }, + "hanmac-building-7f": { + "name": "한맥빌딩 7층", + "html_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_map_7f.html", + "payload_path": INCOMING_FILES_DIR / "seat" / "center_chair_people_payload_7f.js", + }, +} +_fixed_office_cache: dict[str, dict[str, object]] = {} AUTH_DEFAULT_PASSWORD = "1111" AUTH_PASSWORD_ITERATIONS = 390000 AUTH_SESSION_HOURS = 12 @@ -462,8 +478,11 @@ def fetch_active_seat_map() -> dict[str, object] | None: return cur.fetchone() -def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]: - template = parse_fixed_office_template() +def ensure_fixed_office_seat_map(office_key: str = FIXED_OFFICE_SOURCE_KEY, activate: bool = True) -> dict[str, object]: + config = FIXED_OFFICE_CONFIGS.get(office_key) + if not config: + raise HTTPException(status_code=404, detail="Fixed office configuration not found.") + template = parse_fixed_office_template(office_key) slots = template["slots"] with get_conn() as conn: with conn.cursor() as cur: @@ -475,7 +494,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]: AND source_url = %s LIMIT 1 """, - (FIXED_OFFICE_SOURCE_KEY,), + (office_key,), ) row = cur.fetchone() if activate: @@ -491,7 +510,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]: VALUES (%s, '', 'fixed_html', %s, '', NULL, NULL, NULL, NULL, NULL, NULL, 1, 1, 0, %s) RETURNING id """, - (FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate), + (str(config["name"]), office_key, activate), ) seat_map_id = int(cur.fetchone()["id"]) else: @@ -511,7 +530,7 @@ def ensure_fixed_office_seat_map(activate: bool = True) -> dict[str, object]: updated_at = NOW() WHERE id = %s """, - (FIXED_OFFICE_NAME, FIXED_OFFICE_SOURCE_KEY, activate, seat_map_id), + (str(config["name"]), office_key, activate, seat_map_id), ) cur.execute("SELECT id, slot_key FROM seat_slots WHERE seat_map_id = %s", (seat_map_id,)) @@ -591,18 +610,36 @@ def decode_segment_values(raw_base64: str) -> list[int]: return [item[0] for item in struct.iter_unpack(" dict[str, object]: - global _fixed_office_cache - if _fixed_office_cache is not None: - return _fixed_office_cache - if not FIXED_OFFICE_TEMPLATE_PATH.exists(): - raise HTTPException(status_code=500, detail="Fixed office viewer template not found.") +def parse_fixed_office_template(office_key: str = FIXED_OFFICE_SOURCE_KEY) -> dict[str, object]: + cached = _fixed_office_cache.get(office_key) + if cached is not None: + return cached - html = FIXED_OFFICE_TEMPLATE_PATH.read_text(encoding="utf-8") - match = re.search(r"const DATA = (\{.*?\});\n\s*function decodeSegments", html, flags=re.S) - if not match: - raise HTTPException(status_code=500, detail="Fixed office viewer data not found.") - data = json.loads(match.group(1)) + config = FIXED_OFFICE_CONFIGS.get(office_key) + if not config: + raise HTTPException(status_code=404, detail="Fixed office configuration not found.") + + html_path = Path(str(config["html_path"])) + payload_path = Path(str(config["payload_path"])) + if not html_path.exists(): + raise HTTPException(status_code=500, detail=f"Fixed office viewer template not found: {office_key}") + if not payload_path.exists(): + raise HTTPException(status_code=500, detail=f"Fixed office payload not found: {office_key}") + + html = html_path.read_text(encoding="utf-8") + payload_js = payload_path.read_text(encoding="utf-8") + payload_match = re.search(r"window\.CHAIR_MAP_DATA\s*=\s*(\{.*\});?\s*$", payload_js, flags=re.S) + if not payload_match: + raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}") + + html = re.sub( + r'', + f"", + html, + count=1, + ) + + data = json.loads(payload_match.group(1)) chair_values = decode_segment_values(str(data["chairSegsB64"])) slots: list[dict[str, object]] = [] for index, chair in enumerate(data["chairs"]): @@ -633,12 +670,13 @@ def parse_fixed_office_template() -> dict[str, object]: "layer_name": str(name), } ) - _fixed_office_cache = { + parsed = { "html": html, "data": data, "slots": slots, } - return _fixed_office_cache + _fixed_office_cache[office_key] = parsed + return parsed def is_chair_layer(layer_name: str) -> bool: @@ -1170,12 +1208,14 @@ def fetch_seat_layout(seat_map_id: int) -> dict[str, object]: ) placements = cur.fetchall() viewer_data: dict[str, object] | None = None - if seat_map["source_type"] == "fixed_html" and seat_map.get("source_url") == FIXED_OFFICE_SOURCE_KEY: - template = parse_fixed_office_template() + office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY) + fixed_office = FIXED_OFFICE_CONFIGS.get(office_key) + if seat_map["source_type"] == "fixed_html" and fixed_office: + template = parse_fixed_office_template(office_key) viewer_data = { "meta": { "chair_count": len(template["slots"]), - "office": FIXED_OFFICE_NAME, + "office": str(fixed_office["name"]), } } elif seat_map["source_type"] == "dxf" and seat_map.get("source_url"): @@ -1230,7 +1270,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str: placed_literal = json.dumps(sorted(set(placed_keys)), ensure_ascii=False, separators=(",", ":")) assignments_literal = json.dumps(assignment_items, ensure_ascii=False, separators=(",", ":")) if seat_map.get("source_type") == "fixed_html": - html = parse_fixed_office_template()["html"] + office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY) + html = parse_fixed_office_template(office_key)["html"] else: viewer_data = layout.get("viewer_data") if not isinstance(viewer_data, dict): @@ -2806,8 +2847,35 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d with conn.cursor() as cur: cur.execute( """ - WITH work_by_project AS ( + WITH project_base AS ( SELECT + CASE + WHEN COALESCE(project_code, '') <> '' THEN project_code + ELSE regexp_replace( + lower(COALESCE(NULLIF(project_name, ''), NULLIF(display_name, ''), NULLIF(intranet_name, ''), '')), + '[^0-9a-z가-힣]+', + '', + 'g' + ) + END AS project_key, + project_code, + project_name, + display_name, + business_area, + business_subarea + FROM integration_projects + ), + work_by_project AS ( + SELECT + CASE + WHEN COALESCE(project_code, '') <> '' THEN project_code + ELSE regexp_replace( + lower(COALESCE(NULLIF(project_name, ''), '')), + '[^0-9a-z가-힣]+', + '', + 'g' + ) + END AS project_key, COALESCE(project_code, '') AS project_code, COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name, SUM(hours) AS total_hours, @@ -2817,10 +2885,19 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d JOIN integration_work_logs ON integration_work_logs.id = integration_work_log_segments.work_log_id WHERE (%s::date IS NULL OR integration_work_logs.work_date >= %s::date) AND (%s::date IS NULL OR integration_work_logs.work_date <= %s::date) - GROUP BY 1, 2 + GROUP BY 1, 2, 3 ), voucher_by_project AS ( SELECT + CASE + WHEN COALESCE(project_code, '') <> '' THEN project_code + ELSE regexp_replace( + lower(COALESCE(NULLIF(project_name, ''), '')), + '[^0-9a-z가-힣]+', + '', + 'g' + ) + END AS project_key, COALESCE(project_code, '') AS project_code, COALESCE(NULLIF(project_name, ''), COALESCE(project_code, '')) AS project_name, SUM(income_amount) AS total_income, @@ -2829,7 +2906,8 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d FROM integration_vouchers WHERE (%s::date IS NULL OR COALESCE(issue_date, claim_date) >= %s::date) AND (%s::date IS NULL OR COALESCE(issue_date, claim_date) <= %s::date) - GROUP BY 1, 2 + AND COALESCE(voucher_type, '') <> '제외' + GROUP BY 1, 2, 3 ) SELECT COALESCE(p.project_code, w.project_code, v.project_code) AS project_code, @@ -2844,9 +2922,9 @@ def fetch_project_metrics(limit: int = 500, start_date: str | None = None, end_d COALESCE(w.overtime_hours, 0) AS overtime_hours, COALESCE(v.voucher_count, 0) AS voucher_count, COALESCE(w.work_log_count, 0) AS work_log_count - FROM integration_projects p - FULL OUTER JOIN work_by_project w ON w.project_code = p.project_code - FULL OUTER JOIN voucher_by_project v ON v.project_code = COALESCE(p.project_code, w.project_code) + FROM project_base p + FULL OUTER JOIN work_by_project w ON w.project_key = p.project_key + FULL OUTER JOIN voucher_by_project v ON v.project_key = COALESCE(p.project_key, w.project_key) ORDER BY project_code ASC LIMIT %s """, @@ -2986,6 +3064,19 @@ def payment_analysis_parse_number(value: object) -> float: return 0.0 +def round_half_up_to_int(value: float | Decimal) -> int: + return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def round_half_up_to_2(value: float | Decimal) -> float: + return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + +def calculate_labor_cost(hours: float | Decimal, rate: int | float | Decimal, multiplier: float | Decimal) -> int: + amount = Decimal(str(hours)) * Decimal(str(rate)) * Decimal(str(multiplier)) + return round_half_up_to_int(amount) + + def build_payment_work_rows_from_raw_mh( raw_rows: list[list[object]], category_by_project_key: dict[str, dict[str, str]], @@ -3057,6 +3148,7 @@ def build_payment_work_rows_from_raw_mh( position = clean_text(payment_analysis_get_value(row, ["직책", "직급"])) user_state = clean_text(payment_analysis_get_value(row, ["user_state", "User State", "user state", "userstate", "User_State"])) weekend_flag = clean_text(payment_analysis_get_value(row, ["주말/지각"])) + is_weekend = "주말" in user_state or "주말" in weekend_flag member_name = clean_text(payment_analysis_get_value(row, ["이름"])) work_date = clean_text(payment_analysis_get_value(row, ["근무일자", "날짜", "일자"])) imported_labor = payment_analysis_parse_number(payment_analysis_get_value(row, ["산정금액", "인건비"])) @@ -3073,7 +3165,7 @@ def build_payment_work_rows_from_raw_mh( weighted = [] total_weight = 0.0 for idx, segment in enumerate(segments): - multiplier = 1.5 if ("주말" in user_state or segment["overtime"]) else 1.0 + multiplier = 1.5 if (is_weekend or segment["overtime"]) else 1.0 weight = segment["hours"] * multiplier weighted.append((idx, weight)) total_weight += weight @@ -3108,8 +3200,8 @@ def build_payment_work_rows_from_raw_mh( if allocations: labor = int(allocations[idx] or 0) else: - multiplier = 1.5 if ("주말" in user_state or segment["overtime"]) else 1.0 - labor = round(hours * rate * multiplier) + multiplier = 1.5 if (is_weekend or segment["overtime"]) else 1.0 + labor = calculate_labor_cost(hours, rate, multiplier) parsed_row = { "__values": [ work_date, @@ -3245,8 +3337,8 @@ def fetch_payment_source_rows() -> dict[str, object]: position = clean_text(row["title"]) raw_hours = float(row["hours"] or 0) adjusted_overtime_hours = float(row["overtime_hours_adjusted"] or 0) - hours = adjusted_overtime_hours if bool(row["is_overtime"]) and adjusted_overtime_hours > 0 else raw_hours - hours = round(hours, 2) + hours = adjusted_overtime_hours if bool(row["is_overtime"]) else raw_hours + hours = round_half_up_to_2(hours) rate = 28900 if "이사" in position or "수석" in position: rate = 46600 @@ -3254,7 +3346,11 @@ def fetch_payment_source_rows() -> dict[str, object]: rate = 40500 elif "선임" in position: rate = 35300 - labor = round(hours * rate * (1.5 if bool(row["is_overtime"]) or "주말" in clean_text(row["weekend_late_flag"]) else 1)) + labor = calculate_labor_cost( + hours, + rate, + 1.5 if bool(row["is_overtime"]) or "주말" in clean_text(row["weekend_late_flag"]) else 1, + ) parsed_row = { "__values": [ clean_text(row["work_date"]), @@ -3785,8 +3881,9 @@ async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(... @app.get("/api/seat-maps/active") -def get_active_seat_map() -> dict[str, dict[str, object]]: - seat_map = ensure_fixed_office_seat_map(activate=True) +def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]: + requested_key = (office_key or "").strip() or FIXED_OFFICE_SOURCE_KEY + seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == FIXED_OFFICE_SOURCE_KEY) if seat_map is None: raise HTTPException(status_code=404, detail="Active seat map not found.") return {"item": seat_map} diff --git a/docs/DEV_PROD_DB_PROTOCOL.md b/docs/DEV_PROD_DB_PROTOCOL.md new file mode 100644 index 0000000..a3a5ac2 --- /dev/null +++ b/docs/DEV_PROD_DB_PROTOCOL.md @@ -0,0 +1,182 @@ +# Dev / Prod DB Protocol + +## 목적 + +- `8081` 작업용은 기능 개발과 화면 검증을 먼저 수행하는 환경이다. +- `8080` 공개용은 실제 기준 데이터와 운영 화면을 제공하는 환경이다. +- 코드와 데이터의 기준을 분리해서 관리하되, 데이터 정본은 항상 `8080` 공개용 DB로 유지한다. + +## 현재 구조 + +### 코드 경로 + +- 공개용 `8080`: `/home/hyunho/projects/mh-dashboard-organization` +- 작업용 `8081`: `/tmp/mh-dashboard-organization-dev` + +### DB 볼륨 + +- 공개용 `8080`: `mh-dashboard-organization_postgres_data` +- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data` + +즉 현재는 코드도 분리, DB도 분리 상태다. + +## 정본 기준 + +- 코드 선행 환경: `8081` +- 데이터 정본: `8080` +- 공개 반영 기준: `8081`에서 검증 완료된 코드만 `8080`에 승격 + +중요: +- `8081` DB는 독립 정본이 아니다. +- `8081` DB는 `8080` DB를 기준으로 맞춘 검증용 복제본이어야 한다. + +## 왜 이 규칙이 필요한가 + +- 조직현황, 조직도, 자리배치 인원, 퇴사자 제외, 멤버 수는 코드보다 DB 영향이 크다. +- 작업용 DB가 공개용과 달라지면 기능 검증 결과 자체가 왜곡된다. +- 원인 분석 시 `코드 차이`와 `DB 차이`를 분리할 수 있어야 한다. + +## 현재 확인된 차이 예시 + +2026-03-27 확인 기준: + +- `members` + - `8080`: `227` + - `8081`: `236` +- `member_retirements` + - `8080`: `9` + - `8081`: `0` +- `seat_maps` + - `8080`: `21` + - `8081`: `3` +- `seat_positions` + - `8080`: `5` + - `8081`: `0` +- `seat_slots` + - `8080`: `57308` + - `8081`: `370` + +## 기준 테이블 분류 + +### A. 공개용 정본 기준으로 항상 맞춰야 하는 테이블 + +- `members` +- `member_aliases` +- `member_overrides` +- `member_retirements` +- `seat_maps` +- `seat_slots` +- `seat_positions` + +### B. 원본 재적재로 다시 만들 수 있는 통합 테이블 + +- `integration_import_batches` +- `integration_raw_organization_rows` +- `integration_raw_mh_rows` +- `integration_raw_mh_pm_rows` +- `integration_raw_payment_rows` +- `integration_projects` +- `integration_project_aliases` +- `integration_project_category_mappings` +- `integration_project_pm_assignments` +- `integration_work_logs` +- `integration_work_log_segments` +- `integration_vouchers` + +### C. 별도 정책이 필요한 영역 + +- `snapshots` +- 인증 관련 스키마와 테이블 + +## 작업 프로토콜 + +### 1. 작업 시작 전 + +1. `8080`과 `8081` 모두 기동 상태 확인 +2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분 +3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤 + +### 2. 기능 개발 중 + +1. 코드 수정은 먼저 `8081`에서 수행 +2. UI, 계산식, 자리배치도 동작은 `8081`에서 확인 +3. 조직도/멤버/자리배치 검증은 공개용 기준 데이터가 반영된 `8081` DB에서만 수행 + +### 3. 검증 완료 후 + +1. 코드만 `8080`으로 승격 +2. 데이터 반영이 필요한 기능은 별도 절차를 문서화한 뒤 적용 +3. 공개용 DB를 개발 실험용으로 사용하지 않음 + +## 금지 사항 + +- `8081` DB를 장기간 독립 정본처럼 취급하지 않기 +- 퇴사자, 멤버, 좌석 정보를 작업용에서 수작업으로만 유지하지 않기 +- DB 차이를 무시하고 `8081` 검증 결과가 `8080`과 같다고 가정하지 않기 + +## 권장 동기화 범위 + +### 최소 범위 + +조직도/자리배치도 검증 전 반드시 동기화: + +1. `members` +2. `member_aliases` +3. `member_overrides` +4. `member_retirements` +5. `seat_maps` +6. `seat_slots` +7. `seat_positions` + +### 전체 범위 + +분석 화면까지 공개용 기준으로 검증해야 하면 아래도 포함: + +1. `integration_import_batches` +2. `integration_raw_*` +3. `integration_projects` +4. `integration_project_*` +5. `integration_work_logs` +6. `integration_work_log_segments` +7. `integration_vouchers` + +## 세션 시작 체크 + +1. 지금 작업이 `코드 변경`인지 `데이터 변경`인지 구분 +2. 공개용 기준 데이터가 필요한지 판단 +3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화 +4. 그 뒤 기능 개발과 검증 수행 +5. 검증 완료 후 공개용에 코드 승격 + +## 다음 액션 + +- `8081` DB를 `8080` 기준으로 맞추는 반복 가능한 동기화 절차를 만든다 +- 최소한 `A 그룹` 테이블은 수동 기억에 의존하지 않고 다시 수행 가능해야 한다 +- 이후 모든 작업은 이 문서를 기본 프로토콜로 따른다 + +## 실행 절차 + +반복 가능한 동기화 스크립트: + +- [sync_prod_db_to_dev.sh](/home/hyunho/projects/mh-dashboard-organization/scripts/sync_prod_db_to_dev.sh) + +사용 방법: + +```bash +chmod +x scripts/sync_prod_db_to_dev.sh +./scripts/sync_prod_db_to_dev.sh minimal +./scripts/sync_prod_db_to_dev.sh full +``` + +규칙: + +- `minimal` + - 조직도, 멤버, 자리배치도 검증 전 사용 +- `full` + - 분석 화면까지 공개용 기준 데이터로 맞춰야 할 때 사용 + +주의: + +- 스크립트는 `8080` DB 데이터를 덤프해서 `8081` DB의 대상 테이블을 비우고 다시 적재한다 +- `8081`에서만 존재하던 대상 테이블 데이터는 사라진다 +- 따라서 실행 전 현재 작업용 DB 상태를 유지해야 하면 별도 백업 후 실행한다 diff --git a/docs/INFRA_VALIDATION_CHECKLIST.md b/docs/INFRA_VALIDATION_CHECKLIST.md index 3777a8b..b7230db 100644 --- a/docs/INFRA_VALIDATION_CHECKLIST.md +++ b/docs/INFRA_VALIDATION_CHECKLIST.md @@ -1,5 +1,12 @@ # 인프라 검증 체크리스트 +## 현재 확인 상태 +- 2026-03-27 기준 `docker compose ps` 에서 `proxy`, `frontend`, `backend`, `db` 모두 `healthy` +- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상 +- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음 +- 다른 PC 접속도 현재 확인됨 +- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) 기준으로 관리 + ## 1. 컨테이너 기동 - `docker compose build` - `docker compose up -d` @@ -32,4 +39,8 @@ - 확인 기준: - DB 데이터 유지 - 업로드 파일 유지 - - 스냅샷 파일 유지 + +## 6. 제외 또는 후속 검증 항목 +- 월간 스냅샷 파일 유지 검증은 현재 코드 기준 미구현 항목 +- 스냅샷 기능을 다시 범위에 넣을 경우 별도 API/파일 경로/다운로드 검증 절차를 추가해야 함 +- `8081`에서 조직도, 멤버, 자리배치도 검증 전에는 `8080` 정본 DB 기준 동기화가 필요함 diff --git a/docs/NEXT_SESSION_CHECKPOINT.md b/docs/NEXT_SESSION_CHECKPOINT.md index 481ebd3..ae1cf08 100644 --- a/docs/NEXT_SESSION_CHECKPOINT.md +++ b/docs/NEXT_SESSION_CHECKPOINT.md @@ -3,8 +3,9 @@ ## Current Base - branch: `total` -- latest integration commit: `61b5638` +- latest checked commit: `1d15cf9` - main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md) +- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md) ## What Was Finished @@ -50,15 +51,36 @@ - `member_retirements` - `member_overrides` +### Auth Baseline + +- 실제 로그인 API 연결 완료 +- 프런트 로그인 화면이 `/api/auth/login` 사용 +- 세션/로그아웃/세션 조회 API 구성 완료 +- 사용 테이블: + - `auth.users` + - `auth.sessions` + - `auth.login_audit_logs` +- 현재 남은 범위: + - mock login 정리 + - 역할별 권한 체크 적용 + - 쓰기 API 보호 범위 정리 + ### External Access - WSL 내부 8080 리슨 확인 -- Windows `portproxy`를 이용해 다른 PC에서 접속 가능하게 설정 +- 현재 다른 PC에서 접속 확인 - 현재 기준 주소: - `http://172.16.40.144:8080` ## Important Runtime Notes +### Dev / Prod Protocol + +- 코드 선행은 `8081`, 공개 반영은 `8080` +- 데이터 정본은 `8080` DB +- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함 +- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인 + ### Seat Map Save - 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인 @@ -79,14 +101,18 @@ ## Open Issues -- `#2` 백엔드 영속 저장 구조 운영 마무리 및 스냅샷 검증 +- `#2` 백엔드 영속 저장 구조 운영 마무리 - `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화 - `#5` 실제 인증 체계 전환 -- `#6` 4개 기능 통합 대시보드 프레임 및 공통 헤더 구축 - `#7` 자리배치도 팀별 색상 오버레이 표시 - `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시 - `#9` 조직도·자리배치도 변경 이력 버전 누적 저장 +현재 해석: +- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상 +- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심 +- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요 + ## Unfinished Ideas Discussed Today ### Seat Map UX @@ -101,10 +127,15 @@ ### History / Versioning - 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장 -- 원본 DB와 별도의 history/snapshot 구조 설계 -- 날짜/버전 형식 예: - - `00.00.00` - - 또는 날짜 기반 revision +- 원본 DB와 별도의 history/version 구조 설계 +- `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용 +- 날짜 또는 revision label 기준으로 버전 묶음 관리 +- 상세 설계 문서: + - [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md) + +주의: +- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음 +- 월간 스냅샷 방향은 범위에서 제외 ### Project Analysis Accuracy @@ -113,25 +144,28 @@ ### Auth / Permission -- mock login을 실제 인증 체계로 전환 +- mock login을 개발용 fallback 수준으로 제한하거나 제거 - 역할별 접근 제어 정리 - 조직도/자리배치도/분석 화면 권한 경계 재정리 ## Recommended Next Work Order -1. 자리배치도 저장/표시를 브라우저에서 한 번 더 실사용 검증 -2. `#7`, `#8`, `#9` 중 우선순위 확정 -3. 프로젝트별 분석 남은 오차 정밀 보정 -4. 실제 인증 체계 설계/구현 +1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료 +2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용 +3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련 +4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화 +5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행 ## Quick Resume Prompt 다음 세션 시작 시 아래 기준으로 이어가면 된다. - 브랜치 `total`에서 시작 -- 최근 커밋 `61b5638` 확인 +- 최근 커밋 `1d15cf9` 확인 - `docs/DEVELOPMENT_HISTORY.md` - `docs/NEXT_SESSION_CHECKPOINT.md` -- Gitea 이슈 `#7`, `#8`, `#9` +- `docs/DEV_PROD_DB_PROTOCOL.md` +- `docs/HISTORY_ASOF_DB_PLAN.md` +- Gitea 이슈 `#2`, `#5`, `#9` -그리고 먼저 현재 외부 접속과 자리배치 저장이 정상인지 확인한 뒤 다음 기능 개발로 넘어간다. +그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다. diff --git a/frontend/public/app.js b/frontend/public/app.js index ab0d877..347a968 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -101,8 +101,8 @@ const APP_BASE_URL = String(window.__MH_BASE_URL || "").replace(/\/$/, ""); const seatMapOffices = [ { key: "technical-development-center", label: "기술개발센터", ready: true }, - { key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: false }, - { key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: false }, + { key: "hanmac-building-6f", label: "한맥빌딩 6층", ready: true }, + { key: "hanmac-building-7f", label: "한맥빌딩 7층", ready: true }, ]; const viewLabels = { @@ -148,7 +148,7 @@ const seatMapState = { forceReadOnly: false, }; -let currentView = "organization"; +let currentView = "project"; const globalDateState = { loaded: true, startDate: "2026-01-01", @@ -1168,22 +1168,7 @@ async function loadSeatMapData(force = false) { try { const office = getCurrentSeatMapOffice(); - if (!office.ready) { - const membersPayload = await fetchJson("/api/members"); - seatMapState.seatMap = null; - seatMapState.members = Array.isArray(membersPayload.items) ? membersPayload.items : []; - seatMapState.slots = []; - seatMapState.placements = []; - seatMapState.zoom = 1; - seatMapState.hoveredSlotId = null; - seatMapState.editMode = canEditSeatMap(); - resetSeatMapDraft(); - seatMapState.loaded = true; - setSeatMapStatus(`${office.label} 도면은 아직 등록 전입니다.`, "info"); - renderSeatMap(); - return; - } - const activePayload = await fetchJson("/api/seat-maps/active"); + const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`); const activeSeatMap = activePayload.item; const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout`); seatMapState.seatMap = { @@ -1479,12 +1464,10 @@ if (loginForm) { body: formData, }); setSession(payload); + setActiveView("project"); loginForm.reset(); loginMessage.textContent = ""; renderAuth(); - if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") { - await loadSeatMapData(true); - } } catch (error) { loginMessage.textContent = error.message || "로그인에 실패했습니다."; } diff --git a/frontend/public/index.html b/frontend/public/index.html index e8b713c..68c3f09 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,7 +3,7 @@ - MH 조직현황 대시보드 + MH 대시보드-공개용 diff --git a/incoming-files/mh.html b/incoming-files/mh.html index c7afcbc..839814f 100644 --- a/incoming-files/mh.html +++ b/incoming-files/mh.html @@ -1316,8 +1316,9 @@
- - + + +