feat(expense): 개인경비 모듈 + ERP 좌측메뉴 셸 + expense_db 연결

- ERP 셸: 좌측 사이드바 + 상단 헤더 + 콘텐츠 영역(erp_base.html)
- 분기: 슈퍼관리자 → 업무모듈 카드, 일반사용자 → ERP 메인(erp_home.html)
- 신규 라우트: /modules (슈퍼관리자 모듈 카드 재진입)
- 개인경비 모듈: app/modules/expense/ (라우터/저장소/템플릿)
  - JSON 저장소(ExpenseStore) + PostgreSQL 저장소(ExpenseDBStore)
  - 팩토리: EXPENSE_DB_URL 있으면 DB, 없으면 JSON 폴백
- DB: scripts/sql/expense_db_init.sql (멱등 DDL, postgres-db 컨테이너)
  - expense_db, 역할 expense_app, 테이블 expense_items, 인덱스/트리거
- 마이그레이션: scripts/migrate_expense_json_to_db.py (멱등, --dry-run)
- 의존성: psycopg[binary,pool]>=3.2
- 환경변수: EXPENSE_DB_URL 추가
- 문서: CLAUDE.md 작업 기준 + docs/{PROJECT_OVERVIEW,SERVER_ARCHITECTURE,DATABASES,DEPLOYMENT}.md
  - main-app 배포 경로 /opt/www/main 고정 명시
  - 위험 명령(DROP/TRUNCATE/rm -rf/volume 삭제) 사용자 승인 정책
This commit is contained in:
2026-05-29 02:21:03 +09:00
parent d7b6c7d029
commit 9c58b022e2
19 changed files with 2337 additions and 5 deletions
+5
View File
@@ -15,3 +15,8 @@ PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org
CS_ORDER_URL=/corm/ CS_ORDER_URL=/corm/
# 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨 # 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨
CUSTOMER_ORDER_LIST_URL=/orderlist/ 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
+103
View File
@@ -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 ← 이 문서
```
+119 -5
View File
@@ -9,9 +9,12 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, FileSystemLoader
from pydantic import BaseModel from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from .modules.expense import build_expense_store
from .modules.expense import router as expense_router
from .store import ( from .store import (
MODULE_KEYS, MODULE_KEYS,
SUPER_ADMIN_EMAIL, SUPER_ADMIN_EMAIL,
@@ -83,10 +86,32 @@ app.add_middleware(
) )
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정.
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
_MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "expense" / "templates",
]
templates = Jinja2Templates(directory=str(BASE_DIR / "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() oauth = build_google_oauth()
user_store = UserStore(DATA_DIR / "users.json") 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: def public_url_for(request: Request, route_name: str) -> str:
public_base_url = env("PUBLIC_BASE_URL").rstrip("/") 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": "개인경비", "title": "개인경비",
"subtitle": "Personal Expense", "subtitle": "Personal Expense",
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.", "description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
"url": "#", "url": "/expense/",
"health_url": None, "health_url": "/expense/health",
"status": "preparing", "status": "ready",
"category": "관리", "category": "관리",
}, },
{ {
@@ -225,6 +250,62 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
return items return items
def _icon_svg(name: str) -> str:
"""좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG."""
paths = {
"home": '<path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/>',
"expense": '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h4"/>',
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
}
body = paths.get(name, paths["modules"])
return (
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
f"{body}</svg>"
)
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) @app.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse: async def home(request: Request) -> HTMLResponse:
sess = get_session_user(request) sess = get_session_user(request)
@@ -235,13 +316,46 @@ async def home(request: Request) -> HTMLResponse:
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인 # 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
request.session.clear() request.session.clear()
return render_template(request, "login.html") 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( return render_template(
request, request,
"main.html", "main.html",
{ {
"user": user_rec, "user": user_rec,
"menu_items": menu_items, "menu_items": _menu_items_for(user_rec),
"is_admin": is_admin(user_rec), "is_admin": is_admin(user_rec),
}, },
) )
View File
+35
View File
@@ -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)
+244
View File
@@ -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
+150
View File
@@ -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"}
+159
View File
@@ -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
@@ -0,0 +1,238 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-expense">
<!-- ── 요약 카드 ── -->
<div class="erp-summary-grid">
<div class="erp-summary-card">
<span class="erp-summary-label">총 건수</span>
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
</div>
<div class="erp-summary-card">
<span class="erp-summary-label">총 금액</span>
<strong class="erp-summary-value" id="ex-total-amount">
{{ "{:,}".format(summary.total) }} 원
</strong>
</div>
{% for status in statuses %}
<div class="erp-summary-card erp-summary-card--mini">
<span class="erp-summary-label">{{ status }}</span>
<strong class="erp-summary-value erp-summary-value--sm">
{{ summary.by_status.get(status, 0) }}
</strong>
</div>
{% endfor %}
</div>
<!-- ── 입력 폼 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 등록</h2>
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
</div>
<form id="ex-form" class="erp-form-grid">
<input type="hidden" name="id" />
<label class="erp-field">
<span>사용일</span>
<input type="date" name="spent_at" required />
</label>
<label class="erp-field">
<span>분류</span>
<select name="category" required>
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select>
</label>
<label class="erp-field">
<span>결제수단</span>
<select name="method" required>
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
</select>
</label>
<label class="erp-field">
<span>금액</span>
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
</label>
<label class="erp-field erp-field-wide">
<span>가맹점/사용처</span>
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
</label>
<label class="erp-field erp-field-wide">
<span>메모</span>
<input type="text" name="memo" placeholder="비고" />
</label>
<div class="erp-form-actions">
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
</div>
</form>
</div>
<!-- ── 항목 목록 ── -->
<div class="erp-card-block">
<div class="erp-card-block-head">
<h2>경비 내역</h2>
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="ex-table">
<thead>
<tr>
<th style="width: 110px;">사용일</th>
<th style="width: 90px;">분류</th>
<th style="width: 100px;">수단</th>
<th>가맹점</th>
<th style="width: 120px; text-align: right;">금액</th>
<th style="width: 90px;">상태</th>
<th style="width: 130px; text-align: right;">동작</th>
</tr>
</thead>
<tbody id="ex-tbody">
{% for it in items %}
<tr data-id="{{ it.id }}">
<td>{{ it.spent_at }}</td>
<td>{{ it.category }}</td>
<td>{{ it.method }}</td>
<td>
<div>{{ it.merchant }}</div>
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">
{{ "{:,}".format(it.amount) }} 원
</td>
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
</td>
</tr>
{% else %}
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
const form = document.getElementById("ex-form");
const submitBtn = document.getElementById("ex-submit");
const resetBtn = document.getElementById("ex-reset");
const tbody = document.getElementById("ex-tbody");
const countEl = document.getElementById("ex-count");
const totalEl = document.getElementById("ex-total-amount");
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")}`;
function setEditing(id, data) {
form.id.value = id || "";
if (data) {
form.spent_at.value = data.spent_at || "";
form.category.value = data.category || "";
form.method.value = data.method || "";
form.amount.value = data.amount || 0;
form.merchant.value = data.merchant || "";
form.memo.value = data.memo || "";
submitBtn.textContent = "수정 저장";
} else {
submitBtn.textContent = "등록";
}
}
resetBtn.addEventListener("click", () => setEditing("", null));
form.addEventListener("submit", async (e) => {
e.preventDefault();
const id = form.id.value.trim();
const payload = {
spent_at: form.spent_at.value,
category: form.category.value,
method: form.method.value,
amount: parseInt(form.amount.value || "0", 10),
merchant: form.merchant.value,
memo: form.memo.value,
};
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
const method = id ? "PUT" : "POST";
try {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
await reload();
form.reset();
setEditing("", null);
} catch (err) {
alert(`저장 실패: ${err.message || err}`);
}
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr");
const id = tr && tr.dataset.id;
if (!id) return;
if (btn.classList.contains("js-delete")) {
if (!confirm("이 항목을 삭제할까요?")) return;
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
if (res.ok) reload(); else alert("삭제 실패");
} else if (btn.classList.contains("js-edit")) {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const found = (j.items || []).find((x) => x.id === id);
if (found) setEditing(id, found);
window.scrollTo({ top: 0, behavior: "smooth" });
}
});
async function reload() {
const res = await fetch(`/expense/api/items`);
if (!res.ok) return;
const j = await res.json();
const items = j.items || [];
const summary = j.summary || { count: 0, total: 0 };
countEl.textContent = `${items.length}`;
if (totalEl) totalEl.textContent = fmt(summary.total);
tbody.innerHTML = "";
if (items.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
return;
}
items
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
.forEach((it) => {
const tr = document.createElement("tr");
tr.dataset.id = it.id;
tr.innerHTML = `
<td>${it.spent_at || ""}</td>
<td>${it.category || ""}</td>
<td>${it.method || ""}</td>
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
<td style="text-align:right;">
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
</td>`;
tbody.appendChild(tr);
});
}
// 사용일 기본값 = 오늘
if (!form.spent_at.value) {
const d = new Date();
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
})();
</script>
{% endblock %}
+288
View File
@@ -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); }
}
+164
View File
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
<link rel="stylesheet" href="/static/erp.css" />
<link rel="stylesheet" href="/static/erp-shell.css" />
{% block head_extra %}{% endblock %}
</head>
<body class="erp-body erp-app-body">
<div class="erp-app">
<!-- ── 좌측 사이드바 ── -->
<aside class="erp-sidebar" data-collapsed="false">
<div class="erp-sidebar-head">
<a href="/" class="erp-sidebar-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
<span class="erp-sidebar-system">ERP</span>
</a>
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
</div>
<nav class="erp-sidebar-nav">
{% set last_group = namespace(value=None) %}
{% for item in nav_items %}
{% if item.group != last_group.value %}
<div class="erp-sidebar-group">{{ item.group }}</div>
{% set last_group.value = item.group %}
{% endif %}
{% set classes = "erp-sidebar-item" %}
{% if item.active %}{% set classes = classes + " is-active" %}{% endif %}
{% if item.disabled %}{% set classes = classes + " is-disabled" %}{% endif %}
{% if item.disabled %}
<div class="{{ classes }}" title="{{ item.disabled_reason or '준비중' }}">
{% else %}
<a class="{{ classes }}" href="{{ item.url }}"
{% if item.target == '_blank' %}target="_blank" rel="noopener noreferrer"{% endif %}>
{% endif %}
<span class="erp-sidebar-icon">{{ item.icon | safe }}</span>
<span class="erp-sidebar-label">{{ item.label }}</span>
{% if item.badge %}<span class="erp-sidebar-badge">{{ item.badge }}</span>{% endif %}
{% if item.target == '_blank' %}
<span class="erp-sidebar-ext" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</span>
{% endif %}
{% if item.disabled %}
</div>
{% else %}
</a>
{% endif %}
{% endfor %}
</nav>
<div class="erp-sidebar-foot">
{% if user.is_super_admin %}
<a class="erp-sidebar-item" href="/modules" title="업무 모듈 선택 화면">
<span class="erp-sidebar-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
</span>
<span class="erp-sidebar-label">업무 모듈</span>
</a>
{% endif %}
{% if is_admin %}
<a class="erp-sidebar-item" href="/admin">
<span class="erp-sidebar-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 4 6v6c0 5 3.4 9.4 8 10 4.6-.6 8-5 8-10V6l-8-4Z"/>
</svg>
</span>
<span class="erp-sidebar-label">관리자</span>
</a>
{% endif %}
</div>
</aside>
<!-- ── 우측 콘텐츠 ── -->
<div class="erp-content">
<header class="erp-topbar">
<div class="erp-topbar-left">
<button type="button" class="erp-sidebar-mobile-toggle" id="erp-sidebar-mobile-toggle" aria-label="메뉴 열기">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="erp-topbar-title">
<h1>{{ page_title or "" }}</h1>
{% if page_subtitle %}<p>{{ page_subtitle }}</p>{% endif %}
</div>
</div>
<div class="erp-topbar-right">
<div class="erp-user-chip">
{% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
{% else %}
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
{% endif %}
<div class="erp-user-info">
<span class="erp-user-name">{{ user.name }}</span>
<span class="erp-user-email">{{ user.email }}</span>
</div>
</div>
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
</div>
</header>
<main class="erp-page">
{% block content %}{% endblock %}
</main>
</div>
</div>
<script>
(function () {
const sidebar = document.querySelector(".erp-sidebar");
const collapseBtn = document.getElementById("erp-sidebar-toggle");
const mobileBtn = document.getElementById("erp-sidebar-mobile-toggle");
if (collapseBtn) {
collapseBtn.addEventListener("click", () => {
const next = sidebar.dataset.collapsed === "true" ? "false" : "true";
sidebar.dataset.collapsed = next;
try { localStorage.setItem("erp_sidebar_collapsed", next); } catch (_) {}
});
try {
const saved = localStorage.getItem("erp_sidebar_collapsed");
if (saved === "true") sidebar.dataset.collapsed = "true";
} catch (_) {}
}
if (mobileBtn) {
mobileBtn.addEventListener("click", () => {
sidebar.classList.toggle("is-open");
});
}
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="erp-home">
<div class="erp-hero">
<h2 class="erp-hero-title">안녕하세요, {{ user.name }}님</h2>
<p class="erp-hero-sub" id="erp-hero-sub"></p>
</div>
<div class="erp-home-grid">
<article class="erp-tile">
<div class="erp-tile-head">
<span class="erp-tile-label">바로가기</span>
<span class="erp-badge erp-badge-neutral">개인업무</span>
</div>
<h3 class="erp-tile-title">개인경비</h3>
<p class="erp-tile-desc">법인카드/개인지출 등록과 정산 신청.</p>
<a class="erp-btn erp-btn-primary" href="/expense/">개인경비 열기</a>
</article>
<article class="erp-tile">
<div class="erp-tile-head">
<span class="erp-tile-label">바로가기</span>
<span class="erp-badge erp-badge-outline">준비중</span>
</div>
<h3 class="erp-tile-title">휴가</h3>
<p class="erp-tile-desc">연차/반차 신청과 잔여일수 확인.</p>
<button class="erp-btn erp-btn-outline" disabled>오픈 예정</button>
</article>
<article class="erp-tile">
<div class="erp-tile-head">
<span class="erp-tile-label">알림</span>
<span class="erp-badge erp-badge-outline">공지</span>
</div>
<h3 class="erp-tile-title">시스템 안내</h3>
<ul class="erp-tile-list">
<li>좌측 메뉴에서 사용 가능한 업무 모듈을 확인하세요.</li>
<li>외부 모듈(주문/CORM)은 새 창에서 열립니다.</li>
<li>권한이 필요한 모듈은 관리자에게 요청하세요.</li>
</ul>
</article>
</div>
</section>
<script>
(function() {
const d = new Date();
const h = d.getHours();
let greet = "오늘도 좋은 하루 되세요.";
if (h < 5) greet = "늦은 시간까지 수고 많으세요.";
else if (h < 12) greet = "활기찬 아침입니다.";
else if (h < 18) greet = "오후 업무 화이팅입니다.";
else greet = "오늘도 고생 많으셨습니다.";
const days = ["일","월","화","수","목","금","토"];
const dateStr = `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} (${days[d.getDay()]})`;
const el = document.getElementById("erp-hero-sub");
if (el) el.textContent = `${dateStr} · ${greet}`;
})();
</script>
{% endblock %}
+174
View File
@@ -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 <postgres_volume>` | 운영 데이터 영구 손실 |
| `docker compose down -v` | 볼륨까지 제거. 운영에서 금지 |
실행 전 반드시:
1. 백업 확인 (`pg_dump`, 컨테이너 외부 마운트)
2. 영향 범위 설명
3. 사용자 명시 승인
---
## 자주 쓰는 점검 명령
```bash
# 컨테이너/네트워크
docker ps
docker network ls
docker inspect <postgres_container_name>
# DB 목록 / 접속
sudo -u postgres psql -l
docker exec -it <postgres_container_name> psql -U postgres -l
# 특정 DB 접속
docker exec -it <postgres_container_name> psql -U <user> -d itemcode_db
# 테이블/권한 확인
\dt
\dn+
\du
\z <table_name>
```
---
## 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:<APP_PWD>@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 <postgres_container_name> \
pg_dump -U <user> -F c -d orderlist_db \
> /var/backups/postgres/orderlist_db_$(date +%F).dump
```
### 복구 (덮어쓰기 위험 → 사용자 승인 필수)
```bash
# 1) 신규 DB로 먼저 복구해 검증
docker exec -i <postgres_container_name> \
pg_restore -U <user> -d <new_db_name> < backup.dump
# 2) 검증 완료 후 운영 DB 교체 (필요 시)
```
> `pg_restore --clean`은 기존 객체를 삭제한다. **운영 대상 DB에서 절대 무단 실행 금지**.
---
## .env 관련
- DB 접속 정보(`*_HOST`, `*_PORT`, `*_USER`, `*_PASSWORD`, `*_NAME`)는 모두 `.env`로 관리한다.
- `.env`**Git에 올리지 않는다**. `.env.example`만 커밋한다.
- 비밀값 유출이 의심되면 즉시 회전(비밀번호/키 변경)을 진행한다.
+189
View File
@@ -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:<pwd>@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`로 확인)
+117
View File
@@ -0,0 +1,117 @@
# Project Overview
## 한 줄 정의
`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다.
주문 수집부터 상품코드 매칭, 재고, CS, 반품, 외부 쇼핑몰 API 연동까지 ERP 운영 전체를 담당한다.
---
## 기능 범위
| 모듈 | 설명 |
| --- | --- |
| 주문관리 | 다채널 주문 수집·통합, 상태 추적, 분석 |
| 상품코드 매칭 | 채널별 상품코드 ↔ 사내 표준 상품코드 매핑, 단품/세트 구성 |
| 재고관리 | 입고/출고/재고 조정, 채널별 재고 동기화 |
| CS관리 | 문의/응대 이력, 발주 업무 트리거 |
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) |
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
---
## 모듈 디렉토리 규약
신규 업무 모듈은 **별도 디렉토리 한 곳**에 라우터·저장소·템플릿을 모은다.
```
app/modules/<name>/
├─ __init__.py # router export
├─ router.py # FastAPI APIRouter (prefix=/<name>)
├─ store.py # 데이터 저장소 (JSON → 향후 <name>_db)
└─ templates/<name>/ # Jinja 템플릿 (ChoiceLoader 로 검색)
```
신규 모듈 등록 시 `app/main.py` 의:
1. `_MODULE_TEMPLATE_DIRS` 에 템플릿 경로 추가
2. `app.include_router(<name>_router)` 추가
3. `app.state.<name>_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에 절대 올리지 않는다.
+90
View File
@@ -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`)
+1
View File
@@ -5,3 +5,4 @@ httpx>=0.28
jinja2>=3.1 jinja2>=3.1
itsdangerous>=2.2 itsdangerous>=2.2
python-dotenv>=1.0 python-dotenv>=1.0
psycopg[binary,pool]>=3.2
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""JSON 저장소 → expense_db 마이그레이션.
사용법:
EXPENSE_DB_URL=postgresql://expense_app:<pwd>@<host>: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())
+81
View File
@@ -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;