초기 커밋: Docker/PostgreSQL 이관, 구글 OAuth 제거, Gitea 푸시 스크립트 추가
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
venv*
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.db
|
||||||
|
*.sqlite*
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
+76
@@ -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
@@ -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)
|
||||||
@@ -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 != ''"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
@@ -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>")
|
||||||
@@ -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)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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] 완료되었습니다."
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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">×</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">×</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">×</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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user