초기 커밋: Docker/PostgreSQL 이관, 구글 OAuth 제거, Gitea 푸시 스크립트 추가

This commit is contained in:
king
2026-05-24 03:07:53 +09:00
commit 54707c99a3
23 changed files with 6933 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
venv*
__pycache__
*.pyc
.env
.git
.gitignore
*.db
*.sqlite*
node_modules
.idea
.vscode
+28
View File
@@ -0,0 +1,28 @@
# ===== Database (PostgreSQL) =====
# 앱 컨테이너는 같은 Docker network 안의 postgres-db 컨테이너에 접속합니다.
DB_HOST=postgres-db
DB_PORT=5432
DB_USER=king
DB_PASSWORD=change-me-strong-password
# 프로젝트별 DB 이름
ORDER_DB_NAME=orderlist_db
ITEM_DB_NAME=itemcode_db
RETURN_DB_NAME=return_db
CHECKLIST_DB_NAME=checklist_db
# (선택) DATABASE_URL을 직접 지정하면 위 DB_* 값보다 우선 사용됩니다.
# DATABASE_URL=postgresql+psycopg2://king:password@postgres-db:5432/orderlist_db
# ===== App =====
APP_ROOT_PATH=
SESSION_SECRET_KEY=please-change-this-to-a-long-random-string
# ===== Default admin (구글 로그인 제거 후, /login 시 자동 생성/로그인되는 계정) =====
DEFAULT_ADMIN_EMAIL=king@dbxcorp.co.kr
DEFAULT_ADMIN_NAME=Administrator
# ===== Docker network =====
# 이미 운영 중인 postgres-db가 속한 외부 네트워크 이름.
# `docker inspect postgres-db --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}'` 로 확인.
POSTGRES_NETWORK=postgres-net
+47
View File
@@ -0,0 +1,47 @@
# ===== Secrets / Env =====
.env
*.env
!.env.example
# ===== Python =====
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# ===== Virtual envs =====
.venv/
venv/
venv*/
env/
# ===== Node =====
node_modules/
# ===== Logs / runtime =====
*.log
logs/
*.pid
# ===== Local DB / backups =====
*.db
*.sqlite
*.sqlite3
*.db-journal
app.db.pre-restore-*.db
# ===== OS junk =====
.DS_Store
Thumbs.db
# ===== Editor / IDE =====
.idea/
.vscode/
*.swp
*.swo
# ===== Docker runtime =====
*.pid
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# psycopg2-binary는 빌드툴 없이도 설치 가능하지만, 일부 환경에서 libpq가
# 필요할 수 있어 런타임 의존성만 가볍게 설치합니다.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY app ./app
COPY static ./static
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/health/db || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
View File
+76
View File
@@ -0,0 +1,76 @@
import os
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
router = APIRouter(tags=["auth"])
DEFAULT_ADMIN_EMAIL = os.getenv("DEFAULT_ADMIN_EMAIL", "king@dbxcorp.co.kr")
DEFAULT_ADMIN_NAME = os.getenv("DEFAULT_ADMIN_NAME", "Administrator")
def app_path(path: str = "/") -> str:
root_path = os.getenv("APP_ROOT_PATH", "").rstrip("/")
if path == "/":
return root_path or "/"
return f"{root_path}{path}"
def get_current_user(request: Request, db: Session = Depends(get_db)):
user_email = request.session.get("user_email")
if not user_email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = db.query(User).filter(User.email == user_email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if not user.is_approved and not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not approved by administrator")
return user
def get_admin_user(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user
def _login_as_default_admin(request: Request, db: Session) -> RedirectResponse:
email = DEFAULT_ADMIN_EMAIL
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(email=email, name=DEFAULT_ADMIN_NAME, is_admin=True, is_approved=True)
db.add(user)
db.commit()
db.refresh(user)
request.session["user_email"] = email
return RedirectResponse(url=app_path("/"))
@router.get("/login")
async def login(request: Request, db: Session = Depends(get_db)):
return _login_as_default_admin(request, db)
@router.get("/login/test")
async def login_test(request: Request, db: Session = Depends(get_db)):
return _login_as_default_admin(request, db)
@router.get("/logout")
async def logout(request: Request):
request.session.pop("user_email", None)
return RedirectResponse(url=app_path("/"))
@router.get("/api/users/me")
async def read_users_me(current_user: User = Depends(get_current_user)):
return {"email": current_user.email, "name": current_user.name, "is_admin": current_user.is_admin}
+113
View File
@@ -0,0 +1,113 @@
import logging
import os
from urllib.parse import quote_plus
from sqlalchemy import create_engine, text
from sqlalchemy.orm import declarative_base, sessionmaker
logger = logging.getLogger(__name__)
def load_env_file(path: str = ".env"):
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as env_file:
for raw_line in env_file:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
load_env_file()
def _build_pg_url(db_name: str) -> str:
user = os.getenv("DB_USER", "")
password = os.getenv("DB_PASSWORD", "")
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
return (
f"postgresql+psycopg2://{quote_plus(user)}:{quote_plus(password)}"
f"@{host}:{port}/{db_name}"
)
def _resolve_database_url() -> str:
explicit_url = os.getenv("DATABASE_URL")
if explicit_url:
return explicit_url
order_db = os.getenv("ORDER_DB_NAME")
if order_db and os.getenv("DB_HOST"):
return _build_pg_url(order_db)
return "sqlite:///./app.db"
DATABASE_URL = _resolve_database_url()
connect_args = {}
engine_kwargs = {}
if DATABASE_URL.startswith("sqlite"):
connect_args = {"check_same_thread": False, "timeout": 30}
else:
engine_kwargs.update(pool_pre_ping=True, pool_recycle=1800)
engine = create_engine(DATABASE_URL, connect_args=connect_args, **engine_kwargs)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# --- Secondary engines for the other PostgreSQL databases ----------------
# These are exposed for code that needs to query item / return / checklist
# databases. They are created lazily and only when PostgreSQL is configured.
_secondary_engines: dict[str, object] = {}
def get_secondary_engine(env_var: str):
"""Get (or create) an engine for ITEM_DB_NAME / RETURN_DB_NAME / CHECKLIST_DB_NAME."""
db_name = os.getenv(env_var)
if not db_name or not os.getenv("DB_HOST"):
return None
if env_var in _secondary_engines:
return _secondary_engines[env_var]
eng = create_engine(_build_pg_url(db_name), pool_pre_ping=True, pool_recycle=1800)
_secondary_engines[env_var] = eng
return eng
# Dependency for FastAPI to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def check_db_connection() -> tuple[bool, str]:
"""Ping the primary database. Returns (ok, message)."""
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True, "ok"
except Exception as exc: # noqa: BLE001
return False, str(exc)
def log_db_startup_status() -> None:
safe_url = engine.url.render_as_string(hide_password=True)
ok, msg = check_db_connection()
if ok:
logger.warning("[DB] Connected: %s", safe_url)
else:
logger.error("[DB] Connection FAILED: %s -> %s", safe_url, msg)
+278
View File
@@ -0,0 +1,278 @@
from sqlalchemy import inspect, text
SEARCH_OPTIMIZATION_VERSION = "2026-04-24-search-v1"
PHONE_FTS_VERSION = "2026-04-24-phone-fts-v1"
ADDRESS_NORMALIZATION_VERSION = "2026-04-29-address-normalized-v1"
def _supports_trigram_fts(conn) -> bool:
try:
conn.execute(
text(
"CREATE VIRTUAL TABLE IF NOT EXISTS temp_fts_trigram_check "
"USING fts5(value, tokenize='trigram')"
)
)
conn.execute(text("DROP TABLE IF EXISTS temp_fts_trigram_check"))
return True
except Exception:
return False
def _setting_value(conn, key: str) -> str | None:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"), {"key": key}
).fetchone()
return row[0] if row else None
def _set_setting(conn, key: str, value: str) -> None:
conn.execute(
text(
"INSERT INTO settings(key, value) VALUES (:key, :value) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
{"key": key, "value": value},
)
def ensure_search_optimizations(engine) -> None:
if engine.dialect.name != "sqlite":
ensure_standard_indexes(engine)
return
inspector = inspect(engine)
existing_columns = {column["name"] for column in inspector.get_columns("orders")}
with engine.begin() as conn:
needs_rebuild = _setting_value(conn, "search_optimization_version") != SEARCH_OPTIMIZATION_VERSION
if needs_rebuild:
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_ai"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_ad"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_au"))
conn.execute(text("DROP TABLE IF EXISTS orders_name_address_fts"))
if "recipient_phone_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_phone_normalized TEXT"))
if "recipient_mobile_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_mobile_normalized TEXT"))
if "tracking_number_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN tracking_number_normalized TEXT"))
if "address_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN address_normalized TEXT"))
added_customer_note = "customer_note" not in existing_columns
if added_customer_note:
conn.execute(text("ALTER TABLE orders ADD COLUMN customer_note TEXT"))
if "order_note" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN order_note TEXT"))
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_phone_normalized "
"ON orders(recipient_phone_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_mobile_normalized "
"ON orders(recipient_mobile_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_tracking_normalized "
"ON orders(tracking_number_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_address_normalized "
"ON orders(address_normalized)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_customer_note "
"ON orders(customer_note)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_order_note "
"ON orders(order_note)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_tracking_number_id "
"ON orders(tracking_number, id DESC)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_upload_date_id "
"ON orders(upload_date, id DESC)"
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_orders_order_date_id "
"ON orders(order_date, id DESC)"
)
)
if needs_rebuild:
conn.execute(
text(
"UPDATE orders SET "
"recipient_phone_normalized = replace(replace(replace(replace(replace(coalesce(recipient_phone, ''), '-', ''), ' ', ''), '.', ''), ')', ''), '(', ''), "
"recipient_mobile_normalized = replace(replace(replace(replace(replace(coalesce(recipient_mobile, ''), '-', ''), ' ', ''), '.', ''), ')', ''), '(', ''), "
"tracking_number_normalized = upper(replace(replace(replace(replace(coalesce(tracking_number, ''), '-', ''), ' ', ''), '/', ''), '.', ''))"
)
)
if added_customer_note:
conn.execute(
text(
"UPDATE orders SET customer_note = note "
"WHERE note IS NOT NULL AND note != ''"
)
)
address_normalization_rebuild = (
_setting_value(conn, "address_normalization_version") != ADDRESS_NORMALIZATION_VERSION
)
if address_normalization_rebuild:
conn.execute(
text(
"UPDATE orders SET address_normalized = "
"trim(coalesce(address, ''), "
"' ' || char(9) || char(10) || char(13) || char(160) || "
"char(8203) || char(8204) || char(8205) || char(65279))"
)
)
_set_setting(conn, "address_normalization_version", ADDRESS_NORMALIZATION_VERSION)
tokenizer = "trigram" if _supports_trigram_fts(conn) else "unicode61"
conn.execute(
text(
"CREATE VIRTUAL TABLE IF NOT EXISTS orders_name_address_fts "
"USING fts5("
"recipient_name, address, "
"content='orders', content_rowid='id', "
f"tokenize='{tokenizer}'"
")"
)
)
if needs_rebuild:
conn.execute(text("INSERT INTO orders_name_address_fts(orders_name_address_fts) VALUES('rebuild')"))
_set_setting(conn, "search_optimization_version", SEARCH_OPTIMIZATION_VERSION)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_fts_ai AFTER INSERT ON orders BEGIN "
"INSERT INTO orders_name_address_fts(rowid, recipient_name, address) "
"VALUES (new.id, new.recipient_name, new.address); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_fts_ad AFTER DELETE ON orders BEGIN "
"INSERT INTO orders_name_address_fts(orders_name_address_fts, rowid, recipient_name, address) "
"VALUES('delete', old.id, old.recipient_name, old.address); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_fts_au AFTER UPDATE ON orders BEGIN "
"INSERT INTO orders_name_address_fts(orders_name_address_fts, rowid, recipient_name, address) "
"VALUES('delete', old.id, old.recipient_name, old.address); "
"INSERT INTO orders_name_address_fts(rowid, recipient_name, address) "
"VALUES (new.id, new.recipient_name, new.address); "
"END"
)
)
phone_fts_rebuild = _setting_value(conn, "phone_fts_version") != PHONE_FTS_VERSION
if phone_fts_rebuild:
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_ai"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_ad"))
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_au"))
conn.execute(text("DROP TABLE IF EXISTS orders_phone_fts"))
conn.execute(
text(
"CREATE VIRTUAL TABLE IF NOT EXISTS orders_phone_fts "
"USING fts5("
"recipient_phone_normalized, recipient_mobile_normalized, "
"content='orders', content_rowid='id', tokenize='trigram'"
")"
)
)
if phone_fts_rebuild:
conn.execute(text("INSERT INTO orders_phone_fts(orders_phone_fts) VALUES('rebuild')"))
_set_setting(conn, "phone_fts_version", PHONE_FTS_VERSION)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_ai AFTER INSERT ON orders BEGIN "
"INSERT INTO orders_phone_fts(rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES (new.id, new.recipient_phone_normalized, new.recipient_mobile_normalized); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_ad AFTER DELETE ON orders BEGIN "
"INSERT INTO orders_phone_fts(orders_phone_fts, rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES('delete', old.id, old.recipient_phone_normalized, old.recipient_mobile_normalized); "
"END"
)
)
conn.execute(
text(
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_au AFTER UPDATE ON orders BEGIN "
"INSERT INTO orders_phone_fts(orders_phone_fts, rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES('delete', old.id, old.recipient_phone_normalized, old.recipient_mobile_normalized); "
"INSERT INTO orders_phone_fts(rowid, recipient_phone_normalized, recipient_mobile_normalized) "
"VALUES (new.id, new.recipient_phone_normalized, new.recipient_mobile_normalized); "
"END"
)
)
def ensure_standard_indexes(engine) -> None:
inspector = inspect(engine)
existing_columns = {column["name"] for column in inspector.get_columns("orders")}
with engine.begin() as conn:
if "recipient_phone_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_phone_normalized VARCHAR"))
if "recipient_mobile_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_mobile_normalized VARCHAR"))
if "tracking_number_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN tracking_number_normalized VARCHAR"))
if "address_normalized" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN address_normalized VARCHAR"))
added_customer_note = "customer_note" not in existing_columns
if added_customer_note:
conn.execute(text("ALTER TABLE orders ADD COLUMN customer_note VARCHAR"))
if "order_note" not in existing_columns:
conn.execute(text("ALTER TABLE orders ADD COLUMN order_note VARCHAR"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_phone_normalized ON orders(recipient_phone_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_mobile_normalized ON orders(recipient_mobile_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_tracking_normalized ON orders(tracking_number_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_address_normalized ON orders(address_normalized)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_customer_note ON orders(customer_note)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_order_note ON orders(order_note)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_order_date_id ON orders(order_date, id DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_upload_date_id ON orders(upload_date, id DESC)"))
if added_customer_note:
conn.execute(
text(
"UPDATE orders SET customer_note = note "
"WHERE note IS NOT NULL AND note != ''"
)
)
+43
View File
@@ -0,0 +1,43 @@
import csv
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
LOG_DIR = "logs"
EVENT_LOG_PATH = os.path.join(LOG_DIR, "event_log.csv")
LOG_COLUMNS = ["date", "time", "user", "action", "status", "details"]
KST = ZoneInfo("Asia/Seoul")
def user_label(user) -> str:
if not user:
return ""
return getattr(user, "email", None) or getattr(user, "name", None) or str(getattr(user, "id", ""))
def log_event(user, action: str, status: str = "success", details: dict | str | None = None):
os.makedirs(LOG_DIR, exist_ok=True)
now = datetime.now(KST)
file_exists = os.path.exists(EVENT_LOG_PATH)
details_text = ""
if isinstance(details, dict):
details_text = json.dumps(details, ensure_ascii=False)
elif details is not None:
details_text = str(details)
with open(EVENT_LOG_PATH, "a", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=LOG_COLUMNS)
if not file_exists or os.path.getsize(EVENT_LOG_PATH) == 0:
writer.writeheader()
writer.writerow(
{
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"user": user_label(user),
"action": action,
"status": status,
"details": details_text,
}
)
+61
View File
@@ -0,0 +1,61 @@
from fastapi import FastAPI, Depends, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from starlette.middleware.sessions import SessionMiddleware
import logging
import os
from app.database import engine, Base, check_db_connection, log_db_startup_status
from app.database_maintenance import ensure_search_optimizations
from app.routers import orders, settings
from app.auth import router as auth_router
logging.basicConfig(level=logging.INFO)
APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/")
# Verify DB connectivity at startup (log only — do not crash so the
# container can stay up and report status via /health/db).
log_db_startup_status()
# Create all DB tables
Base.metadata.create_all(bind=engine)
ensure_search_optimizations(engine)
app = FastAPI(title="Order Management System", root_path=APP_ROOT_PATH)
@app.get("/health/db")
async def health_db():
ok, msg = check_db_connection()
status_code = 200 if ok else 503
return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code)
# Session middleware for OAuth
# In production, use a secure secret key from environment variables
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "super-secret-key"))
# Include routers
app.include_router(auth_router)
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
# Mount static files for frontend
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", response_class=HTMLResponse)
async def read_root():
# Return the main index.html file
if os.path.exists("static/index.html"):
with open("static/index.html", "r", encoding="utf-8") as f:
content = f.read()
content = content.replace("__APP_BASE_PATH__", APP_ROOT_PATH)
content = content.replace('src="/static/', f'src="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'src="/static/')
content = content.replace('href="/static/', f'href="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'href="/static/')
content = content.replace("window.location.href='/login", f"window.location.href='{APP_ROOT_PATH}/login")
content = content.replace('window.location.href="/login', f'window.location.href="{APP_ROOT_PATH}/login')
content = content.replace("window.location.href='/logout", f"window.location.href='{APP_ROOT_PATH}/logout")
content = content.replace('window.location.href="/logout', f'window.location.href="{APP_ROOT_PATH}/logout')
return HTMLResponse(content=content)
return HTMLResponse(content="<h1>Welcome. static/index.html is missing.</h1>")
+54
View File
@@ -0,0 +1,54 @@
from sqlalchemy import Column, Integer, String, Boolean
from .database import Base
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
order_date = Column(String, nullable=True) # 주문날짜
sequence_num = Column(String, nullable=True) # 번호
order_no = Column(String, nullable=True) # 주문번호
order_no_mall = Column(String, nullable=True) # 주문번호(쇼핑몰)
recipient_name = Column(String, index=True, nullable=True) # 수령인명
product_code = Column(String, nullable=True) # 상품코드
product_name = Column(String, nullable=True) # 상품명
order_quantity = Column(String, nullable=True) # 주문수량
address = Column(String, index=True, nullable=True) # 주소
address_normalized = Column(String, index=True, nullable=True)
postal_code = Column(String, nullable=True) # 우편번호
recipient_phone = Column(String, nullable=True) # 수령인 전화번호 (일반전화)
recipient_mobile = Column(String, index=True, nullable=True) # 수령인 휴대폰
recipient_phone_normalized = Column(String, index=True, nullable=True)
recipient_mobile_normalized = Column(String, index=True, nullable=True)
delivery_memo = Column(String, nullable=True) # 배송시 요구사항
tracking_number = Column(String, index=True, nullable=True) # 송장번호
tracking_number_normalized = Column(String, index=True, nullable=True)
vendor = Column(String, nullable=True) # 발주처
order_list_1 = Column(String, nullable=True) # 주문목록
order_list_2 = Column(String, nullable=True) # 주문목록2
note = Column(String, index=True) # 비고 (옵션)
customer_note = Column(String, index=True, nullable=True) # 고객 메모
order_note = Column(String, index=True, nullable=True) # 주문 메모
upload_date = Column(String, nullable=True) # 추가된 날짜 및 시간 (yyyy-MM-dd HH:mm:ss)
class User(Base):
"""
Users table for Google Workspace authentication.
Only approved users can access the system.
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
name = Column(String, nullable=True)
is_admin = Column(Boolean, default=False)
is_approved = Column(Boolean, default=False)
class Setting(Base):
"""
Application settings (e.g., Excel export path)
"""
__tablename__ = "settings"
key = Column(String, primary_key=True, index=True)
value = Column(String, nullable=True)
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import re
_NON_DIGIT_RE = re.compile(r"\D+")
_NON_ALNUM_RE = re.compile(r"[^0-9A-Za-z]+")
def normalize_phone(value: str | None) -> str | None:
if not value:
return None
normalized = _NON_DIGIT_RE.sub("", str(value))
return normalized or None
def normalize_tracking(value: str | None) -> str | None:
if not value:
return None
normalized = _NON_ALNUM_RE.sub("", str(value)).upper()
return normalized or None
def fts_phrase(value: str) -> str:
escaped = str(value).strip().replace('"', '""')
return f'"{escaped}"'
def can_use_trigram_fts(value: str | None) -> bool:
if not value:
return False
return len(str(value).strip()) >= 3
def prefix_upper_bound(value: str) -> str | None:
if not value:
return None
last = value[-1]
if last == "\uffff":
return None
return value[:-1] + chr(ord(last) + 1)
+32
View File
@@ -0,0 +1,32 @@
services:
app:
build: .
image: orderlist-app:latest
container_name: orderlist-app
restart: unless-stopped
env_file:
- .env
environment:
# .env에 적힌 값이 컨테이너에서 그대로 보입니다.
# 컨테이너 내부에서 DB_HOST는 반드시 postgres-db 여야 합니다.
DB_HOST: ${DB_HOST:-postgres-db}
ports:
- "8000:8000"
networks:
- postgres_net
- default
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health/db"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
networks:
# 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크에
# 그대로 attach 합니다. (compose가 새로 만들지 않음)
postgres_net:
external: true
name: ${POSTGRES_NETWORK:-postgres-net}
default:
driver: bridge
+110
View File
@@ -0,0 +1,110 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
chcp 65001 >nul
REM ===============================================================
REM Push project to Gitea (Windows)
REM Usage:
REM push_to_gitea.bat
REM push_to_gitea.bat "commit message"
REM 비밀번호/토큰은 이 파일에 절대 저장하지 않습니다.
REM 인증은 Git Credential Manager 또는 푸시 시 직접 입력으로 처리됩니다.
REM ===============================================================
set "REMOTE_URL=https://gitea.no1king.freeddns.org/king/dbx-orderlist.git"
set "BRANCH=main"
cd /d "%~dp0"
where git >nul 2>nul
if errorlevel 1 (
echo [ERROR] git 가 PATH 에 없습니다. Git for Windows 를 설치하세요.
exit /b 1
)
REM ---- 1) 저장소 초기화 (이미 있으면 그대로 사용) ----
if not exist ".git" (
echo [INFO] Git 저장소를 초기화합니다.
git init -b %BRANCH%
if errorlevel 1 (
git init
git symbolic-ref HEAD refs/heads/%BRANCH%
)
) else (
echo [INFO] 이미 Git 저장소가 초기화되어 있습니다.
)
REM ---- 2) 현재 브랜치를 main 으로 맞춤 ----
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "CURRENT_BRANCH=%%b"
if not defined CURRENT_BRANCH set "CURRENT_BRANCH=HEAD"
if /I not "%CURRENT_BRANCH%"=="%BRANCH%" (
if "%CURRENT_BRANCH%"=="HEAD" (
git checkout -b %BRANCH% 2>nul
) else (
echo [INFO] 현재 브랜치 %CURRENT_BRANCH% -> %BRANCH% 로 이름 변경합니다.
git branch -M %BRANCH%
)
)
REM ---- 3) origin 설정 / 확인 ----
git remote get-url origin >nul 2>nul
if errorlevel 1 (
echo [INFO] origin 을 추가합니다: %REMOTE_URL%
git remote add origin "%REMOTE_URL%"
) else (
for /f "delims=" %%u in ('git remote get-url origin') do set "EXIST_URL=%%u"
if /I not "!EXIST_URL!"=="%REMOTE_URL%" (
echo [INFO] origin URL 이 다릅니다.
echo 기존: !EXIST_URL!
echo 변경: %REMOTE_URL%
git remote set-url origin "%REMOTE_URL%"
) else (
echo [INFO] origin 이 이미 올바르게 설정되어 있습니다.
)
)
REM ---- 4) 변경 사항 add ----
git add -A
REM ---- 5) status 보여주고 사용자 확인 ----
echo.
echo =============== git status ===============
git status
echo ==========================================
echo.
set /p CONFIRM="이 내용을 커밋 & push 하시겠습니까? (Y/N): "
if /I not "%CONFIRM%"=="Y" (
echo [INFO] 사용자가 취소했습니다.
exit /b 0
)
REM ---- 6) 커밋 메시지 결정 ----
set "MSG=%~1"
if "%MSG%"=="" (
set /p MSG="커밋 메시지를 입력하세요 (빈 값이면 'update project'): "
)
if "%MSG%"=="" set "MSG=update project"
REM ---- 7) 커밋 (변경 없으면 건너뜀) ----
git diff --cached --quiet
if errorlevel 1 (
git commit -m "%MSG%"
if errorlevel 1 (
echo [ERROR] 커밋 실패.
exit /b 1
)
) else (
echo [INFO] 스테이징된 변경이 없습니다. 커밋을 건너뜁니다.
)
REM ---- 8) push ----
echo [INFO] origin/%BRANCH% 로 push 합니다. 인증창이 뜨면 Gitea ID/비밀번호 또는 토큰을 입력하세요.
git push -u origin %BRANCH%
if errorlevel 1 (
echo [ERROR] push 실패. 인증 또는 네트워크 상태를 확인하세요.
exit /b 1
)
echo.
echo [OK] 완료되었습니다.
endlocal
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# =================================================================
# Push project to Gitea (Ubuntu / Linux / macOS)
# Usage:
# ./push_to_gitea.sh
# ./push_to_gitea.sh "commit message"
# 비밀번호/토큰은 이 파일에 절대 저장하지 않습니다.
# HTTPS 인증은 Git Credential Manager / git-credential-store /
# 또는 push 시 직접 입력으로 처리됩니다.
# =================================================================
set -euo pipefail
REMOTE_URL="https://gitea.no1king.freeddns.org/king/dbx-orderlist.git"
BRANCH="main"
cd "$(dirname "$0")"
if ! command -v git >/dev/null 2>&1; then
echo "[ERROR] git 가 설치되어 있지 않습니다." >&2
exit 1
fi
# ---- 1) 저장소 초기화 (이미 있으면 그대로 사용) ----
if [ ! -d ".git" ]; then
echo "[INFO] Git 저장소를 초기화합니다."
if ! git init -b "$BRANCH" 2>/dev/null; then
git init
git symbolic-ref HEAD "refs/heads/$BRANCH"
fi
else
echo "[INFO] 이미 Git 저장소가 초기화되어 있습니다."
fi
# ---- 2) 현재 브랜치를 main 으로 맞춤 ----
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
if [ "$current_branch" != "$BRANCH" ]; then
if [ "$current_branch" = "HEAD" ]; then
git checkout -b "$BRANCH" 2>/dev/null || true
else
echo "[INFO] 현재 브랜치 $current_branch -> $BRANCH 로 이름 변경합니다."
git branch -M "$BRANCH"
fi
fi
# ---- 3) origin 설정 / 확인 ----
if ! git remote get-url origin >/dev/null 2>&1; then
echo "[INFO] origin 을 추가합니다: $REMOTE_URL"
git remote add origin "$REMOTE_URL"
else
existing="$(git remote get-url origin)"
if [ "$existing" != "$REMOTE_URL" ]; then
echo "[INFO] origin URL 이 다릅니다."
echo " 기존: $existing"
echo " 변경: $REMOTE_URL"
git remote set-url origin "$REMOTE_URL"
else
echo "[INFO] origin 이 이미 올바르게 설정되어 있습니다."
fi
fi
# ---- 4) 변경 사항 add ----
git add -A
# ---- 5) status 보여주고 사용자 확인 ----
echo
echo "=============== git status ==============="
git status
echo "=========================================="
echo
read -r -p "이 내용을 커밋 & push 하시겠습니까? (Y/N): " CONFIRM
case "${CONFIRM:-N}" in
[yY]|[yY][eE][sS]) ;;
*) echo "[INFO] 사용자가 취소했습니다."; exit 0;;
esac
# ---- 6) 커밋 메시지 결정 ----
MSG="${1:-}"
if [ -z "$MSG" ]; then
read -r -p "커밋 메시지를 입력하세요 (빈 값이면 'update project'): " MSG
fi
MSG="${MSG:-update project}"
# ---- 7) 커밋 (변경 없으면 건너뜀) ----
if ! git diff --cached --quiet; then
git commit -m "$MSG"
else
echo "[INFO] 스테이징된 변경이 없습니다. 커밋을 건너뜁니다."
fi
# ---- 8) push ----
echo "[INFO] origin/$BRANCH 로 push 합니다. 인증창이 뜨면 Gitea ID/비밀번호 또는 토큰을 입력하세요."
git push -u origin "$BRANCH"
echo
echo "[OK] 완료되었습니다."
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.110
uvicorn[standard]>=0.27
sqlalchemy>=2.0
psycopg2-binary>=2.9
python-multipart>=0.0.9
itsdangerous>=2.1
httpx>=0.27
openpyxl>=3.1
pydantic>=2.6
+1772
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+433
View File
@@ -0,0 +1,433 @@
<!DOCTYPE None>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order Management System</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css?v=27">
<!-- FontAwesome for Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<!-- Background Elements for Glassmorphism Context -->
<div class="bg-shape shape-1"></div>
<div class="bg-shape shape-2"></div>
<div class="bg-shape shape-3"></div>
<!-- MAIN APP CONTAINER -->
<div class="app-container" id="app">
<!-- LOGIN SCREEN (Visible when unauthenticated) -->
<div id="login-screen" class="screen active glass-panel">
<div class="login-box">
<div class="logo">
<i class="fa-solid fa-boxes-packing logo-icon"></i>
<h2>OMS system</h2>
</div>
<p class="subtitle">Enter the corporate order management center.</p>
<div class="oauth-buttons">
<button class="btn btn-primary" style="width: 100%;"
onclick="window.location.href='/login'">
<i class="fa-solid fa-right-to-bracket"></i> 로그인
</button>
</div>
<p class="auth-hint">Access restricted to approved users.</p>
</div>
</div>
<!-- DASHBOARD SCREEN (Visible when authenticated) -->
<div id="dashboard-screen" class="screen hidden">
<!-- Unified Top Header Bar -->
<header class="unified-header glass-panel fade-in">
<!-- 1. Logo Section -->
<div class="nav-brand">
<img src="/static/img/king_logo.png" alt="King" class="brand-logo-img">
</div>
<!-- 2. Search Section -->
<div class="nav-center-area">
<button id="btn-reset-search" class="btn btn-reset-search"><i
class="fa-solid fa-rotate-left"></i> 초기화</button>
<div class="search-grid-2x2">
<div class="input-group-original compact-field">
<label>이름</label>
<div class="input-wrapper mini-input">
<i class="fa-solid fa-user"></i>
<input type="text" id="search-name" placeholder="이름 입력...">
</div>
</div>
<div class="input-group-original compact-field">
<label>전화</label>
<div class="input-wrapper mini-input">
<i class="fa-solid fa-mobile-screen-button"></i>
<input type="text" id="search-phone" placeholder="전화 입력...">
</div>
</div>
<div class="input-group-original compact-field">
<label>송장</label>
<div class="input-wrapper mini-input">
<i class="fa-solid fa-barcode"></i>
<input type="text" id="search-tracking" placeholder="송장 입력...">
</div>
</div>
<div class="input-group-original address-field">
<label>주소</label>
<div class="input-wrapper mini-input">
<i class="fa-solid fa-map-location-dot"></i>
<input type="text" id="search-address" placeholder="주소 입력...">
</div>
</div>
<div class="input-group-original date-range-filter">
<label>날짜</label>
<div class="date-range-inputs">
<div class="input-wrapper mini-input">
<i class="fa-regular fa-calendar-days"></i>
<input type="date" id="search-start-date">
</div>
<span class="date-range-separator">~</span>
<div class="input-wrapper mini-input">
<i class="fa-regular fa-calendar-days"></i>
<input type="date" id="search-end-date">
</div>
</div>
</div>
</div>
<button id="btn-search" class="btn btn-primary btn-search-tall"><i
class="fa-solid fa-magnifying-glass"></i> 검색</button>
<div class="upload-section-mini"
style="display: flex; flex-direction: row; gap: 0.5rem; align-items: center;">
<label for="file-upload" class="custom-file-upload btn-upload-tall btn-excel">
<i class="fa-solid fa-file-excel"></i>
<span class="upload-label-text">
<span>데이터 추가</span>
<span>(.xls, .xlsx)</span>
</span>
</label>
<input id="file-upload" type="file" accept=".xls, .xlsx" />
</div>
</div>
<!-- 3. Profile Section -->
<div class="nav-profile-area">
<button id="btn-data-management" class="icon-btn mini-icon-btn nav-tool-btn" title="데이터 관리"><i
class="fa-solid fa-database"></i></button>
<div class="user-profile mini-profile">
<span id="user-name">Loading...</span>
<div class="avatar mini-avatar"><i class="fa-solid fa-user"></i></div>
</div>
<button class="icon-btn mini-icon-btn" onclick="window.location.href='/logout'" title="Logout"><i
class="fa-solid fa-right-from-bracket"></i></button>
</div>
</header>
<!-- Main Content Area -->
<main class="main-content">
<!-- Results Panel -->
<div class="results-panel glass-panel fade-in delay-3">
<div class="results-header" style="flex-wrap: wrap; gap: 0.5rem;">
<h3 style="white-space: nowrap;">검색 결과 <span id="result-count" class="badge">0</span></h3>
<!-- Pagination Container -->
<div id="pagination-container" class="pagination"
style="flex: 1; padding: 0; justify-content: center; margin: 0;"></div>
<button id="btn-download-search-results" class="btn btn-success btn-search-download" disabled>
<i class="fa-solid fa-download"></i> 다운로드
</button>
</div>
<div class="table-container">
<table id="results-table">
<thead>
<tr>
<th>주문날짜<div class="resizer"></div>
</th>
<th>수령인명<div class="resizer"></div>
</th>
<th>상품코드<div class="resizer"></div>
</th>
<th>상품명<div class="resizer"></div>
</th>
<th>수량<div class="resizer"></div>
</th>
<th class="col-address">주소<div class="resizer"></div>
</th>
<th>휴대폰<div class="resizer"></div>
</th>
<th>송장번호<div class="resizer"></div>
</th>
<th>발주처<div class="resizer"></div>
</th>
<th class="col-order-no">주문번호(쇼핑몰)<div class="resizer"></div>
</th>
<th class="col-ext">주문목록<div class="resizer"></div>
</th>
</tr>
</thead>
<tbody id="results-body">
<tr>
<td colspan="11" class="empty-state">검색어를 입력하고 검색 버튼을 눌러주세요.</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
</div>
<!-- Order Contact Edit Modal -->
<div id="order-edit-modal" class="modal">
<div class="modal-content glass-panel order-edit-modal-content">
<span class="close-btn" id="order-edit-close">&times;</span>
<h2><i class="fa-solid fa-pen-to-square"></i> 주문 정보 수정</h2>
<div class="form-group">
<label for="edit-recipient-name">이름</label>
<input type="text" id="edit-recipient-name">
</div>
<div class="form-group">
<label for="edit-address">주소</label>
<textarea id="edit-address" rows="3"></textarea>
</div>
<div class="form-group">
<label for="edit-recipient-mobile">휴대폰 번호</label>
<input type="text" id="edit-recipient-mobile">
</div>
<div class="form-group">
<label for="edit-customer-note">고객 메모</label>
<textarea id="edit-customer-note" rows="3" placeholder="이름과 전화번호가 같은 고객 주문에 함께 표시됩니다."></textarea>
</div>
<div class="form-group">
<label for="edit-order-note">주문 메모</label>
<textarea id="edit-order-note" rows="3" placeholder="주문번호가 같은 주문에 함께 표시됩니다."></textarea>
</div>
<div class="modal-actions order-edit-actions">
<button id="btn-delete-order-edit" class="btn btn-danger">
<i class="fa-solid fa-trash-can"></i> 삭제
</button>
<div class="order-edit-action-group">
<button id="btn-open-return-request" class="btn btn-warning">
<i class="fa-solid fa-rotate-left"></i> 반품 입력
</button>
<button id="btn-cancel-order-edit" class="btn btn-secondary">취소</button>
<button id="btn-save-order-edit" class="btn btn-primary">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
</div>
</div>
</div>
</div>
<!-- Return Request Modal -->
<div id="return-request-modal" class="modal">
<div class="modal-content glass-panel return-request-modal-content">
<span class="close-btn" id="return-request-close">&times;</span>
<h2><i class="fa-solid fa-rotate-left"></i> 반품 신청</h2>
<div class="form-group">
<label for="return-request-date">반품 신청일</label>
<input type="date" id="return-request-date">
</div>
<div class="form-group">
<label for="return-request-type">반품 사유/유형</label>
<select id="return-request-type">
<option value="단순변심">단순변심</option>
<option value="상품불량">상품불량</option>
<option value="오배송">오배송</option>
<option value="파손">파손</option>
<option value="맞교환">맞교환</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label for="return-request-tracking">송장번호</label>
<select id="return-request-tracking"></select>
</div>
<div class="form-group">
<label for="return-request-remarks">비고 및 참고사항</label>
<textarea id="return-request-remarks" rows="4"></textarea>
</div>
<div class="modal-actions return-request-actions">
<button id="btn-cancel-return-request" class="btn btn-secondary">취소</button>
<button id="btn-send-return-request" class="btn btn-primary">
<i class="fa-solid fa-paper-plane"></i> 전송
</button>
</div>
</div>
</div>
<!-- Upload Progress Modal -->
<div id="upload-progress-modal" class="modal">
<div class="modal-content glass-panel upload-progress-modal-content">
<h2><i class="fa-solid fa-file-arrow-up"></i> 엑셀 데이터 업로드</h2>
<p id="upload-modal-message" class="upload-progress-message">업로드를 준비하는 중입니다...</p>
<div class="upload-progress-block">
<div class="upload-progress-header">
<span><i class="fa-solid fa-table"></i> 엑셀 데이터 처리</span>
<strong id="upload-data-progress-text">0%</strong>
</div>
<div class="upload-progress-track">
<div id="upload-data-progress-fill" class="upload-progress-fill upload-data-fill"></div>
</div>
<div id="upload-data-progress-count" class="upload-progress-count">0 / 0건</div>
</div>
<div class="upload-progress-block">
<div class="upload-progress-header">
<span><i class="fa-solid fa-magnifying-glass-chart"></i> DB 저장 및 검색 인덱스 반영</span>
<strong id="upload-index-progress-text">0%</strong>
</div>
<div class="upload-progress-track">
<div id="upload-index-progress-fill" class="upload-progress-fill upload-index-fill"></div>
</div>
<div id="upload-index-progress-count" class="upload-progress-count">0 / 0건</div>
</div>
<div class="modal-actions upload-progress-actions">
<button id="btn-close-upload-progress" class="btn btn-primary" disabled>닫기</button>
</div>
</div>
</div>
<!-- Data Management Modal -->
<div id="data-management-modal" class="modal">
<div class="modal-content glass-panel data-management-modal">
<span class="close-btn" id="data-management-close">&times;</span>
<h2 class="data-management-title">
<span class="title-main"><i class="fa-solid fa-database"></i> 데이터 관리</span>
<span id="db-size-badge" class="db-size-badge">용량 확인 중</span>
</h2>
<div class="management-grid management-grid-compact">
<section class="management-card">
<h3><i class="fa-solid fa-file-excel"></i> DB 데이터 엑셀 다운로드</h3>
<label class="inline-check">
<input type="checkbox" id="export-all-dates-chk">
전체 날짜 다운로드
</label>
<div id="export-date-range-container" class="modal-date-range">
<input type="date" id="export-start-date" class="mini-input">
<span>~</span>
<input type="date" id="export-end-date" class="mini-input">
</div>
<button id="btn-export-db" class="btn btn-success full-width-btn">
<i class="fa-solid fa-download"></i> 지정된 범위 데이터 다운로드
</button>
<div id="export-progress-container" class="export-progress-container">
<div class="progress-label-row">
<span id="export-progress-message">파일 생성 준비 중...</span>
<span id="export-progress-text">0%</span>
</div>
<div class="progress-bar-wrapper">
<div id="export-progress-fill"></div>
</div>
<small id="export-progress-count">0 / 0건</small>
</div>
<small>브라우저 다운로드 폴더로 엑셀 파일을 저장합니다.</small>
</section>
<section class="management-card">
<h3><i class="fa-solid fa-hard-drive"></i> DB 백업 및 복구</h3>
<div class="action-stack">
<button id="btn-backup-db" class="btn btn-success full-width-btn">
<i class="fa-solid fa-download"></i> 현재 DB 백업 다운로드
</button>
<button id="btn-download-event-log" class="btn btn-secondary full-width-btn">
<i class="fa-solid fa-file-lines"></i> 이벤트 로그 다운로드
</button>
<input id="restore-db-file" type="file" accept=".db,.sqlite,.sqlite3">
<label for="restore-db-file" class="btn btn-primary full-width-btn">
<i class="fa-solid fa-upload"></i> 백업 DB 파일로 복구
</label>
</div>
<small>복구 시 현재 DB는 자동으로 안전백업 후 교체됩니다.</small>
</section>
<section class="management-card">
<h3><i class="fa-solid fa-broom"></i> 데이터베이스 최적화</h3>
<button id="btn-optimize-db" class="btn btn-primary full-width-btn">
<i class="fa-solid fa-broom"></i> 데이터베이스 최적화
</button>
<div id="optimize-progress-container" class="optimize-progress-container">
<div class="progress-label-row">
<span>최적화 진행 중...</span>
<span id="optimize-progress-text">0%</span>
</div>
<div class="progress-bar-wrapper">
<div id="optimize-progress-fill"></div>
</div>
</div>
<small>DB 파일 용량을 줄이고 내부 저장 공간을 정리합니다.</small>
</section>
<section class="management-card">
<h3><i class="fa-solid fa-repeat"></i> 반복 구매 리스트</h3>
<div class="modal-date-range">
<input type="date" id="repeat-start-date" class="mini-input">
<span>~</span>
<input type="date" id="repeat-end-date" class="mini-input">
</div>
<div class="repeat-purchase-row">
<input type="number" id="repeat-min-count" class="mini-input" min="1" value="3">
<span>회 이상 구매</span>
</div>
<button id="btn-download-repeat-purchase" class="btn btn-success full-width-btn">
<i class="fa-solid fa-download"></i> 반복 구매 주문 다운로드
</button>
<small>선택 기간에서 이름+전화번호 기준으로 구매일수가 지정 회수 이상인 주문을 저장합니다.</small>
</section>
<section class="management-card danger-zone">
<h3><i class="fa-solid fa-calendar-xmark"></i> 날짜 범위 데이터 삭제</h3>
<div class="modal-date-range">
<input type="date" id="delete-start-date" class="mini-input">
<span>~</span>
<input type="date" id="delete-end-date" class="mini-input">
</div>
<button id="btn-delete-date-range-orders" class="btn btn-danger full-width-btn">
<i class="fa-solid fa-calendar-xmark"></i> 지정된 범위 주문 데이터 삭제
</button>
<div id="delete-date-progress-container" class="delete-progress-container">
<div class="progress-label-row">
<span id="delete-date-progress-message">삭제 진행</span>
<span id="delete-date-progress-text">0%</span>
</div>
<div class="progress-bar-wrapper">
<div id="delete-date-progress-fill"></div>
</div>
<small id="delete-date-progress-count">0 / 0건</small>
<div class="progress-label-row delete-cleanup-row">
<span id="delete-cleanup-progress-message">정리 대기</span>
<span id="delete-cleanup-progress-text">0%</span>
</div>
<div class="progress-bar-wrapper">
<div id="delete-cleanup-progress-fill"></div>
</div>
</div>
<small>주문날짜 기준으로 선택한 기간의 데이터만 삭제합니다. 삭제 전 DB 백업을 권장합니다.</small>
</section>
<section class="management-card danger-zone">
<h3><i class="fa-solid fa-trash-can"></i> 전체 데이터 삭제</h3>
<button id="btn-delete-all-orders" class="btn btn-danger full-width-btn">
<i class="fa-solid fa-trash-can"></i> 전체 주문 데이터 삭제
</button>
<small>삭제 전 DB 백업을 먼저 다운로드하는 것을 권장합니다.</small>
</section>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container"></div>
<script>
window.APP_BASE_PATH = "__APP_BASE_PATH__";
</script>
<script src="/static/js/app.js?v=32"></script>
</body>
</html>
+1584
View File
File diff suppressed because it is too large Load Diff