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:
@@ -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