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:
@@ -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
|
||||
|
||||
@@ -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 ← 이 문서
|
||||
```
|
||||
+117
-3
@@ -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" / "<name>" / "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": '<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)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
sess = get_session_user(request)
|
||||
@@ -235,6 +316,10 @@ async def home(request: Request) -> HTMLResponse:
|
||||
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
|
||||
request.session.clear()
|
||||
return render_template(request, "login.html")
|
||||
|
||||
# 슈퍼 관리자 → 업무 모듈 선택 화면(main.html).
|
||||
# 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠).
|
||||
if user_rec.get("is_super_admin"):
|
||||
menu_items = _menu_items_for(user_rec)
|
||||
return render_template(
|
||||
request,
|
||||
@@ -246,6 +331,35 @@ async def home(request: Request) -> HTMLResponse:
|
||||
},
|
||||
)
|
||||
|
||||
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_for(user_rec),
|
||||
"is_admin": is_admin(user_rec),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login(request: Request):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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 %}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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`만 커밋한다.
|
||||
- 비밀값 유출이 의심되면 즉시 회전(비밀번호/키 변경)을 진행한다.
|
||||
@@ -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`로 확인)
|
||||
@@ -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에 절대 올리지 않는다.
|
||||
@@ -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`)
|
||||
@@ -5,3 +5,4 @@ httpx>=0.28
|
||||
jinja2>=3.1
|
||||
itsdangerous>=2.2
|
||||
python-dotenv>=1.0
|
||||
psycopg[binary,pool]>=3.2
|
||||
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user