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/
|
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
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
jinja2>=3.1
|
||||||
itsdangerous>=2.2
|
itsdangerous>=2.2
|
||||||
python-dotenv>=1.0
|
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