초기 커밋: 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