diff --git a/.env.example b/.env.example index 1618467..dad6d3b 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,8 @@ PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org CS_ORDER_URL=/corm/ # 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨 CUSTOMER_ORDER_LIST_URL=/orderlist/ + +# ─── 개인경비 모듈 (expense_db) ─── +# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용. +# DB/역할 생성: scripts/sql/expense_db_init.sql 참고. +# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33fd800 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# main-app ERP 프로젝트 작업 기준 + +> 이 문서는 Claude Code가 이 저장소에서 작업할 때 가장 먼저 확인하는 기준 문서입니다. +> 작업 시작 전, 아래 "반드시 먼저 읽을 문서"를 모두 확인한 뒤 작업을 시작합니다. + +--- + +## 반드시 먼저 읽을 문서 + +Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아래 문서를 순서대로 읽고 맥락을 확보**한 뒤 작업한다. + +1. `docs/PROJECT_OVERVIEW.md` — 프로젝트 정의, 기능 범위, 연동 대상 +2. `docs/SERVER_ARCHITECTURE.md` — 서버 구성도와 네트워크 흐름 +3. `docs/DATABASES.md` — PostgreSQL DB 구성과 명명 규칙 +4. `docs/DEPLOYMENT.md` — 배포 경로, 서비스 실행 방식, 복구 절차 + +문서 간 내용이 충돌하면 위의 우선순위(1 → 4)를 따른다. + +--- + +## 프로젝트 한 줄 정의 + +`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다. + +담당 영역: + +- 주문관리 +- 상품코드 매칭 +- 재고관리 +- CS관리 +- 반품관리 +- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등) + +상세는 `docs/PROJECT_OVERVIEW.md`. + +--- + +## 개발 원칙 + +- 기존 코드를 수정하기 전 관련 파일을 먼저 읽고 구조를 파악한다. +- 위험 명령은 **반드시 사용자 확인 후** 실행한다 (아래 "위험 명령" 절 참고). +- `.env`, API 키, DB 비밀번호, OAuth Secret, 토큰은 절대 Git에 올리지 않는다. +- 신규 DB가 필요하면 **승인 요청 후** 생성하며, DB명은 반드시 `_db`로 끝낸다 (예: `inventory_db`). +- 예전 문서/코드의 `orderlist_app`은 현재 기준 `orderlist_db`이다. 발견 시 수정 대상. + +--- + +## 위험 명령 (사용자 확인 없이 실행 금지) + +아래 명령은 **반드시 사용자에게 의도를 설명하고 명시적 승인을 받은 뒤** 실행한다. + +| 분류 | 명령 예시 | +| --- | --- | +| 파일 삭제 | `rm -rf`, `Remove-Item -Recurse -Force` | +| DB 파괴 | `DROP DATABASE`, `DROP TABLE`, `DROP SCHEMA` | +| 데이터 삭제 | `TRUNCATE`, 조건 없는 대량 `DELETE`, `UPDATE` | +| Docker 파괴 | `docker volume rm`, `docker volume prune`, `docker system prune -a --volumes` | +| Git 파괴 | `git reset --hard`, `git push --force`, `git clean -fd`, `git branch -D` | +| 운영 초기화 | 운영 DB 덤프 덮어쓰기, 마이그레이션 롤백 | + +원칙: + +1. 실행 전 현재 상태 확인 명령을 먼저 보여준다 (예: `docker ps`, `\l`, `git status`). +2. 백업 존재 여부와 위치를 명시한다. +3. 실행 후 결과 확인 절차를 같이 제시한다. + +--- + +## 서버 작업 원칙 + +- 배포, DB 복구, Docker 작업 전에는 현재 상태 확인 명령을 먼저 제안한다. +- PostgreSQL 작업 전에는 DB명, 컨테이너명, 포트, 백업 위치를 확인한다. +- 운영 서버 경로와 개발 PC 경로를 혼동하지 않는다. + - 개발 PC: `G:\내 드라이브\프로젝트\Main-app` + - **운영 서버 (main-app): `/opt/www/main`** ← 본 프로젝트 경로 + - 참고용 (같은 호스트 내 다른 서비스 경로): `/opt/dbx-corm`, `/opt/dbx-orderlist` + - 명령 예시·문서 작성 시 main-app 경로는 **반드시 `/opt/www/main`** 사용. 상세는 `docs/DEPLOYMENT.md`. + +--- + +## 환경 변수 / 비밀값 + +- `.env`, `.env.local`, `.env.production` 은 **Git에 절대 커밋하지 않는다**. +- 예시 파일(`*.example`)만 커밋한다. +- 비밀값 유출이 의심되면 즉시 회전(rotate)을 권고한다. +- 신규 환경변수 추가 시 `*.example` 파일과 본 문서(또는 `docs/DEPLOYMENT.md`)에 변수 설명을 함께 갱신한다. + +--- + +## 디렉터리 구조 (요약) + +``` +Main-app/ +├─ app/ FastAPI 앱 소스 +├─ docs/ 운영/설계 문서 (작업 전 필독) +├─ scripts/ 배포·유지보수 스크립트 +├─ skills/ Claude Code 규칙/스킬 +├─ docker-compose.yml 운영 컴포즈 +├─ docker-compose.local.yml 로컬 컴포즈 +├─ Dockerfile +├─ requirements.txt +└─ CLAUDE.md ← 이 문서 +``` diff --git a/app/main.py b/app/main.py index f1784ed..50575f5 100644 --- a/app/main.py +++ b/app/main.py @@ -9,9 +9,12 @@ from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from jinja2 import ChoiceLoader, FileSystemLoader from pydantic import BaseModel from starlette.middleware.sessions import SessionMiddleware +from .modules.expense import build_expense_store +from .modules.expense import router as expense_router from .store import ( MODULE_KEYS, SUPER_ADMIN_EMAIL, @@ -83,10 +86,32 @@ app.add_middleware( ) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") +# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정. +# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "" / "templates"` 만 추가. +_MODULE_TEMPLATE_DIRS = [ + BASE_DIR / "modules" / "expense" / "templates", +] templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.loader = ChoiceLoader( + [ + FileSystemLoader(str(BASE_DIR / "templates")), + *[FileSystemLoader(str(p)) for p in _MODULE_TEMPLATE_DIRS if p.exists()], + ] +) + oauth = build_google_oauth() user_store = UserStore(DATA_DIR / "users.json") +# 모듈별 데이터 저장소. +# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일. +app.state.expense_store = build_expense_store( + dsn=env("EXPENSE_DB_URL") or None, + json_path=DATA_DIR / "expense.json", +) + +# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. +app.include_router(expense_router) + def public_url_for(request: Request, route_name: str) -> str: public_base_url = env("PUBLIC_BASE_URL").rstrip("/") @@ -203,9 +228,9 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]: "title": "개인경비", "subtitle": "Personal Expense", "description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.", - "url": "#", - "health_url": None, - "status": "preparing", + "url": "/expense/", + "health_url": "/expense/health", + "status": "ready", "category": "관리", }, { @@ -225,6 +250,62 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]: return items +def _icon_svg(name: str) -> str: + """좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG.""" + paths = { + "home": '', + "expense": '', + "vacation": '', + "corm": '', + "order": '', + "modules": '', + } + body = paths.get(name, paths["modules"]) + return ( + '' + f"{body}" + ) + + +def build_erp_nav( + user_rec: dict[str, Any], active: str | None = None +) -> list[dict[str, Any]]: + """좌측 사이드바 메뉴. 모듈 정의(_menu_items_for)와 동기화한다. + + 각 항목: key, label, group, url, target, icon, active, disabled, disabled_reason. + """ + items: list[dict[str, Any]] = [ + { + "key": "home", + "label": "홈", + "group": "ERP", + "url": "/", + "target": "_self", + "icon": _icon_svg("home"), + }, + ] + for m in _menu_items_for(user_rec): + if not m["allowed"]: + continue + target = "_blank" if m["url"].startswith("http") else "_self" + items.append( + { + "key": m["key"], + "label": m["title"], + "group": m["category"], + "url": m["url"] if m["status"] == "ready" else "#", + "target": target, + "icon": _icon_svg(m["key"]), + "disabled": m["status"] != "ready", + "disabled_reason": "준비중" if m["status"] != "ready" else None, + } + ) + for it in items: + it["active"] = it["key"] == active + return items + + @app.get("/", response_class=HTMLResponse) async def home(request: Request) -> HTMLResponse: sess = get_session_user(request) @@ -235,13 +316,46 @@ async def home(request: Request) -> HTMLResponse: # 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인 request.session.clear() return render_template(request, "login.html") - menu_items = _menu_items_for(user_rec) + + # 슈퍼 관리자 → 업무 모듈 선택 화면(main.html). + # 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠). + if user_rec.get("is_super_admin"): + menu_items = _menu_items_for(user_rec) + return render_template( + request, + "main.html", + { + "user": user_rec, + "menu_items": menu_items, + "is_admin": is_admin(user_rec), + }, + ) + + return render_template( + request, + "erp_home.html", + { + "user": user_rec, + "is_admin": is_admin(user_rec), + "nav_items": build_erp_nav(user_rec, active="home"), + "page_title": "ERP 홈", + "page_subtitle": "오늘의 업무를 시작하세요.", + }, + ) + + +@app.get("/modules", response_class=HTMLResponse) +async def modules_page(request: Request) -> HTMLResponse: + """업무 모듈 선택 화면. 슈퍼 관리자가 사이드바에서 다시 진입할 때 사용.""" + user_rec = get_current_user_record(request) + if user_rec is None: + return RedirectResponse(url="/login", status_code=303) return render_template( request, "main.html", { "user": user_rec, - "menu_items": menu_items, + "menu_items": _menu_items_for(user_rec), "is_admin": is_admin(user_rec), }, ) diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/expense/__init__.py b/app/modules/expense/__init__.py new file mode 100644 index 0000000..5d502a8 --- /dev/null +++ b/app/modules/expense/__init__.py @@ -0,0 +1,35 @@ +"""개인경비(expense) 모듈. + +라우터/저장소/템플릿을 한 디렉토리에서 관리한다. +- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense) +- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보) +- 템플릿: `templates/expense/index.html` +""" + +from pathlib import Path +from typing import Any + +from .router import router +from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore + +__all__ = [ + "router", + "ExpenseStore", + "CATEGORIES", + "METHODS", + "STATUSES", + "build_expense_store", +] + + +def build_expense_store(*, dsn: str | None, json_path: Path) -> Any: + """env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소. + + DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 — + 의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함. + """ + if dsn: + from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음) + + return ExpenseDBStore(dsn) + return ExpenseStore(json_path) diff --git a/app/modules/expense/db.py b/app/modules/expense/db.py new file mode 100644 index 0000000..5157c8e --- /dev/null +++ b/app/modules/expense/db.py @@ -0,0 +1,244 @@ +"""expense_db PostgreSQL 저장소. + +JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를 +바꾸지 않고도 교체할 수 있다. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) +- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db) +- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는 + 앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다. +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone +from typing import Any + +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from .store import CATEGORIES, METHODS, STATUSES + +_DDL = """ +CREATE TABLE IF NOT EXISTS expense_items ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + spent_at DATE NOT NULL, + category TEXT NOT NULL, + method TEXT NOT NULL, + merchant TEXT NOT NULL DEFAULT '', + amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), + memo TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '작성중', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC); +CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status); +""" + + +class ExpenseDBStore: + """`ExpenseStore` 와 동일한 메서드 시그니처.""" + + def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5): + self._pool = ConnectionPool( + conninfo=dsn, + min_size=min_size, + max_size=max_size, + kwargs={"row_factory": dict_row, "autocommit": True}, + open=True, + ) + self._ensure_schema() + + def close(self) -> None: + self._pool.close() + + def _ensure_schema(self) -> None: + with self._pool.connection() as conn: + conn.execute(_DDL) + + # ── 조회 ── + def list_for(self, email: str) -> list[dict[str, Any]]: + email = email.lower().strip() + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM expense_items WHERE owner = %s " + "ORDER BY spent_at DESC, created_at DESC", + (email,), + ).fetchall() + return [self._serialize(r) for r in rows] + + def list_all(self) -> list[dict[str, Any]]: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT * FROM expense_items ORDER BY created_at DESC" + ).fetchall() + return [self._serialize(r) for r in rows] + + def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None: + owner = owner.lower().strip() + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM expense_items WHERE id = %s AND owner = %s", + (item_id, owner), + ).fetchone() + return self._serialize(row) if row else None + + # ── 변경 ── + def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]: + owner = owner.lower().strip() + norm = self._normalize(payload) + if not norm["spent_at"]: + raise ValueError("spent_at 필수") + status = (payload.get("status") or "작성중").strip() + item_id = uuid.uuid4().hex[:12] + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO expense_items + (id, owner, spent_at, category, method, merchant, amount, memo, status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + item_id, + owner, + norm["spent_at"], + norm["category"], + norm["method"], + norm["merchant"], + norm["amount"], + norm["memo"], + status, + ), + ).fetchone() + return self._serialize(row) + + def update( + self, *, item_id: str, owner: str, payload: dict[str, Any] + ) -> dict[str, Any]: + owner = owner.lower().strip() + norm = self._normalize(payload) + status = payload.get("status") + with self._pool.connection() as conn: + if status: + row = conn.execute( + """ + UPDATE expense_items + SET spent_at = %s, category = %s, method = %s, + merchant = %s, amount = %s, memo = %s, status = %s + WHERE id = %s AND owner = %s + RETURNING * + """, + ( + norm["spent_at"] or None, + norm["category"], + norm["method"], + norm["merchant"], + norm["amount"], + norm["memo"], + status, + item_id, + owner, + ), + ).fetchone() + else: + row = conn.execute( + """ + UPDATE expense_items + SET spent_at = %s, category = %s, method = %s, + merchant = %s, amount = %s, memo = %s + WHERE id = %s AND owner = %s + RETURNING * + """, + ( + norm["spent_at"] or None, + norm["category"], + norm["method"], + norm["merchant"], + norm["amount"], + norm["memo"], + item_id, + owner, + ), + ).fetchone() + if not row: + raise KeyError(item_id) + return self._serialize(row) + + def delete(self, *, item_id: str, owner: str) -> None: + owner = owner.lower().strip() + with self._pool.connection() as conn: + cur = conn.execute( + "DELETE FROM expense_items WHERE id = %s AND owner = %s", + (item_id, owner), + ) + if cur.rowcount == 0: + raise KeyError(item_id) + + # ── 요약 ── + def summary_for(self, email: str) -> dict[str, Any]: + email = email.lower().strip() + with self._pool.connection() as conn: + head = conn.execute( + "SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total " + "FROM expense_items WHERE owner = %s", + (email,), + ).fetchone() + status_rows = conn.execute( + "SELECT status, COUNT(*) AS c FROM expense_items " + "WHERE owner = %s GROUP BY status", + (email,), + ).fetchall() + cat_rows = conn.execute( + "SELECT category, COALESCE(SUM(amount), 0) AS s " + "FROM expense_items WHERE owner = %s GROUP BY category", + (email,), + ).fetchall() + by_status = {s: 0 for s in STATUSES} + for r in status_rows: + by_status[r["status"]] = int(r["c"]) + by_category = {c: 0 for c in CATEGORIES} + for r in cat_rows: + by_category[r["category"]] = int(r["s"]) + return { + "count": int(head["count"]) if head else 0, + "total": int(head["total"]) if head else 0, + "by_status": by_status, + "by_category": by_category, + } + + # ── helpers ── + @staticmethod + def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + if isinstance(out.get("spent_at"), date): + out["spent_at"] = out["spent_at"].isoformat() + for k in ("created_at", "updated_at"): + v = out.get(k) + if isinstance(v, datetime): + out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") + out["amount"] = int(out.get("amount", 0)) + return out + + @staticmethod + def _normalize( + payload: dict[str, Any], base: dict[str, Any] | None = None + ) -> dict[str, Any]: + b = dict(base or {}) + b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip() + category = str(payload.get("category") or b.get("category") or "기타").strip() + method = str(payload.get("method") or b.get("method") or "법인카드").strip() + b["category"] = category if category in CATEGORIES else "기타" + b["method"] = method if method in METHODS else "법인카드" + b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip() + try: + b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0)) + except (TypeError, ValueError): + b["amount"] = 0 + b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip() + return b diff --git a/app/modules/expense/router.py b/app/modules/expense/router.py new file mode 100644 index 0000000..43057b4 --- /dev/null +++ b/app/modules/expense/router.py @@ -0,0 +1,150 @@ +"""개인경비 모듈 라우터. + +- 경로: /expense +- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과). +- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel, Field + +from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore + +router = APIRouter(prefix="/expense", tags=["expense"]) + + +def _store(request: Request) -> ExpenseStore: + store = getattr(request.app.state, "expense_store", None) + if store is None: + raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.") + return store + + +def _require_user(request: Request) -> dict[str, Any]: + # 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다. + from app.main import get_current_user_record # noqa: WPS433 + from app.store import has_module # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + raise HTTPException(status_code=401, detail="로그인이 필요합니다.") + if not has_module(user, "expense"): + raise HTTPException(status_code=403, detail="개인경비 모듈 권한이 없습니다.") + return user + + +class ExpensePayload(BaseModel): + spent_at: str = Field(..., description="YYYY-MM-DD") + category: str + method: str + merchant: str = "" + amount: int = Field(ge=0) + memo: str = "" + status: str | None = None + + +@router.get("/", response_class=HTMLResponse) +async def expense_index(request: Request) -> HTMLResponse: + from app.main import ( # noqa: WPS433 + build_erp_nav, + get_current_user_record, + render_template, + ) + from app.store import has_module, is_admin # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + return RedirectResponse(url="/login", status_code=303) + if not has_module(user, "expense"): + return render_template( + request, + "denied.html", + {"reason": "개인경비 모듈 접근 권한이 없습니다."}, + status_code=403, + ) + + store = _store(request) + items = sorted( + store.list_for(user["email"]), + key=lambda i: (i.get("spent_at", ""), i.get("created_at", "")), + reverse=True, + ) + summary = store.summary_for(user["email"]) + return render_template( + request, + "expense/index.html", + { + "user": user, + "is_admin": is_admin(user), + "items": items, + "summary": summary, + "categories": list(CATEGORIES), + "methods": list(METHODS), + "statuses": list(STATUSES), + "nav_items": build_erp_nav(user, active="expense"), + "page_title": "개인경비", + "page_subtitle": "법인카드 · 개인지출 · 정산 신청", + }, + ) + + +@router.get("/api/items") +async def list_items( + request: Request, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + return JSONResponse( + { + "items": _store(request).list_for(user["email"]), + "summary": _store(request).summary_for(user["email"]), + } + ) + + +@router.post("/api/items") +async def create_item( + request: Request, + body: ExpensePayload, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + item = _store(request).create(owner=user["email"], payload=body.model_dump()) + return JSONResponse({"item": item}, status_code=201) + + +@router.put("/api/items/{item_id}") +async def update_item( + request: Request, + item_id: str, + body: ExpensePayload, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + try: + item = _store(request).update( + item_id=item_id, owner=user["email"], payload=body.model_dump() + ) + except KeyError: + raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.") + return JSONResponse({"item": item}) + + +@router.delete("/api/items/{item_id}") +async def delete_item( + request: Request, + item_id: str, + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + try: + _store(request).delete(item_id=item_id, owner=user["email"]) + except KeyError: + raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.") + return JSONResponse({"ok": True}) + + +@router.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "module": "expense"} diff --git a/app/modules/expense/store.py b/app/modules/expense/store.py new file mode 100644 index 0000000..cd7e96e --- /dev/null +++ b/app/modules/expense/store.py @@ -0,0 +1,159 @@ +"""개인경비 항목 JSON 저장소. + +- 저장 위치: DATA_DIR/expense.json +- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제. +- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용. +- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타") +METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금") +STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료") + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class ExpenseStore: + def __init__(self, path: Path): + self._path = path + self._lock = threading.Lock() + self._path.parent.mkdir(parents=True, exist_ok=True) + if not self._path.exists(): + self._write_atomic({"items": []}) + + def _read(self) -> dict[str, Any]: + try: + with self._path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + data = {"items": []} + if not isinstance(data.get("items"), list): + data["items"] = [] + return data + + def _write_atomic(self, data: dict[str, Any]) -> None: + fd, tmp = tempfile.mkstemp( + prefix=".expense.", suffix=".json.tmp", dir=str(self._path.parent) + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, self._path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + def list_for(self, email: str) -> list[dict[str, Any]]: + email = email.lower().strip() + with self._lock: + data = self._read() + return [it for it in data["items"] if it.get("owner") == email] + + def list_all(self) -> list[dict[str, Any]]: + with self._lock: + return list(self._read()["items"]) + + def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None: + owner = owner.lower().strip() + with self._lock: + data = self._read() + for it in data["items"]: + if it["id"] == item_id and it.get("owner") == owner: + return dict(it) + return None + + def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]: + owner = owner.lower().strip() + item = self._normalize(payload) + item["id"] = uuid.uuid4().hex[:12] + item["owner"] = owner + item["status"] = payload.get("status") or "작성중" + item["created_at"] = _now_iso() + item["updated_at"] = item["created_at"] + with self._lock: + data = self._read() + data["items"].append(item) + self._write_atomic(data) + return item + + def update( + self, *, item_id: str, owner: str, payload: dict[str, Any] + ) -> dict[str, Any]: + owner = owner.lower().strip() + with self._lock: + data = self._read() + for idx, it in enumerate(data["items"]): + if it["id"] == item_id and it.get("owner") == owner: + new = self._normalize(payload, base=it) + new["id"] = it["id"] + new["owner"] = it["owner"] + new["created_at"] = it.get("created_at", _now_iso()) + new["status"] = payload.get("status") or it.get("status", "작성중") + new["updated_at"] = _now_iso() + data["items"][idx] = new + self._write_atomic(data) + return new + raise KeyError(item_id) + + def delete(self, *, item_id: str, owner: str) -> None: + owner = owner.lower().strip() + with self._lock: + data = self._read() + before = len(data["items"]) + data["items"] = [ + it + for it in data["items"] + if not (it["id"] == item_id and it.get("owner") == owner) + ] + if len(data["items"]) == before: + raise KeyError(item_id) + self._write_atomic(data) + + def summary_for(self, email: str) -> dict[str, Any]: + items = self.list_for(email) + total = sum(int(i.get("amount", 0)) for i in items) + by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES} + by_category = { + c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c) + for c in CATEGORIES + } + return { + "count": len(items), + "total": total, + "by_status": by_status, + "by_category": by_category, + } + + @staticmethod + def _normalize( + payload: dict[str, Any], base: dict[str, Any] | None = None + ) -> dict[str, Any]: + b = dict(base or {}) + b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip() + category = str(payload.get("category") or b.get("category") or "기타").strip() + method = str(payload.get("method") or b.get("method") or "법인카드").strip() + b["category"] = category if category in CATEGORIES else "기타" + b["method"] = method if method in METHODS else "법인카드" + b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip() + try: + b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0)) + except (TypeError, ValueError): + b["amount"] = 0 + b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip() + return b diff --git a/app/modules/expense/templates/expense/index.html b/app/modules/expense/templates/expense/index.html new file mode 100644 index 0000000..7edc408 --- /dev/null +++ b/app/modules/expense/templates/expense/index.html @@ -0,0 +1,238 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ + +
+
+ 총 건수 + {{ summary.count }} 건 +
+
+ 총 금액 + + {{ "{:,}".format(summary.total) }} 원 + +
+ {% for status in statuses %} +
+ {{ status }} + + {{ summary.by_status.get(status, 0) }} + +
+ {% endfor %} +
+ + +
+
+

경비 등록

+ 필수 항목을 입력하고 등록을 누르세요. +
+
+ + + + + + + +
+ + +
+
+
+ + +
+
+

경비 내역

+ {{ items | length }}건 +
+
+ + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + {% else %} + + {% endfor %} + +
사용일분류수단가맹점금액상태동작
{{ it.spent_at }}{{ it.category }}{{ it.method }} +
{{ it.merchant }}
+ {% if it.memo %}
{{ it.memo }}
{% endif %} +
+ {{ "{:,}".format(it.amount) }} 원 + {{ it.status }} + + +
등록된 경비가 없습니다.
+
+
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/static/erp-shell.css b/app/static/erp-shell.css new file mode 100644 index 0000000..f606323 --- /dev/null +++ b/app/static/erp-shell.css @@ -0,0 +1,288 @@ +/* ════════════════════════════════════════════════════ + ERP Shell — 좌측 사이드바 + 우측 콘텐츠 레이아웃 + erp.css 의 토큰을 그대로 사용한다. 같이 로드 필수. + ════════════════════════════════════════════════════ */ + +:root { + --erp-sidebar-w: 240px; + --erp-sidebar-w-collapsed: 64px; + --erp-topbar-h: 56px; +} + +/* ── 페이지 전체 컨테이너 ── */ +body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; } + +.erp-app { + display: grid; + grid-template-columns: var(--erp-sidebar-w) 1fr; + min-height: 100vh; + transition: grid-template-columns .18s ease; +} +.erp-app:has(.erp-sidebar[data-collapsed="true"]) { + grid-template-columns: var(--erp-sidebar-w-collapsed) 1fr; +} + +/* ── 사이드바 ── */ +.erp-sidebar { + position: sticky; top: 0; + height: 100vh; + border-right: 1px solid var(--color-subtle-ash); + background: var(--color-canvas-white); + display: flex; flex-direction: column; + overflow: hidden; + z-index: 40; +} + +.erp-sidebar-head { + height: var(--erp-topbar-h); + display: flex; align-items: center; justify-content: space-between; + padding: 0 var(--sp-12) 0 var(--sp-16); + border-bottom: 1px solid var(--color-subtle-ash); + flex-shrink: 0; +} +.erp-sidebar-brand { + display: inline-flex; align-items: center; gap: var(--sp-8); + color: var(--color-deep-black); +} +.erp-sidebar-logo { height: 22px; width: auto; display: block; } +.erp-sidebar-system { + font-weight: 600; font-size: 14px; letter-spacing: -0.2px; +} +.erp-sidebar[data-collapsed="true"] .erp-sidebar-system { display: none; } + +.erp-sidebar-toggle { + width: 28px; height: 28px; border-radius: 8px; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: 1px solid transparent; cursor: pointer; + color: var(--color-midtone-gray); +} +.erp-sidebar-toggle:hover { background: var(--color-ghost-gray); color: var(--color-deep-black); } +.erp-sidebar[data-collapsed="true"] .erp-sidebar-toggle svg { transform: rotate(180deg); } + +.erp-sidebar-nav { + flex: 1; overflow-y: auto; + padding: var(--sp-12) var(--sp-8); + display: flex; flex-direction: column; gap: 2px; +} + +.erp-sidebar-group { + font-size: 11px; font-weight: 600; + color: var(--color-midtone-gray); + text-transform: uppercase; letter-spacing: 0.06em; + padding: var(--sp-12) var(--sp-12) var(--sp-6); +} +.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; } + +.erp-sidebar-item { + display: flex; align-items: center; gap: 10px; + padding: 8px var(--sp-12); + border-radius: 10px; + color: var(--color-rich-black); + font-size: 14px; font-weight: 500; + cursor: pointer; + transition: background .12s ease, color .12s ease; + border: 1px solid transparent; + text-decoration: none; + white-space: nowrap; +} +.erp-sidebar-item:hover:not(.is-disabled) { background: var(--color-ghost-gray); } +.erp-sidebar-item.is-active { + background: var(--color-deep-black); + color: var(--color-canvas-white); +} +.erp-sidebar-item.is-disabled { + color: var(--color-midtone-gray); + cursor: not-allowed; +} +.erp-sidebar-icon { + width: 18px; height: 18px; + display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.erp-sidebar-label { flex: 1; } +.erp-sidebar-badge { + background: var(--color-ghost-gray); color: var(--color-midtone-gray); + padding: 2px 8px; border-radius: var(--r-badge); + font-size: 11px; font-weight: 500; +} +.erp-sidebar-item.is-active .erp-sidebar-badge { + background: rgba(255,255,255,0.18); color: var(--color-canvas-white); +} +.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; } + +.erp-sidebar[data-collapsed="true"] .erp-sidebar-label, +.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge, +.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext { + display: none; +} +.erp-sidebar[data-collapsed="true"] .erp-sidebar-item { justify-content: center; padding: 8px 0; } + +.erp-sidebar-foot { + padding: var(--sp-8); + border-top: 1px solid var(--color-subtle-ash); + display: flex; flex-direction: column; gap: 2px; + flex-shrink: 0; +} + +/* ── 콘텐츠 영역 ── */ +.erp-content { display: flex; flex-direction: column; min-width: 0; } + +.erp-topbar { + height: var(--erp-topbar-h); + position: sticky; top: 0; z-index: 30; + background: var(--color-canvas-white); + border-bottom: 1px solid var(--color-subtle-ash); + display: flex; align-items: center; justify-content: space-between; + padding: 0 var(--sp-24); + gap: var(--sp-16); +} +.erp-topbar-left { display: flex; align-items: center; gap: var(--sp-12); min-width: 0; } +.erp-topbar-title h1 { + font-size: 16px; font-weight: 600; margin: 0; line-height: 1.2; + letter-spacing: -0.2px; +} +.erp-topbar-title p { + margin: 2px 0 0; color: var(--color-midtone-gray); font-size: 12px; +} +.erp-topbar-right { display: flex; align-items: center; gap: var(--sp-12); } + +.erp-sidebar-mobile-toggle { + display: none; + width: 32px; height: 32px; border-radius: 8px; + align-items: center; justify-content: center; + background: transparent; border: 1px solid var(--color-subtle-ash); cursor: pointer; + color: var(--color-rich-black); +} + +.erp-page { + padding: var(--sp-24); + max-width: 1280px; width: 100%; margin: 0 auto; +} + +/* ── 홈 대시보드 타일 ── */ +.erp-hero { margin: var(--sp-8) 0 var(--sp-24); } +.erp-hero-title { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -0.6px; } +.erp-hero-sub { margin: 6px 0 0; color: var(--color-midtone-gray); font-size: 13px; } + +.erp-home-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--sp-16); +} +.erp-tile { + border: 1px solid var(--color-subtle-ash); + border-radius: var(--r-card); + padding: var(--sp-20); + background: var(--color-canvas-white); + display: flex; flex-direction: column; gap: 8px; +} +.erp-tile-head { + display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; +} +.erp-tile-label { + font-size: 11px; color: var(--color-midtone-gray); text-transform: uppercase; letter-spacing: 0.08em; +} +.erp-tile-title { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; } +.erp-tile-desc { margin: 0 0 8px; color: var(--color-midtone-gray); font-size: 13px; } +.erp-tile-list { margin: 0; padding-left: 18px; color: var(--color-rich-black); font-size: 13px; } +.erp-tile-list li { margin: 4px 0; } + +/* ── 개인경비 페이지 ── */ +.erp-expense { display: flex; flex-direction: column; gap: var(--sp-20); } + +.erp-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--sp-12); +} +.erp-summary-card { + border: 1px solid var(--color-subtle-ash); + border-radius: var(--r-card); + padding: var(--sp-16); + display: flex; flex-direction: column; gap: 6px; + background: var(--color-canvas-white); +} +.erp-summary-card--mini { padding: var(--sp-12) var(--sp-16); } +.erp-summary-label { font-size: 12px; color: var(--color-midtone-gray); } +.erp-summary-value { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; font-variant-numeric: tabular-nums; } +.erp-summary-value--sm { font-size: 16px; } + +.erp-card-block { + border: 1px solid var(--color-subtle-ash); + border-radius: var(--r-card); + background: var(--color-canvas-white); + overflow: hidden; +} +.erp-card-block-head { + display: flex; align-items: baseline; justify-content: space-between; + padding: var(--sp-16) var(--sp-20); + border-bottom: 1px solid var(--color-subtle-ash); +} +.erp-card-block-head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.2px; } +.erp-muted { color: var(--color-midtone-gray); font-size: 12px; } + +.erp-form-grid { + display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); + gap: var(--sp-12); padding: var(--sp-16) var(--sp-20); +} +.erp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--color-midtone-gray); } +.erp-field-wide { grid-column: span 2; } +.erp-field input, .erp-field select { + border: 1px solid var(--color-subtle-ash); + border-radius: var(--r-input); + padding: 6px 10px; + font-family: inherit; font-size: 14px; color: var(--color-rich-black); + background: var(--color-canvas-white); + outline: none; +} +.erp-field input:focus, .erp-field select:focus { + border-color: var(--color-rich-black); + box-shadow: 0 0 0 3px rgba(0,0,0,0.06); +} +.erp-form-actions { + grid-column: 1 / -1; + display: flex; justify-content: flex-end; gap: var(--sp-8); + padding-top: 4px; +} + +.erp-btn-sm { padding: 4px 10px !important; font-size: 12px !important; } + +.erp-table-wrap { overflow-x: auto; } +.erp-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.erp-table thead th { + text-align: left; + font-weight: 500; color: var(--color-midtone-gray); + padding: 10px var(--sp-16); + border-bottom: 1px solid var(--color-subtle-ash); + background: var(--color-canvas-white); +} +.erp-table tbody td { + padding: 10px var(--sp-16); + border-bottom: 1px solid var(--color-subtle-ash); + vertical-align: top; +} +.erp-table tbody tr:last-child td { border-bottom: 0; } +.erp-table tbody tr:hover { background: var(--color-ghost-gray); } +.erp-row-sub { font-size: 12px; color: var(--color-midtone-gray); margin-top: 2px; } +.erp-empty { text-align: center; color: var(--color-midtone-gray); padding: var(--sp-24); } + +/* ── 반응형 ── */ +@media (max-width: 900px) { + .erp-app { grid-template-columns: 0 1fr; } + .erp-sidebar { + position: fixed; left: 0; top: 0; height: 100vh; + width: var(--erp-sidebar-w); + transform: translateX(-100%); + transition: transform .2s ease; + box-shadow: 0 0 0 1px var(--color-subtle-ash); + } + .erp-sidebar.is-open { transform: translateX(0); } + .erp-sidebar-mobile-toggle { display: inline-flex; } + .erp-form-grid { grid-template-columns: 1fr 1fr; } + .erp-field-wide { grid-column: span 2; } +} +@media (max-width: 560px) { + .erp-form-grid { grid-template-columns: 1fr; } + .erp-field-wide { grid-column: auto; } + .erp-page { padding: var(--sp-16); } +} diff --git a/app/templates/erp_base.html b/app/templates/erp_base.html new file mode 100644 index 0000000..f7e39cd --- /dev/null +++ b/app/templates/erp_base.html @@ -0,0 +1,164 @@ + + + + + + {{ page_title or "ERP" }} — DBX Corporation + + + {% block head_extra %}{% endblock %} + + + +
+ + + + + +
+ +
+
+ +
+

{{ page_title or "" }}

+ {% if page_subtitle %}

{{ page_subtitle }}

{% endif %} +
+
+ +
+
+ {% if user.picture %} + {{ user.name }} + {% else %} + {{ user.name[0] | upper }} + {% endif %} + +
+ 로그아웃 +
+
+ +
+ {% block content %}{% endblock %} +
+
+
+ + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/erp_home.html b/app/templates/erp_home.html new file mode 100644 index 0000000..f9f751f --- /dev/null +++ b/app/templates/erp_home.html @@ -0,0 +1,64 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ +
+

안녕하세요, {{ user.name }}님

+

+
+ +
+ +
+
+ 바로가기 + 개인업무 +
+

개인경비

+

법인카드/개인지출 등록과 정산 신청.

+ 개인경비 열기 +
+ +
+
+ 바로가기 + 준비중 +
+

휴가

+

연차/반차 신청과 잔여일수 확인.

+ +
+ +
+
+ 알림 + 공지 +
+

시스템 안내

+
    +
  • 좌측 메뉴에서 사용 가능한 업무 모듈을 확인하세요.
  • +
  • 외부 모듈(주문/CORM)은 새 창에서 열립니다.
  • +
  • 권한이 필요한 모듈은 관리자에게 요청하세요.
  • +
+
+ +
+
+ + +{% endblock %} diff --git a/docs/DATABASES.md b/docs/DATABASES.md new file mode 100644 index 0000000..f6a1105 --- /dev/null +++ b/docs/DATABASES.md @@ -0,0 +1,174 @@ +# Databases + +## PostgreSQL DB 목록 + +| DB명 | 용도 | +| --- | --- | +| `itemcode_db` | 상품코드, 단품/세트 구성, 채널 ↔ 사내 코드 매칭 | +| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) | +| `return_db` | 반품·교환·CS 데이터 | +| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 | + +--- + +## 명명 규칙 + +- 모든 DB 이름은 **소문자 + 언더스코어**, **`_db`로 끝낸다**. + - ✅ `inventory_db`, `cs_db` + - ❌ `inventoryApp`, `cs-database` +- 신규 DB가 필요하면 **사용자 승인 후** 생성한다. +- 신규 DB 생성 시 함께 정리할 항목: + - 용도와 책임 모듈 + - 소유자(OWNER) 계정 + - 백업 주기와 위치 + - `.env`의 연결 정보 변수명 + +--- + +## 레거시 이름 매핑 + +| 예전 이름 | 현재 기준 | +| --- | --- | +| `orderlist_app` | `orderlist_db` | + +코드/문서/설정에서 `orderlist_app`을 발견하면 `orderlist_db`로 수정한다 (수정 전 영향 범위 확인). + +--- + +## DB 작업 원칙 + +1. **작업 전 백업 우선**. 백업 없는 변경은 진행하지 않는다. +2. 테이블 owner, 권한, sequence 권한을 확인한다. +3. 운영 DB의 `DROP`, `TRUNCATE`, 조건 없는 대량 `DELETE/UPDATE`는 **사용자 확인 없이 실행 금지**. +4. 스키마 변경은 마이그레이션 스크립트(`scripts/` 또는 alembic 등)로 관리한다. +5. 운영 DB와 개발 DB의 접속 정보를 혼동하지 않는다 (`.env`로 분리). + +--- + +## 위험 명령 (사용자 승인 필수) + +| 명령 | 비고 | +| --- | --- | +| `DROP DATABASE` | 복구 불가. 백업 없으면 절대 실행 금지 | +| `DROP TABLE` / `DROP SCHEMA` | 의존 객체 확인 필수 | +| `TRUNCATE` | FK CASCADE 시 광범위 삭제 위험 | +| 조건 없는 `DELETE` / `UPDATE` | `WHERE` 없는 문 차단 | +| `docker volume rm ` | 운영 데이터 영구 손실 | +| `docker compose down -v` | 볼륨까지 제거. 운영에서 금지 | + +실행 전 반드시: + +1. 백업 확인 (`pg_dump`, 컨테이너 외부 마운트) +2. 영향 범위 설명 +3. 사용자 명시 승인 + +--- + +## 자주 쓰는 점검 명령 + +```bash +# 컨테이너/네트워크 +docker ps +docker network ls +docker inspect + +# DB 목록 / 접속 +sudo -u postgres psql -l +docker exec -it psql -U postgres -l + +# 특정 DB 접속 +docker exec -it psql -U -d itemcode_db + +# 테이블/권한 확인 +\dt +\dn+ +\du +\z +``` + +--- + +## expense_db 스키마 / 초기화 + +DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱스·트리거를 한 번에 생성. + +### 테이블 `expense_items` + +| 컬럼 | 타입 | 비고 | +| --- | --- | --- | +| `id` | TEXT PK | 12자 hex (uuid4 앞 12자) | +| `owner` | TEXT | 소유자 email (소문자) | +| `spent_at` | DATE | 사용일 | +| `category` | TEXT | 식대/교통/숙박/비품/접대/통신/기타 | +| `method` | TEXT | 법인카드/개인지출/현금 | +| `merchant` | TEXT | 가맹점 | +| `amount` | BIGINT | 원 단위, ≥ 0 | +| `memo` | TEXT | 비고 | +| `status` | TEXT | 작성중/제출/승인/반려/정산완료 | +| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 | + +인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`. + +### 운영 서버 초기화 (1회) + +```bash +# 1) 비밀번호 변수 준비 (셸 히스토리에 남지 않게 환경변수 사용) +read -s -p "expense_app password: " APP_PWD; echo + +# 2) PostgreSQL 컨테이너에 DDL 적용 +docker exec -i postgres-db psql -U postgres \ + -v app_password="$APP_PWD" \ + < scripts/sql/expense_db_init.sql + +# 3) main-app .env 에 EXPENSE_DB_URL 추가 +# EXPENSE_DB_URL=postgresql://expense_app:@postgres-db:5432/expense_db + +# 4) main-app 재기동 +docker compose up -d --build +``` + +### JSON → DB 마이그레이션 + +```bash +docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \ + python scripts/migrate_expense_json_to_db.py \ + --json /data/expense.json --dry-run +# 결과 확인 후 +docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \ + python scripts/migrate_expense_json_to_db.py --json /data/expense.json +``` + +> 멱등 INSERT(`ON CONFLICT DO NOTHING`). 원본 JSON 은 건드리지 않는다. + +--- + +## 백업 / 복구 (안전 절차) + +### 백업 + +```bash +# 단일 DB 덤프 (운영 권장) +docker exec -t \ + pg_dump -U -F c -d orderlist_db \ + > /var/backups/postgres/orderlist_db_$(date +%F).dump +``` + +### 복구 (덮어쓰기 위험 → 사용자 승인 필수) + +```bash +# 1) 신규 DB로 먼저 복구해 검증 +docker exec -i \ + pg_restore -U -d < backup.dump + +# 2) 검증 완료 후 운영 DB 교체 (필요 시) +``` + +> `pg_restore --clean`은 기존 객체를 삭제한다. **운영 대상 DB에서 절대 무단 실행 금지**. + +--- + +## .env 관련 + +- DB 접속 정보(`*_HOST`, `*_PORT`, `*_USER`, `*_PASSWORD`, `*_NAME`)는 모두 `.env`로 관리한다. +- `.env`는 **Git에 올리지 않는다**. `.env.example`만 커밋한다. +- 비밀값 유출이 의심되면 즉시 회전(비밀번호/키 변경)을 진행한다. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..0cd91de --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,189 @@ +# Deployment + +## 기본 배포 흐름 + +```text +[Windows 개발 PC] + │ git push (Gitea) + ▼ +[Gitea Repository] + │ git pull (서버에서) + ▼ +[Ubuntu Server] + │ .env 확인 → docker compose up --build -d + ▼ +[Docker] + │ main-app, postgres 컨테이너 실행 + ▼ +[NPM (Nginx Proxy Manager)] + │ 외부 HTTPS 종단 → 내부 :80 + ▼ +[서비스 정상 동작] +``` + +--- + +## 운영 서버 배포 경로 + +운영 서버는 Ubuntu Server. 동일 호스트에 여러 서비스가 있으므로 경로를 분리한다. + +| 경로 | 용도 | +| --- | --- | +| **`/opt/www/main`** | **main-app (본 프로젝트) 배포 경로 — 기본** | +| `/opt/dbx-corm` | CORM (CS/발주/반품/코드관리) 서비스 경로 | +| `/opt/dbx-orderlist` | 주문관리/orderlist 서비스 경로 | + +> main-app 관련 모든 명령/문서 작성 시 `/opt/www/main` 사용. +> 새로운 경로가 필요하면 **사용자 지시**를 받은 뒤 결정한다. + +--- + +## 사전 준비 + +- 서버에 Docker, Docker Compose 설치 완료 +- Gitea 접근 권한 있는 SSH/HTTPS 인증 설정 완료 +- NPM에 호스트 매핑 등록: `dbx.no1king.freeddns.org → http://192.168.0.194:80` +- `.env` 값 확보 (Google OAuth, 세션 키, DB 비밀번호 등) + +--- + +## 최초 배포 + +```bash +# 1) main-app 배포 경로 +sudo mkdir -p /opt/www/main +sudo chown $USER:$USER /opt/www/main +cd /opt/www/main + +# 2) 저장소 클론 +git clone https://gitea.no1king.freeddns.org/king/dbx-main.git . + +# 3) 환경 변수 파일 준비 +cp .env.example .env +nano .env # 실제 값으로 수정 (절대 Git에 커밋 금지) + +# 4) 빌드 및 백그라운드 실행 +docker compose up --build -d + +# 5) 로그 확인 +docker compose logs -f +``` + +--- + +## 업데이트 배포 + +```bash +cd /opt/www/main +git pull +docker compose up --build -d +docker compose logs -f --tail=200 +``` + +--- + +## 컨테이너 관리 + +```bash +docker compose ps # 상태 확인 +docker compose restart web # 단일 서비스 재시작 +docker compose stop # 중지 (볼륨 유지) +docker compose down # 컨테이너 제거 (볼륨 유지) +# docker compose down -v # ⚠ 볼륨까지 삭제. 운영에서 금지 +``` + +> `docker compose down -v`, `docker volume rm`, `docker system prune -a --volumes` 는 **사용자 승인 없이 실행 금지**. + +--- + +## .env / 비밀값 관리 + +- `.env`, `.env.local`, `.env.production` 은 **절대 Git에 올리지 않는다**. +- 예시 파일(`.env.example`)만 커밋한다. +- 신규 변수 추가 시 `.env.example`과 본 문서를 함께 갱신한다. +- 비밀값 유출 의심 시 즉시 회전(rotate)한다 (특히 `GOOGLE_CLIENT_SECRET`, `SESSION_SECRET_KEY`, DB 비밀번호). + +### 주요 환경 변수 + +| 변수 | 설명 | +| --- | --- | +| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth 클라이언트 보안 비밀 | +| `SESSION_SECRET_KEY` | 세션 쿠키 서명용 랜덤 문자열 | +| `SESSION_COOKIE_SECURE` | HTTPS 환경 `true` / 로컬 HTTP `false` | +| `PUBLIC_BASE_URL` | 외부 접속 주소 (예: `https://dbx.no1king.freeddns.org`) | +| `CS_ORDER_URL` | CS 발주 업무 버튼 이동 주소 | +| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 | +| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 | +| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 | + +--- + +## 로컬 Docker 테스트 + +```powershell +# 1) 로컬 env 파일 준비 +copy .env.local.example .env.local +# .env.local 을 열어 실제 값으로 수정 + +# 2) 이미지 빌드 및 실행 +docker compose -f docker-compose.local.yml up --build + +# 3) 브라우저 확인 +# http://localhost:8080 +``` + +> 로컬 OAuth: Google Cloud Console 리디렉션 URI에 `http://localhost:8080/auth/google` 추가 필요. + +--- + +## 복구 절차 + +### 컨테이너만 깨진 경우 + +```bash +docker compose up --build -d +docker compose logs -f +``` + +### DB 데이터가 깨진 경우 (사용자 승인 필수) + +1. 즉시 트래픽 차단 (NPM 비활성화 또는 점검 페이지) +2. 가장 최근 백업 확인 +3. **새 DB 이름으로 먼저 복구**해 검증 +4. 검증 완료 후 운영 DB 교체 +5. 복구 후 로그/주문 정합성 점검 + +> 운영 DB에 직접 `pg_restore --clean`을 실행하지 않는다. `DATABASES.md` 참고. + +--- + +## 위험 명령 (사용자 승인 없이 실행 금지) + +| 명령 | 위험 | +| --- | --- | +| `rm -rf` | 파일/디렉터리 영구 삭제 | +| `docker compose down -v` | 볼륨 포함 삭제 → DB 손실 | +| `docker volume rm`, `docker volume prune` | 볼륨 영구 삭제 | +| `docker system prune -a --volumes` | 이미지·네트워크·볼륨 일괄 삭제 | +| `DROP DATABASE`, `DROP TABLE`, `TRUNCATE` | DB 파괴 (`DATABASES.md` 참고) | +| `git reset --hard`, `git push --force`, `git clean -fd` | 작업 내역 손실 | +| 운영 `.env` 덮어쓰기 / 삭제 | 인증·세션 붕괴 | + +원칙: + +1. 실행 전 현재 상태 확인 명령을 먼저 보여준다. +2. 백업 위치를 명시한다. +3. 사용자 명시 승인 후에만 실행한다. + +--- + +## 점검 체크리스트 + +- [ ] `docker compose ps` 모든 서비스 `running` +- [ ] `docker compose logs --tail=200` 에러 없음 +- [ ] `https://dbx.no1king.freeddns.org` 200 응답 +- [ ] Google 로그인 정상 동작 +- [ ] PostgreSQL 컨테이너 볼륨 마운트 정상 +- [ ] 최근 DB 백업 존재 여부 +- [ ] `.env` 미커밋 상태 (`git status`로 확인) diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..0006c63 --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,117 @@ +# Project Overview + +## 한 줄 정의 + +`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다. +주문 수집부터 상품코드 매칭, 재고, CS, 반품, 외부 쇼핑몰 API 연동까지 ERP 운영 전체를 담당한다. + +--- + +## 기능 범위 + +| 모듈 | 설명 | +| --- | --- | +| 주문관리 | 다채널 주문 수집·통합, 상태 추적, 분석 | +| 상품코드 매칭 | 채널별 상품코드 ↔ 사내 표준 상품코드 매핑, 단품/세트 구성 | +| 재고관리 | 입고/출고/재고 조정, 채널별 재고 동기화 | +| CS관리 | 문의/응대 이력, 발주 업무 트리거 | +| 반품관리 | 반품/교환 접수, 처리, 환불 연계 | +| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 | +| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) | +| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | + +--- + +## 모듈 디렉토리 규약 + +신규 업무 모듈은 **별도 디렉토리 한 곳**에 라우터·저장소·템플릿을 모은다. + +``` +app/modules// +├─ __init__.py # router export +├─ router.py # FastAPI APIRouter (prefix=/) +├─ store.py # 데이터 저장소 (JSON → 향후 _db) +└─ templates// # Jinja 템플릿 (ChoiceLoader 로 검색) +``` + +신규 모듈 등록 시 `app/main.py` 의: + +1. `_MODULE_TEMPLATE_DIRS` 에 템플릿 경로 추가 +2. `app.include_router(_router)` 추가 +3. `app.state._store = ...` 등 상태 등록 +4. `MODULE_KEYS` (`app/store.py`) 와 `_menu_items_for()` 메뉴 항목 동기화 + +--- + +## 외부 연동 대상 + +- **쇼핑몰**: 카페24, 네이버 스마트스토어 +- **통합 관리**: 사방넷 +- **물류**: CJ대한통운(CJ Logistics) 외 택배사 API +- **알림**: 문자 발송 API +- **인증**: Google OAuth (Google Workspace 계정 기반) + +--- + +## 기술 스택 + +| 영역 | 사용 기술 | +| --- | --- | +| Backend | Python, FastAPI | +| DB | PostgreSQL (Docker 컨테이너) | +| 컨테이너 | Docker, Docker Compose | +| Reverse Proxy | Nginx Proxy Manager (NPM) | +| OS | Ubuntu Server (Proxmox VM) | +| 인증 | Google OAuth + 화이트리스트 | +| 저장소 | Gitea (self-hosted) | + +--- + +## 관련 DB + +| DB명 | 용도 | +| --- | --- | +| `itemcode_db` | 상품코드, 단품/세트 구성, 매칭 정보 | +| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) | +| `return_db` | 반품·교환·CS 데이터 | +| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) | + +### DB 후보 + +| 모듈 | 현재 저장소 | 비고 | +| --- | --- | --- | +| 휴가 | (미개발) | `vacation_db` (승인 후 생성) | + +> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`. + +--- + +## 접속/도메인 + +| 환경 | 주소 | +| --- | --- | +| 운영 | `https://dbx.no1king.freeddns.org` | +| 로컬 | `http://localhost:8080` | +| Git 저장소 | `https://gitea.no1king.freeddns.org/king/dbx-main.git` | + +--- + +## 허용 사용자 (서버 측 화이트리스트) + +`app/main.py`의 `ALLOWED_EMAILS`에서 검사한다. + +- king@dbxcorp.co.kr +- julie@dbxcorp.co.kr +- ellen@dbxcorp.co.kr +- bj@dbxcorp.co.kr + +Google `hd=dbxcorp.co.kr` 힌트는 클라이언트 편의용이며, **실제 권한 검사는 항상 서버 측**에서 수행한다. + +--- + +## 작업 우선순위 / 원칙 + +1. 운영 데이터 안전이 최우선. 위험 명령은 사용자 승인 후 실행. +2. 신규 기능보다 기존 데이터 일관성 유지 우선. +3. 외부 API 연동은 실패 재시도와 로깅을 기본으로 한다. +4. 비밀값은 `.env`로만 관리하고 Git에 절대 올리지 않는다. diff --git a/docs/SERVER_ARCHITECTURE.md b/docs/SERVER_ARCHITECTURE.md new file mode 100644 index 0000000..61e0722 --- /dev/null +++ b/docs/SERVER_ARCHITECTURE.md @@ -0,0 +1,90 @@ +# Server Architecture + +## 전체 구성도 + +```text +[Windows 개발 PC] + │ Git push / 파일 수정 + ▼ +[Gitea Repository] (https://gitea.no1king.freeddns.org/king/dbx-main.git) + │ git pull + ▼ +[Ubuntu Server / Proxmox VM] + │ + ├─ Nginx Proxy Manager (NPM) + │ dbx.no1king.freeddns.org → http://192.168.0.194:80 + │ + ├─ Docker + │ ├─ main-app 컨테이너 (FastAPI / Uvicorn) + │ ├─ PostgreSQL 컨테이너 + │ └─ 기타 부속 컨테이너 + │ + └─ PostgreSQL Databases + ├─ itemcode_db + ├─ orderlist_db + └─ return_db +``` + +--- + +## 요청 흐름 + +1. 사용자 브라우저 → `https://dbx.no1king.freeddns.org` +2. NPM(외부 HTTPS) → 내부 HTTP `192.168.0.194:80` +3. 서버 내부 Nginx/Compose 포워딩 → `main-app` 컨테이너 (FastAPI) +4. FastAPI → PostgreSQL 컨테이너(같은 Docker 네트워크) 또는 외부 쇼핑몰/택배 API + +--- + +## 구성요소 + +| 구성요소 | 설명 | +| --- | --- | +| **Ubuntu Server** | 호스트 OS. 도커 호스트. systemd로 docker.service 관리 | +| **Docker** | 모든 앱·DB는 컨테이너로 운영. Compose 파일 단위 배포 | +| **PostgreSQL** | Docker 컨테이너에서 운영. 데이터는 named volume | +| **Nginx Proxy Manager** | 외부 HTTPS 종단, 인증서 자동 갱신, 호스트명 라우팅 | +| **FastAPI (main-app)** | 메인 ERP 백엔드. 인증·라우팅·외부 API 연동 | +| **Gitea** | 소스 저장소(사내 호스팅) | + +--- + +## 네트워크 / 포트 + +| 항목 | 값 | +| --- | --- | +| 외부 도메인 | `dbx.no1king.freeddns.org` (HTTPS, NPM 종단) | +| 내부 호스트 | `192.168.0.194` | +| 내부 노출 포트 | `80` (NPM → 컨테이너) | +| 로컬 개발 포트 | `8080` | +| PostgreSQL | Docker 내부 네트워크에서만 접근 (호스트 노출 금지 권장) | + +--- + +## 배포 경로 + +운영 서버 기준, 아래 중 하나에 클론한다. 상세는 `DEPLOYMENT.md`. + +- `/opt/dbx-corm` +- `/opt/dbx-orderlist` +- `/opt/www/main` + +> 같은 호스트에 여러 서비스를 운영하므로, 경로는 서비스별로 분리한다. + +--- + +## 인증 흐름 + +1. 사용자가 `/auth/google` 진입 → Google OAuth 동의 화면 +2. 콜백에서 `id_token` 검증 (`hd=dbxcorp.co.kr` 힌트는 참고용) +3. 서버에서 이메일 도메인 + `ALLOWED_EMAILS` 화이트리스트 검사 +4. 통과 시 세션 쿠키 발급(`SESSION_SECRET_KEY` 서명) + +--- + +## 운영 시 점검 포인트 + +- NPM 호스트 매핑이 살아있는가 (`dbx.no1king.freeddns.org → 192.168.0.194:80`) +- `docker compose ps`로 `main-app`, `postgres` 컨테이너 상태 +- PostgreSQL volume 마운트 경로와 백업 위치 +- `.env` 값 유실 여부 (특히 `GOOGLE_CLIENT_*`, `SESSION_SECRET_KEY`) diff --git a/requirements.txt b/requirements.txt index 6c13006..4412fcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ httpx>=0.28 jinja2>=3.1 itsdangerous>=2.2 python-dotenv>=1.0 +psycopg[binary,pool]>=3.2 diff --git a/scripts/migrate_expense_json_to_db.py b/scripts/migrate_expense_json_to_db.py new file mode 100644 index 0000000..a3defa0 --- /dev/null +++ b/scripts/migrate_expense_json_to_db.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""JSON 저장소 → expense_db 마이그레이션. + +사용법: + EXPENSE_DB_URL=postgresql://expense_app:@:5432/expense_db \ + python scripts/migrate_expense_json_to_db.py \ + --json /data/expense.json \ + [--dry-run] + +원칙: +- 멱등(idempotent): id 가 같으면 INSERT ... ON CONFLICT DO NOTHING. +- JSON 원본은 건드리지 않는다. 검증 완료 후 사용자가 직접 보존/이관. +- 실패 시 트랜잭션 롤백 후 종료. + +위험 경고: +- 본 스크립트는 운영 데이터에 쓰기 작업을 한다. 백업/덤프 후 실행할 것. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +REQUIRED_KEYS = { + "id", + "owner", + "spent_at", + "category", + "method", + "merchant", + "amount", + "memo", + "status", +} + + +def load_items(path: Path) -> list[dict]: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + items = data.get("items") if isinstance(data, dict) else None + if not isinstance(items, list): + raise SystemExit(f"형식 오류: {path} 의 'items' 가 리스트가 아닙니다.") + return items + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--json", required=True, help="원본 expense.json 경로") + parser.add_argument("--dry-run", action="store_true", help="DB 에 쓰지 않고 검증만") + args = parser.parse_args() + + dsn = os.environ.get("EXPENSE_DB_URL", "").strip() + if not dsn: + raise SystemExit("EXPENSE_DB_URL 환경변수가 설정되지 않았습니다.") + + json_path = Path(args.json) + if not json_path.exists(): + raise SystemExit(f"파일을 찾을 수 없습니다: {json_path}") + + items = load_items(json_path) + print(f"읽음: {len(items)} 건 ({json_path})") + + bad = [i for i in items if not REQUIRED_KEYS.issubset(i.keys())] + if bad: + print(f"경고: 필드 누락 {len(bad)}건 — 누락 키는 기본값 채움", file=sys.stderr) + + if args.dry_run: + print("dry-run 완료. DB 에 쓰지 않았습니다.") + return 0 + + import psycopg # type: ignore + + inserted = skipped = 0 + with psycopg.connect(dsn, autocommit=False) as conn: + with conn.cursor() as cur: + for it in items: + row = ( + it["id"], + str(it.get("owner", "")).lower().strip(), + it.get("spent_at") or None, + it.get("category") or "기타", + it.get("method") or "법인카드", + it.get("merchant") or "", + int(it.get("amount") or 0), + it.get("memo") or "", + it.get("status") or "작성중", + it.get("created_at"), + it.get("updated_at"), + ) + cur.execute( + """ + INSERT INTO expense_items + (id, owner, spent_at, category, method, merchant, + amount, memo, status, created_at, updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s, + COALESCE(%s::timestamptz, now()), + COALESCE(%s::timestamptz, now())) + ON CONFLICT (id) DO NOTHING + """, + row, + ) + if cur.rowcount == 1: + inserted += 1 + else: + skipped += 1 + conn.commit() + + print(f"완료: insert={inserted}, skip(중복)={skipped}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sql/expense_db_init.sql b/scripts/sql/expense_db_init.sql new file mode 100644 index 0000000..9d4007f --- /dev/null +++ b/scripts/sql/expense_db_init.sql @@ -0,0 +1,81 @@ +-- ===================================================================== +-- expense_db 초기화 스크립트 (PostgreSQL) +-- ===================================================================== +-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): +-- +-- 1) DB / 역할 생성 (superuser 로 1회) +-- docker exec -i postgres-db psql -U postgres -v app_password='<강한비밀번호>' \ +-- < scripts/sql/expense_db_init.sql +-- +-- 2) main-app .env 에 연결 정보 등록 +-- EXPENSE_DB_URL=postgresql://expense_app:<강한비밀번호>@postgres-db:5432/expense_db +-- +-- 주의: +-- - 기존 DB 가 있으면 DROP 하지 않는다. CREATE DATABASE 만 IF NOT EXISTS 대체. +-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 또는 \set 으로만 전달. +-- - DROP/TRUNCATE 가 필요한 경우 본 스크립트를 수정하지 말고 별도 작업으로 진행. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- DB 가 없을 때만 생성 +SELECT 'CREATE DATABASE expense_db ENCODING ''UTF8'' TEMPLATE template0' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'expense_db') +\gexec + +-- 앱 전용 로그인 역할 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'expense_app') THEN + EXECUTE format('CREATE ROLE expense_app LOGIN PASSWORD %L', :'app_password'); + ELSE + EXECUTE format('ALTER ROLE expense_app WITH LOGIN PASSWORD %L', :'app_password'); + END IF; +END$$; + +GRANT CONNECT ON DATABASE expense_db TO expense_app; + +-- 이제 expense_db 컨텍스트로 전환 +\connect expense_db + +CREATE TABLE IF NOT EXISTS expense_items ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + spent_at DATE NOT NULL, + category TEXT NOT NULL, + method TEXT NOT NULL, + merchant TEXT NOT NULL DEFAULT '', + amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), + memo TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '작성중', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC); +CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status); +CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC); + +-- updated_at 자동 갱신 트리거 +CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_expense_set_updated_at ON expense_items; +CREATE TRIGGER trg_expense_set_updated_at + BEFORE UPDATE ON expense_items + FOR EACH ROW EXECUTE FUNCTION expense_set_updated_at(); + +-- 권한 +GRANT USAGE ON SCHEMA public TO expense_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO expense_app; + +-- 확인용 출력 +SELECT 'expense_db ready' AS status;