Compare commits
73 Commits
9c58b022e2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 13ac11cc0a | |||
| 069d998c9d | |||
| e782cb4ea7 | |||
| 5582c72467 | |||
| b678d7c37f | |||
| 5b0fe2f1a1 | |||
| 6ae0d90b97 | |||
| 09e2f06a14 | |||
| a107b06826 | |||
| 506db5ff07 | |||
| 0b04a05b26 | |||
| ec8c9edcc9 | |||
| 7ff3500844 | |||
| 84624baf8e | |||
| d728c55e2a | |||
| 55c98b6b95 | |||
| ca53042eb4 | |||
| e2c6a50b46 | |||
| cb43fe1257 | |||
| 8f3761aeae | |||
| d30dcbada4 | |||
| 9f361c4e6a | |||
| fde2c9c2e9 | |||
| 162e1d22a9 | |||
| 1806c75ecb | |||
| 8ab5223790 | |||
| fa1027fa1b | |||
| 32fc12e283 | |||
| 8562c4f65d | |||
| bec9c5e5a6 | |||
| 8c6539d6cb | |||
| 269e779129 | |||
| 3cf22513ca | |||
| 33ce9482dd | |||
| ce1014e6bc | |||
| 652e53c3e8 | |||
| 5c7730d4a1 | |||
| 57f0c3426f | |||
| bcbf3de24c | |||
| 2f16457ac6 | |||
| fdabeec4e3 | |||
| f25948185b | |||
| 7d39c2edec | |||
| 90e286dc4d | |||
| 2e3ea610b7 | |||
| c11c5733de | |||
| 478cde8caf | |||
| 574c053077 | |||
| 800fbd1da4 | |||
| 2607e0610f | |||
| 150335cf87 | |||
| d4e823f1fa | |||
| b75fda5901 | |||
| d9ea7550b0 | |||
| 4113a034f4 | |||
| f9c1dc140b | |||
| 17dfd23cfc | |||
| 45f6b3b174 | |||
| 867bc510da | |||
| 69b84db4bb | |||
| 1d33761003 | |||
| 96e2c49506 | |||
| 34c7e2f964 | |||
| 9e707be5ac | |||
| 6918426264 | |||
| 151de492ad | |||
| d4af2dc064 | |||
| d2c1dfa2a0 | |||
| a4eaf9b770 | |||
| 57ea186061 | |||
| 004b2dff1f | |||
| 550867ba0c | |||
| afacc9a7db |
@@ -20,3 +20,23 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
||||
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
|
||||
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
|
||||
# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db
|
||||
|
||||
# ─── 쿠팡 밀크런 모듈 (cupang_db) ───
|
||||
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||
# DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고.
|
||||
# CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db
|
||||
|
||||
# ─── 휴가 관리 모듈 (vacation_db) ───
|
||||
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||
# DB/역할/스키마/공휴일 seed 생성: scripts/sql/vacation_db_init.sql 참고.
|
||||
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
|
||||
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db
|
||||
|
||||
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||
# itemcode_db 실제 테이블: single_items(낱개) / set_items(세트)
|
||||
# 컬럼: item_code, sabangnet_code, name
|
||||
# 읽기 전용 역할 itemcode_ro 를 먼저 생성하고(아래 DSN), 낱개+세트 UNION 검색 SQL 사용:
|
||||
# ITEMCODE_DB_URL=postgresql://itemcode_ro:replace-me@postgres-db:5432/itemcode_db
|
||||
# ITEMCODE_SEARCH_SQL=SELECT item_code AS code, name AS name, '낱개' AS type FROM single_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s UNION ALL SELECT item_code AS code, name AS name, '세트' AS type FROM set_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s ORDER BY code ASC LIMIT %(limit)s
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
|
||||
app/data/
|
||||
|
||||
# 로컬 전용 스크립트 (자격증명 포함 가능)
|
||||
push-gitea.bat
|
||||
run-local.bat
|
||||
|
||||
@@ -30,6 +30,9 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
||||
- CS관리
|
||||
- 반품관리
|
||||
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
||||
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
||||
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
||||
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
||||
|
||||
상세는 `docs/PROJECT_OVERVIEW.md`.
|
||||
|
||||
|
||||
+74
-5
@@ -13,9 +13,14 @@ from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .modules.expense import build_expense_store
|
||||
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||
from .modules.cupang import router as cupang_router
|
||||
from .modules.expense import CategoryStore, build_expense_store
|
||||
from .modules.expense import router as expense_router
|
||||
from .modules.vacation import build_vacation_store
|
||||
from .modules.vacation import router as vacation_router
|
||||
from .store import (
|
||||
APPROVER_KEYS,
|
||||
MODULE_KEYS,
|
||||
SUPER_ADMIN_EMAIL,
|
||||
UserStore,
|
||||
@@ -24,6 +29,17 @@ from .store import (
|
||||
is_admin,
|
||||
)
|
||||
|
||||
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
|
||||
MODULE_LABELS: dict[str, str] = {
|
||||
"corm": "CORM",
|
||||
"order": "Order",
|
||||
"expense": "개인경비",
|
||||
"vacation": "휴가",
|
||||
"cupang": "쿠팡 밀크런",
|
||||
"expense_approver": "개인경비",
|
||||
"vacation_approver": "휴가",
|
||||
}
|
||||
|
||||
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
||||
SESSION_COOKIE_DEFAULT = "session"
|
||||
|
||||
@@ -90,6 +106,8 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
|
||||
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
||||
_MODULE_TEMPLATE_DIRS = [
|
||||
BASE_DIR / "modules" / "expense" / "templates",
|
||||
BASE_DIR / "modules" / "cupang" / "templates",
|
||||
BASE_DIR / "modules" / "vacation" / "templates",
|
||||
]
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
templates.env.loader = ChoiceLoader(
|
||||
@@ -104,13 +122,24 @@ user_store = UserStore(DATA_DIR / "users.json")
|
||||
|
||||
# 모듈별 데이터 저장소.
|
||||
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
||||
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
|
||||
app.state.expense_store = build_expense_store(
|
||||
dsn=env("EXPENSE_DB_URL") or None,
|
||||
json_path=DATA_DIR / "expense.json",
|
||||
)
|
||||
# 분류(category) 설정 — 관리자가 추가/삭제, 저장 즉시 반영. 항상 JSON 파일.
|
||||
app.state.expense_category_store = CategoryStore(DATA_DIR / "expense_categories.json")
|
||||
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
|
||||
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
|
||||
app.state.itemcode_reader = build_itemcode_reader()
|
||||
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None)
|
||||
|
||||
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
||||
app.include_router(expense_router)
|
||||
app.include_router(cupang_router)
|
||||
app.include_router(vacation_router)
|
||||
|
||||
|
||||
def public_url_for(request: Request, route_name: str) -> str:
|
||||
@@ -233,14 +262,24 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"status": "ready",
|
||||
"category": "관리",
|
||||
},
|
||||
{
|
||||
"key": "cupang",
|
||||
"title": "쿠팡 밀크런",
|
||||
"subtitle": "Coupang Milk-run",
|
||||
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
|
||||
"url": "/cupang/",
|
||||
"health_url": "/cupang/health",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "vacation",
|
||||
"title": "휴가",
|
||||
"subtitle": "Vacation",
|
||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
|
||||
"url": "#",
|
||||
"health_url": None,
|
||||
"status": "preparing",
|
||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
|
||||
"url": "/vacation/",
|
||||
"health_url": "/vacation/health",
|
||||
"status": "ready",
|
||||
"category": "관리",
|
||||
},
|
||||
]
|
||||
@@ -258,6 +297,7 @@ def _icon_svg(name: str) -> str:
|
||||
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
|
||||
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
||||
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
||||
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
|
||||
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||
}
|
||||
body = paths.get(name, paths["modules"])
|
||||
@@ -301,6 +341,9 @@ def build_erp_nav(
|
||||
"disabled_reason": "준비중" if m["status"] != "ready" else None,
|
||||
}
|
||||
)
|
||||
# 같은 그룹끼리 묶이도록 정렬(그룹 헤더 중복 방지). 그룹 내 순서는 유지(stable).
|
||||
group_order = {"ERP": 0, "운영": 1, "관리": 2}
|
||||
items.sort(key=lambda it: group_order.get(it["group"], 9))
|
||||
for it in items:
|
||||
it["active"] = it["key"] == active
|
||||
return items
|
||||
@@ -459,6 +502,8 @@ async def admin_page(request: Request) -> HTMLResponse:
|
||||
"user": rec,
|
||||
"users": users,
|
||||
"module_keys": list(MODULE_KEYS),
|
||||
"module_labels": MODULE_LABELS,
|
||||
"approver_keys": list(APPROVER_KEYS),
|
||||
"super_admin_email": SUPER_ADMIN_EMAIL,
|
||||
"is_admin": True,
|
||||
},
|
||||
@@ -471,11 +516,35 @@ class UpdatePermissionsBody(BaseModel):
|
||||
modules: dict[str, bool] | None = None
|
||||
|
||||
|
||||
class CreateUserBody(BaseModel):
|
||||
email: str
|
||||
name: str = ""
|
||||
role: str = "user"
|
||||
modules: dict[str, bool] | None = None
|
||||
|
||||
|
||||
@app.get("/api/users")
|
||||
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
||||
return JSONResponse({"users": user_store.list_all()})
|
||||
|
||||
|
||||
@app.post("/api/users")
|
||||
async def api_create_user(
|
||||
body: CreateUserBody,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
rec = user_store.create_user(
|
||||
email=body.email,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
modules=body.modules,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"user": rec}, status_code=201)
|
||||
|
||||
|
||||
@app.put("/api/users/{email}")
|
||||
async def api_update_user(
|
||||
email: str,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""쿠팡 밀크런(cupang) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cupang)
|
||||
- 저장소: `db.py` (cupang_db / PostgreSQL 전용) + `store.py` (상수/계산)
|
||||
- 상품검색: `itemcode.py` (itemcode_db 읽기 전용)
|
||||
- 템플릿: `templates/cupang/`
|
||||
|
||||
데이터 저장은 cupang_db 전용이다. CUPANG_DB_URL 미설정 시 build_cupang_store 는
|
||||
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import DEFAULT_CENTERS, SHIP_METHODS, STATUSES, compute_boxes
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"STATUSES",
|
||||
"SHIP_METHODS",
|
||||
"DEFAULT_CENTERS",
|
||||
"compute_boxes",
|
||||
"build_cupang_store",
|
||||
"build_itemcode_reader",
|
||||
]
|
||||
|
||||
|
||||
def build_cupang_store(*, dsn: str | None) -> Any:
|
||||
"""CUPANG_DB_URL 이 있으면 CupangDBStore, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import CupangDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return CupangDBStore(dsn)
|
||||
|
||||
|
||||
def build_itemcode_reader() -> Any:
|
||||
"""itemcode_db 읽기 전용 상품 검색 리더. 설정 없으면 비활성(enabled=False)."""
|
||||
from .itemcode import ItemcodeReader # 지연 import
|
||||
|
||||
return ItemcodeReader()
|
||||
@@ -0,0 +1,626 @@
|
||||
"""cupang_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `CUPANG_DB_URL`
|
||||
(예: postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db)
|
||||
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||
`scripts/sql/cupang_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
|
||||
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
|
||||
클라이언트가 보낸 박스 수는 신뢰하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
from .store import STATUSES, compute_boxes
|
||||
|
||||
|
||||
class CupangDBStore:
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 입고센터 (cupang_centers)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_centers(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_centers {where} "
|
||||
"ORDER BY active DESC, sort_order ASC, name ASC"
|
||||
).fetchall()
|
||||
return [self._center_serialize(r) for r in rows]
|
||||
|
||||
def get_center(self, *, center_id: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cupang_centers WHERE id = %s", (center_id,)
|
||||
).fetchone()
|
||||
return self._center_serialize(row) if row else None
|
||||
|
||||
def create_center(self, *, name: str, sort_order: int = 0) -> dict[str, Any]:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("센터명 필수")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_centers (name, sort_order)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET active = TRUE, sort_order = EXCLUDED.sort_order
|
||||
RETURNING *
|
||||
""",
|
||||
(name, sort_order),
|
||||
).fetchone()
|
||||
return self._center_serialize(row)
|
||||
|
||||
def update_center(
|
||||
self,
|
||||
*,
|
||||
center_id: int,
|
||||
name: str | None = None,
|
||||
active: bool | None = None,
|
||||
sort_order: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
if name is not None:
|
||||
n = name.strip()
|
||||
if not n:
|
||||
raise ValueError("센터명은 비울 수 없습니다.")
|
||||
sets.append("name = %s")
|
||||
params.append(n)
|
||||
if active is not None:
|
||||
sets.append("active = %s")
|
||||
params.append(bool(active))
|
||||
if sort_order is not None:
|
||||
sets.append("sort_order = %s")
|
||||
params.append(int(sort_order))
|
||||
if not sets:
|
||||
raise ValueError("변경할 값이 없습니다.")
|
||||
params.append(center_id)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
f"UPDATE cupang_centers SET {', '.join(sets)} "
|
||||
"WHERE id = %s RETURNING *",
|
||||
params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(center_id)
|
||||
return self._center_serialize(row)
|
||||
|
||||
def center_in_use(self, *, center_id: int) -> bool:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM cupang_shipments WHERE center_id = %s LIMIT 1",
|
||||
(center_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def delete_center(self, *, center_id: int) -> dict[str, Any]:
|
||||
"""사용 중이면 hard delete 하지 않고 active=false 로 비활성화한다.
|
||||
|
||||
반환: {"deleted": bool, "deactivated": bool}
|
||||
"""
|
||||
if self.center_in_use(center_id=center_id):
|
||||
self.update_center(center_id=center_id, active=False)
|
||||
return {"deleted": False, "deactivated": True}
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_centers WHERE id = %s", (center_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(center_id)
|
||||
return {"deleted": True, "deactivated": False}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 박스 입수량 규칙 (cupang_box_rules)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_box_rules {where} "
|
||||
"ORDER BY product_code ASC"
|
||||
).fetchall()
|
||||
return [self._rule_serialize(r) for r in rows]
|
||||
|
||||
def upsert_box_rule(
|
||||
self,
|
||||
*,
|
||||
product_code: str,
|
||||
units_per_box: int,
|
||||
product_name_snapshot: str = "",
|
||||
box_name: str = "쿠팡박스",
|
||||
memo: str = "",
|
||||
) -> dict[str, Any]:
|
||||
code = (product_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("제품코드 필수")
|
||||
upb = int(units_per_box)
|
||||
if upb <= 0:
|
||||
raise ValueError("박스당 입수량은 1 이상이어야 합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_box_rules
|
||||
(product_code, product_name_snapshot, box_name, units_per_box, memo)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (product_code) DO UPDATE
|
||||
SET product_name_snapshot = EXCLUDED.product_name_snapshot,
|
||||
box_name = EXCLUDED.box_name,
|
||||
units_per_box = EXCLUDED.units_per_box,
|
||||
memo = EXCLUDED.memo,
|
||||
active = TRUE
|
||||
RETURNING *
|
||||
""",
|
||||
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()),
|
||||
).fetchone()
|
||||
return self._rule_serialize(row)
|
||||
|
||||
def delete_box_rule(self, *, rule_id: int) -> None:
|
||||
"""완전 삭제(hard). 라인의 box_rule_id 는 ON DELETE 미설정이므로
|
||||
참조 중이면 FK 위반 가능 → 참조 라인의 box_rule_id 를 먼저 NULL 처리."""
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"UPDATE cupang_shipment_lines SET box_rule_id = NULL WHERE box_rule_id = %s",
|
||||
(rule_id,),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_box_rules WHERE id = %s", (rule_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(rule_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 제품명 카탈로그 (cupang_products)
|
||||
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
|
||||
# 제품명 선택 → product_code 자동 채움.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_products(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_products {where} "
|
||||
"ORDER BY product_code ASC"
|
||||
).fetchall()
|
||||
return [self._product_serialize(r) for r in rows]
|
||||
|
||||
def upsert_product(
|
||||
self, *, product_code: str, product_name: str, sort_order: int = 0
|
||||
) -> dict[str, Any]:
|
||||
code = (product_code or "").strip()
|
||||
name = (product_name or "").strip()
|
||||
if not code or not name:
|
||||
raise ValueError("제품코드와 제품명 모두 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_products (product_code, product_name, sort_order)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (product_code) DO UPDATE
|
||||
SET product_name = EXCLUDED.product_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
active = TRUE
|
||||
RETURNING *
|
||||
""",
|
||||
(code, name, sort_order),
|
||||
).fetchone()
|
||||
return self._product_serialize(row)
|
||||
|
||||
def set_product_active(self, *, product_id: int, active: bool) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE cupang_products SET active = %s WHERE id = %s",
|
||||
(bool(active), product_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(product_id)
|
||||
|
||||
def delete_product(self, *, product_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_products WHERE id = %s", (product_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(product_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 출고 묶음 (cupang_shipments + cupang_shipment_lines)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_shipments(
|
||||
self,
|
||||
*,
|
||||
year: int | None = None,
|
||||
month: int | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""헤더 목록(라인 제외). 달력/리스트 표시에 사용.
|
||||
|
||||
year+month 가 주어지면 작성일/출고일/센터입고일 중 하나라도 해당 월에
|
||||
걸치는 묶음을 모두 포함한다(달력 표시용).
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if year and month:
|
||||
clauses.append(
|
||||
"(date_trunc('month', document_date) = make_date(%s, %s, 1)"
|
||||
" OR date_trunc('month', ship_date) = make_date(%s, %s, 1)"
|
||||
" OR date_trunc('month', center_arrival_date) = make_date(%s, %s, 1))"
|
||||
)
|
||||
params.extend([year, month, year, month, year, month])
|
||||
if date_from:
|
||||
clauses.append("ship_date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("ship_date <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_shipments {where} "
|
||||
"ORDER BY ship_date ASC, id ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._shipment_serialize(r) for r in rows]
|
||||
|
||||
def get_shipment(self, *, shipment_id: int, with_lines: bool = True) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cupang_shipments WHERE id = %s", (shipment_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
head = self._shipment_serialize(row)
|
||||
if with_lines:
|
||||
line_rows = conn.execute(
|
||||
"SELECT * FROM cupang_shipment_lines WHERE shipment_id = %s "
|
||||
"ORDER BY line_no ASC",
|
||||
(shipment_id,),
|
||||
).fetchall()
|
||||
head["lines"] = [self._line_serialize(r) for r in line_rows]
|
||||
return head
|
||||
|
||||
def create_shipment(
|
||||
self, *, created_by: str, header: dict[str, Any], lines: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_header(header)
|
||||
norm_lines = self._normalize_lines(lines)
|
||||
if not norm_lines:
|
||||
raise ValueError("품목 라인이 최소 1개 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_shipments
|
||||
(created_by, document_date, ship_date,
|
||||
center_arrival_date, center_id, center_name_snapshot,
|
||||
ship_method, outbound_summary, worker, status, memo)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
created_by.lower().strip(),
|
||||
h["document_date"],
|
||||
h["ship_date"],
|
||||
h["center_arrival_date"],
|
||||
h["center_id"],
|
||||
h["center_name_snapshot"],
|
||||
h["ship_method"],
|
||||
h["outbound_summary"],
|
||||
h["worker"],
|
||||
h["status"],
|
||||
h["memo"],
|
||||
),
|
||||
).fetchone()
|
||||
shipment_id = row["id"]
|
||||
self._insert_lines(conn, shipment_id, norm_lines)
|
||||
return self.get_shipment(shipment_id=shipment_id)
|
||||
|
||||
def update_shipment(
|
||||
self, *, shipment_id: int, header: dict[str, Any], lines: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_header(header)
|
||||
norm_lines = self._normalize_lines(lines)
|
||||
if not norm_lines:
|
||||
raise ValueError("품목 라인이 최소 1개 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE cupang_shipments
|
||||
SET document_date = %s, ship_date = %s,
|
||||
center_arrival_date = %s, center_id = %s,
|
||||
center_name_snapshot = %s, ship_method = %s,
|
||||
outbound_summary = %s, worker = %s, memo = %s
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
h["document_date"],
|
||||
h["ship_date"],
|
||||
h["center_arrival_date"],
|
||||
h["center_id"],
|
||||
h["center_name_snapshot"],
|
||||
h["ship_method"],
|
||||
h["outbound_summary"],
|
||||
h["worker"],
|
||||
h["memo"],
|
||||
shipment_id,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(shipment_id)
|
||||
# 라인 전체 교체 (CASCADE 아님 — 명시적 삭제 후 재삽입)
|
||||
conn.execute(
|
||||
"DELETE FROM cupang_shipment_lines WHERE shipment_id = %s",
|
||||
(shipment_id,),
|
||||
)
|
||||
self._insert_lines(conn, shipment_id, norm_lines)
|
||||
return self.get_shipment(shipment_id=shipment_id)
|
||||
|
||||
def set_status(self, *, shipment_id: int, status: str) -> dict[str, Any]:
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"허용되지 않는 상태: {status}")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"UPDATE cupang_shipments SET status = %s WHERE id = %s RETURNING *",
|
||||
(status, shipment_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(shipment_id)
|
||||
return self._shipment_serialize(row)
|
||||
|
||||
def soft_delete(self, *, shipment_id: int) -> dict[str, Any]:
|
||||
"""운영 안전을 위한 기본 삭제 — status='취소'."""
|
||||
return self.set_status(shipment_id=shipment_id, status="취소")
|
||||
|
||||
def hard_delete(self, *, shipment_id: int) -> None:
|
||||
"""완전 삭제(라인은 ON DELETE CASCADE). 취소 처리로 충분하므로 기본 미사용."""
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_shipments WHERE id = %s", (shipment_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(shipment_id)
|
||||
|
||||
def _insert_lines(self, conn: Any, shipment_id: int, lines: list[dict[str, Any]]) -> None:
|
||||
for idx, ln in enumerate(lines, start=1):
|
||||
calc = compute_boxes(ln["quantity"], ln.get("units_per_box"))
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_shipment_lines
|
||||
(shipment_id, line_no, product_code, product_name_snapshot,
|
||||
quantity, box_rule_id, units_per_box, calculated_boxes,
|
||||
remainder_units, manual_box_text, memo)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
shipment_id,
|
||||
idx,
|
||||
ln["product_code"],
|
||||
ln["product_name_snapshot"],
|
||||
ln["quantity"],
|
||||
ln.get("box_rule_id"),
|
||||
calc["units_per_box"],
|
||||
calc["required_boxes"],
|
||||
calc["remainder_units"],
|
||||
ln.get("manual_box_text", ""),
|
||||
ln.get("memo", ""),
|
||||
),
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 달력 집계
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def calendar_counts(self, *, year: int, month: int) -> dict[str, dict[str, int]]:
|
||||
"""해당 월의 날짜별 작성/출고/입고 건수.
|
||||
|
||||
반환: { "YYYY-MM-DD": {"document": n, "ship": n, "arrival": n} }
|
||||
취소 상태는 제외한다.
|
||||
"""
|
||||
first = (year, month)
|
||||
out: dict[str, dict[str, int]] = {}
|
||||
|
||||
def _accumulate(rows: list[dict[str, Any]], key: str) -> None:
|
||||
for r in rows:
|
||||
d = r["d"]
|
||||
ds = d.isoformat() if isinstance(d, date) else str(d)
|
||||
out.setdefault(ds, {"document": 0, "ship": 0, "arrival": 0})
|
||||
out[ds][key] = int(r["c"])
|
||||
|
||||
with self._pool.connection() as conn:
|
||||
doc = conn.execute(
|
||||
"SELECT document_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||
"WHERE status <> '취소' AND date_trunc('month', document_date) = make_date(%s,%s,1) "
|
||||
"GROUP BY 1",
|
||||
first,
|
||||
).fetchall()
|
||||
ship = conn.execute(
|
||||
"SELECT ship_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||
"WHERE status <> '취소' AND date_trunc('month', ship_date) = make_date(%s,%s,1) "
|
||||
"GROUP BY 1",
|
||||
first,
|
||||
).fetchall()
|
||||
arr = conn.execute(
|
||||
"SELECT center_arrival_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||
"WHERE status <> '취소' AND date_trunc('month', center_arrival_date) = make_date(%s,%s,1) "
|
||||
"GROUP BY 1",
|
||||
first,
|
||||
).fetchall()
|
||||
_accumulate(doc, "document")
|
||||
_accumulate(ship, "ship")
|
||||
_accumulate(arr, "arrival")
|
||||
return out
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 정규화 / 직렬화 helpers
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@staticmethod
|
||||
def _normalize_header(header: dict[str, Any]) -> dict[str, Any]:
|
||||
def _d(key: str) -> str:
|
||||
v = str(header.get(key) or "").strip()
|
||||
return v
|
||||
|
||||
document_date = _d("document_date")
|
||||
ship_date = _d("ship_date")
|
||||
center_arrival_date = _d("center_arrival_date")
|
||||
if not document_date or not ship_date or not center_arrival_date:
|
||||
raise ValueError("작성일/출고일/센터입고일은 필수입니다.")
|
||||
|
||||
status = str(header.get("status") or "작성중").strip()
|
||||
if status not in STATUSES:
|
||||
status = "작성중"
|
||||
|
||||
center_id_raw = header.get("center_id")
|
||||
try:
|
||||
center_id = int(center_id_raw) if center_id_raw not in (None, "", "0") else None
|
||||
except (TypeError, ValueError):
|
||||
center_id = None
|
||||
|
||||
return {
|
||||
"document_date": document_date,
|
||||
"ship_date": ship_date,
|
||||
"center_arrival_date": center_arrival_date,
|
||||
"center_id": center_id,
|
||||
"center_name_snapshot": str(header.get("center_name_snapshot") or "").strip(),
|
||||
"ship_method": str(header.get("ship_method") or "택배").strip() or "택배",
|
||||
"outbound_summary": str(header.get("outbound_summary") or "").strip(),
|
||||
"worker": str(header.get("worker") or "").strip(),
|
||||
"status": status,
|
||||
"memo": str(header.get("memo") or "").strip(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in lines or []:
|
||||
code = str(raw.get("product_code") or "").strip()
|
||||
name = str(raw.get("product_name_snapshot") or raw.get("product_name") or "").strip()
|
||||
try:
|
||||
qty = int(raw.get("quantity") or 0)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
if not code or qty <= 0:
|
||||
continue # 빈 라인 스킵
|
||||
upb_raw = raw.get("units_per_box")
|
||||
try:
|
||||
upb = int(upb_raw) if upb_raw not in (None, "", "0") else None
|
||||
except (TypeError, ValueError):
|
||||
upb = None
|
||||
rule_id_raw = raw.get("box_rule_id")
|
||||
try:
|
||||
rule_id = int(rule_id_raw) if rule_id_raw not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
rule_id = None
|
||||
out.append(
|
||||
{
|
||||
"product_code": code,
|
||||
"product_name_snapshot": name or code,
|
||||
"quantity": qty,
|
||||
"units_per_box": upb,
|
||||
"box_rule_id": rule_id,
|
||||
"manual_box_text": str(raw.get("manual_box_text") or "").strip(),
|
||||
"memo": str(raw.get("memo") or "").strip(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _center_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["active"] = bool(out.get("active", True))
|
||||
out["sort_order"] = int(out.get("sort_order", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _rule_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["units_per_box"] = int(out.get("units_per_box", 0))
|
||||
out["active"] = bool(out.get("active", True))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["active"] = bool(out.get("active", True))
|
||||
out["sort_order"] = int(out.get("sort_order", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
|
||||
for k in ("document_date", "ship_date", "center_arrival_date"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _line_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["line_no"] = int(out.get("line_no", 0))
|
||||
out["quantity"] = int(out.get("quantity", 0))
|
||||
for k in ("box_rule_id", "units_per_box", "calculated_boxes", "remainder_units"):
|
||||
v = out.get(k)
|
||||
out[k] = int(v) if v is not None else None
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
@@ -0,0 +1,62 @@
|
||||
"""대한민국 공휴일 판정 (달력 색상용).
|
||||
|
||||
- 고정 양력 공휴일은 매년 동일 → 연도 무관 판정.
|
||||
- 음력 공휴일(설날/부처님오신날/추석)과 대체공휴일은 매년 달라짐 →
|
||||
연도별 dict(`_LUNAR_AND_SUBSTITUTE`)에 명시. 새 연도는 KASI 발표값을 추가한다.
|
||||
|
||||
미수록 연도는 고정 양력 공휴일만 빨강 처리된다(음력/대체는 누락).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
# 매년 동일한 양력 공휴일 (month, day)
|
||||
_FIXED_SOLAR: set[tuple[int, int]] = {
|
||||
(1, 1), # 신정
|
||||
(3, 1), # 삼일절
|
||||
(5, 5), # 어린이날
|
||||
(6, 6), # 현충일
|
||||
(8, 15), # 광복절
|
||||
(10, 3), # 개천절
|
||||
(10, 9), # 한글날
|
||||
(12, 25), # 성탄절
|
||||
}
|
||||
|
||||
# 연도별 음력 공휴일 + 대체공휴일 (ISO 날짜 문자열). KASI 발표 기준.
|
||||
_LUNAR_AND_SUBSTITUTE: dict[int, set[str]] = {
|
||||
2025: {
|
||||
"2025-01-28", "2025-01-29", "2025-01-30", # 설날 연휴
|
||||
"2025-03-03", # 삼일절 대체(3/1 토)
|
||||
"2025-05-06", # 부처님오신날 대체(5/5 겹침)
|
||||
"2025-05-05", # 부처님오신날(어린이날과 동일일)
|
||||
"2025-10-06", "2025-10-07", "2025-10-08", # 추석 연휴
|
||||
"2025-10-08", # 추석 대체 가능
|
||||
},
|
||||
2026: {
|
||||
"2026-02-16", "2026-02-17", "2026-02-18", # 설날 연휴 (설날 2/17)
|
||||
"2026-03-02", # 삼일절 대체 (3/1 일)
|
||||
"2026-05-24", # 부처님오신날 (일)
|
||||
"2026-05-25", # 부처님오신날 대체
|
||||
"2026-08-17", # 광복절 대체 (8/15 토)
|
||||
"2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴 (추석 9/25)
|
||||
"2026-09-28", # 추석 대체 (9/26 토)
|
||||
"2026-10-05", # 개천절 대체 (10/3 토)
|
||||
},
|
||||
2027: {
|
||||
"2027-02-06", "2027-02-07", "2027-02-08", # 설날 연휴 (설날 2/7)
|
||||
"2027-02-09", # 설날 대체 (2/7 일)
|
||||
"2027-05-13", # 부처님오신날 (목)
|
||||
"2027-08-16", # 광복절 대체 (8/15 일)
|
||||
"2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴 (추석 9/15)
|
||||
"2027-10-04", # 개천절 대체 (10/3 일)
|
||||
"2027-10-11", # 한글날 대체 (10/9 토)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_holiday(d: date) -> bool:
|
||||
"""공휴일(일요일 제외)이면 True. 일/토 색상은 요일로 따로 판정한다."""
|
||||
if (d.month, d.day) in _FIXED_SOLAR:
|
||||
return True
|
||||
return d.isoformat() in _LUNAR_AND_SUBSTITUTE.get(d.year, set())
|
||||
@@ -0,0 +1,155 @@
|
||||
r"""itemcode_db 읽기 전용 상품 검색.
|
||||
|
||||
cupang 모듈은 itemcode_db 의 상품(낱개코드/세트코드)을 **읽기만** 한다.
|
||||
cupang_db 에 상품을 복제 저장하지 않는다. 출고 라인에는 product_code 와
|
||||
product_name_snapshot 만 보존한다.
|
||||
|
||||
⚠️ itemcode_db 의 실제 테이블/컬럼명은 이 저장소(main-app)에 정의되어 있지 않다.
|
||||
운영 서버에서 다음으로 먼저 구조를 확인한 뒤 환경변수를 설정해야 한다:
|
||||
|
||||
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\dt"
|
||||
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\d <테이블명>"
|
||||
|
||||
환경변수 (모두 미설정 시 검색 비활성 → 폼에서 수동 입력으로 폴백):
|
||||
|
||||
ITEMCODE_DB_URL 읽기 전용 DSN. 예: postgresql://itemcode_ro:<pwd>@postgres-db:5432/itemcode_db
|
||||
ITEMCODE_SEARCH_SQL (선택) 검색 SQL 직접 지정. 아래 자동 생성 대신 사용.
|
||||
반드시 code, name, type 컬럼을 별칭으로 반환하고,
|
||||
%(q)s 파라미터를 LIKE 패턴으로 받는다.
|
||||
|
||||
자동 생성용 (ITEMCODE_SEARCH_SQL 미설정 시):
|
||||
ITEMCODE_TABLE 검색 대상 테이블/뷰 (예: products 또는 item_master)
|
||||
ITEMCODE_CODE_COL 코드 컬럼명 (기본: product_code)
|
||||
ITEMCODE_NAME_COL 상품명 컬럼명 (기본: product_name)
|
||||
ITEMCODE_TYPE_COL (선택) 단품/세트 구분 컬럼명. 없으면 type 은 빈 문자열.
|
||||
|
||||
낱개코드와 세트코드가 별도 테이블이면 ITEMCODE_SEARCH_SQL 에 UNION 으로 직접 작성한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("cupang.itemcode")
|
||||
|
||||
# 안전한 SQL 식별자(테이블/컬럼)만 허용 — 인젝션 방지.
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
||||
|
||||
|
||||
def _ident(value: str, *, what: str) -> str:
|
||||
v = (value or "").strip()
|
||||
if not _IDENT_RE.match(v):
|
||||
raise ValueError(f"안전하지 않은 {what} 식별자: {value!r}")
|
||||
return v
|
||||
|
||||
|
||||
class ItemcodeReader:
|
||||
"""itemcode_db 읽기 전용 커넥션 풀 + 상품 검색.
|
||||
|
||||
설정이 없거나 불완전하면 `enabled=False` 로 두고, search()는 빈 리스트를 반환한다.
|
||||
앱 부팅이나 cupang 모듈 진입을 막지 않는다.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pool: Any = None
|
||||
self._sql: str | None = None
|
||||
self.enabled = False
|
||||
self.reason = ""
|
||||
self.last_error = "" # 마지막 조회 오류(진단용, 비밀값 없음)
|
||||
self._configure()
|
||||
|
||||
def _configure(self) -> None:
|
||||
dsn = os.getenv("ITEMCODE_DB_URL", "").strip()
|
||||
if not dsn:
|
||||
self.reason = "ITEMCODE_DB_URL 미설정 — 상품 검색 비활성(수동 입력 사용)."
|
||||
return
|
||||
|
||||
custom_sql = os.getenv("ITEMCODE_SEARCH_SQL", "").strip()
|
||||
if custom_sql:
|
||||
self._sql = custom_sql
|
||||
else:
|
||||
table = os.getenv("ITEMCODE_TABLE", "").strip()
|
||||
if not table:
|
||||
self.reason = (
|
||||
"ITEMCODE_TABLE(또는 ITEMCODE_SEARCH_SQL) 미설정 — "
|
||||
"상품 검색 비활성(수동 입력 사용)."
|
||||
)
|
||||
return
|
||||
try:
|
||||
table_id = _ident(table, what="테이블")
|
||||
code_col = _ident(os.getenv("ITEMCODE_CODE_COL", "product_code"), what="코드 컬럼")
|
||||
name_col = _ident(os.getenv("ITEMCODE_NAME_COL", "product_name"), what="상품명 컬럼")
|
||||
type_col_raw = os.getenv("ITEMCODE_TYPE_COL", "").strip()
|
||||
type_expr = _ident(type_col_raw, what="구분 컬럼") if type_col_raw else "''"
|
||||
except ValueError as exc:
|
||||
self.reason = f"itemcode 검색 설정 오류: {exc}"
|
||||
return
|
||||
self._sql = (
|
||||
f"SELECT {code_col} AS code, {name_col} AS name, {type_expr} AS type "
|
||||
f"FROM {table_id} "
|
||||
f"WHERE {code_col} ILIKE %(q)s OR {name_col} ILIKE %(q)s "
|
||||
f"ORDER BY {code_col} ASC LIMIT %(limit)s"
|
||||
)
|
||||
|
||||
# 풀은 lazy open — 부팅 시 itemcode_db 가 잠시 끊겨도 죽지 않게.
|
||||
try:
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=1,
|
||||
max_size=3,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
self.enabled = True
|
||||
self.reason = ""
|
||||
except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음
|
||||
self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}"
|
||||
|
||||
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""code/name 부분 일치 검색. 반환: [{"code","name","type"}].
|
||||
|
||||
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
return self._run(f"%{q}%", limit)
|
||||
|
||||
def list_all(self, *, limit: int = 2000) -> list[dict[str, Any]]:
|
||||
"""전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트용."""
|
||||
return self._run("%", limit)
|
||||
|
||||
def _run(self, like: str, limit: int) -> list[dict[str, Any]]:
|
||||
if not self.enabled or not self._pool or not self._sql:
|
||||
return []
|
||||
try:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
self._sql, {"q": like, "limit": int(limit)}
|
||||
).fetchall()
|
||||
self.last_error = ""
|
||||
except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음. 원인은 로그 + last_error.
|
||||
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||
logger.exception("itemcode 조회 실패 (SQL/스키마 확인 필요)")
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"code": str(r.get("code") or "").strip(),
|
||||
"name": str(r.get("name") or "").strip(),
|
||||
"type": str(r.get("type") or "").strip(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def close(self) -> None:
|
||||
if self._pool is not None:
|
||||
self._pool.close()
|
||||
@@ -0,0 +1,701 @@
|
||||
"""쿠팡 밀크런 모듈 라우터.
|
||||
|
||||
- 경로: /cupang
|
||||
- 권한: 로그인 + `cupang` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||
- 데이터: CupangDBStore (cupang_db / PostgreSQL) 전용.
|
||||
CUPANG_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||
- 상품 검색: itemcode_db 읽기 전용(ItemcodeReader). 미설정 시 수동 입력 폴백.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _calendar
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.timezone import today_kst
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from .holidays import is_holiday
|
||||
from .store import SHIP_METHODS
|
||||
|
||||
router = APIRouter(prefix="/cupang", tags=["cupang"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 공용 헬퍼
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _store(request: Request) -> Any:
|
||||
"""CupangDBStore 또는 None(CUPANG_DB_URL 미설정)."""
|
||||
return getattr(request.app.state, "cupang_store", None)
|
||||
|
||||
|
||||
def _itemcode(request: Request) -> Any:
|
||||
return getattr(request.app.state, "itemcode_reader", None)
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "cupang"):
|
||||
raise HTTPException(status_code=403, detail="쿠팡 밀크런 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": "쿠팡 밀크런 모듈이 아직 설정되지 않았습니다. "
|
||||
"CUPANG_DB_URL 환경변수를 설정하고 scripts/sql/cupang_db_init.sql 로 "
|
||||
"cupang_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||
"""로그인+권한+store 점검을 한 번에. 페이지 핸들러 진입부에서 사용."""
|
||||
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not has_module(user, "cupang"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "쿠팡 밀크런 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return _render_config_needed(request, user)
|
||||
return store, user
|
||||
|
||||
|
||||
def _parse_lines(lines_json: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(lines_json or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="라인 데이터 형식 오류")
|
||||
if not isinstance(data, list):
|
||||
raise HTTPException(status_code=400, detail="라인 데이터는 배열이어야 합니다.")
|
||||
return data
|
||||
|
||||
|
||||
def _ym(request: Request) -> tuple[int, int]:
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or today.month)
|
||||
except ValueError:
|
||||
year, month = today.year, today.month
|
||||
if not (1 <= month <= 12):
|
||||
year, month = today.year, today.month
|
||||
return year, month
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 메인 — 월간 달력 + 선택일 출고 리스트
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
|
||||
year, month = _ym(request)
|
||||
counts = store.calendar_counts(year=year, month=month)
|
||||
shipments = store.list_shipments(year=year, month=month)
|
||||
|
||||
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
|
||||
sel = request.query_params.get("date") or ""
|
||||
today = today_kst()
|
||||
if not sel:
|
||||
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01"
|
||||
|
||||
# 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함)
|
||||
sel_shipments = [
|
||||
s for s in shipments
|
||||
if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date"))
|
||||
]
|
||||
# 각 묶음에 품목 요약(제품명/수량) 첨부 — hover 툴팁용
|
||||
for s in sel_shipments:
|
||||
full = store.get_shipment(shipment_id=s["id"])
|
||||
s["tip_items"] = [
|
||||
{"name": ln.get("product_name_snapshot") or ln.get("product_code"),
|
||||
"qty": ln.get("quantity", 0)}
|
||||
for ln in (full.get("lines") if full else [])
|
||||
]
|
||||
|
||||
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||
weeks = cal.monthdatescalendar(year, month)
|
||||
cal_weeks = [
|
||||
[
|
||||
{
|
||||
"date": d.isoformat(),
|
||||
"day": d.day,
|
||||
"in_month": d.month == month,
|
||||
"is_today": d == today,
|
||||
"is_selected": d.isoformat() == sel,
|
||||
"is_sunday": d.weekday() == 6,
|
||||
"is_saturday": d.weekday() == 5,
|
||||
"is_holiday": is_holiday(d),
|
||||
"counts": counts.get(d.isoformat(), {}),
|
||||
}
|
||||
for d in week
|
||||
]
|
||||
for week in weeks
|
||||
]
|
||||
|
||||
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런",
|
||||
"page_subtitle": f"{year}년 {month}월 출고 일정",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"prev_y": prev_y, "prev_m": prev_m,
|
||||
"next_y": next_y, "next_m": next_m,
|
||||
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||
"cal_weeks": cal_weeks,
|
||||
"selected_date": sel,
|
||||
"sel_shipments": sel_shipments,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 출고 묶음 — 등록 / 수정 / 상세
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[str, Any]:
|
||||
from app.main import build_erp_nav # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
reader = _itemcode(request)
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"centers": sorted(store.list_centers(), key=lambda c: c["name"]),
|
||||
"box_rules": store.list_box_rules(),
|
||||
"products": store.list_products(),
|
||||
"ship_methods": list(SHIP_METHODS),
|
||||
"search_enabled": bool(reader and reader.enabled),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_form(request: Request) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
ctx = _form_context(request, store, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "쿠팡 밀크런 — 신규 등록",
|
||||
"page_subtitle": "공통 헤더 1개 + 품목 라인",
|
||||
"mode": "new",
|
||||
"shipment": None,
|
||||
"default_date": today_kst().isoformat(),
|
||||
}
|
||||
)
|
||||
return render_template(request, "cupang/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
async def create(
|
||||
request: Request,
|
||||
lines_json: str = Form("[]"),
|
||||
document_date: str = Form(...),
|
||||
ship_date: str = Form(...),
|
||||
center_arrival_date: str = Form(...),
|
||||
center_id: str = Form(""),
|
||||
center_name_snapshot: str = Form(""),
|
||||
ship_method: str = Form("택배"),
|
||||
outbound_summary: str = Form(""),
|
||||
worker: str = Form(""),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
header = {
|
||||
"document_date": document_date,
|
||||
"ship_date": ship_date,
|
||||
"center_arrival_date": center_arrival_date,
|
||||
"center_id": center_id,
|
||||
"center_name_snapshot": center_name_snapshot,
|
||||
"ship_method": ship_method,
|
||||
"outbound_summary": outbound_summary,
|
||||
"worker": worker,
|
||||
"memo": memo,
|
||||
}
|
||||
try:
|
||||
ship = store.create_shipment(
|
||||
created_by=user["email"], header=header, lines=_parse_lines(lines_json)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
|
||||
async def detail(request: Request, shipment_id: int) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
ship = store.get_shipment(shipment_id=shipment_id)
|
||||
if not ship:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/detail.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": f"출고 #{ship['id']}",
|
||||
"page_subtitle": f"{ship['ship_date']} · {ship['center_name_snapshot']}",
|
||||
"shipment": ship,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{shipment_id:int}/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request, shipment_id: int) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
ship = store.get_shipment(shipment_id=shipment_id)
|
||||
if not ship:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
ctx = _form_context(request, store, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": f"출고 #{ship['id']} 수정",
|
||||
"page_subtitle": "헤더/라인 수정 후 저장",
|
||||
"mode": "edit",
|
||||
"shipment": ship,
|
||||
"default_date": ship["document_date"],
|
||||
}
|
||||
)
|
||||
return render_template(request, "cupang/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/{shipment_id:int}/edit")
|
||||
async def update(
|
||||
request: Request,
|
||||
shipment_id: int,
|
||||
lines_json: str = Form("[]"),
|
||||
document_date: str = Form(...),
|
||||
ship_date: str = Form(...),
|
||||
center_arrival_date: str = Form(...),
|
||||
center_id: str = Form(""),
|
||||
center_name_snapshot: str = Form(""),
|
||||
ship_method: str = Form("택배"),
|
||||
outbound_summary: str = Form(""),
|
||||
worker: str = Form(""),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
header = {
|
||||
"document_date": document_date,
|
||||
"ship_date": ship_date,
|
||||
"center_arrival_date": center_arrival_date,
|
||||
"center_id": center_id,
|
||||
"center_name_snapshot": center_name_snapshot,
|
||||
"ship_method": ship_method,
|
||||
"outbound_summary": outbound_summary,
|
||||
"worker": worker,
|
||||
"memo": memo,
|
||||
}
|
||||
try:
|
||||
store.update_shipment(
|
||||
shipment_id=shipment_id, header=header, lines=_parse_lines(lines_json)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{shipment_id:int}/delete")
|
||||
async def delete(
|
||||
request: Request,
|
||||
shipment_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""운영 안전: 기본은 status='취소' soft delete."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.soft_delete(shipment_id=shipment_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{shipment_id:int}/hard-delete")
|
||||
async def hard_delete(
|
||||
request: Request,
|
||||
shipment_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""완전 삭제(헤더+라인 CASCADE). 달력으로 복귀."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.hard_delete(shipment_id=shipment_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 입고센터 관리
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/centers", response_class=HTMLResponse)
|
||||
async def centers_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
centers = sorted(store.list_centers(include_inactive=True), key=lambda c: c["name"])
|
||||
# 사용 중 여부 표시
|
||||
for c in centers:
|
||||
c["in_use"] = store.center_in_use(center_id=c["id"])
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/centers.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 입고센터 관리",
|
||||
"page_subtitle": "추가 · 수정 · 비활성화. 사용 중 센터는 삭제되지 않고 비활성화됩니다.",
|
||||
"centers": centers,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/centers")
|
||||
async def center_create(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
sort_order: int = Form(0),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.create_center(name=name, sort_order=sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||
|
||||
|
||||
@router.post("/centers/{center_id}/edit")
|
||||
async def center_edit(
|
||||
request: Request,
|
||||
center_id: int,
|
||||
name: str = Form(""),
|
||||
active: str = Form(""),
|
||||
sort_order: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
kwargs: dict[str, Any] = {"center_id": center_id}
|
||||
if name.strip():
|
||||
kwargs["name"] = name
|
||||
if active != "":
|
||||
kwargs["active"] = active in ("1", "true", "on", "True")
|
||||
if sort_order.strip():
|
||||
try:
|
||||
kwargs["sort_order"] = int(sort_order)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
store.update_center(**kwargs)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||
|
||||
|
||||
@router.post("/centers/{center_id}/delete")
|
||||
async def center_delete(
|
||||
request: Request,
|
||||
center_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.delete_center(center_id=center_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 박스 입수량 관리
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/box-rules", response_class=HTMLResponse)
|
||||
async def box_rules_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/box_rules.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 박스 입수량",
|
||||
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정",
|
||||
"box_rules": store.list_box_rules(include_inactive=True),
|
||||
"products": store.list_products(),
|
||||
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/box-rules")
|
||||
async def box_rule_upsert(
|
||||
request: Request,
|
||||
product_code: str = Form(...),
|
||||
units_per_box: int = Form(...),
|
||||
product_name_snapshot: str = Form(""),
|
||||
box_name: str = Form("쿠팡박스"),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.upsert_box_rule(
|
||||
product_code=product_code,
|
||||
units_per_box=units_per_box,
|
||||
product_name_snapshot=product_name_snapshot,
|
||||
box_name=box_name,
|
||||
memo=memo,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||
|
||||
|
||||
@router.post("/box-rules/{rule_id}/delete")
|
||||
async def box_rule_delete(
|
||||
request: Request,
|
||||
rule_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.delete_box_rule(rule_id=rule_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="규칙을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
async def products_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
reader = _itemcode(request)
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/products.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
|
||||
"page_subtitle": "왼쪽 itemcode_db 목록에서 선택해 등록하면 폼 드롭다운에 노출됩니다.",
|
||||
"products": store.list_products(include_inactive=True),
|
||||
"registered_codes": [p["product_code"] for p in store.list_products(include_inactive=True)],
|
||||
"search_enabled": bool(reader and reader.enabled),
|
||||
"search_reason": (reader.reason if reader else ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/products/all")
|
||||
async def product_all(
|
||||
request: Request, _: dict[str, Any] = Depends(_require_user)
|
||||
) -> JSONResponse:
|
||||
"""itemcode_db 전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트 소스."""
|
||||
reader = _itemcode(request)
|
||||
results = reader.list_all() if reader else []
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": bool(reader and reader.enabled),
|
||||
"reason": (reader.reason if reader else "itemcode 리더 미초기화"),
|
||||
"error": (getattr(reader, "last_error", "") if reader else ""),
|
||||
"count": len(results),
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/bulk")
|
||||
async def product_bulk(
|
||||
request: Request,
|
||||
items: list[dict[str, Any]] = Body(..., embed=True),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
"""선택한 상품들을 일괄 등록(upsert). body: {"items":[{"code","name"}, ...]}."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
added = 0
|
||||
for it in items:
|
||||
code = str(it.get("code") or "").strip()
|
||||
name = str(it.get("name") or "").strip()
|
||||
if not code or not name:
|
||||
continue
|
||||
try:
|
||||
store.upsert_product(product_code=code, product_name=name)
|
||||
added += 1
|
||||
except ValueError:
|
||||
continue
|
||||
return JSONResponse({"ok": True, "added": added})
|
||||
|
||||
|
||||
@router.post("/products")
|
||||
async def product_upsert(
|
||||
request: Request,
|
||||
product_code: str = Form(...),
|
||||
product_name: str = Form(...),
|
||||
sort_order: int = Form(0),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.upsert_product(
|
||||
product_code=product_code, product_name=product_name, sort_order=sort_order
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.post("/products/{product_id:int}/active")
|
||||
async def product_set_active(
|
||||
request: Request,
|
||||
product_id: int,
|
||||
active: str = Form(...),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.set_product_active(
|
||||
product_id=product_id, active=active in ("1", "true", "on", "True")
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.post("/products/{product_id:int}/delete")
|
||||
async def product_delete(
|
||||
request: Request,
|
||||
product_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""완전 삭제(hard delete)."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.delete_product(product_id=product_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "cupang"}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""쿠팡 밀크런 모듈 상수 및 공용 헬퍼.
|
||||
|
||||
- 데이터 저장은 cupang_db(PostgreSQL) 전용이다(`db.py`).
|
||||
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||
CUPANG_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||
- 이 모듈에는 DB/JSON 양쪽이 공유하는 상수와 순수 계산 헬퍼만 둔다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
# 출고 묶음 상태 (expense 의 STATUSES 패턴과 동일하게 한글 라벨 그대로 저장)
|
||||
STATUSES: tuple[str, ...] = (
|
||||
"작성중",
|
||||
"출고준비",
|
||||
"출고완료",
|
||||
"센터입고완료",
|
||||
"취소",
|
||||
)
|
||||
|
||||
# 출고방식 기본 후보 (자유 입력 허용, 아래는 select 기본값)
|
||||
SHIP_METHODS: tuple[str, ...] = ("택배", "직접배송", "화물", "파렛트", "기타")
|
||||
|
||||
# 초기 입고센터 seed — cupang_db_init.sql 에도 동일 목록을 INSERT 한다.
|
||||
# 화면에서 추가/수정/비활성화 가능. 사용 중인 센터는 hard delete 하지 않는다.
|
||||
DEFAULT_CENTERS: tuple[str, ...] = (
|
||||
"대구3",
|
||||
"인천32",
|
||||
"이천1",
|
||||
"인천42",
|
||||
"인천26",
|
||||
"인천16",
|
||||
"인천28",
|
||||
"안성8",
|
||||
"천안8(RC)",
|
||||
"시흥2",
|
||||
"인천36",
|
||||
"MGMH5",
|
||||
"XRC10(RC)",
|
||||
"인천14",
|
||||
"경기광주5",
|
||||
"경기광주3",
|
||||
"XRC06(RC)",
|
||||
"용인1",
|
||||
"인천30",
|
||||
"마장1",
|
||||
"안성4",
|
||||
"대구6",
|
||||
"전라광주2",
|
||||
"창원1",
|
||||
"고양1",
|
||||
"동탄1",
|
||||
"이천4",
|
||||
"XRC09(RC)",
|
||||
)
|
||||
|
||||
|
||||
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
|
||||
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다.
|
||||
|
||||
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
|
||||
|
||||
- units_per_box 가 없거나 0 이하면 "미설정" — 자동 계산하지 않는다.
|
||||
- full_boxes = quantity // units_per_box
|
||||
- remainder_units = quantity % units_per_box
|
||||
- required_boxes = ceil(quantity / units_per_box)
|
||||
"""
|
||||
try:
|
||||
qty = int(quantity)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
|
||||
upb: int | None
|
||||
try:
|
||||
upb = int(units_per_box) if units_per_box is not None else None
|
||||
except (TypeError, ValueError):
|
||||
upb = None
|
||||
|
||||
if not upb or upb <= 0 or qty <= 0:
|
||||
return {
|
||||
"configured": False,
|
||||
"units_per_box": upb if (upb and upb > 0) else None,
|
||||
"full_boxes": None,
|
||||
"remainder_units": None,
|
||||
"required_boxes": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"configured": True,
|
||||
"units_per_box": upb,
|
||||
"full_boxes": qty // upb,
|
||||
"remainder_units": qty % upb,
|
||||
"required_boxes": math.ceil(qty / upb),
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-brule-layout">
|
||||
|
||||
<!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) -->
|
||||
<div class="erp-card cpg-form-card cpg-brule-add">
|
||||
<div class="cpg-card-head">
|
||||
<h2>박스 입수량 추가 / 수정</h2>
|
||||
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span>
|
||||
</div>
|
||||
<form method="post" action="/cupang/box-rules">
|
||||
<input type="hidden" name="product_name_snapshot" id="brule-name-snap" />
|
||||
<div class="cpg-brule-fields">
|
||||
<div class="cpg-brule-row">
|
||||
<label class="erp-field"><span>제품명 *</span>
|
||||
<select class="erp-select cpg-brule-name" id="brule-name" required>
|
||||
<option value="">— 제품명 선택 —</option>
|
||||
{% for p in products %}
|
||||
<option value="{{ p.product_code }}" data-name="{{ p.product_name }}">{{ p.product_name }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<label class="erp-field"><span>제품코드</span>
|
||||
<input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label>
|
||||
</div>
|
||||
<div class="cpg-brule-row">
|
||||
<label class="erp-field"><span>박스이름</span>
|
||||
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡박스" /></label>
|
||||
<label class="erp-field"><span>박스당 입수량 *</span>
|
||||
<span class="cpg-upb-wrap">
|
||||
<input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required />
|
||||
<span class="cpg-upb-unit">개</span>
|
||||
</span></label>
|
||||
</div>
|
||||
<div class="cpg-brule-row">
|
||||
<label class="erp-field cpg-brule-memo-field"><span>메모</span>
|
||||
<input class="erp-input cpg-brule-memo" type="text" name="memo" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="erp-page-actions" style="margin-top:12px;">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if not products %}
|
||||
<p class="erp-muted"><a href="/cupang/products">설정에서 제품명을 먼저 등록</a>하면 드롭다운에 표시됩니다.</p>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var sel = document.getElementById("brule-name");
|
||||
var code = document.getElementById("brule-code");
|
||||
var snap = document.getElementById("brule-name-snap");
|
||||
if (!sel) return;
|
||||
sel.addEventListener("change", function () {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
code.value = sel.value;
|
||||
snap.value = opt ? (opt.getAttribute("data-name") || "") : "";
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 목록 -->
|
||||
<div class="erp-card cpg-form-card cpg-brule-list">
|
||||
<div class="cpg-card-head"><h2>입수량 규칙 ({{ box_rules|length }})</h2></div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>제품명</th><th>제품코드</th><th>박스명</th><th>입수량</th><th>메모</th><th>동작</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in box_rules %}
|
||||
<tr>
|
||||
<td>{{ r.product_name_snapshot or '—' }}</td>
|
||||
<td>{{ r.product_code }}</td>
|
||||
<td>{{ r.box_name }}</td>
|
||||
<td>{{ r.units_per_box }}개</td>
|
||||
<td>{{ r.memo or '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/cupang/box-rules/{{ r.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('이 규칙을 삭제합니다. 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not box_rules %}
|
||||
<tr><td colspan="6" class="erp-muted">등록된 입수량 규칙이 없습니다.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /cpg-brule-layout -->
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-center-layout">
|
||||
|
||||
<!-- 왼쪽: 입고센터 추가 -->
|
||||
<div class="erp-card cpg-form-card cpg-center-add">
|
||||
<h2 class="cpg-center-add-title">입고센터 추가</h2>
|
||||
<form method="post" action="/cupang/centers" class="cpg-inline-form">
|
||||
<input class="erp-input" type="text" name="name" placeholder="센터명 (예: 대구3)" required style="flex:1 1 auto;min-width:0" />
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 센터 목록 (높이 900 고정, 내부 스크롤) -->
|
||||
<div class="erp-card cpg-form-card cpg-center-listcard">
|
||||
<div class="cpg-card-head">
|
||||
<h2>센터 목록 ({{ centers|length }})</h2>
|
||||
<span class="erp-muted">사용 중 센터는 삭제 시 비활성화됩니다.</span>
|
||||
</div>
|
||||
|
||||
<div class="cpg-center-list">
|
||||
{% for c in centers %}
|
||||
<div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}">
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-center-edit">
|
||||
<input class="erp-input cpg-center-name" type="text" name="name" value="{{ c.name }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm" title="이름 저장">저장</button>
|
||||
</form>
|
||||
<span class="cpg-center-state">
|
||||
{% if c.in_use %}<span class="erp-badge erp-badge-inverse cpg-mini">사용중</span>{% endif %}
|
||||
{% if c.active %}<span class="erp-badge erp-badge-success cpg-mini">활성</span>
|
||||
{% else %}<span class="erp-badge erp-badge-neutral cpg-mini">비활성</span>{% endif %}
|
||||
</span>
|
||||
<span class="cpg-center-act">
|
||||
{% if c.active %}
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="0" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">비활성</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="1" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">활성</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('{% if c.in_use %}사용 중 → 비활성화됩니다.{% else %}삭제합니다.{% endif %} 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger cpg-btn-sm">삭제</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions cpg-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/?date={{ shipment.ship_date }}">◀◀ 달력</a>
|
||||
|
||||
<form method="post" action="/cupang/{{ shipment.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('이 출고 묶음을 취소 처리합니다(상태=취소). 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">취소 처리</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/cupang/{{ shipment.id }}/hard-delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('이 출고를 완전 삭제합니다(복구 불가, 품목 포함). 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
|
||||
<a class="erp-btn erp-btn-primary cpg-push-right" href="/cupang/{{ shipment.id }}/edit">수정</a>
|
||||
</div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head">
|
||||
<h2>출고 #{{ shipment.id }}
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if shipment.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif shipment.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif shipment.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }}">{{ shipment.status }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<dl class="cpg-detail-grid">
|
||||
<div><dt>작성일</dt><dd>{{ shipment.document_date }}</dd></div>
|
||||
<div><dt>출고일</dt><dd>{{ shipment.ship_date }}</dd></div>
|
||||
<div><dt>센터입고일</dt><dd>{{ shipment.center_arrival_date }}</dd></div>
|
||||
<div><dt>입고센터</dt><dd>{{ shipment.center_name_snapshot or '—' }}</dd></div>
|
||||
<div><dt>출고방식</dt><dd>{{ shipment.ship_method }}</dd></div>
|
||||
<div><dt>작업자</dt><dd>{{ shipment.worker or '—' }}</dd></div>
|
||||
<div class="cpg-full"><dt>출고/박스 요약</dt><dd>{{ shipment.outbound_summary or '—' }}</dd></div>
|
||||
<div class="cpg-full"><dt>메모</dt><dd>{{ shipment.memo or '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- 라인 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head"><h2>품목 ({{ shipment.lines|length }})</h2></div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
|
||||
<th>입수량</th><th>필요박스</th><th>잔량</th><th>수동보정</th><th>메모</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ln in shipment.lines %}
|
||||
<tr>
|
||||
<td>{{ ln.line_no }}</td>
|
||||
<td>{{ ln.product_code }}</td>
|
||||
<td>{{ ln.product_name_snapshot }}</td>
|
||||
<td>{{ ln.quantity }}</td>
|
||||
<td>{{ ln.units_per_box if ln.units_per_box else '미설정' }}</td>
|
||||
<td>{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}</td>
|
||||
<td>{{ ln.remainder_units if ln.remainder_units is not none else '—' }}</td>
|
||||
<td>{{ ln.manual_box_text or '—' }}</td>
|
||||
<td>{{ ln.memo or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
{% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %}
|
||||
<form id="cpg-form" method="post" action="{{ action }}">
|
||||
<input type="hidden" name="lines_json" id="cpg-lines-json" value="[]" />
|
||||
|
||||
<div class="cpg-form-2col">
|
||||
|
||||
<!-- ── 공통 헤더 (왼쪽) ── -->
|
||||
<div class="erp-card cpg-form-card cpg-form-head">
|
||||
<div class="cpg-card-head"><h2>공통 헤더</h2></div>
|
||||
<div class="cpg-header-grid">
|
||||
<label class="erp-field"><span>작성일 *</span>
|
||||
<input class="erp-input" type="date" name="document_date" required
|
||||
value="{{ shipment.document_date if shipment else default_date }}" /></label>
|
||||
<label class="erp-field"><span>출고일 *</span>
|
||||
<input class="erp-input" type="date" name="ship_date" required
|
||||
value="{{ shipment.ship_date if shipment else default_date }}" /></label>
|
||||
<label class="erp-field"><span>센터입고일 *</span>
|
||||
<input class="erp-input" type="date" name="center_arrival_date" required
|
||||
value="{{ shipment.center_arrival_date if shipment else default_date }}" /></label>
|
||||
|
||||
<label class="erp-field"><span>입고센터</span>
|
||||
<select class="erp-select" name="center_id" id="cpg-center-select">
|
||||
<option value="">— 선택 —</option>
|
||||
{% for c in centers %}
|
||||
<option value="{{ c.id }}" data-name="{{ c.name }}"
|
||||
{% if shipment and shipment.center_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<!-- 센터 스냅샷(자유 입력 허용: 과거 명칭 보존/센터 미등록 시) -->
|
||||
<input type="hidden" name="center_name_snapshot" id="cpg-center-name"
|
||||
value="{{ shipment.center_name_snapshot if shipment else '' }}" />
|
||||
|
||||
<label class="erp-field"><span>출고방식</span>
|
||||
<select class="erp-select" name="ship_method">
|
||||
{% for m in ship_methods %}
|
||||
<option value="{{ m }}" {% if shipment and shipment.ship_method == m %}selected{% endif %}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
|
||||
<label class="erp-field"><span>작업자</span>
|
||||
<input class="erp-input" type="text" name="worker"
|
||||
value="{{ shipment.worker if shipment else '' }}" /></label>
|
||||
</div>
|
||||
|
||||
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
|
||||
<input class="erp-input" type="text" name="outbound_summary"
|
||||
placeholder="예: 쿠팡박스 50, 6호상자 1, (50번 박스)"
|
||||
value="{{ shipment.outbound_summary if shipment else '' }}" /></label>
|
||||
<label class="erp-field cpg-full"><span>메모</span>
|
||||
<textarea class="erp-input" name="memo" rows="2">{{ shipment.memo if shipment else '' }}</textarea></label>
|
||||
|
||||
<div class="erp-page-actions cpg-form-actions">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
|
||||
{% if mode == 'edit' %}
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/{{ shipment.id }}">취소</a>
|
||||
{% else %}
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/">취소</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 품목 라인 (오른쪽) ── -->
|
||||
<div class="erp-card cpg-form-card cpg-form-lines">
|
||||
<div class="cpg-card-head cpg-lines-head">
|
||||
<h2>품목 라인</h2>
|
||||
<span class="erp-muted">
|
||||
제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산.
|
||||
{% if not products %}<a href="/cupang/products">설정에서 제품명 먼저 등록</a>{% endif %}
|
||||
</span>
|
||||
<div class="cpg-lines-btns">
|
||||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-add-line">+ 라인 추가</button>
|
||||
<button type="button" class="erp-btn erp-btn-danger" id="cpg-del-line">선택 라인 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap cpg-lines-scroll">
|
||||
<table class="erp-table cpg-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cpg-check-col"><input type="checkbox" id="cpg-check-all" title="전체 선택" /></th>
|
||||
<th>제품명</th><th>제품코드</th><th>수량</th>
|
||||
<th>입수량</th><th>박스 계산</th><th>라인메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cpg-lines-body"><!-- JS 렌더 --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /cpg-form-2col -->
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script type="application/json" id="cpg-init-lines">
|
||||
{% if shipment and shipment.lines %}{{ shipment.lines | tojson }}{% else %}[]{% endif %}
|
||||
</script>
|
||||
<script type="application/json" id="cpg-box-rules">
|
||||
{{ box_rules | tojson }}
|
||||
</script>
|
||||
<script type="application/json" id="cpg-products">
|
||||
{{ products | tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/cupang.js?v=20260530p" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,139 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<!-- 페이지 액션 (달력 컬럼 폭에 맞춤: 신규 좌측 / 설정 달력 오른쪽 끝) -->
|
||||
<div class="cpg-actions-grid">
|
||||
<div class="cpg-actions-main">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
|
||||
<span class="cpg-settings-btns">
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="cpg-actions-spacer"></div>
|
||||
</div>
|
||||
|
||||
<div class="cpg-layout">
|
||||
<!-- ── 왼쪽: 큰 월간 달력 ── -->
|
||||
<div class="erp-card cpg-cal-card">
|
||||
<div class="cpg-cal-head">
|
||||
<a class="erp-btn erp-btn-outline cpg-nav-btn"
|
||||
href="/cupang/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||
<h2 class="cpg-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||
<a class="erp-btn erp-btn-outline cpg-nav-btn"
|
||||
href="/cupang/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-cal-grid">
|
||||
{% for wd in weekdays %}
|
||||
<div class="cpg-cal-wd {% if loop.index0 == 0 %}cpg-sun{% elif loop.index0 == 6 %}cpg-sat{% endif %}">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for week in cal_weeks %}
|
||||
{% for cell in week %}
|
||||
<a class="cpg-cal-cell
|
||||
{% if not cell.in_month %}cpg-out{% endif %}
|
||||
{% if cell.is_today %}cpg-today{% endif %}
|
||||
{% if cell.is_selected %}cpg-selected{% endif %}
|
||||
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}"
|
||||
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
|
||||
<span class="cpg-cal-day">{{ cell.day }}</span>
|
||||
<span class="cpg-cal-badges">
|
||||
{% if cell.counts.document %}<span class="erp-badge erp-badge-neutral cpg-mini">작성 {{ cell.counts.document }}</span>{% endif %}
|
||||
{% if cell.counts.ship %}<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>{% endif %}
|
||||
{% if cell.counts.arrival %}<span class="erp-badge erp-badge-success cpg-mini">입고 {{ cell.counts.arrival }}</span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── -->
|
||||
<div class="erp-card cpg-list-card">
|
||||
<div class="cpg-list-head">
|
||||
<h2>{{ selected_date }} 출고</h2>
|
||||
<span class="erp-muted">{{ sel_shipments|length }}건</span>
|
||||
</div>
|
||||
|
||||
{% if sel_shipments %}
|
||||
<ul class="cpg-list">
|
||||
{% for s in sel_shipments %}
|
||||
<li class="cpg-list-item">
|
||||
<a href="/cupang/{{ s.id }}" class="cpg-list-link cpg-hover-item"
|
||||
data-items='{{ s.tip_items | tojson }}'>
|
||||
<div class="cpg-list-top">
|
||||
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
|
||||
</div>
|
||||
<div class="cpg-list-meta erp-muted">
|
||||
출고 {{ s.ship_date }} · 입고 {{ s.center_arrival_date }} · {{ s.ship_method }}
|
||||
{% if s.outbound_summary %}<br>{{ s.outbound_summary }}{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
|
||||
<a href="/cupang/new">신규 등록</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- hover 툴팁 (마우스 따라다님, 커서 왼쪽) -->
|
||||
<div id="cpg-hover-tip" class="cpg-hover-tip" hidden></div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var tip = document.getElementById("cpg-hover-tip");
|
||||
if (!tip) return;
|
||||
|
||||
function buildHtml(items) {
|
||||
if (!items || !items.length) return '<div class="cpg-tip-empty">품목 없음</div>';
|
||||
var html = "";
|
||||
items.forEach(function (it) {
|
||||
var d1 = document.createElement("div");
|
||||
d1.className = "cpg-tip-row";
|
||||
var n = document.createElement("span"); n.className = "cpg-tip-name"; n.textContent = it.name || "";
|
||||
var q = document.createElement("span"); q.className = "cpg-tip-qty"; q.textContent = (it.qty != null ? it.qty : 0) + "개";
|
||||
d1.appendChild(n); d1.appendChild(q);
|
||||
html += d1.outerHTML;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
// 커서 왼쪽에 표시
|
||||
var w = tip.offsetWidth || 200;
|
||||
var x = e.clientX - w - 14;
|
||||
if (x < 6) x = e.clientX + 16; // 화면 왼쪽 벗어나면 오른쪽으로
|
||||
var y = e.clientY + 12;
|
||||
var maxY = window.innerHeight - tip.offsetHeight - 8;
|
||||
if (y > maxY) y = maxY;
|
||||
tip.style.left = x + "px";
|
||||
tip.style.top = y + "px";
|
||||
}
|
||||
|
||||
document.querySelectorAll(".cpg-hover-item").forEach(function (el) {
|
||||
el.addEventListener("mouseenter", function (e) {
|
||||
var items = [];
|
||||
try { items = JSON.parse(el.getAttribute("data-items") || "[]"); } catch (_) {}
|
||||
tip.innerHTML = buildHtml(items);
|
||||
tip.hidden = false;
|
||||
move(e);
|
||||
});
|
||||
el.addEventListener("mousemove", move);
|
||||
el.addEventListener("mouseleave", function () { tip.hidden = true; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-prod-layout">
|
||||
|
||||
<!-- ── 왼쪽: 미라네 주방 상품 목록 (다중선택 → 등록) ── -->
|
||||
<div class="erp-card cpg-form-card cpg-prod-left">
|
||||
<div class="cpg-card-head">
|
||||
<h2>미라네 주방 상품</h2>
|
||||
<span class="erp-muted">
|
||||
{% if search_enabled %}선택(다중) 후 등록. 이미 등록된 항목은 진한 회색.{% else %}
|
||||
검색 비활성: {{ search_reason }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if search_enabled %}
|
||||
<div class="cpg-inline-form" style="margin-bottom:10px;">
|
||||
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
|
||||
<button type="button" class="erp-btn erp-btn-primary" id="cpg-prod-register">선택 등록</button>
|
||||
</div>
|
||||
<div id="cpg-src-list" class="cpg-src-list"><p class="erp-muted">불러오는 중…</p></div>
|
||||
{% else %}
|
||||
<p class="erp-muted">ITEMCODE_DB_URL / ITEMCODE_SEARCH_SQL 설정 후 사용 가능합니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 등록된 제품명 ── -->
|
||||
<div class="erp-card cpg-form-card cpg-prod-right">
|
||||
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
|
||||
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
|
||||
<div class="erp-table-wrap cpg-reg-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>제품명</th><th>제품코드</th><th>상태</th><th>동작</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in products %}
|
||||
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
|
||||
<td>{{ p.product_name }}</td>
|
||||
<td>{{ p.product_code }}</td>
|
||||
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
||||
<td>
|
||||
<div class="cpg-row-actions">
|
||||
{% if p.active %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="0" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">비활성</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="1" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">활성</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('완전 삭제합니다. 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not products %}
|
||||
<tr><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if search_enabled %}
|
||||
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
|
||||
<script>
|
||||
(function () {
|
||||
var listBox = document.getElementById("cpg-src-list");
|
||||
var filter = document.getElementById("cpg-prod-q");
|
||||
var regBtn = document.getElementById("cpg-prod-register");
|
||||
var registered = {};
|
||||
try { (JSON.parse(document.getElementById("cpg-registered").textContent || "[]")).forEach(function(c){ registered[c]=true; }); } catch(e){}
|
||||
var all = [];
|
||||
var selected = {};
|
||||
|
||||
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
|
||||
|
||||
function render() {
|
||||
var term = (filter.value || "").trim().toLowerCase();
|
||||
var rows = all.filter(function (it) {
|
||||
if (!term) return true;
|
||||
return (it.code + " " + it.name).toLowerCase().indexOf(term) >= 0;
|
||||
});
|
||||
if (!rows.length) { listBox.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
|
||||
var html = "";
|
||||
rows.forEach(function (it) {
|
||||
var cls = "cpg-src-item";
|
||||
if (registered[it.code]) cls += " cpg-registered";
|
||||
if (selected[it.code]) cls += " is-selected";
|
||||
html += '<div class="' + cls + '" data-code="' + esc(it.code) + '">' +
|
||||
'<span class="cpg-src-name">' + esc(it.name) + '</span>' +
|
||||
'<span class="cpg-src-code">' + esc(it.code) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
listBox.innerHTML = html;
|
||||
}
|
||||
|
||||
listBox.addEventListener("click", function (e) {
|
||||
var item = e.target.closest(".cpg-src-item");
|
||||
if (!item) return;
|
||||
var code = item.getAttribute("data-code");
|
||||
if (selected[code]) { delete selected[code]; item.classList.remove("is-selected"); }
|
||||
else { selected[code] = true; item.classList.add("is-selected"); }
|
||||
});
|
||||
|
||||
filter.addEventListener("input", render);
|
||||
|
||||
regBtn.addEventListener("click", function () {
|
||||
var items = all.filter(function (it) { return selected[it.code]; })
|
||||
.map(function (it) { return { code: it.code, name: it.name }; });
|
||||
if (!items.length) { alert("등록할 상품을 선택하세요."); return; }
|
||||
regBtn.disabled = true;
|
||||
fetch("/cupang/products/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: items })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function () { location.reload(); })
|
||||
.catch(function () { regBtn.disabled = false; alert("등록 실패"); });
|
||||
});
|
||||
|
||||
fetch("/cupang/api/products/all")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
all = (data && data.results) || [];
|
||||
if (!all.length) {
|
||||
var msg = "itemcode_db 결과 없음";
|
||||
if (data && data.error) { msg += " — 조회 오류: " + esc(data.error); }
|
||||
else if (data && !data.enabled && data.reason) { msg += " — " + esc(data.reason); }
|
||||
else { msg += " (테이블이 비었거나 검색 SQL 조건 불일치)"; }
|
||||
listBox.innerHTML = '<p class="erp-muted">' + msg + '</p>';
|
||||
return;
|
||||
}
|
||||
render();
|
||||
})
|
||||
.catch(function () { listBox.innerHTML = '<p class="erp-muted">목록 로드 실패</p>'; });
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -9,12 +9,15 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .categories import DEFAULT_CATEGORIES, CategoryStore
|
||||
from .router import router
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ExpenseStore",
|
||||
"CategoryStore",
|
||||
"DEFAULT_CATEGORIES",
|
||||
"CATEGORIES",
|
||||
"METHODS",
|
||||
"STATUSES",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""개인경비 분류(category) 설정 저장소.
|
||||
|
||||
- 저장 위치: DATA_DIR/expense_categories.json
|
||||
- 관리자만 추가/삭제. 저장 즉시 사용자 등록 폼/집계에 반영.
|
||||
- JSON 파일 기반 — expense_db(PostgreSQL) 모드와 무관하게 동작(설정값이라
|
||||
트랜잭션 데이터와 분리). DB 스키마 변경(superuser SQL) 불필요.
|
||||
- 동시성: ExpenseStore 와 동일 패턴(threading.Lock + temp→rename 원자적 쓰기).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 최초 1회 시드. 기존 하드코딩 CATEGORIES 와 동일.
|
||||
DEFAULT_CATEGORIES: tuple[str, ...] = (
|
||||
"식대", "교통", "숙박", "비품", "접대", "통신", "기타",
|
||||
)
|
||||
|
||||
|
||||
class CategoryStore:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._lock = threading.Lock()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._path.exists():
|
||||
self._write_atomic({"categories": list(DEFAULT_CATEGORIES)})
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {}
|
||||
cats = data.get("categories")
|
||||
if not isinstance(cats, list) or not cats:
|
||||
data["categories"] = list(DEFAULT_CATEGORIES)
|
||||
else:
|
||||
# 문자열만, 공백 제거, 중복 제거(순서 유지)
|
||||
seen: set[str] = set()
|
||||
clean: list[str] = []
|
||||
for c in cats:
|
||||
name = str(c).strip()
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
clean.append(name)
|
||||
data["categories"] = clean or list(DEFAULT_CATEGORIES)
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".expense_categories.", suffix=".json.tmp",
|
||||
dir=str(self._path.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def list(self) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._read()["categories"])
|
||||
|
||||
def add(self, name: str) -> list[str]:
|
||||
name = str(name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("분류명을 입력하세요.")
|
||||
if len(name) > 30:
|
||||
raise ValueError("분류명은 30자 이하로 입력하세요.")
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if name in data["categories"]:
|
||||
raise ValueError(f"이미 존재하는 분류입니다: {name}")
|
||||
data["categories"].append(name)
|
||||
self._write_atomic(data)
|
||||
return list(data["categories"])
|
||||
|
||||
def delete(self, name: str) -> list[str]:
|
||||
name = str(name or "").strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if name not in data["categories"]:
|
||||
raise KeyError(name)
|
||||
if len(data["categories"]) <= 1:
|
||||
raise ValueError("분류는 최소 1개 이상이어야 합니다.")
|
||||
data["categories"] = [c for c in data["categories"] if c != name]
|
||||
self._write_atomic(data)
|
||||
return list(data["categories"])
|
||||
+318
-28
@@ -18,29 +18,21 @@ from typing import Any
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .store import CATEGORIES, METHODS, STATUSES
|
||||
from app.timezone import KST
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS expense_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
spent_at DATE NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
||||
"""
|
||||
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
|
||||
|
||||
|
||||
class ExpenseDBStore:
|
||||
"""`ExpenseStore` 와 동일한 메서드 시그니처."""
|
||||
"""`ExpenseStore` 와 동일한 메서드 시그니처.
|
||||
|
||||
스키마(테이블/인덱스/트리거)는 앱이 직접 만들지 않는다.
|
||||
`scripts/sql/expense_db_init.sql` 과 `expense_db_002_*.sql` 을 통해
|
||||
superuser 가 사전 적용한다. 앱 계정(expense_app)은 SELECT/INSERT/UPDATE/DELETE
|
||||
권한만 받기 때문에 PostgreSQL 15+ 의 strict public-schema 정책과 충돌하지 않음.
|
||||
|
||||
연결 풀은 lazy open — 부팅 시점에 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
@@ -48,17 +40,13 @@ class ExpenseDBStore:
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=True,
|
||||
open=False,
|
||||
)
|
||||
self._ensure_schema()
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute(_DDL)
|
||||
|
||||
# ── 조회 ──
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
@@ -178,6 +166,307 @@ class ExpenseDBStore:
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(item_id)
|
||||
|
||||
# ── 워크플로 ──
|
||||
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||
return self._transition_owner(
|
||||
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
|
||||
)
|
||||
|
||||
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = '작성중',
|
||||
reject_reason = NULL,
|
||||
decided_at = NULL,
|
||||
approver_email = NULL
|
||||
WHERE id = %s AND owner = %s
|
||||
AND status IN ('제출', '반려')
|
||||
RETURNING *
|
||||
""",
|
||||
(item_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
|
||||
return self._serialize(row)
|
||||
|
||||
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||
return self._transition_approver(
|
||||
item_id=item_id,
|
||||
approver_email=approver_email,
|
||||
from_statuses=("제출",),
|
||||
to_status="승인",
|
||||
)
|
||||
|
||||
def reject(
|
||||
self, *, item_id: str, approver_email: str, reason: str
|
||||
) -> dict[str, Any]:
|
||||
if not reason.strip():
|
||||
raise ValueError("반려 사유 필수")
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = '반려',
|
||||
approver_email = %s,
|
||||
decided_at = now(),
|
||||
reject_reason = %s
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, reason.strip(), item_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 항목 없음")
|
||||
return self._serialize(row)
|
||||
|
||||
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||
return self._transition_approver(
|
||||
item_id=item_id,
|
||||
approver_email=approver_email,
|
||||
from_statuses=("승인",),
|
||||
to_status="정산완료",
|
||||
)
|
||||
|
||||
def _transition_owner(
|
||||
self, *, item_id: str, owner: str, from_status: str, to_status: str
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = %s
|
||||
WHERE id = %s AND owner = %s AND status = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(to_status, item_id, owner, from_status),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"전이 불가: {from_status} → {to_status}")
|
||||
return self._serialize(row)
|
||||
|
||||
def _transition_approver(
|
||||
self,
|
||||
*,
|
||||
item_id: str,
|
||||
approver_email: str,
|
||||
from_statuses: tuple[str, ...],
|
||||
to_status: str,
|
||||
) -> dict[str, Any]:
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = %s,
|
||||
approver_email = %s,
|
||||
decided_at = now()
|
||||
WHERE id = %s AND status = ANY(%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(to_status, approver_email, item_id, list(from_statuses)),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"전이 불가 → {to_status}")
|
||||
return self._serialize(row)
|
||||
|
||||
# ── 승인자 대기열 ──
|
||||
def list_pending_approval(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE status = '제출' "
|
||||
"ORDER BY created_at ASC"
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
|
||||
"""승인자/관리자용 — owner 무시하고 단일 조회."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
# ── 첨부 ──
|
||||
def add_attachment(
|
||||
self,
|
||||
*,
|
||||
item_id: str,
|
||||
owner: str,
|
||||
kind: str,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in ("receipt", "other"):
|
||||
raise ValueError("kind 는 receipt|other")
|
||||
owner = owner.lower().strip()
|
||||
att_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO expense_attachments
|
||||
(id, item_id, owner, kind, filename, stored_path,
|
||||
content_type, size_bytes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
att_id,
|
||||
item_id,
|
||||
owner,
|
||||
kind,
|
||||
filename,
|
||||
stored_path,
|
||||
content_type,
|
||||
size_bytes,
|
||||
),
|
||||
).fetchone()
|
||||
return self._att_serialize(row)
|
||||
|
||||
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_attachments WHERE item_id = %s "
|
||||
"ORDER BY uploaded_at ASC",
|
||||
(item_id,),
|
||||
).fetchall()
|
||||
return [self._att_serialize(r) for r in rows]
|
||||
|
||||
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
|
||||
).fetchone()
|
||||
return self._att_serialize(row) if row else None
|
||||
|
||||
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
|
||||
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"DELETE FROM expense_attachments "
|
||||
"WHERE id = %s AND owner = %s RETURNING *",
|
||||
(att_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(att_id)
|
||||
return self._att_serialize(row)
|
||||
|
||||
@staticmethod
|
||||
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
v = out.get("uploaded_at")
|
||||
if isinstance(v, datetime):
|
||||
out["uploaded_at"] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
out["size_bytes"] = int(out.get("size_bytes", 0))
|
||||
return out
|
||||
|
||||
# ── 승인완료 (월별, 전 직원) ──
|
||||
def list_approved(self, *, year: int, month: int) -> list[dict[str, Any]]:
|
||||
"""해당 월(spent_at)의 승인완료 항목 — 전 직원."""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM expense_items
|
||||
WHERE status = ANY(%s)
|
||||
AND EXTRACT(YEAR FROM spent_at) = %s
|
||||
AND EXTRACT(MONTH FROM spent_at) = %s
|
||||
ORDER BY owner ASC, spent_at ASC, created_at ASC
|
||||
""",
|
||||
(list(APPROVED_STATUSES), year, month),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def approved_attachments(
|
||||
self, *, year: int, month: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""해당 월 승인완료 항목의 첨부 — zip 다운로드용.
|
||||
|
||||
uploaded_at(datetime), spent_at(date) 를 가공 없이 반환(파일명 생성용).
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
|
||||
a.stored_path, a.content_type, a.uploaded_at,
|
||||
i.spent_at, i.category, i.method, i.amount, i.merchant
|
||||
FROM expense_attachments a
|
||||
JOIN expense_items i ON i.id = a.item_id
|
||||
WHERE i.status = ANY(%s)
|
||||
AND EXTRACT(YEAR FROM i.spent_at) = %s
|
||||
AND EXTRACT(MONTH FROM i.spent_at) = %s
|
||||
ORDER BY a.owner ASC, a.uploaded_at ASC
|
||||
""",
|
||||
(list(APPROVED_STATUSES), year, month),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── 집계 (월별) ──
|
||||
def monthly_summary(
|
||||
self, *, email: str, year: int
|
||||
) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT to_char(spent_at, 'YYYY-MM') AS month,
|
||||
category,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(amount), 0) AS total
|
||||
FROM expense_items
|
||||
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
""",
|
||||
(email, year),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"month": r["month"],
|
||||
"category": r["category"],
|
||||
"count": int(r["cnt"]),
|
||||
"total": int(r["total"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_for_export(
|
||||
self,
|
||||
*,
|
||||
email: str | None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if email:
|
||||
clauses.append("owner = %s")
|
||||
params.append(email.lower().strip())
|
||||
if date_from:
|
||||
clauses.append("spent_at >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("spent_at <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM expense_items {where} "
|
||||
f"ORDER BY spent_at ASC, created_at ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
# ── 요약 ──
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
email = email.lower().strip()
|
||||
@@ -218,10 +507,10 @@ class ExpenseDBStore:
|
||||
out = dict(row)
|
||||
if isinstance(out.get("spent_at"), date):
|
||||
out["spent_at"] = out["spent_at"].isoformat()
|
||||
for k in ("created_at", "updated_at"):
|
||||
for k in ("created_at", "updated_at", "decided_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
out["amount"] = int(out.get("amount", 0))
|
||||
return out
|
||||
|
||||
@@ -233,7 +522,8 @@ class ExpenseDBStore:
|
||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||
b["category"] = category if category in CATEGORIES else "기타"
|
||||
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||
b["category"] = category or "기타"
|
||||
b["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
|
||||
+975
-11
File diff suppressed because it is too large
Load Diff
@@ -13,17 +13,20 @@ import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.timezone import now_kst_iso
|
||||
|
||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
|
||||
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return now_kst_iso()
|
||||
|
||||
|
||||
class ExpenseStore:
|
||||
@@ -148,7 +151,8 @@ class ExpenseStore:
|
||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||
b["category"] = category if category in CATEGORIES else "기타"
|
||||
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||
b["category"] = category or "기타"
|
||||
b["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-ex-approved">
|
||||
|
||||
<!-- ── 상단 액션 + 월 선택 ── -->
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||
<input type="month" name="month" value="{{ month }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/attachments.zip?month={{ month }}">전부 다운로드(zip)</a>
|
||||
</div>
|
||||
|
||||
<!-- ── 직원별 합계 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>직원별 승인 금액 합계</h2>
|
||||
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>이메일</th>
|
||||
<th style="width:100px; text-align:right;">건수</th>
|
||||
<th style="width:160px; text-align:right;">합계금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in owner_summary %}
|
||||
<tr>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="erp-muted">{{ r.owner }}</td>
|
||||
<td style="text-align:right;">{{ r.count }}</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if owner_summary %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="2" style="text-align:right;">합계</th>
|
||||
<th style="text-align:right;">{{ count }}</th>
|
||||
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 승인완료 항목 목록 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>승인완료 항목 ({{ count }}건)</h2>
|
||||
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="appr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:110px;">사용일</th>
|
||||
<th style="width:120px;">이름</th>
|
||||
<th style="width:90px;">분류</th>
|
||||
<th style="width:100px;">수단</th>
|
||||
<th>가맹점/메모</th>
|
||||
<th style="width:120px; text-align:right;">금액</th>
|
||||
<th style="width:90px;">상태</th>
|
||||
<th style="width:60px; text-align:center;">첨부</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>{{ it.method }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
|
||||
<td>
|
||||
{% if it.status == '정산완료' %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display:flex; gap:var(--sp-8); margin-bottom:var(--sp-16); flex-wrap:wrap; align-items:center; }
|
||||
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
|
||||
#appr-table td { vertical-align: middle; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
// 월 선택 변경 시 자동 조회
|
||||
const monInput = document.querySelector('#mon-form input[name="month"]');
|
||||
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
|
||||
|
||||
// 첨부 보기 (공용 뷰어)
|
||||
const tbody = document.querySelector("#appr-table tbody");
|
||||
if (tbody) {
|
||||
tbody.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button.js-view-att");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
const id = tr.dataset.id;
|
||||
const who = tr.children[1].textContent.trim();
|
||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,18 +3,25 @@
|
||||
{% block content %}
|
||||
<section class="erp-expense">
|
||||
|
||||
<!-- ── 페이지 액션 ── -->
|
||||
<div class="erp-page-actions">
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||
</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
|
||||
{% endif %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||
{% endif %}
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── 요약 카드 ── -->
|
||||
<div class="erp-summary-grid">
|
||||
<div class="erp-summary-card">
|
||||
<span class="erp-summary-label">총 건수</span>
|
||||
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
|
||||
</div>
|
||||
<div class="erp-summary-card">
|
||||
<span class="erp-summary-label">총 금액</span>
|
||||
<strong class="erp-summary-value" id="ex-total-amount">
|
||||
{{ "{:,}".format(summary.total) }} 원
|
||||
</strong>
|
||||
</div>
|
||||
{% for status in statuses %}
|
||||
<div class="erp-summary-card erp-summary-card--mini">
|
||||
<span class="erp-summary-label">{{ status }}</span>
|
||||
@@ -25,40 +32,35 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── 등록(좌) / 내역(우) 2단 ── -->
|
||||
<div class="ex-two-col">
|
||||
|
||||
<!-- ── 입력 폼 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 등록</h2>
|
||||
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
|
||||
<span class="erp-muted">필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.</span>
|
||||
</div>
|
||||
<form id="ex-form" class="erp-form-grid">
|
||||
<input type="hidden" name="id" />
|
||||
<label class="erp-field">
|
||||
<span>사용일</span>
|
||||
<input type="date" name="spent_at" required />
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>분류</span>
|
||||
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
|
||||
<label class="erp-field"><span>분류</span>
|
||||
<select name="category" required>
|
||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>결제수단</span>
|
||||
<label class="erp-field"><span>결제수단</span>
|
||||
<select name="method" required>
|
||||
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>금액</span>
|
||||
<label class="erp-field"><span>금액</span>
|
||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide">
|
||||
<span>가맹점/사용처</span>
|
||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
||||
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" required />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide">
|
||||
<span>메모</span>
|
||||
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||
<input type="text" name="memo" placeholder="비고" />
|
||||
</label>
|
||||
<div class="erp-form-actions">
|
||||
@@ -72,62 +74,170 @@
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 내역</h2>
|
||||
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
|
||||
<div style="display: flex; align-items: center; gap: var(--sp-12); flex-wrap: wrap;">
|
||||
<form method="get" action="/expense/" id="ex-month-form" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||
<input type="month" name="month" value="{{ month }}" />
|
||||
</form>
|
||||
<span class="ex-month-stat" id="ex-count">{{ items | length }}건</span>
|
||||
<span class="ex-month-stat">합계 <strong>{{ "{:,}".format(month_total) }}</strong> 원</span>
|
||||
{% if supports_workflow %}
|
||||
<button id="ex-bulk-submit" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||
선택 항목 제출 (<span id="ex-bulk-count">0</span>)
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 36px; text-align: center;">
|
||||
<input type="checkbox" id="ex-select-all" title="전체 선택" />
|
||||
</th>
|
||||
<th style="width: 110px;">사용일</th>
|
||||
<th style="width: 90px;">분류</th>
|
||||
<th style="width: 100px;">수단</th>
|
||||
<th>가맹점</th>
|
||||
<th style="width: 120px; text-align: right;">금액</th>
|
||||
<th style="width: 120px;">금액</th>
|
||||
<th style="width: 90px;">상태</th>
|
||||
<th style="width: 130px; text-align: right;">동작</th>
|
||||
<th style="width: 240px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ex-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
||||
<td style="text-align: center;">
|
||||
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||
<input type="checkbox" class="js-select" />
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>{{ it.method }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
{% if it.reject_reason %}
|
||||
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(it.amount) }} 원
|
||||
</td>
|
||||
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
|
||||
<td style="text-align: right;">
|
||||
<td>
|
||||
{% if it.status == '작성중' or it.status == '반려' %}
|
||||
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
|
||||
{% elif it.status == '제출' %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% elif it.status == '승인' %}
|
||||
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right;" class="js-actions">
|
||||
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||
영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
|
||||
</label>
|
||||
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||
기타 파일<input type="file" class="js-upload" data-kind="other" hidden />
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if it.status in ('작성중', '반려') %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
{% elif it.status == '제출' and supports_workflow %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
||||
{% endif %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /ex-two-col -->
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions {
|
||||
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
||||
}
|
||||
#ex-tbody td { vertical-align: middle; }
|
||||
|
||||
/* 경비 내역 월 통계(건수/합계) 강조 */
|
||||
.ex-month-stat { font-size: 18px; font-weight: 600; color: var(--color-text, #1a1a1a); }
|
||||
.ex-month-stat strong { font-size: 20px; }
|
||||
#ex-tbody .js-actions { white-space: nowrap; }
|
||||
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
|
||||
|
||||
/* 상단 요약 카드: 세로 70px 고정 (grid 행 높이 고정 → stretch 무력화) */
|
||||
.erp-expense .erp-summary-grid {
|
||||
grid-auto-rows: 70px !important;
|
||||
margin-bottom: var(--sp-20); /* 등록 폼과 간격 */
|
||||
}
|
||||
.erp-expense .erp-summary-card {
|
||||
height: 70px !important; min-height: 0 !important; max-height: 70px;
|
||||
padding: 8px 14px; justify-content: center; gap: 2px; overflow: hidden;
|
||||
}
|
||||
.erp-expense .erp-summary-card--mini { padding: 8px 14px; }
|
||||
/* 요약줄과 2단 블록 사이 간격 */
|
||||
.ex-two-col { margin-top: var(--sp-20); }
|
||||
|
||||
/* 경비 내역 테이블: 전부 가운데 정렬 + 한 줄(2줄 방지) + 너비 자동 */
|
||||
#ex-table { table-layout: auto; }
|
||||
#ex-table th, #ex-table td {
|
||||
text-align: center !important;
|
||||
white-space: nowrap;
|
||||
width: auto !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#ex-table th:first-child, #ex-table td:first-child { width: 36px !important; }
|
||||
#ex-table td > div { white-space: nowrap; } /* 가맹점/메모 줄바꿈 방지 */
|
||||
#ex-tbody .js-actions { text-align: center !important; }
|
||||
#ex-tbody .js-actions > * { margin: 0 2px; }
|
||||
|
||||
/* 첫 행(헤더) 모서리 라운드 제거 — 라운드는 .erp-table-wrap 에 걸려 있음 */
|
||||
.erp-expense .erp-table-wrap { border-radius: 0 !important; box-shadow: none; }
|
||||
|
||||
/* 첨부(클립) 아이콘 크게 */
|
||||
#ex-tbody .js-view-att { font-size: 22px !important; line-height: 1; padding: 2px 8px; }
|
||||
.erp-expense .erp-summary-value { font-size: 20px; line-height: 1.1; }
|
||||
.erp-expense .erp-summary-value--sm { font-size: 18px; }
|
||||
|
||||
/* 경비 등록(좌) / 경비 내역(우) 2단 */
|
||||
.ex-two-col {
|
||||
display: grid; grid-template-columns: 520px minmax(0, 1fr);
|
||||
gap: var(--sp-16); align-items: start;
|
||||
}
|
||||
.ex-two-col > .erp-card-block { margin: 0; }
|
||||
/* 좌측 폼은 2열로 (가맹점/메모는 전체폭) */
|
||||
.ex-two-col .erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||
.ex-two-col .erp-form-grid .erp-field-wide,
|
||||
.ex-two-col .erp-form-grid .erp-form-actions { grid-column: 1 / -1; }
|
||||
@media (max-width: 1100px) {
|
||||
.ex-two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
|
||||
const form = document.getElementById("ex-form");
|
||||
const submitBtn = document.getElementById("ex-submit");
|
||||
const resetBtn = document.getElementById("ex-reset");
|
||||
const tbody = document.getElementById("ex-tbody");
|
||||
const countEl = document.getElementById("ex-count");
|
||||
const totalEl = document.getElementById("ex-total-amount");
|
||||
|
||||
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
||||
const selectAll = document.getElementById("ex-select-all");
|
||||
const bulkBtn = document.getElementById("ex-bulk-submit");
|
||||
const bulkCountEl = document.getElementById("ex-bulk-count");
|
||||
|
||||
function setEditing(id, data) {
|
||||
form.id.value = id || "";
|
||||
@@ -143,11 +253,39 @@
|
||||
submitBtn.textContent = "등록";
|
||||
}
|
||||
}
|
||||
|
||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||
|
||||
// 필수 입력 검증 — 누락 시 경고창
|
||||
function validateRequired() {
|
||||
const required = [
|
||||
["spent_at", "사용일"],
|
||||
["category", "분류"],
|
||||
["method", "결제수단"],
|
||||
["amount", "금액"],
|
||||
["merchant", "가맹점/사용처"],
|
||||
];
|
||||
const missing = [];
|
||||
for (const [field, label] of required) {
|
||||
const val = (form[field].value || "").trim();
|
||||
if (!val) missing.push([form[field], label]);
|
||||
}
|
||||
// 금액은 0 이하도 미입력 취급
|
||||
if (!missing.some(([f]) => f === form.amount)) {
|
||||
if (!(parseInt(form.amount.value, 10) > 0)) {
|
||||
missing.push([form.amount, "금액(1원 이상)"]);
|
||||
}
|
||||
}
|
||||
if (missing.length) {
|
||||
alert("다음 항목을 입력하세요:\n- " + missing.map(([, l]) => l).join("\n- "));
|
||||
missing[0][0].focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateRequired()) return;
|
||||
const id = form.id.value.trim();
|
||||
const payload = {
|
||||
spent_at: form.spent_at.value,
|
||||
@@ -165,74 +303,116 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await reload();
|
||||
form.reset();
|
||||
setEditing("", null);
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert(`저장 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 체크박스 상태 갱신
|
||||
function refreshSelection() {
|
||||
if (!bulkBtn) return;
|
||||
const cbs = tbody.querySelectorAll("input.js-select");
|
||||
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||
bulkBtn.disabled = checked.length === 0;
|
||||
if (bulkCountEl) bulkCountEl.textContent = checked.length;
|
||||
if (selectAll && cbs.length > 0) {
|
||||
selectAll.checked = checked.length === cbs.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
refreshSelection();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.addEventListener("change", (e) => {
|
||||
if (e.target.classList.contains("js-select")) refreshSelection();
|
||||
});
|
||||
|
||||
if (bulkBtn) {
|
||||
bulkBtn.addEventListener("click", async () => {
|
||||
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
|
||||
.map((cb) => cb.closest("tr").dataset.id);
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`${ids.length}건을 결재 제출할까요? 제출 후에는 수정 불가.`)) return;
|
||||
bulkBtn.disabled = true;
|
||||
bulkBtn.textContent = "제출 중…";
|
||||
const fails = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${id}/submit`, { method: "POST" });
|
||||
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
|
||||
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
|
||||
}
|
||||
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// 행 동작 (수정/삭제/취소/첨부보기 + 업로드)
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const id = tr && tr.dataset.id;
|
||||
if (!id) return;
|
||||
if (btn.classList.contains("js-delete")) {
|
||||
if (!confirm("이 항목을 삭제할까요?")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||
if (res.ok) reload(); else alert("삭제 실패");
|
||||
} else if (btn.classList.contains("js-edit")) {
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
if (!tr) return;
|
||||
const id = tr.dataset.id;
|
||||
|
||||
if (btn.classList.contains("js-edit")) {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const found = (j.items || []).find((x) => x.id === id);
|
||||
const { items } = await res.json();
|
||||
const found = items.find((x) => x.id === id);
|
||||
if (found) setEditing(id, found);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else if (btn.classList.contains("js-delete")) {
|
||||
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
||||
} else if (btn.classList.contains("js-revert")) {
|
||||
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
|
||||
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
|
||||
} else if (btn.classList.contains("js-view-att")) {
|
||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
|
||||
}
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const items = j.items || [];
|
||||
const summary = j.summary || { count: 0, total: 0 };
|
||||
countEl.textContent = `${items.length}건`;
|
||||
if (totalEl) totalEl.textContent = fmt(summary.total);
|
||||
|
||||
tbody.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
items
|
||||
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
|
||||
.forEach((it) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.id = it.id;
|
||||
tr.innerHTML = `
|
||||
<td>${it.spent_at || ""}</td>
|
||||
<td>${it.category || ""}</td>
|
||||
<td>${it.method || ""}</td>
|
||||
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
|
||||
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
|
||||
<td style="text-align:right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
// 첨부 업로드
|
||||
tbody.addEventListener("change", async (e) => {
|
||||
const input = e.target.closest("input.js-upload");
|
||||
if (!input || !input.files.length) return;
|
||||
const tr = input.closest("tr[data-id]");
|
||||
const itemId = tr.dataset.id;
|
||||
const fd = new FormData();
|
||||
fd.append("file", input.files[0]);
|
||||
fd.append("kind", input.dataset.kind || "other");
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
|
||||
method: "POST", body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
input.value = "";
|
||||
alert("첨부 업로드 완료");
|
||||
} catch (err) {
|
||||
alert(`업로드 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 사용일 기본값 = 오늘
|
||||
if (!form.spent_at.value) {
|
||||
const d = new Date();
|
||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||
}
|
||||
|
||||
// 월 선택 변경 시 자동 조회
|
||||
const monthInput = document.querySelector('#ex-month-form input[name="month"]');
|
||||
if (monthInput) monthInput.addEventListener("change", () => monthInput.form.submit());
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-pending">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>승인 대기 ({{ items | length }}건)</h2>
|
||||
<div style="display: flex; align-items: center; gap: var(--sp-12);">
|
||||
<span class="erp-muted">제출 상태 항목 — 일괄 승인 / 항목별 반려</span>
|
||||
<button id="pend-bulk-approve" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||
선택 일괄 승인 (<span id="pend-bulk-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 36px; text-align: center;">
|
||||
<input type="checkbox" id="pend-select-all" title="전체 선택" />
|
||||
</th>
|
||||
<th style="width: 110px;">사용일</th>
|
||||
<th style="width: 200px;">소유자</th>
|
||||
<th style="width: 90px;">분류</th>
|
||||
<th>가맹점/메모</th>
|
||||
<th style="width: 120px; text-align: right;">금액</th>
|
||||
<th style="width: 200px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pend-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" class="js-select" />
|
||||
</td>
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.owner }}</td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(it.amount) }} 원
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-attach" title="첨부 보기" aria-label="첨부 보기">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||
#pend-tbody td { vertical-align: middle; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const tbody = document.getElementById("pend-tbody");
|
||||
const selectAll = document.getElementById("pend-select-all");
|
||||
const bulkBtn = document.getElementById("pend-bulk-approve");
|
||||
const bulkCountEl = document.getElementById("pend-bulk-count");
|
||||
|
||||
function refreshSelection() {
|
||||
const cbs = tbody.querySelectorAll("input.js-select");
|
||||
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||
bulkBtn.disabled = checked.length === 0;
|
||||
bulkCountEl.textContent = checked.length;
|
||||
if (cbs.length > 0) {
|
||||
selectAll.checked = checked.length === cbs.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||
}
|
||||
}
|
||||
|
||||
selectAll.addEventListener("change", () => {
|
||||
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
refreshSelection();
|
||||
});
|
||||
|
||||
tbody.addEventListener("change", (e) => {
|
||||
if (e.target.classList.contains("js-select")) refreshSelection();
|
||||
});
|
||||
|
||||
bulkBtn.addEventListener("click", async () => {
|
||||
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
|
||||
.map((cb) => cb.closest("tr").dataset.id);
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`${ids.length}건을 일괄 승인할까요?`)) return;
|
||||
bulkBtn.disabled = true;
|
||||
bulkBtn.textContent = "승인 중…";
|
||||
const fails = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
|
||||
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
|
||||
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
|
||||
}
|
||||
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
|
||||
location.reload();
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
const id = tr.dataset.id;
|
||||
|
||||
if (btn.classList.contains("js-attach")) {
|
||||
const owner = tr.children[2]?.textContent?.trim() || "";
|
||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
|
||||
return;
|
||||
}
|
||||
if (btn.classList.contains("js-reject")) {
|
||||
const reason = prompt("반려 사유를 입력하세요:");
|
||||
if (!reason || !reason.trim()) return;
|
||||
const res = await fetch(`/expense/api/items/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
if (res.ok) { tr.remove(); refreshSelection(); }
|
||||
else { alert((await res.json()).detail || "반려 실패"); }
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-reports">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
|
||||
<label class="erp-muted">연도</label>
|
||||
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
|
||||
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input); font-family: inherit;" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
<a class="erp-btn erp-btn-outline"
|
||||
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
|
||||
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px;">월</th>
|
||||
{% for c in categories %}
|
||||
<th style="text-align: right;">{{ c }}</th>
|
||||
{% endfor %}
|
||||
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in months %}
|
||||
{% set row_total = pivot[m].values() | sum %}
|
||||
<tr>
|
||||
<td><strong>{{ m }}</strong></td>
|
||||
{% for c in categories %}
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
|
||||
<strong>{{ "{:,}".format(row_total) }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if months %}
|
||||
<tfoot>
|
||||
<tr style="background: var(--color-ghost-gray);">
|
||||
<th>합계</th>
|
||||
{% for c in categories %}
|
||||
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(cat_totals[c]) }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(grand_total) }}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-ex-settings">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block" style="max-width: 640px;">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>분류 항목 관리</h2>
|
||||
<span class="erp-muted">추가/삭제 즉시 사용자 등록 폼에 반영됩니다.</span>
|
||||
</div>
|
||||
|
||||
<form id="cat-form" class="erp-form-grid" style="grid-template-columns: 1fr auto; align-items: end; gap: var(--sp-12);">
|
||||
<label class="erp-field"><span>새 분류명</span>
|
||||
<input type="text" name="name" maxlength="30" placeholder="예) 마케팅" required />
|
||||
</label>
|
||||
<div class="erp-form-actions" style="margin: 0;">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap" style="margin-top: var(--sp-16);">
|
||||
<table class="erp-table" id="cat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>분류명</th>
|
||||
<th style="width: 100px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cat-tbody">
|
||||
{% for c in categories %}
|
||||
<tr data-name="{{ c }}">
|
||||
<td>{{ c }}</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="erp-muted" style="margin-top: var(--sp-12);">
|
||||
삭제해도 이미 등록된 경비 항목의 분류는 그대로 유지됩니다. 분류는 최소 1개 이상이어야 합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||
#cat-tbody td { vertical-align: middle; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById("cat-form");
|
||||
const tbody = document.getElementById("cat-tbody");
|
||||
|
||||
function rowFor(name) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.name = name;
|
||||
tr.innerHTML =
|
||||
`<td></td>` +
|
||||
`<td style="text-align: right;">` +
|
||||
`<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button></td>`;
|
||||
tr.children[0].textContent = name;
|
||||
return tr;
|
||||
}
|
||||
|
||||
function render(categories) {
|
||||
tbody.innerHTML = "";
|
||||
categories.forEach((c) => tbody.appendChild(rowFor(c)));
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = form.name.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await fetch("/expense/api/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || res.status);
|
||||
render(data.categories);
|
||||
form.reset();
|
||||
form.name.focus();
|
||||
} catch (err) {
|
||||
alert(`추가 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button.js-del");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-name]");
|
||||
const name = tr.dataset.name;
|
||||
if (!confirm(`분류 "${name}" 을(를) 삭제할까요?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/expense/api/categories/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || res.status);
|
||||
render(data.categories);
|
||||
} catch (err) {
|
||||
alert(`삭제 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""휴가 관리(vacation) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/vacation)
|
||||
- 저장소: `db.py` (vacation_db / PostgreSQL 전용) + `store.py` (상수/일수 계산)
|
||||
- 템플릿: `templates/vacation/`
|
||||
|
||||
데이터 저장은 vacation_db 전용이다. VACATION_DB_URL 미설정 시 build_vacation_store 는
|
||||
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||
|
||||
권한:
|
||||
- `vacation` : 휴가 관리 접근
|
||||
- `vacation_approver` : 승인/반려 (admin 은 항상 통과)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import HALF_LABELS, HALVES, STATUSES, VACATION_TYPES, compute_days
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"VACATION_TYPES",
|
||||
"STATUSES",
|
||||
"HALVES",
|
||||
"HALF_LABELS",
|
||||
"compute_days",
|
||||
"build_vacation_store",
|
||||
]
|
||||
|
||||
|
||||
def build_vacation_store(*, dsn: str | None) -> Any:
|
||||
"""VACATION_DB_URL 이 있으면 VacationDBStore, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import VacationDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return VacationDBStore(dsn)
|
||||
@@ -0,0 +1,509 @@
|
||||
"""vacation_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense/cupang 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `VACATION_DB_URL`
|
||||
(예: postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db)
|
||||
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||
`scripts/sql/vacation_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||
앱 계정(vacation_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
|
||||
휴가 일수는 서버에서 `store.compute_days` 로 재계산하여 저장한다.
|
||||
공휴일 집합은 vacation_holidays(is_red=TRUE)에서 읽어 계산에 넘긴다(holiday provider).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half
|
||||
|
||||
|
||||
class VacationDBStore:
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 공휴일 (vacation_holidays) — holiday provider
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_holidays(self, *, year: int | None = None) -> list[dict[str, Any]]:
|
||||
clause = ""
|
||||
params: list[Any] = []
|
||||
if year:
|
||||
clause = "WHERE EXTRACT(YEAR FROM holiday_date) = %s"
|
||||
params.append(year)
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vacation_holidays {clause} ORDER BY holiday_date ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._holiday_serialize(r) for r in rows]
|
||||
|
||||
def red_holiday_set(self, *, date_from: str, date_to: str) -> set[str]:
|
||||
"""[date_from, date_to] 범위의 is_red=TRUE 공휴일 ISO 날짜 집합.
|
||||
|
||||
휴가일수 계산/달력 색상의 단일 진실 공급원.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT holiday_date FROM vacation_holidays "
|
||||
"WHERE is_red = TRUE AND holiday_date BETWEEN %s AND %s",
|
||||
(date_from, date_to),
|
||||
).fetchall()
|
||||
out: set[str] = set()
|
||||
for r in rows:
|
||||
d = r["holiday_date"]
|
||||
out.add(d.isoformat() if isinstance(d, date) else str(d))
|
||||
return out
|
||||
|
||||
def upsert_holiday(
|
||||
self,
|
||||
*,
|
||||
holiday_date: str,
|
||||
name: str,
|
||||
kind: str = "public",
|
||||
is_red: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
hd = (holiday_date or "").strip()
|
||||
nm = (name or "").strip()
|
||||
if not hd or not nm:
|
||||
raise ValueError("공휴일 날짜와 이름은 필수입니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_holidays (holiday_date, name, kind, is_red)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (holiday_date) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
kind = EXCLUDED.kind,
|
||||
is_red = EXCLUDED.is_red
|
||||
RETURNING *
|
||||
""",
|
||||
(hd, nm, (kind or "public").strip(), bool(is_red)),
|
||||
).fetchone()
|
||||
return self._holiday_serialize(row)
|
||||
|
||||
def delete_holiday(self, *, holiday_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM vacation_holidays WHERE id = %s", (holiday_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(holiday_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 잔여 연차 (vacation_balances)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def get_balance(self, *, user_email: str, year: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vacation_balances WHERE user_email = %s AND year = %s",
|
||||
(user_email.lower().strip(), year),
|
||||
).fetchone()
|
||||
return self._balance_serialize(row) if row else None
|
||||
|
||||
def list_balances(self, *, year: int) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_balances WHERE year = %s ORDER BY user_email ASC",
|
||||
(year,),
|
||||
).fetchall()
|
||||
return [self._balance_serialize(r) for r in rows]
|
||||
|
||||
def upsert_balance(
|
||||
self, *, user_email: str, year: int, total_days: float, memo: str = ""
|
||||
) -> dict[str, Any]:
|
||||
email = (user_email or "").lower().strip()
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일이 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_balances (user_email, year, total_days, memo)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (user_email, year) DO UPDATE
|
||||
SET total_days = EXCLUDED.total_days,
|
||||
memo = EXCLUDED.memo
|
||||
RETURNING *
|
||||
""",
|
||||
(email, year, total_days, (memo or "").strip()),
|
||||
).fetchone()
|
||||
return self._balance_serialize(row)
|
||||
|
||||
def used_days(self, *, user_email: str, year: int) -> float:
|
||||
"""해당 연도 승인된 휴가의 합계 일수(사용 연차). start_date 연도 기준."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(days), 0) AS s FROM vacation_requests "
|
||||
"WHERE owner = %s AND status = '승인' "
|
||||
"AND EXTRACT(YEAR FROM start_date) = %s",
|
||||
(user_email.lower().strip(), year),
|
||||
).fetchone()
|
||||
return float(row["s"]) if row else 0.0
|
||||
|
||||
def balance_summary(self, *, user_email: str, year: int) -> dict[str, Any]:
|
||||
bal = self.get_balance(user_email=user_email, year=year)
|
||||
total = float(bal["total_days"]) if bal else 0.0
|
||||
used = self.used_days(user_email=user_email, year=year)
|
||||
return {
|
||||
"year": year,
|
||||
"total_days": total,
|
||||
"used_days": used,
|
||||
"remaining_days": round(total - used, 2),
|
||||
"memo": bal["memo"] if bal else "",
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 휴가 신청 (vacation_requests)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_for(self, owner: str) -> list[dict[str, Any]]:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE owner = %s "
|
||||
"ORDER BY start_date DESC, created_at DESC",
|
||||
(owner,),
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def list_overlapping(self, *, date_from: str, date_to: str) -> list[dict[str, Any]]:
|
||||
"""[date_from, date_to] 와 겹치는 모든 신청(달력 bar 표시용).
|
||||
|
||||
취소 포함(흐리게 표시). 범위가 한 칸이라도 겹치면 포함.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests "
|
||||
"WHERE start_date <= %s AND end_date >= %s "
|
||||
"ORDER BY start_date ASC, owner ASC",
|
||||
(date_to, date_from),
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def get_request(self, *, request_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE id = %s", (request_id,)
|
||||
).fetchone()
|
||||
return self._req_serialize(row) if row else None
|
||||
|
||||
def create_request(
|
||||
self, *, owner: str, owner_name: str, payload: dict[str, Any], status: str = "작성중"
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_payload(payload)
|
||||
if status not in STATUSES:
|
||||
status = "작성중"
|
||||
days = self._calc_days(h)
|
||||
req_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_requests
|
||||
(id, owner, owner_name, vacation_type, start_date, end_date,
|
||||
start_half, end_half, days, reason, status)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
req_id,
|
||||
owner.lower().strip(),
|
||||
(owner_name or "").strip(),
|
||||
h["vacation_type"],
|
||||
h["start_date"],
|
||||
h["end_date"],
|
||||
h["start_half"],
|
||||
h["end_half"],
|
||||
days,
|
||||
h["reason"],
|
||||
status,
|
||||
),
|
||||
).fetchone()
|
||||
return self._req_serialize(row)
|
||||
|
||||
def update_request(
|
||||
self, *, request_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""작성중/반려 상태에서만 본인이 수정 가능."""
|
||||
owner = owner.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if current["owner"] != owner:
|
||||
raise PermissionError("본인 신청만 수정할 수 있습니다.")
|
||||
if current["status"] not in EDITABLE_STATUSES:
|
||||
raise ValueError(f"{current['status']} 상태에서는 수정할 수 없습니다.")
|
||||
h = self._normalize_payload(payload)
|
||||
days = self._calc_days(h)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET vacation_type = %s, start_date = %s, end_date = %s,
|
||||
start_half = %s, end_half = %s, days = %s, reason = %s,
|
||||
status = '작성중', reject_reason = '',
|
||||
approver_email = NULL, decided_at = NULL
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
h["vacation_type"],
|
||||
h["start_date"],
|
||||
h["end_date"],
|
||||
h["start_half"],
|
||||
h["end_half"],
|
||||
days,
|
||||
h["reason"],
|
||||
request_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(request_id)
|
||||
return self._req_serialize(row)
|
||||
|
||||
# ── 워크플로 ──
|
||||
def submit(self, *, request_id: str, owner: str) -> dict[str, Any]:
|
||||
"""작성중/반려 → 제출 (본인)."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '제출', reject_reason = '',
|
||||
approver_email = NULL, decided_at = NULL
|
||||
WHERE id = %s AND owner = %s AND status IN ('작성중', '반려')
|
||||
RETURNING *
|
||||
""",
|
||||
(request_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("작성중/반려 상태에서만 제출할 수 있습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def approve(self, *, request_id: str, approver_email: str) -> dict[str, Any]:
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '승인', approver_email = %s, decided_at = now(),
|
||||
reject_reason = ''
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, request_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def reject(
|
||||
self, *, request_id: str, approver_email: str, reason: str
|
||||
) -> dict[str, Any]:
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("반려 사유는 필수입니다.")
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '반려', approver_email = %s, decided_at = now(),
|
||||
reject_reason = %s
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, reason.strip(), request_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def cancel(self, *, request_id: str, user_email: str, is_admin: bool) -> dict[str, Any]:
|
||||
"""soft delete — status='취소'. owner 또는 admin 만."""
|
||||
user_email = user_email.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if not is_admin and current["owner"] != user_email:
|
||||
raise PermissionError("본인 신청만 취소할 수 있습니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"UPDATE vacation_requests SET status = '취소' WHERE id = %s RETURNING *",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return self._req_serialize(row)
|
||||
|
||||
def hard_delete(self, *, request_id: str, user_email: str, is_admin: bool) -> None:
|
||||
"""완전 삭제.
|
||||
|
||||
- 관리자(is_admin): 모든 상태 삭제 가능.
|
||||
- 일반 사용자: 본인 + 승인 전(작성중/제출/반려)만. 승인/취소 건은 불가.
|
||||
"""
|
||||
user_email = user_email.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if not is_admin:
|
||||
if current["owner"] != user_email:
|
||||
raise PermissionError("본인 신청만 삭제할 수 있습니다.")
|
||||
if current["status"] not in ("작성중", "제출", "반려"):
|
||||
raise ValueError("승인/취소된 휴가는 삭제할 수 없습니다. (관리자 문의)")
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM vacation_requests WHERE id = %s", (request_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(request_id)
|
||||
|
||||
def list_pending(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE status = '제출' "
|
||||
"ORDER BY start_date ASC, created_at ASC"
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def list_for_export(
|
||||
self, *, date_from: str | None = None, date_to: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if date_from:
|
||||
clauses.append("end_date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("start_date <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vacation_requests {where} "
|
||||
"ORDER BY start_date ASC, owner ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 정규화 / 계산 / 직렬화 helpers
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _calc_days(self, h: dict[str, Any]) -> float:
|
||||
holidays = self.red_holiday_set(
|
||||
date_from=h["start_date"], date_to=h["end_date"]
|
||||
)
|
||||
return compute_days(
|
||||
start_date=date.fromisoformat(h["start_date"]),
|
||||
end_date=date.fromisoformat(h["end_date"]),
|
||||
start_half=h["start_half"],
|
||||
end_half=h["end_half"],
|
||||
vacation_type=h["vacation_type"],
|
||||
holidays=holidays,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
vtype = str(payload.get("vacation_type") or "연차").strip()
|
||||
if vtype not in VACATION_TYPES:
|
||||
vtype = "연차"
|
||||
start_date = str(payload.get("start_date") or "").strip()
|
||||
end_date = str(payload.get("end_date") or "").strip()
|
||||
if not start_date:
|
||||
raise ValueError("시작일은 필수입니다.")
|
||||
if not end_date:
|
||||
end_date = start_date
|
||||
# 날짜 형식/순서 검증
|
||||
try:
|
||||
sd = date.fromisoformat(start_date)
|
||||
ed = date.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
raise ValueError("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).")
|
||||
if ed < sd:
|
||||
raise ValueError("종료일이 시작일보다 빠를 수 없습니다.")
|
||||
start_half = normalize_half(payload.get("start_half"))
|
||||
end_half = normalize_half(payload.get("end_half"))
|
||||
# 반차 종류면 단일 일자로 강제
|
||||
if vtype in ("오전반차", "오후반차"):
|
||||
end_date = start_date
|
||||
start_half = "am" if vtype == "오전반차" else "pm"
|
||||
end_half = start_half
|
||||
return {
|
||||
"vacation_type": vtype,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"start_half": start_half,
|
||||
"end_half": end_half,
|
||||
"reason": str(payload.get("reason") or "").strip(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _num(v: Any) -> float:
|
||||
if isinstance(v, Decimal):
|
||||
return float(v)
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _req_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k in ("start_date", "end_date"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("created_at", "updated_at", "decided_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
out["days"] = cls._num(out.get("days", 0))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _holiday_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
v = out.get("holiday_date")
|
||||
if isinstance(v, date):
|
||||
out["holiday_date"] = v.isoformat()
|
||||
out["is_red"] = bool(out.get("is_red", True))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _balance_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["year"] = int(out["year"])
|
||||
out["total_days"] = cls._num(out.get("total_days", 0))
|
||||
out["used_days"] = cls._num(out.get("used_days", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
@@ -0,0 +1,827 @@
|
||||
"""휴가 관리 모듈 라우터.
|
||||
|
||||
- 경로: /vacation
|
||||
- 권한: 로그인 + `vacation` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||
승인/반려는 `vacation_approver` 또는 admin.
|
||||
- 데이터: VacationDBStore (vacation_db / PostgreSQL) 전용.
|
||||
VACATION_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _calendar
|
||||
import io
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import (
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
RedirectResponse,
|
||||
StreamingResponse,
|
||||
)
|
||||
|
||||
from app.timezone import now_kst, today_kst
|
||||
|
||||
from .store import HALF_LABELS, HALVES, VACATION_TYPES
|
||||
|
||||
router = APIRouter(prefix="/vacation", tags=["vacation"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 공용 헬퍼
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _store(request: Request) -> Any:
|
||||
return getattr(request.app.state, "vacation_store", None)
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "vacation"):
|
||||
raise HTTPException(status_code=403, detail="휴가 관리 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _require_approver(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not (is_admin(user) or has_module(user, "vacation_approver")):
|
||||
raise HTTPException(status_code=403, detail="휴가 승인자 권한이 필요합니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _is_approver(request: Request, user: dict[str, Any]) -> bool:
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
return is_admin(user) or has_module(user, "vacation_approver")
|
||||
|
||||
|
||||
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": "휴가 관리 모듈이 아직 설정되지 않았습니다. "
|
||||
"VACATION_DB_URL 환경변수를 설정하고 scripts/sql/vacation_db_init.sql 로 "
|
||||
"vacation_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def _guard(
|
||||
request: Request,
|
||||
) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용."""
|
||||
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not has_module(user, "vacation"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return _render_config_needed(request, user)
|
||||
return store, user
|
||||
|
||||
|
||||
def _ym(request: Request) -> tuple[int, int]:
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or today.month)
|
||||
except ValueError:
|
||||
year, month = today.year, today.month
|
||||
if not (1 <= month <= 12):
|
||||
year, month = today.year, today.month
|
||||
return year, month
|
||||
|
||||
|
||||
# 상태별 bar 스타일 클래스 (CSS 와 매핑)
|
||||
_STATUS_CLASS = {
|
||||
"작성중": "vac-bar-draft",
|
||||
"제출": "vac-bar-submit",
|
||||
"승인": "vac-bar-approve",
|
||||
"반려": "vac-bar-reject",
|
||||
"취소": "vac-bar-cancel",
|
||||
}
|
||||
|
||||
|
||||
def _assign_lanes(bars: list[dict[str, Any]]) -> int:
|
||||
"""주(week) 내 bar 들에 겹치지 않는 lane(행) 번호를 그리디 배정.
|
||||
|
||||
bars 는 같은 주의 segment 들. 각 bar 에 'lane' 키를 추가하고, 사용된 lane 수를 반환.
|
||||
"""
|
||||
bars.sort(key=lambda b: (b["start_col"], -b["span"]))
|
||||
lane_end: list[int] = [] # lane 별 마지막 점유 col(포함)
|
||||
for b in bars:
|
||||
placed = False
|
||||
for li, end_col in enumerate(lane_end):
|
||||
if b["start_col"] > end_col:
|
||||
b["lane"] = li
|
||||
lane_end[li] = b["start_col"] + b["span"] - 1
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
b["lane"] = len(lane_end)
|
||||
lane_end.append(b["start_col"] + b["span"] - 1)
|
||||
return len(lane_end)
|
||||
|
||||
|
||||
def _build_calendar(
|
||||
store: Any, year: int, month: int, sel: str
|
||||
) -> dict[str, Any]:
|
||||
"""월간 달력 데이터 + bar lane 레이아웃 계산."""
|
||||
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||
weeks_dates = cal.monthdatescalendar(year, month)
|
||||
range_start = weeks_dates[0][0]
|
||||
range_end = weeks_dates[-1][-1]
|
||||
|
||||
red_set = store.red_holiday_set(
|
||||
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||
)
|
||||
holiday_names = {
|
||||
h["holiday_date"]: h["name"]
|
||||
for h in store.list_holidays()
|
||||
if h.get("is_red")
|
||||
}
|
||||
requests = store.list_overlapping(
|
||||
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||
)
|
||||
|
||||
today = today_kst()
|
||||
weeks: list[dict[str, Any]] = []
|
||||
for week in weeks_dates:
|
||||
w_start, w_end = week[0], week[-1]
|
||||
days = [
|
||||
{
|
||||
"date": d.isoformat(),
|
||||
"day": d.day,
|
||||
"in_month": d.month == month,
|
||||
"is_today": d == today,
|
||||
"is_selected": d.isoformat() == sel,
|
||||
"is_sunday": d.weekday() == 6,
|
||||
"is_saturday": d.weekday() == 5,
|
||||
"is_holiday": d.isoformat() in red_set,
|
||||
"holiday_name": holiday_names.get(d.isoformat(), ""),
|
||||
}
|
||||
for d in week
|
||||
]
|
||||
# 이 주에 걸치는 bar segment
|
||||
bars: list[dict[str, Any]] = []
|
||||
for r in requests:
|
||||
rs = date.fromisoformat(r["start_date"])
|
||||
re_ = date.fromisoformat(r["end_date"])
|
||||
if re_ < w_start or rs > w_end:
|
||||
continue
|
||||
seg_start = max(rs, w_start)
|
||||
seg_end = min(re_, w_end)
|
||||
start_col = (seg_start - w_start).days # 0..6
|
||||
span = (seg_end - seg_start).days + 1
|
||||
label = f"{r.get('owner_name') or r.get('owner')} {r['vacation_type']}"
|
||||
bars.append(
|
||||
{
|
||||
"id": r["id"],
|
||||
"label": label,
|
||||
"status": r["status"],
|
||||
"status_class": _STATUS_CLASS.get(r["status"], "vac-bar-draft"),
|
||||
"start_col": start_col,
|
||||
"span": span,
|
||||
"continues_left": rs < w_start,
|
||||
"continues_right": re_ > w_end,
|
||||
"days": r["days"],
|
||||
}
|
||||
)
|
||||
lane_count = _assign_lanes(bars)
|
||||
weeks.append({"days": days, "bars": bars, "lane_count": lane_count})
|
||||
|
||||
return {"weeks": weeks, "requests": requests, "sel_date": sel}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 메인 — 월간 달력 + 선택일 휴가 리스트
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
|
||||
year, month = _ym(request)
|
||||
sel = request.query_params.get("date") or ""
|
||||
today = today_kst()
|
||||
if not sel:
|
||||
sel = (
|
||||
today.isoformat()
|
||||
if (today.year == year and today.month == month)
|
||||
else f"{year:04d}-{month:02d}-01"
|
||||
)
|
||||
|
||||
caldata = _build_calendar(store, year, month, sel)
|
||||
sel_requests = [
|
||||
r
|
||||
for r in caldata["requests"]
|
||||
if r["start_date"] <= sel <= r["end_date"]
|
||||
]
|
||||
|
||||
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
summary = store.balance_summary(user_email=user["email"], year=year)
|
||||
is_approver = _is_approver(request, user)
|
||||
pending_count = len(store.list_pending()) if is_approver else 0
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": is_approver,
|
||||
"pending_count": pending_count,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 관리",
|
||||
"page_subtitle": f"{year}년 {month}월 휴가 달력",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"prev_y": prev_y, "prev_m": prev_m,
|
||||
"next_y": next_y, "next_m": next_m,
|
||||
"today": today.isoformat(),
|
||||
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||
"weeks": caldata["weeks"],
|
||||
"selected_date": sel,
|
||||
"sel_requests": sel_requests,
|
||||
"balance": summary,
|
||||
"half_labels": HALF_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 휴가 신청 — 등록 / 수정 / 상세
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _form_context(request: Request, user: dict[str, Any]) -> dict[str, Any]:
|
||||
from app.main import build_erp_nav # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": _is_approver(request, user),
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"vacation_types": list(VACATION_TYPES),
|
||||
"halves": list(HALVES),
|
||||
"half_labels": HALF_LABELS,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_form(request: Request) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
_store_obj, user = guard
|
||||
ctx = _form_context(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "휴가 신청",
|
||||
"page_subtitle": "휴가 종류 · 기간 · 사유 입력",
|
||||
"mode": "new",
|
||||
"req": None,
|
||||
"default_date": today_kst().isoformat(),
|
||||
}
|
||||
)
|
||||
return render_template(request, "vacation/form.html", ctx)
|
||||
|
||||
|
||||
def _payload_from_form(
|
||||
vacation_type: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
start_half: str,
|
||||
end_half: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"vacation_type": vacation_type,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date or start_date,
|
||||
"start_half": start_half,
|
||||
"end_half": end_half,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
async def create(
|
||||
request: Request,
|
||||
vacation_type: str = Form(...),
|
||||
start_date: str = Form(...),
|
||||
end_date: str = Form(""),
|
||||
start_half: str = Form("full"),
|
||||
end_half: str = Form("full"),
|
||||
reason: str = Form(""),
|
||||
action: str = Form("submit"), # "draft" | "submit"
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
status = "작성중" if action == "draft" else "제출"
|
||||
payload = _payload_from_form(
|
||||
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||
)
|
||||
try:
|
||||
req = store.create_request(
|
||||
owner=user["email"],
|
||||
owner_name=user.get("name") or user["email"],
|
||||
payload=payload,
|
||||
status=status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{req['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/pending", response_class=HTMLResponse)
|
||||
async def pending_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
if not _is_approver(request, user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 승인자 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
pending = store.list_pending()
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/pending.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": True,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 — 승인 대기",
|
||||
"page_subtitle": f"제출 상태 {len(pending)}건",
|
||||
"items": pending,
|
||||
"half_labels": HALF_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
if not is_admin(user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False},
|
||||
status_code=403,
|
||||
)
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
except ValueError:
|
||||
year = today.year
|
||||
balances = store.list_balances(year=year)
|
||||
for b in balances:
|
||||
b["used_days"] = store.used_days(user_email=b["user_email"], year=year)
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/settings.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": True,
|
||||
"is_approver": True,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 — 설정",
|
||||
"page_subtitle": "공휴일 · 연차 잔여 관리",
|
||||
"year": year,
|
||||
"holidays": store.list_holidays(year=year),
|
||||
"balances": balances,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings/holidays")
|
||||
async def holiday_upsert(
|
||||
request: Request,
|
||||
holiday_date: str = Form(...),
|
||||
name: str = Form(...),
|
||||
kind: str = Form("public"),
|
||||
is_red: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.upsert_holiday(
|
||||
holiday_date=holiday_date,
|
||||
name=name,
|
||||
kind=kind,
|
||||
is_red=is_red in ("1", "true", "on", "True"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
year = holiday_date[:4]
|
||||
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/holidays/{holiday_id:int}/delete")
|
||||
async def holiday_delete(
|
||||
request: Request,
|
||||
holiday_id: int,
|
||||
year: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.delete_holiday(holiday_id=holiday_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="공휴일을 찾을 수 없습니다.")
|
||||
suffix = f"?year={year}" if year else ""
|
||||
return RedirectResponse(url=f"/vacation/settings{suffix}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/balances")
|
||||
async def balance_upsert(
|
||||
request: Request,
|
||||
user_email: str = Form(...),
|
||||
year: int = Form(...),
|
||||
total_days: float = Form(...),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.upsert_balance(
|
||||
user_email=user_email, year=year, total_days=total_days, memo=memo
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/api/calendar")
|
||||
async def api_calendar(
|
||||
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> JSONResponse:
|
||||
"""달력 비동기 데이터(JSON). year/month 쿼리."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
year, month = _ym(request)
|
||||
sel = request.query_params.get("date") or f"{year:04d}-{month:02d}-01"
|
||||
caldata = _build_calendar(store, year, month, sel)
|
||||
return JSONResponse(
|
||||
{
|
||||
"year": year,
|
||||
"month": month,
|
||||
"weeks": caldata["weeks"],
|
||||
"requests": caldata["requests"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export.xlsx")
|
||||
async def export_xlsx(
|
||||
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> StreamingResponse:
|
||||
from openpyxl import Workbook # 지연 import
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or 0)
|
||||
except ValueError:
|
||||
year, month = today.year, 0
|
||||
|
||||
if 1 <= month <= 12:
|
||||
last = _calendar.monthrange(year, month)[1]
|
||||
date_from = f"{year:04d}-{month:02d}-01"
|
||||
date_to = f"{year:04d}-{month:02d}-{last:02d}"
|
||||
else:
|
||||
date_from = f"{year:04d}-01-01"
|
||||
date_to = f"{year:04d}-12-31"
|
||||
|
||||
rows = store.list_for_export(date_from=date_from, date_to=date_to)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "vacation"
|
||||
header = [
|
||||
"신청자", "휴가종류", "시작일", "종료일", "시작구분", "종료구분",
|
||||
"사용일수", "상태", "승인자", "승인/반려일", "사유", "반려사유",
|
||||
]
|
||||
ws.append(header)
|
||||
for r in rows:
|
||||
ws.append([
|
||||
r.get("owner_name") or r.get("owner", ""),
|
||||
r.get("vacation_type", ""),
|
||||
r.get("start_date", ""),
|
||||
r.get("end_date", ""),
|
||||
HALF_LABELS.get(r.get("start_half", "full"), ""),
|
||||
HALF_LABELS.get(r.get("end_half", "full"), ""),
|
||||
r.get("days", 0),
|
||||
r.get("status", ""),
|
||||
r.get("approver_email", "") or "",
|
||||
r.get("decided_at", "") or "",
|
||||
r.get("reason", "") or "",
|
||||
r.get("reject_reason", "") or "",
|
||||
])
|
||||
widths = [24, 10, 12, 12, 8, 8, 8, 8, 24, 22, 30, 30]
|
||||
for col, w in enumerate(widths, start=1):
|
||||
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}"
|
||||
fname = f"vacation_{scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "vacation"}
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_class=HTMLResponse)
|
||||
async def detail(request: Request, request_id: str) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
req = store.get_request(request_id=request_id)
|
||||
if not req:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
is_owner = req["owner"] == user["email"]
|
||||
is_approver = _is_approver(request, user)
|
||||
if not (is_owner or is_approver):
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "본인 또는 승인자만 조회할 수 있습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/detail.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": is_approver,
|
||||
"is_owner": is_owner,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 상세",
|
||||
"page_subtitle": f"{req['start_date']} · {req['vacation_type']}",
|
||||
"req": req,
|
||||
"half_labels": HALF_LABELS,
|
||||
"can_edit": is_owner and req["status"] in ("작성중", "반려"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{request_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request, request_id: str) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
req = store.get_request(request_id=request_id)
|
||||
if not req:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
if req["owner"] != user["email"]:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "본인 신청만 수정할 수 있습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
if req["status"] not in ("작성중", "반려"):
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": f"{req['status']} 상태에서는 수정할 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=409,
|
||||
)
|
||||
ctx = _form_context(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "휴가 신청 수정",
|
||||
"page_subtitle": "작성중/반려 상태만 수정 가능",
|
||||
"mode": "edit",
|
||||
"req": req,
|
||||
"default_date": req["start_date"],
|
||||
}
|
||||
)
|
||||
return render_template(request, "vacation/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/{request_id}/edit")
|
||||
async def update(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
vacation_type: str = Form(...),
|
||||
start_date: str = Form(...),
|
||||
end_date: str = Form(""),
|
||||
start_half: str = Form("full"),
|
||||
end_half: str = Form("full"),
|
||||
reason: str = Form(""),
|
||||
action: str = Form("save"), # "save" | "submit"
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
payload = _payload_from_form(
|
||||
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||
)
|
||||
try:
|
||||
store.update_request(request_id=request_id, owner=user["email"], payload=payload)
|
||||
if action == "submit":
|
||||
store.submit(request_id=request_id, owner=user["email"])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/submit")
|
||||
async def submit(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.submit(request_id=request_id, owner=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/approve")
|
||||
async def approve(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_approver),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.approve(request_id=request_id, approver_email=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/reject")
|
||||
async def reject(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
reject_reason: str = Form(...),
|
||||
user: dict[str, Any] = Depends(_require_approver),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.reject(
|
||||
request_id=request_id, approver_email=user["email"], reason=reject_reason
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/cancel")
|
||||
async def cancel(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.cancel(
|
||||
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/delete")
|
||||
async def delete(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""완전 삭제 — 승인 전(작성중/제출/반려)만. 달력으로 복귀."""
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.hard_delete(
|
||||
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url="/vacation/", status_code=303)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""휴가 관리 모듈 상수 및 순수 계산 헬퍼.
|
||||
|
||||
- 데이터 저장은 vacation_db(PostgreSQL) 전용이다(`db.py`).
|
||||
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||
VACATION_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||
- 이 모듈에는 상수와 순수 계산 헬퍼(휴가일수 계산)만 둔다.
|
||||
공휴일 집합은 db.py 가 vacation_holidays 에서 읽어 넘겨준다(holiday provider 분리).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Iterable
|
||||
|
||||
# 휴가 종류 (한글 라벨 그대로 저장)
|
||||
VACATION_TYPES: tuple[str, ...] = (
|
||||
"연차",
|
||||
"오전반차",
|
||||
"오후반차",
|
||||
"병가",
|
||||
"경조",
|
||||
"대체휴무",
|
||||
"기타",
|
||||
)
|
||||
|
||||
# 신청 상태 워크플로
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "취소")
|
||||
|
||||
# 시작/종료일 구분
|
||||
HALVES: tuple[str, ...] = ("full", "am", "pm")
|
||||
HALF_LABELS: dict[str, str] = {"full": "종일", "am": "오전", "pm": "오후"}
|
||||
|
||||
# 수정/삭제(취소) 가능한 상태 — 본인 편집 허용
|
||||
EDITABLE_STATUSES: tuple[str, ...] = ("작성중", "반려")
|
||||
|
||||
# 반차 성격의 휴가 종류 (단일 일자 0.5일 강제)
|
||||
_HALF_DAY_TYPES: dict[str, str] = {"오전반차": "am", "오후반차": "pm"}
|
||||
|
||||
|
||||
def _daterange(start: date, end: date) -> Iterable[date]:
|
||||
cur = start
|
||||
while cur <= end:
|
||||
yield cur
|
||||
cur += timedelta(days=1)
|
||||
|
||||
|
||||
def is_working_day(d: date, holidays: set[str]) -> bool:
|
||||
"""주말(토/일)과 공휴일(holidays: ISO 날짜 집합)을 제외하면 근무일."""
|
||||
if d.weekday() >= 5: # 5=토, 6=일
|
||||
return False
|
||||
return d.isoformat() not in holidays
|
||||
|
||||
|
||||
def compute_days(
|
||||
*,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
start_half: str = "full",
|
||||
end_half: str = "full",
|
||||
vacation_type: str = "연차",
|
||||
holidays: set[str] | None = None,
|
||||
) -> float:
|
||||
"""휴가 일수 계산. 서버에서 항상 이 함수로 재계산한다(클라이언트 신뢰 금지).
|
||||
|
||||
- 주말 + 공휴일(is_red) 제외.
|
||||
- 오전/오후 반차는 0.5일.
|
||||
- 같은 날 + 반차면 0.5일.
|
||||
- 여러 날에서 시작/종료가 반차면 시작일/종료일 각각 0.5 차감.
|
||||
- 휴가 종류가 오전반차/오후반차면 단일 일자 0.5일로 강제.
|
||||
"""
|
||||
holidays = holidays or set()
|
||||
if end_date < start_date:
|
||||
return 0.0
|
||||
|
||||
# 반차 종류는 단일 일자 0.5
|
||||
if vacation_type in _HALF_DAY_TYPES:
|
||||
if is_working_day(start_date, holidays):
|
||||
return 0.5
|
||||
return 0.0
|
||||
|
||||
working = [d for d in _daterange(start_date, end_date) if is_working_day(d, holidays)]
|
||||
n = len(working)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
|
||||
if start_date == end_date:
|
||||
return 0.5 if start_half in ("am", "pm") else 1.0
|
||||
|
||||
total = float(n)
|
||||
if start_half in ("am", "pm") and is_working_day(start_date, holidays):
|
||||
total -= 0.5
|
||||
if end_half in ("am", "pm") and is_working_day(end_date, holidays):
|
||||
total -= 0.5
|
||||
return max(0.0, total)
|
||||
|
||||
|
||||
def normalize_half(value: str | None) -> str:
|
||||
v = (value or "full").strip().lower()
|
||||
return v if v in HALVES else "full"
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530j" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
<div class="erp-card vac-detail-card">
|
||||
|
||||
<div class="vac-detail-head">
|
||||
<div>
|
||||
<h2>{{ req.owner_name or req.owner }}</h2>
|
||||
<span class="erp-muted">{{ req.owner }}</span>
|
||||
</div>
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if req.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif req.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif req.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }} vac-detail-status">{{ req.status }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="vac-detail-grid">
|
||||
<div><dt>휴가 종류</dt><dd>{{ req.vacation_type }}</dd></div>
|
||||
<div><dt>사용 일수</dt><dd><strong>{{ req.days }}</strong>일</dd></div>
|
||||
<div><dt>시작일</dt><dd>{{ req.start_date }} ({{ half_labels[req.start_half] }})</dd></div>
|
||||
<div><dt>종료일</dt><dd>{{ req.end_date }} ({{ half_labels[req.end_half] }})</dd></div>
|
||||
{% if req.approver_email %}
|
||||
<div><dt>승인자</dt><dd>{{ req.approver_email }}</dd></div>
|
||||
<div><dt>결재일시</dt><dd>{{ req.decided_at }}</dd></div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if req.reason %}
|
||||
<div class="vac-detail-block">
|
||||
<div class="vac-detail-label">사유</div>
|
||||
<p>{{ req.reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status == '반려' and req.reject_reason %}
|
||||
<div class="vac-detail-block vac-reject-block">
|
||||
<div class="vac-detail-label">반려 사유</div>
|
||||
<p>{{ req.reject_reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 승인자 액션 (제출 상태) ── -->
|
||||
{% if is_approver and req.status == '제출' %}
|
||||
<div class="vac-approve-box">
|
||||
<form method="post" action="/vacation/{{ req.id }}/approve" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">승인</button>
|
||||
</form>
|
||||
<form method="post" action="/vacation/{{ req.id }}/reject" class="vac-reject-form">
|
||||
<input class="erp-input" type="text" name="reject_reason" placeholder="반려 사유" required />
|
||||
<button type="submit" class="erp-btn erp-btn-danger">반려</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="erp-page-actions vac-detail-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/vacation/">◀◀ 달력</a>
|
||||
|
||||
{% if is_owner and req.status in ('작성중', '반려') %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/submit" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">제출</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# 승인 건: 취소처리(owner/admin). 미승인: 삭제(owner/admin). 관리자는 모든 상태 삭제 가능. #}
|
||||
{% if req.status == '승인' and (is_owner or is_admin) %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/cancel" class="vac-inline-form"
|
||||
onsubmit="return confirm('승인된 휴가를 취소 처리할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">취소처리</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status in ('작성중', '제출', '반려') and (is_owner or is_admin) %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
|
||||
onsubmit="return confirm('이 휴가 신청을 삭제할까요? (복구 불가)');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin and req.status in ('승인', '취소') %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
|
||||
onsubmit="return confirm('[관리자] 이 휴가({{ req.status }})를 완전 삭제할까요? (복구 불가)');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<a class="erp-btn erp-btn-outline vac-push-right" href="/vacation/{{ req.id }}/edit">수정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
{% set action = '/vacation/new' if mode == 'new' else '/vacation/' ~ req.id ~ '/edit' %}
|
||||
<form id="vac-form" method="post" action="{{ action }}" class="erp-card vac-form-card">
|
||||
|
||||
<div class="vac-form-grid">
|
||||
<label class="erp-field"><span>휴가 종류 *</span>
|
||||
<select class="erp-select" name="vacation_type" id="vac-type" required>
|
||||
{% for t in vacation_types %}
|
||||
<option value="{{ t }}" {% if req and req.vacation_type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="erp-field"><span>시작일 *</span>
|
||||
<input class="erp-input" type="date" name="start_date" id="vac-start" required
|
||||
value="{{ req.start_date if req else default_date }}" />
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-start-half-field"><span>시작 구분</span>
|
||||
<select class="erp-select" name="start_half" id="vac-start-half">
|
||||
{% for h in halves %}
|
||||
<option value="{{ h }}" {% if req and req.start_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-end-field"><span>종료일 *</span>
|
||||
<input class="erp-input" type="date" name="end_date" id="vac-end"
|
||||
value="{{ req.end_date if req else default_date }}" />
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-end-half-field"><span>종료 구분</span>
|
||||
<select class="erp-select" name="end_half" id="vac-end-half">
|
||||
{% for h in halves %}
|
||||
<option value="{{ h }}" {% if req and req.end_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="erp-field vac-full"><span>사유</span>
|
||||
<textarea class="erp-input" name="reason" rows="3"
|
||||
placeholder="휴가 사유를 입력하세요.">{{ req.reason if req else '' }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="vac-days-preview">
|
||||
예상 사용 일수: <strong id="vac-days-out">—</strong>
|
||||
<span class="erp-muted">(주말 제외 · 공휴일은 저장 시 반영)</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-page-actions vac-form-actions">
|
||||
<button type="submit" class="erp-btn erp-btn-primary" name="action" value="submit">제출</button>
|
||||
<button type="submit" class="erp-btn erp-btn-outline" name="action"
|
||||
value="{{ 'save' if mode == 'edit' else 'draft' }}">작성중 저장</button>
|
||||
{% if mode == 'edit' %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/{{ req.id }}">취소</a>
|
||||
{% else %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/">취소</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/vacation.js?v=20260530i" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
<!-- 페이지 액션 -->
|
||||
<div class="vac-actions">
|
||||
<div class="vac-actions-main">
|
||||
<a class="erp-btn erp-btn-primary" href="/vacation/new">+ 휴가 신청</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/pending">승인 대기
|
||||
{% if pending_count %}<span class="erp-badge erp-badge-inverse vac-mini">{{ pending_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/export.xlsx?year={{ year }}&month={{ month }}">엑셀</a>
|
||||
{% if is_admin %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/settings?year={{ year }}">설정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="vac-balance">
|
||||
<span class="vac-bal-item">총 <strong>{{ balance.total_days }}</strong></span>
|
||||
<span class="vac-bal-sep">·</span>
|
||||
<span class="vac-bal-item">사용 <strong>{{ balance.used_days }}</strong></span>
|
||||
<span class="vac-bal-sep">·</span>
|
||||
<span class="vac-bal-item vac-bal-remain">잔여 <strong>{{ balance.remaining_days }}</strong></span>
|
||||
<span class="erp-muted vac-bal-year">{{ year }}년</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vac-layout">
|
||||
<!-- ── 왼쪽: 월간 달력 ── -->
|
||||
<div class="erp-card vac-cal-card">
|
||||
<div class="vac-cal-head">
|
||||
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||
href="/vacation/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||
<h2 class="vac-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||
href="/vacation/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||
<a class="erp-btn erp-btn-outline vac-today-btn" href="/vacation/">오늘</a>
|
||||
</div>
|
||||
|
||||
<div class="vac-cal">
|
||||
<div class="vac-wd-row">
|
||||
{% for wd in weekdays %}
|
||||
<div class="vac-wd {% if loop.index0 == 0 %}vac-red{% elif loop.index0 == 6 %}vac-blue{% endif %}">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for week in weeks %}
|
||||
<div class="vac-week">
|
||||
<div class="vac-week-days">
|
||||
{% for cell in week.days %}
|
||||
<a class="vac-day
|
||||
{% if not cell.in_month %}vac-out{% endif %}
|
||||
{% if cell.is_today %}vac-today{% endif %}
|
||||
{% if cell.is_selected %}vac-selected{% endif %}"
|
||||
href="/vacation/?year={{ year }}&month={{ month }}&date={{ cell.date }}"
|
||||
{% if cell.holiday_name %}title="{{ cell.holiday_name }}"{% endif %}>
|
||||
<span class="vac-day-num
|
||||
{% if cell.is_sunday or cell.is_holiday %}vac-red{% elif cell.is_saturday %}vac-blue{% endif %}">{{ cell.day }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="vac-week-bars">
|
||||
{% for bar in week.bars %}
|
||||
<a class="vac-bar {{ bar.status_class }}
|
||||
{% if bar.continues_left %}vac-bar-l{% endif %}
|
||||
{% if bar.continues_right %}vac-bar-r{% endif %}"
|
||||
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};"
|
||||
href="/vacation/{{ bar.id }}"
|
||||
title="{{ bar.label }} ({{ bar.days }}일 · {{ bar.status }})">
|
||||
<span class="vac-bar-label">{{ bar.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="vac-legend">
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-submit"></i>제출</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-approve"></i>승인</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-reject"></i>반려</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-cancel"></i>취소</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 선택일 휴가 리스트 ── -->
|
||||
<div class="erp-card vac-list-card">
|
||||
<div class="vac-list-head">
|
||||
<h2>{{ selected_date }}</h2>
|
||||
<span class="erp-muted">{{ sel_requests|length }}건</span>
|
||||
</div>
|
||||
|
||||
{% if sel_requests %}
|
||||
<ul class="vac-list">
|
||||
{% for r in sel_requests %}
|
||||
<li class="vac-list-item">
|
||||
<a href="/vacation/{{ r.id }}" class="vac-list-link">
|
||||
<div class="vac-list-top">
|
||||
<strong>{{ r.owner_name or r.owner }}</strong>
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if r.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif r.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif r.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }}">{{ r.status }}</span>
|
||||
</div>
|
||||
<div class="vac-list-meta erp-muted">
|
||||
{{ r.vacation_type }} · {{ r.days }}일 ·
|
||||
{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}
|
||||
</div>
|
||||
{% if r.reason %}<div class="vac-list-reason erp-muted">{{ r.reason }}</div>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="erp-muted vac-empty">선택한 날짜의 휴가가 없습니다.
|
||||
<a href="/vacation/new">휴가 신청</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
<div class="erp-card">
|
||||
{% if items %}
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table vac-pending-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청자</th><th>휴가종류</th><th>기간</th><th>일수</th>
|
||||
<th>사유</th><th class="vac-act-col">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in items %}
|
||||
<tr>
|
||||
<td><a href="/vacation/{{ r.id }}">{{ r.owner_name or r.owner }}</a></td>
|
||||
<td>{{ r.vacation_type }}</td>
|
||||
<td>{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td class="vac-reason-cell erp-muted">{{ r.reason }}</td>
|
||||
<td class="vac-act-col">
|
||||
<div class="vac-pending-acts">
|
||||
<form method="post" action="/vacation/{{ r.id }}/approve" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-primary erp-btn-sm">승인</button>
|
||||
</form>
|
||||
<form method="post" action="/vacation/{{ r.id }}/reject" class="vac-reject-form">
|
||||
<input class="erp-input erp-input-sm" type="text" name="reject_reason"
|
||||
placeholder="반려 사유" required />
|
||||
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">반려</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="erp-muted">승인 대기 중인 휴가 신청이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
<div class="vac-settings-head">
|
||||
<form method="get" action="/vacation/settings" class="vac-year-form">
|
||||
<label class="erp-field vac-year-field"><span>연도</span>
|
||||
<input class="erp-input" type="number" name="year" value="{{ year }}" min="2020" max="2100" />
|
||||
</label>
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="vac-settings-grid">
|
||||
|
||||
<!-- ── 공휴일 관리 ── -->
|
||||
<div class="erp-card vac-set-card">
|
||||
<div class="vac-card-head"><h2>공휴일 ({{ year }})</h2></div>
|
||||
|
||||
<form method="post" action="/vacation/settings/holidays" class="vac-holiday-form">
|
||||
<label class="erp-field"><span>날짜 *</span>
|
||||
<input class="erp-input" type="date" name="holiday_date" required value="{{ year }}-01-01" />
|
||||
</label>
|
||||
<label class="erp-field"><span>이름 *</span>
|
||||
<input class="erp-input" type="text" name="name" required placeholder="예: 신정" />
|
||||
</label>
|
||||
<label class="erp-field"><span>종류</span>
|
||||
<select class="erp-select" name="kind">
|
||||
<option value="public">공휴일</option>
|
||||
<option value="lunar">음력</option>
|
||||
<option value="substitute">대체</option>
|
||||
<option value="company">회사지정</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-check"><input type="checkbox" name="is_red" checked /> 빨강(달력 표시)</label>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가 / 수정</button>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap vac-set-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>날짜</th><th>이름</th><th>종류</th><th>빨강</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in holidays %}
|
||||
<tr>
|
||||
<td>{{ h.holiday_date }}</td>
|
||||
<td>{{ h.name }}</td>
|
||||
<td class="erp-muted">{{ h.kind }}</td>
|
||||
<td>{% if h.is_red %}●{% else %}○{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/vacation/settings/holidays/{{ h.id }}/delete"
|
||||
onsubmit="return confirm('{{ h.holiday_date }} {{ h.name }} 삭제?');">
|
||||
<input type="hidden" name="year" value="{{ year }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="erp-muted">등록된 공휴일이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 연차 잔여 관리 ── -->
|
||||
<div class="erp-card vac-set-card">
|
||||
<div class="vac-card-head"><h2>연차 잔여 ({{ year }})</h2></div>
|
||||
|
||||
<form method="post" action="/vacation/settings/balances" class="vac-balance-form">
|
||||
<input type="hidden" name="year" value="{{ year }}" />
|
||||
<label class="erp-field"><span>이메일 *</span>
|
||||
<input class="erp-input" type="email" name="user_email" required
|
||||
placeholder="user@dbxcorp.co.kr" />
|
||||
</label>
|
||||
<label class="erp-field"><span>연차 일수 *</span>
|
||||
<input class="erp-input" type="number" name="total_days" step="0.5" min="0" required value="15" />
|
||||
</label>
|
||||
<label class="erp-field vac-full"><span>메모</span>
|
||||
<input class="erp-input" type="text" name="memo" placeholder="입사일/비고 등" />
|
||||
</label>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">설정</button>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap vac-set-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>이메일</th><th>연차</th><th>사용</th><th>메모</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in balances %}
|
||||
<tr>
|
||||
<td>{{ b.user_email }}</td>
|
||||
<td>{{ b.total_days }}</td>
|
||||
<td class="erp-muted">{{ b.used_days }}</td>
|
||||
<td class="erp-muted">{{ b.memo }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="erp-muted">설정된 연차가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="erp-muted vac-set-note">사용 일수는 승인된 휴가 합계로 자동 계산됩니다.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,307 @@
|
||||
/* 쿠팡 밀크런 — DESIGN.md 토큰 준수 (흰 배경 / 검정·회색 / 10·14px radius / 카드 16px) */
|
||||
|
||||
.cpg { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cpg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
/* 맨 오른쪽으로 밀기 (수정 버튼) */
|
||||
.cpg-push-right { margin-left: auto; }
|
||||
/* 달력 상단 설정 버튼 그룹 — 우측 정렬 */
|
||||
.cpg-settings-btns { margin-left: auto; display: inline-flex; gap: 8px; flex-wrap: wrap; }
|
||||
/* 액션바를 달력 레이아웃(좌:달력 1fr / 우:리스트 320)과 같은 그리드로
|
||||
→ 설정 버튼이 달력 컬럼 오른쪽 끝에 정렬 */
|
||||
.cpg-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cpg-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 980px) {
|
||||
.cpg-actions-grid { grid-template-columns: 1fr; }
|
||||
.cpg-actions-spacer { display: none; }
|
||||
}
|
||||
|
||||
/* ── 레이아웃: 왼쪽 큰 달력 + 오른쪽 리스트 ── */
|
||||
.cpg-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
|
||||
}
|
||||
@media (max-width: 980px) { .cpg-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
.cpg-cal-card, .cpg-list-card { padding: 16px; }
|
||||
.cpg-cal-card { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.cpg-list-card { min-height: 0; overflow-y: auto; }
|
||||
|
||||
.cpg-cal-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 12px; flex-shrink: 0;
|
||||
}
|
||||
.cpg-cal-title { font-size: 18pt; font-weight: 600; letter-spacing: -0.45px; margin: 0; }
|
||||
.cpg-nav-btn { min-width: 36px; padding: 4px 10px; font-size: 18pt; line-height: 1; }
|
||||
|
||||
.cpg-cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: auto repeat(6, 1fr); /* 요일행 auto + 6주 균등 분할 */
|
||||
gap: 6px;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
.cpg-cal-wd {
|
||||
text-align: center; font-size: 15px; font-weight: 600;
|
||||
color: var(--color-midtone-gray); padding: 6px 0;
|
||||
}
|
||||
.cpg-sun { color: var(--color-callout-red); }
|
||||
.cpg-sat { color: #2b5bc2; }
|
||||
|
||||
.cpg-cal-cell {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
min-height: 0; padding: 8px; overflow: hidden;
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.cpg-cal-cell:hover { border-color: var(--color-midtone-gray); }
|
||||
.cpg-out { background: var(--color-ghost-gray); }
|
||||
.cpg-out .cpg-cal-day { color: var(--color-midtone-gray); }
|
||||
.cpg-today { border-color: var(--color-deep-black); }
|
||||
.cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); }
|
||||
.cpg-cal-day { font-size: 17px; font-weight: 600; }
|
||||
/* 일요일/공휴일 빨강, 토요일 파랑 (당월 셀 우선, 전후월은 옅게) */
|
||||
.cpg-red .cpg-cal-day { color: var(--color-callout-red); }
|
||||
.cpg-blue .cpg-cal-day { color: #2b5bc2; }
|
||||
.cpg-out.cpg-red .cpg-cal-day { color: #e3a59b; }
|
||||
.cpg-out.cpg-blue .cpg-cal-day { color: #9db4dd; }
|
||||
.cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; }
|
||||
.cpg-mini { font-size: 12px; padding: 2px 7px; align-self: flex-start; }
|
||||
|
||||
/* ── 오른쪽 리스트 ── */
|
||||
.cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
||||
.cpg-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.cpg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cpg-list-item { border: 1px solid var(--color-subtle-ash); border-radius: 10px; }
|
||||
.cpg-list-link { display: block; padding: 10px 12px; }
|
||||
.cpg-list-link:hover { background: var(--color-ghost-gray); border-radius: 10px; }
|
||||
.cpg-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cpg-list-meta { font-size: 12px; margin-top: 4px; }
|
||||
.cpg-empty { padding: 16px 0; }
|
||||
|
||||
/* 선택일 출고 hover 툴팁 (마우스 따라다님) */
|
||||
.cpg-hover-tip {
|
||||
position: fixed; z-index: 60; pointer-events: none;
|
||||
background: var(--color-deep-black); color: #fff;
|
||||
border-radius: 10px; padding: 8px 10px;
|
||||
min-width: 160px; max-width: 280px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.22);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cpg-tip-row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
.cpg-tip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cpg-tip-qty { font-weight: 600; flex: 0 0 auto; }
|
||||
.cpg-tip-empty { color: #d4d4d4; }
|
||||
|
||||
/* ── 폼 ── */
|
||||
.cpg-form-card { padding: 16px; margin-bottom: 16px; }
|
||||
|
||||
/* 공통 헤더(좌) + 품목 라인(우) 2열 */
|
||||
/* form 이 .cpg(남은 높이) 안에서 세로로 채우도록 */
|
||||
.cpg > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||
.cpg-form-2col {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
/* 공통 헤더: 너비 450 고정 */
|
||||
.cpg-form-head { flex: 0 0 450px; width: 450px; min-width: 0; margin-bottom: 0; align-self: flex-start; }
|
||||
/* 품목 라인: 너비 900 고정, 높이는 가용 영역 채움, 내부 스크롤 */
|
||||
.cpg-form-lines {
|
||||
flex: 0 0 900px; width: 900px; height: 100%; min-width: 0; margin-bottom: 0;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.cpg-form-lines { flex-basis: auto; width: 100%; }
|
||||
}
|
||||
@media (max-width: 940px) {
|
||||
.cpg-form-head { flex-basis: 100%; width: 100%; }
|
||||
}
|
||||
/* 라인 테이블 영역만 스크롤 */
|
||||
.cpg-lines-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
/* 제목행: 제목 + 설명 + (우측) 추가/삭제 버튼 */
|
||||
.cpg-lines-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.cpg-lines-btns { margin-left: auto; display: flex; gap: 8px; flex: 0 0 auto; }
|
||||
|
||||
/* 저장/취소 — 공통 헤더 카드 하단에 위치(우측 라인 수와 무관) */
|
||||
.cpg-form-actions { margin-top: 16px; }
|
||||
.cpg-card-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.cpg-card-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
|
||||
.cpg-header-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 185px);
|
||||
gap: 12px 16px;
|
||||
}
|
||||
/* 작성일·출고일·센터입고일·입고센터·출고방식·작업자 입력 185px 고정
|
||||
(.cpg .erp-field > .erp-input width:100% 보다 특이도 높게) */
|
||||
.cpg .cpg-header-grid .erp-field > .erp-input,
|
||||
.cpg .cpg-header-grid .erp-field > .erp-select {
|
||||
width: 185px; max-width: 185px; min-width: 185px; box-sizing: border-box;
|
||||
}
|
||||
.cpg-rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px 16px; align-items: end;
|
||||
}
|
||||
/* 그리드 항목 겹침 방지: 칸이 줄어들 때 내용이 밖으로 넘치지 않게 */
|
||||
.cpg-header-grid .erp-field,
|
||||
.cpg-rule-grid .erp-field { min-width: 0; }
|
||||
.cpg .erp-field { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
||||
.cpg .erp-field > span { font-size: 12px; color: var(--color-midtone-gray); }
|
||||
/* 입력란이 칸 너비를 넘지 않도록 (date/select 포함) */
|
||||
.cpg .erp-field > .erp-input,
|
||||
.cpg .erp-field > .erp-select,
|
||||
.cpg .erp-field > textarea.erp-input { width: 100%; max-width: 100%; box-sizing: border-box; }
|
||||
.cpg-full { grid-column: 1 / -1; }
|
||||
|
||||
/* 검정 배경 버튼 글자는 항상 흰색 (안전 보강) */
|
||||
.cpg .erp-btn-primary, .cpg .erp-btn-primary:visited { color: var(--color-canvas-white); }
|
||||
|
||||
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
|
||||
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ── 입고센터 관리 (좌: 추가 / 우: 목록) ── */
|
||||
.cpg-center-layout {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start;
|
||||
}
|
||||
.cpg-center-add {
|
||||
width: 360px; flex: 0 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.cpg-center-listcard {
|
||||
flex: 1 1 480px; min-width: 0;
|
||||
height: 900px;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.cpg-center-add, .cpg-center-listcard { width: 100%; flex-basis: 100%; }
|
||||
}
|
||||
|
||||
.cpg-center-add-title { font-size: 15px; font-weight: 600; margin: 0 0 8px; }
|
||||
.cpg-btn-sm { padding: 3px 8px; font-size: 12px; }
|
||||
|
||||
/* 목록: 카드 높이 채우고 내부 스크롤 */
|
||||
.cpg-center-list {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.cpg-center-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cpg-center-row.is-inactive { opacity: .55; }
|
||||
.cpg-center-edit { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.cpg-center-name { flex: 1 1 auto; min-width: 0; }
|
||||
.cpg-center-sort { width: 56px; flex: 0 0 auto; }
|
||||
.cpg-center-state { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||
.cpg-center-act { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||
|
||||
/* ── 라인 테이블 ── */
|
||||
.cpg-lines td { vertical-align: middle; }
|
||||
.cpg-lines th.cpg-check-col,
|
||||
.cpg-lines td.cpg-check-col {
|
||||
width: 36px; text-align: center;
|
||||
padding-left: 0; padding-right: 0; vertical-align: middle;
|
||||
}
|
||||
.cpg-check-col input { display: block; margin: 0 auto; cursor: pointer; }
|
||||
.cpg-lines .cpg-name-sel { width: 100%; min-width: 160px; }
|
||||
/* 폭 고정: 제품코드 140px, 수량·입수량 60px */
|
||||
.cpg-lines .cpg-code { width: 140px; min-width: 140px; box-sizing: border-box; }
|
||||
.cpg-lines .cpg-qty,
|
||||
.cpg-lines .cpg-upb { width: 60px; min-width: 60px; box-sizing: border-box; }
|
||||
.cpg-lines .cpg-memo { width: 100%; min-width: 120px; box-sizing: border-box; }
|
||||
.cpg-line-calc { font-size: 13px; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||
.cpg-line-calc.cpg-warn { color: var(--color-callout-red); }
|
||||
.cpg-line-del { cursor: pointer; }
|
||||
|
||||
/* ── 상세 ── */
|
||||
.cpg-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px; margin: 0;
|
||||
}
|
||||
.cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); }
|
||||
.cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; }
|
||||
|
||||
/* ── 설정(제품명) 2열 레이아웃 ── */
|
||||
.cpg-prod-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px; align-items: flex-start;
|
||||
}
|
||||
/* 미라네 주방 상품: 폭 500 / 높이 900 고정, 내부 스크롤 */
|
||||
.cpg-prod-left {
|
||||
width: 500px; height: 900px;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 등록된 제품명: 폭 600 / 높이 900 고정, 내부 스크롤 */
|
||||
.cpg-prod-right {
|
||||
width: 600px; height: 900px;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.cpg-prod-left, .cpg-prod-right { width: 100%; }
|
||||
}
|
||||
|
||||
.cpg-src-list {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
|
||||
}
|
||||
/* 등록 목록 테이블 스크롤 영역 (카드 높이에서 헤더 제외하고 채움) */
|
||||
.cpg-reg-scroll {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||
}
|
||||
.cpg-src-item {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||
padding: 8px 12px; cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
.cpg-src-item:last-child { border-bottom: 0; }
|
||||
.cpg-src-item:hover { background: var(--color-ghost-gray); }
|
||||
.cpg-src-name { font-size: 14px; font-weight: 500; }
|
||||
.cpg-src-code { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||
/* 선택됨: 검정 테두리 강조 */
|
||||
.cpg-src-item.is-selected { box-shadow: inset 0 0 0 2px var(--color-deep-black); }
|
||||
/* 이미 등록됨: 진한 회색 배경 + 흰 글씨 */
|
||||
.cpg-src-item.cpg-registered { background: #4b4b4b; }
|
||||
.cpg-src-item.cpg-registered .cpg-src-name { color: #fff; }
|
||||
.cpg-src-item.cpg-registered .cpg-src-code { color: #d4d4d4; }
|
||||
.cpg-src-item.cpg-registered:hover { background: #3a3a3a; }
|
||||
|
||||
/* ── 박스 입수량 (좌: 추가/수정 480 / 우: 목록 900) ── */
|
||||
.cpg-brule-layout { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
|
||||
.cpg-brule-add { flex: 0 0 480px; width: 480px; min-width: 0; margin-bottom: 0; }
|
||||
.cpg-brule-list { flex: 0 0 900px; width: 900px; min-width: 0; margin-bottom: 0; }
|
||||
@media (max-width: 1420px) { .cpg-brule-list { flex-basis: auto; width: 100%; } }
|
||||
@media (max-width: 540px) { .cpg-brule-add { flex-basis: 100%; width: 100%; } }
|
||||
|
||||
.cpg-brule-fields { display: flex; flex-direction: column; gap: 12px; }
|
||||
.cpg-brule-row { display: flex; gap: 16px; align-items: flex-end; }
|
||||
.cpg-brule-memo-field { flex: 1 1 auto; min-width: 0; }
|
||||
/* 필드별 고정 폭 (erp.css min-width:240 오버라이드) */
|
||||
.cpg .cpg-brule-fields .cpg-brule-name { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
|
||||
.cpg .cpg-brule-fields .cpg-brule-code { width: 100px; min-width: 100px; max-width: 100px; box-sizing: border-box; }
|
||||
/* 박스이름 폭을 제품명(180)과 동일하게 → 박스당 입수량이 제품코드와 세로 정렬 */
|
||||
.cpg .cpg-brule-fields .cpg-brule-box { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
|
||||
/* 박스당 입수량 60px + "개" */
|
||||
.cpg-upb-wrap { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.cpg .cpg-brule-fields .cpg-upb-wrap > .cpg-brule-upb {
|
||||
width: 60px; min-width: 60px; max-width: 60px; box-sizing: border-box;
|
||||
}
|
||||
.cpg-upb-unit { font-size: 13px; color: var(--color-midtone-gray); }
|
||||
/* 메모: 프레임 전체 너비 */
|
||||
.cpg .cpg-brule-fields .cpg-brule-memo { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; }
|
||||
@@ -0,0 +1,198 @@
|
||||
/* 쿠팡 밀크런 — 출고 폼 동적 라인.
|
||||
제품명 드롭다운(설정에서 등록한 카탈로그) 선택 → 제품코드 자동 입력.
|
||||
수량 입력 시 박스 수 미리보기(서버가 저장 시 store.compute_boxes 로 재계산). */
|
||||
(function () {
|
||||
"use strict";
|
||||
var body = document.getElementById("cpg-lines-body");
|
||||
var form = document.getElementById("cpg-form");
|
||||
if (!body || !form) return;
|
||||
|
||||
// 입수량 규칙: product_code -> units_per_box
|
||||
var ruleMap = {};
|
||||
try {
|
||||
var rules = JSON.parse(document.getElementById("cpg-box-rules").textContent || "[]");
|
||||
rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; });
|
||||
} catch (e) {}
|
||||
|
||||
// 제품 카탈로그: [{product_code, product_name}]
|
||||
var products = [];
|
||||
try { products = JSON.parse(document.getElementById("cpg-products").textContent || "[]"); } catch (e) {}
|
||||
var nameByCode = {};
|
||||
products.forEach(function (p) { nameByCode[p.product_code] = p.product_name; });
|
||||
|
||||
var initLines = [];
|
||||
try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {}
|
||||
|
||||
function ceilDiv(a, b) { return Math.ceil(a / b); }
|
||||
|
||||
function recalc(row) {
|
||||
var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0;
|
||||
var upb = parseInt(row.querySelector(".cpg-upb").value, 10);
|
||||
var cell = row.querySelector(".cpg-line-calc");
|
||||
if (!upb || upb <= 0) { cell.textContent = "미설정"; cell.classList.add("cpg-warn"); return; }
|
||||
cell.classList.remove("cpg-warn");
|
||||
if (qty <= 0) { cell.textContent = "—"; return; }
|
||||
var boxes = ceilDiv(qty, upb), rem = qty % upb;
|
||||
cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)");
|
||||
}
|
||||
|
||||
function buildNameSelect(selectedCode) {
|
||||
var sel = document.createElement("select");
|
||||
sel.className = "erp-select cpg-name-sel";
|
||||
var opt0 = document.createElement("option");
|
||||
opt0.value = ""; opt0.textContent = "— 제품명 선택 —";
|
||||
sel.appendChild(opt0);
|
||||
var found = false;
|
||||
products.forEach(function (p) {
|
||||
var o = document.createElement("option");
|
||||
o.value = p.product_code;
|
||||
o.textContent = p.product_name;
|
||||
o.setAttribute("data-name", p.product_name);
|
||||
if (p.product_code === selectedCode) { o.selected = true; found = true; }
|
||||
sel.appendChild(o);
|
||||
});
|
||||
// 카탈로그에 없는 기존 라인 코드 → 임시 옵션으로 표시
|
||||
if (selectedCode && !found) {
|
||||
var o = document.createElement("option");
|
||||
o.value = selectedCode;
|
||||
o.textContent = (nameByCode[selectedCode] || selectedCode) + " (미등록)";
|
||||
o.setAttribute("data-name", nameByCode[selectedCode] || selectedCode);
|
||||
o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
function makeRow(data) {
|
||||
data = data || {};
|
||||
var tr = document.createElement("tr");
|
||||
// 컬럼: 체크 / 제품명(select) / 제품코드 / 수량 / 입수량 / 박스계산 / 라인메모
|
||||
tr.innerHTML =
|
||||
'<td class="cpg-check-col"><input type="checkbox" class="cpg-row-check" /></td>' +
|
||||
'<td class="cpg-cell-name"></td>' +
|
||||
'<td><input class="erp-input cpg-code" type="text" placeholder="제품코드" /></td>' +
|
||||
'<td><input class="erp-input cpg-qty" type="number" min="1" /></td>' +
|
||||
'<td><input class="erp-input cpg-upb" type="number" min="1" placeholder="입수량" /></td>' +
|
||||
'<td><span class="cpg-line-calc">—</span></td>' +
|
||||
'<td><input class="erp-input cpg-memo" type="text" /></td>';
|
||||
|
||||
var code = data.product_code || "";
|
||||
var nameSel = buildNameSelect(code);
|
||||
tr.querySelector(".cpg-cell-name").appendChild(nameSel);
|
||||
|
||||
var codeInput = tr.querySelector(".cpg-code");
|
||||
codeInput.value = code;
|
||||
tr.querySelector(".cpg-qty").value = data.quantity || "";
|
||||
var upb = data.units_per_box;
|
||||
if (upb == null && code && ruleMap[code] != null) upb = ruleMap[code];
|
||||
tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
|
||||
|
||||
// 제품명 선택 → 코드 자동 입력 + 입수량 자동
|
||||
nameSel.addEventListener("change", function () {
|
||||
var c = nameSel.value;
|
||||
codeInput.value = c;
|
||||
if (c && ruleMap[c] != null) tr.querySelector(".cpg-upb").value = ruleMap[c];
|
||||
recalc(tr);
|
||||
});
|
||||
// 코드 직접 입력 시 입수량 규칙 자동
|
||||
codeInput.addEventListener("input", function () {
|
||||
var c = codeInput.value.trim();
|
||||
if (c && ruleMap[c] != null && !tr.querySelector(".cpg-upb").value) {
|
||||
tr.querySelector(".cpg-upb").value = ruleMap[c];
|
||||
}
|
||||
});
|
||||
|
||||
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
|
||||
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
|
||||
|
||||
body.appendChild(tr);
|
||||
recalc(tr);
|
||||
syncCheckAll();
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 전체 선택 체크박스 상태 동기화
|
||||
var checkAll = document.getElementById("cpg-check-all");
|
||||
function syncCheckAll() {
|
||||
if (!checkAll) return;
|
||||
var checks = body.querySelectorAll(".cpg-row-check");
|
||||
var total = checks.length;
|
||||
var on = 0;
|
||||
Array.prototype.forEach.call(checks, function (c) { if (c.checked) on++; });
|
||||
checkAll.checked = total > 0 && on === total;
|
||||
checkAll.indeterminate = on > 0 && on < total;
|
||||
}
|
||||
if (checkAll) {
|
||||
checkAll.addEventListener("change", function () {
|
||||
Array.prototype.forEach.call(body.querySelectorAll(".cpg-row-check"), function (c) {
|
||||
c.checked = checkAll.checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
body.addEventListener("change", function (e) {
|
||||
if (e.target && e.target.classList.contains("cpg-row-check")) syncCheckAll();
|
||||
});
|
||||
|
||||
// 초기 라인
|
||||
if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); }
|
||||
document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); });
|
||||
|
||||
// 선택 라인 삭제 (체크된 행 삭제, 최소 1줄 유지)
|
||||
var delBtn = document.getElementById("cpg-del-line");
|
||||
if (delBtn) {
|
||||
delBtn.addEventListener("click", function () {
|
||||
var checked = body.querySelectorAll(".cpg-row-check:checked");
|
||||
if (!checked.length) { alert("삭제할 라인을 선택하세요."); return; }
|
||||
Array.prototype.forEach.call(checked, function (c) {
|
||||
var tr = c.closest("tr");
|
||||
if (tr) tr.remove();
|
||||
});
|
||||
if (!body.querySelectorAll("tr").length) makeRow(); // 최소 1줄
|
||||
if (checkAll) { checkAll.checked = false; checkAll.indeterminate = false; }
|
||||
syncCheckAll();
|
||||
});
|
||||
}
|
||||
|
||||
// 제출: 라인 직렬화
|
||||
form.addEventListener("submit", function (e) {
|
||||
var lines = [];
|
||||
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) {
|
||||
var code = tr.querySelector(".cpg-code").value.trim();
|
||||
var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0;
|
||||
if (!code || qty <= 0) return;
|
||||
var upb = parseInt(tr.querySelector(".cpg-upb").value, 10);
|
||||
var sel = tr.querySelector(".cpg-name-sel");
|
||||
var name = "";
|
||||
if (sel && sel.selectedIndex >= 0) {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
name = opt ? (opt.getAttribute("data-name") || "") : "";
|
||||
}
|
||||
if (!name) name = nameByCode[code] || code;
|
||||
lines.push({
|
||||
product_code: code,
|
||||
product_name_snapshot: name,
|
||||
quantity: qty,
|
||||
units_per_box: (upb > 0 ? upb : null),
|
||||
memo: tr.querySelector(".cpg-memo").value.trim()
|
||||
});
|
||||
});
|
||||
if (!lines.length) {
|
||||
e.preventDefault();
|
||||
alert("품목 라인을 최소 1개 입력하세요 (제품명/코드 + 수량).");
|
||||
return;
|
||||
}
|
||||
document.getElementById("cpg-lines-json").value = JSON.stringify(lines);
|
||||
});
|
||||
|
||||
// 입고센터 select → 스냅샷 hidden 동기화
|
||||
var centerSel = document.getElementById("cpg-center-select");
|
||||
var centerName = document.getElementById("cpg-center-name");
|
||||
if (centerSel && centerName) {
|
||||
function syncCenter() {
|
||||
var opt = centerSel.options[centerSel.selectedIndex];
|
||||
if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text;
|
||||
}
|
||||
centerSel.addEventListener("change", syncCenter);
|
||||
if (!centerName.value) syncCenter();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,129 @@
|
||||
/* ════════════════════════════════════════════════════
|
||||
첨부 뷰어 모달 — 이미지 패닝 + 엑셀 시트
|
||||
════════════════════════════════════════════════════ */
|
||||
|
||||
.eav-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
display: none;
|
||||
align-items: stretch; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.eav-overlay[data-open="true"] { display: flex; }
|
||||
|
||||
.eav-modal {
|
||||
background: var(--color-canvas-white, #fff);
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.eav-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
}
|
||||
.eav-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.eav-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.eav-btn {
|
||||
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
background: var(--color-canvas-white, #fff);
|
||||
color: var(--color-rich-black, #0a0a0a);
|
||||
padding: 5px 10px; font-size: 12px; border-radius: 8px; cursor: pointer;
|
||||
font-family: inherit; text-decoration: none; display: inline-block;
|
||||
}
|
||||
.eav-btn:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||
.eav-btn.is-active { background: var(--color-deep-black, #000); color: #fff; border-color: #000; }
|
||||
.eav-close {
|
||||
width: 28px; height: 28px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 0; background: transparent; cursor: pointer; font-size: 18px;
|
||||
}
|
||||
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||
|
||||
.eav-body {
|
||||
display: grid; grid-template-columns: 220px 1fr; min-height: 0; height: 100%;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.eav-list {
|
||||
background: #fff; border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.eav-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px; border-radius: 8px; cursor: pointer;
|
||||
border: 1px solid transparent; background: transparent;
|
||||
text-align: left; font-family: inherit; font-size: 12px;
|
||||
}
|
||||
.eav-item:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||
.eav-item.is-active { background: var(--color-ghost-gray, #f2f2f2); border-color: var(--color-subtle-ash, #e5e5e5); }
|
||||
.eav-item-thumb {
|
||||
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; color: var(--color-midtone-gray, #737373);
|
||||
overflow: hidden;
|
||||
}
|
||||
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.eav-item-meta { flex: 1; min-width: 0; }
|
||||
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); display: block; }
|
||||
|
||||
/* 이미지 패닝 스테이지 */
|
||||
.eav-canvas-wrap {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.eav-canvas-wrap.eav-pan { cursor: grab; }
|
||||
.eav-canvas-wrap.eav-pan.is-panning { cursor: grabbing; }
|
||||
.eav-img { display: block; user-select: none; -webkit-user-drag: none; margin: 0 auto; }
|
||||
|
||||
/* 엑셀/시트 */
|
||||
.eav-sheet-wrap {
|
||||
background: #fff;
|
||||
width: 100%; height: 100%;
|
||||
display: grid; grid-template-rows: auto 1fr;
|
||||
}
|
||||
.eav-sheet-tabs {
|
||||
display: flex; gap: 4px; padding: 8px;
|
||||
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.eav-sheet-body {
|
||||
overflow: auto; padding: 12px;
|
||||
}
|
||||
.eav-sheet-body table {
|
||||
border-collapse: collapse;
|
||||
font-family: 'Geist Mono', Menlo, monospace; font-size: 12px;
|
||||
}
|
||||
.eav-sheet-body td, .eav-sheet-body th {
|
||||
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
padding: 4px 8px;
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.eav-sheet-body th { background: var(--color-ghost-gray, #f2f2f2); font-weight: 600; }
|
||||
.eav-sheet-loading { color: #fff; text-align: center; padding: 40px; }
|
||||
|
||||
.eav-fallback {
|
||||
color: #fff; text-align: center; padding: 40px;
|
||||
}
|
||||
.eav-fallback a {
|
||||
display: inline-block; margin-top: 12px;
|
||||
background: #fff; color: #000; padding: 8px 16px; border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.eav-body { grid-template-columns: 1fr; grid-template-rows: 100px 1fr; }
|
||||
.eav-list { flex-direction: row; overflow-x: auto; }
|
||||
.eav-item { flex-shrink: 0; min-width: 180px; }
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// ErpAttachViewer — 첨부 모달
|
||||
// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
|
||||
// - 엑셀 (xlsx/xls/csv): SheetJS 로 시트 표시 (CDN lazy load).
|
||||
// - 기타: 다운로드 링크 표시.
|
||||
// API: ErpAttachViewer.openFor(itemId, { title })
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
if (window.ErpAttachViewer) return;
|
||||
|
||||
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
||||
const SHEET_EXTS = new Set(["xlsx", "xls", "xlsm", "csv", "ods"]);
|
||||
const XLSX_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
|
||||
|
||||
let overlay = null;
|
||||
let state = {
|
||||
items: [],
|
||||
currentIdx: -1,
|
||||
title: "",
|
||||
zoom: 1, // 이미지 줌 (1 = 100%)
|
||||
// 패닝
|
||||
panning: false,
|
||||
panStartX: 0, panStartY: 0,
|
||||
scrollStartLeft: 0, scrollStartTop: 0,
|
||||
};
|
||||
let xlsxLoading = null;
|
||||
|
||||
function ensureOverlay() {
|
||||
if (overlay) return overlay;
|
||||
overlay = document.createElement("div");
|
||||
overlay.className = "eav-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="eav-modal" role="dialog" aria-modal="true">
|
||||
<div class="eav-head">
|
||||
<div class="eav-title" data-role="title">첨부 보기</div>
|
||||
<div class="eav-toolbar" data-role="toolbar"></div>
|
||||
<button class="eav-close" data-role="close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="eav-body">
|
||||
<div class="eav-list" data-role="list"></div>
|
||||
<div class="eav-canvas-wrap" data-role="stage"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (overlay.dataset.open === "true" && e.key === "Escape") close();
|
||||
});
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function loadXlsxLib() {
|
||||
if (window.XLSX) return Promise.resolve(window.XLSX);
|
||||
if (xlsxLoading) return xlsxLoading;
|
||||
xlsxLoading = new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = XLSX_CDN;
|
||||
s.onload = () => resolve(window.XLSX);
|
||||
s.onerror = () => reject(new Error("XLSX 라이브러리 로드 실패"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return xlsxLoading;
|
||||
}
|
||||
|
||||
function buildToolbar() {
|
||||
const tb = overlay.querySelector('[data-role="toolbar"]');
|
||||
tb.innerHTML = "";
|
||||
const cur = state.items[state.currentIdx];
|
||||
if (!cur) return;
|
||||
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||
|
||||
if (IMG_EXTS.has(ext)) {
|
||||
[
|
||||
{ label: "-", title: "축소", fn: () => setZoom(state.zoom / 1.25) },
|
||||
{ label: "100%", title: "원본", fn: () => setZoom(1) },
|
||||
{ label: "+", title: "확대", fn: () => setZoom(state.zoom * 1.25) },
|
||||
{ label: "맞춤", title: "화면에 맞춤", fn: () => setZoom("fit") },
|
||||
].forEach((b) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "eav-btn";
|
||||
btn.textContent = b.label;
|
||||
btn.title = b.title;
|
||||
btn.onclick = b.fn;
|
||||
tb.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
const dl = document.createElement("a");
|
||||
dl.className = "eav-btn";
|
||||
dl.textContent = "다운로드";
|
||||
dl.href = `/expense/api/attachments/${cur.id}`;
|
||||
dl.setAttribute("download", cur.filename);
|
||||
tb.appendChild(dl);
|
||||
|
||||
const open = document.createElement("a");
|
||||
open.className = "eav-btn";
|
||||
open.textContent = "새 창";
|
||||
open.href = `/expense/api/attachments/${cur.id}`;
|
||||
open.target = "_blank";
|
||||
tb.appendChild(open);
|
||||
}
|
||||
|
||||
function buildList() {
|
||||
const list = overlay.querySelector('[data-role="list"]');
|
||||
list.innerHTML = "";
|
||||
state.items.forEach((a, i) => {
|
||||
const item = document.createElement("button");
|
||||
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
|
||||
const ext = (a.filename.split(".").pop() || "").toLowerCase();
|
||||
const isImg = IMG_EXTS.has(ext);
|
||||
const isSheet = SHEET_EXTS.has(ext);
|
||||
const icon = isImg ? `<img src="/expense/api/attachments/${a.id}" alt="" />`
|
||||
: isSheet ? "📊" : "📄";
|
||||
item.innerHTML = `
|
||||
<span class="eav-item-thumb">${icon}</span>
|
||||
<span class="eav-item-meta">
|
||||
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
|
||||
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}</span>
|
||||
</span>
|
||||
`;
|
||||
item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const stage = overlay.querySelector('[data-role="stage"]');
|
||||
stage.innerHTML = "";
|
||||
stage.className = "eav-canvas-wrap";
|
||||
const cur = state.items[state.currentIdx];
|
||||
if (!cur) {
|
||||
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
|
||||
return;
|
||||
}
|
||||
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||
|
||||
if (IMG_EXTS.has(ext)) return renderImage(stage, cur);
|
||||
if (SHEET_EXTS.has(ext)) return renderSheet(stage, cur, ext);
|
||||
return renderFallback(stage, cur);
|
||||
}
|
||||
|
||||
function renderImage(stage, cur) {
|
||||
stage.classList.add("eav-pan");
|
||||
const img = document.createElement("img");
|
||||
img.className = "eav-img";
|
||||
img.alt = cur.filename;
|
||||
img.src = `/expense/api/attachments/${cur.id}`;
|
||||
img.draggable = false;
|
||||
img.onload = () => { applyZoom(); };
|
||||
img.onerror = () => { stage.innerHTML = `<div class="eav-fallback">이미지 로드 실패</div>`; };
|
||||
stage.appendChild(img);
|
||||
state.image = img;
|
||||
|
||||
// 마우스 드래그 패닝
|
||||
stage.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
state.panning = true;
|
||||
state.panStartX = e.clientX;
|
||||
state.panStartY = e.clientY;
|
||||
state.scrollStartLeft = stage.scrollLeft;
|
||||
state.scrollStartTop = stage.scrollTop;
|
||||
stage.setPointerCapture(e.pointerId);
|
||||
stage.classList.add("is-panning");
|
||||
});
|
||||
stage.addEventListener("pointermove", (e) => {
|
||||
if (!state.panning) return;
|
||||
stage.scrollLeft = state.scrollStartLeft - (e.clientX - state.panStartX);
|
||||
stage.scrollTop = state.scrollStartTop - (e.clientY - state.panStartY);
|
||||
});
|
||||
const endPan = (e) => {
|
||||
if (!state.panning) return;
|
||||
state.panning = false;
|
||||
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
stage.classList.remove("is-panning");
|
||||
};
|
||||
stage.addEventListener("pointerup", endPan);
|
||||
stage.addEventListener("pointercancel", endPan);
|
||||
stage.addEventListener("pointerleave", endPan);
|
||||
}
|
||||
|
||||
function applyZoom() {
|
||||
if (!state.image) return;
|
||||
const stage = overlay.querySelector('[data-role="stage"]');
|
||||
const img = state.image;
|
||||
if (state.zoom === "fit") {
|
||||
img.style.maxWidth = "100%";
|
||||
img.style.maxHeight = "calc(100vh - 200px)";
|
||||
img.style.width = "";
|
||||
img.style.height = "";
|
||||
} else {
|
||||
img.style.maxWidth = "none";
|
||||
img.style.maxHeight = "none";
|
||||
img.style.width = (img.naturalWidth * state.zoom) + "px";
|
||||
img.style.height = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
function setZoom(z) {
|
||||
state.zoom = z;
|
||||
applyZoom();
|
||||
}
|
||||
|
||||
async function renderSheet(stage, cur, ext) {
|
||||
stage.classList.remove("eav-pan");
|
||||
stage.innerHTML = `<div class="eav-sheet-loading">시트 로드 중…</div>`;
|
||||
try {
|
||||
const XLSX = await loadXlsxLib();
|
||||
const res = await fetch(`/expense/api/attachments/${cur.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
stage.innerHTML = "";
|
||||
|
||||
const sheetWrap = document.createElement("div");
|
||||
sheetWrap.className = "eav-sheet-wrap";
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "eav-sheet-tabs";
|
||||
const body = document.createElement("div");
|
||||
body.className = "eav-sheet-body";
|
||||
|
||||
function showSheet(name) {
|
||||
const ws = wb.Sheets[name];
|
||||
const html = XLSX.utils.sheet_to_html(ws, { editable: false });
|
||||
body.innerHTML = html;
|
||||
tabs.querySelectorAll("button").forEach((b) => {
|
||||
b.classList.toggle("is-active", b.dataset.sheet === name);
|
||||
});
|
||||
}
|
||||
wb.SheetNames.forEach((name) => {
|
||||
const b = document.createElement("button");
|
||||
b.className = "eav-btn";
|
||||
b.dataset.sheet = name;
|
||||
b.textContent = name;
|
||||
b.onclick = () => showSheet(name);
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
sheetWrap.appendChild(tabs);
|
||||
sheetWrap.appendChild(body);
|
||||
stage.appendChild(sheetWrap);
|
||||
showSheet(wb.SheetNames[0]);
|
||||
} catch (err) {
|
||||
stage.innerHTML = `<div class="eav-fallback">시트 로드 실패: ${escapeHtml(err.message || String(err))}
|
||||
<br/><a href="/expense/api/attachments/${cur.id}" target="_blank">원본 다운로드</a></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFallback(stage, cur) {
|
||||
stage.classList.remove("eav-pan");
|
||||
stage.innerHTML = `
|
||||
<div class="eav-fallback">
|
||||
미리보기를 지원하지 않는 파일입니다.<br/>
|
||||
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function fmtSize(b) {
|
||||
b = Number(b || 0);
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
||||
);
|
||||
}
|
||||
|
||||
function open(items, opts) {
|
||||
ensureOverlay();
|
||||
state.items = items || [];
|
||||
state.currentIdx = state.items.length ? 0 : -1;
|
||||
state.zoom = 1;
|
||||
state.title = (opts && opts.title) || "첨부 보기";
|
||||
overlay.querySelector('[data-role="title"]').textContent = state.title;
|
||||
overlay.dataset.open = "true";
|
||||
buildList();
|
||||
buildToolbar();
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
overlay.dataset.open = "false";
|
||||
state.items = [];
|
||||
state.currentIdx = -1;
|
||||
state.image = null;
|
||||
}
|
||||
|
||||
async function openFor(itemId, opts) {
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
const { attachments } = await res.json();
|
||||
if (!attachments || !attachments.length) {
|
||||
alert("첨부 파일이 없습니다.");
|
||||
return;
|
||||
}
|
||||
open(attachments, opts);
|
||||
} catch (err) {
|
||||
alert(`첨부 로드 실패: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.ErpAttachViewer = { open, openFor, close };
|
||||
})();
|
||||
+32
-15
@@ -9,13 +9,15 @@
|
||||
--erp-topbar-h: 56px;
|
||||
}
|
||||
|
||||
/* ── 페이지 전체 컨테이너 ── */
|
||||
body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
/* ── 페이지 전체 컨테이너 ──
|
||||
뷰포트(예: 1883×938)에 고정 — 문서 전체 세로 스크롤 제거.
|
||||
넘치는 콘텐츠는 .erp-page 내부에서만 처리한다(CORM/Order 는 별도 창이라 무관). */
|
||||
body.erp-app-body { background: var(--color-canvas-white); height: 100vh; overflow: hidden; }
|
||||
|
||||
.erp-app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--erp-sidebar-w) 1fr;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
transition: grid-template-columns .18s ease;
|
||||
}
|
||||
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
|
||||
@@ -66,7 +68,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
}
|
||||
|
||||
.erp-sidebar-group {
|
||||
font-size: 11px; font-weight: 600;
|
||||
font-size: 12px; font-weight: 600;
|
||||
color: var(--color-midtone-gray);
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
padding: var(--sp-12) var(--sp-12) var(--sp-6);
|
||||
@@ -74,18 +76,19 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
|
||||
|
||||
.erp-sidebar-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px var(--sp-12);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px var(--sp-12);
|
||||
border-radius: 10px;
|
||||
color: var(--color-rich-black);
|
||||
font-size: 14px; font-weight: 500;
|
||||
font-size: 16px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.erp-sidebar-item:hover:not(.is-disabled) { background: var(--color-ghost-gray); }
|
||||
/* 실행중(is-active) 메뉴는 hover 시 변화 없음. 그 외 메뉴만 진한 회색. */
|
||||
.erp-sidebar-item:hover:not(.is-disabled):not(.is-active) { background: #d9d9d9; }
|
||||
.erp-sidebar-item.is-active {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
@@ -95,10 +98,11 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.erp-sidebar-icon {
|
||||
width: 18px; height: 18px;
|
||||
width: 22px; height: 22px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.erp-sidebar-icon svg { width: 21px; height: 21px; }
|
||||
.erp-sidebar-label { flex: 1; }
|
||||
.erp-sidebar-badge {
|
||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||
@@ -110,12 +114,19 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
}
|
||||
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
|
||||
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label,
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
|
||||
display: none;
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item { justify-content: center; padding: 8px 0; }
|
||||
/* 접힌 상태: 아이콘 위 / 프로그램 이름 작은 글씨 아래 */
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item {
|
||||
flex-direction: column; gap: 3px; padding: 8px 2px;
|
||||
justify-content: center; text-align: center;
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label {
|
||||
flex: none; font-size: 10px; line-height: 1.1; font-weight: 500;
|
||||
white-space: normal; word-break: keep-all;
|
||||
}
|
||||
|
||||
.erp-sidebar-foot {
|
||||
padding: var(--sp-8);
|
||||
@@ -125,10 +136,10 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
}
|
||||
|
||||
/* ── 콘텐츠 영역 ── */
|
||||
.erp-content { display: flex; flex-direction: column; min-width: 0; }
|
||||
.erp-content { display: flex; flex-direction: column; min-width: 0; height: 100vh; min-height: 0; }
|
||||
|
||||
.erp-topbar {
|
||||
height: var(--erp-topbar-h);
|
||||
height: var(--erp-topbar-h); flex-shrink: 0;
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
background: var(--color-canvas-white);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
@@ -156,8 +167,14 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
|
||||
.erp-page {
|
||||
padding: var(--sp-24);
|
||||
max-width: 1280px; width: 100%; margin: 0 auto;
|
||||
max-width: 1550px; width: 100%; margin: 0 auto;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
overflow-y: auto; /* 넘치면 페이지 영역만 스크롤(문서 전체 X) */
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
/* 달력 등 "한 화면 채움" 페이지: 첫 섹션이 남은 높이를 모두 차지 */
|
||||
.erp-page > .vac,
|
||||
.erp-page > .cpg { flex: 1 1 auto; min-height: 0; }
|
||||
|
||||
/* ── 홈 대시보드 타일 ── */
|
||||
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
|
||||
@@ -259,7 +276,7 @@ body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
.erp-table tbody td {
|
||||
padding: 10px var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
vertical-align: top;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
||||
|
||||
@@ -197,6 +197,14 @@ body.erp-body {
|
||||
background: var(--color-rich-black);
|
||||
}
|
||||
|
||||
/* 검정 배경 요소는 글자 무조건 흰색.
|
||||
<a> 버튼/배지의 a:link/a:visited(특이도 0,1,1)가 클래스 색을 덮어쓰는 문제 차단. */
|
||||
a.erp-btn-primary, a.erp-btn-primary:link, a.erp-btn-primary:visited, a.erp-btn-primary:hover,
|
||||
.erp-badge-inverse, a.erp-badge-inverse:link, a.erp-badge-inverse:visited,
|
||||
.erp-sidebar-item.is-active, a.erp-sidebar-item.is-active:link, a.erp-sidebar-item.is-active:visited {
|
||||
color: var(--color-canvas-white) !important;
|
||||
}
|
||||
|
||||
.erp-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-rich-black);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/* 휴가 관리(vacation) 모듈 스타일.
|
||||
DESIGN.md 토큰 준수 — 모노크롬, 10/14px radius, 카드 padding 16px.
|
||||
캐시 버전: ?v=20260530a (변경 시 템플릿 head_extra 의 ?v= 도 함께 올린다) */
|
||||
|
||||
:root {
|
||||
--vac-ash: #e5e5e5;
|
||||
--vac-ghost: #f2f2f2;
|
||||
--vac-muted: #737373;
|
||||
--vac-black: #0a0a0a;
|
||||
--vac-red: #c22b10;
|
||||
--vac-blue: #1d4ed8;
|
||||
--vac-green: #10733a;
|
||||
}
|
||||
|
||||
.vac { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* 검정 배경 버튼 글자 흰색 강제 */
|
||||
.vac .erp-btn-primary { color: #fff !important; }
|
||||
|
||||
/* ── 페이지 액션 + 잔여 요약 ── */
|
||||
.vac-actions {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap; flex-shrink: 0;
|
||||
}
|
||||
.vac-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.vac-mini { margin-left: 4px; }
|
||||
.vac-balance {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
font-size: 14px; color: var(--vac-black);
|
||||
}
|
||||
.vac-balance strong { font-weight: 600; }
|
||||
.vac-bal-sep { color: var(--vac-ash); }
|
||||
.vac-bal-remain strong { color: var(--vac-green); }
|
||||
.vac-bal-year { margin-left: 6px; font-size: 12px; }
|
||||
|
||||
/* ── 레이아웃: 달력 좌 / 리스트 우 ── */
|
||||
.vac-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.vac-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── 달력 카드 ── */
|
||||
.vac-cal-card {
|
||||
padding: 16px;
|
||||
display: flex; flex-direction: column; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.vac-cal-head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vac-cal-title { font-size: 18pt; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
|
||||
.vac-nav-btn { padding: 4px 12px; font-size: 18pt; line-height: 1; }
|
||||
.vac-today-btn { margin-left: auto; }
|
||||
|
||||
.vac-cal {
|
||||
border: 1px solid var(--vac-ash); border-radius: 10px; overflow: hidden;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.vac-wd-row {
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
background: var(--vac-ghost); border-bottom: 1px solid var(--vac-ash);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vac-wd {
|
||||
text-align: center; padding: 8px 0; font-size: 15px; font-weight: 600;
|
||||
color: var(--vac-black);
|
||||
border-right: 1px solid var(--vac-ash);
|
||||
}
|
||||
.vac-wd:last-child { border-right: none; }
|
||||
|
||||
/* 주(week) — 날짜 셀 위에 bar 레이어를 오버레이. 6주가 높이를 균등 분할(유동). */
|
||||
.vac-week {
|
||||
position: relative; border-bottom: 1px solid var(--vac-ash);
|
||||
flex: 1 1 0; min-height: 88px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.vac-week:last-child { border-bottom: none; }
|
||||
|
||||
.vac-week-days {
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
.vac-day {
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--vac-ash);
|
||||
padding: 6px 8px; text-decoration: none;
|
||||
display: block; box-sizing: border-box;
|
||||
}
|
||||
.vac-day:last-child { border-right: none; }
|
||||
.vac-day-num { font-size: 20px; font-weight: 600; color: var(--vac-black); }
|
||||
.vac-out { background: #fafafa; }
|
||||
.vac-out .vac-day-num { color: #bbb; }
|
||||
.vac-today { background: #f5f5f5; }
|
||||
.vac-today .vac-day-num {
|
||||
background: var(--vac-black);
|
||||
color: #fff !important; /* vac-red/vac-blue 글자색(!important) 이김 */
|
||||
border-radius: 9999px; padding: 2px 9px;
|
||||
}
|
||||
/* 오늘이 일/공휴일=빨강배경, 토요일=파랑배경, 평일=검정배경 (모두 흰글씨) */
|
||||
.vac-today .vac-day-num.vac-red { background: var(--vac-red); }
|
||||
.vac-today .vac-day-num.vac-blue { background: var(--vac-blue); }
|
||||
.vac-selected { box-shadow: inset 0 0 0 2px var(--vac-black); }
|
||||
.vac-red { color: var(--vac-red) !important; }
|
||||
.vac-blue { color: var(--vac-blue) !important; }
|
||||
|
||||
/* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */
|
||||
.vac-week-bars {
|
||||
position: absolute; left: 0; right: 0; top: 38px; bottom: 4px;
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: 22px; row-gap: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 휴가 bar (구글 달력 스타일) ── */
|
||||
.vac-bar {
|
||||
margin: 0 3px; padding: 0 8px; height: 20px; line-height: 20px;
|
||||
border-radius: 6px; font-size: 13px; text-decoration: none;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
align-self: center; pointer-events: auto;
|
||||
}
|
||||
.vac-bar-label { pointer-events: none; }
|
||||
.vac-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
|
||||
.vac-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
|
||||
|
||||
.vac-bar-draft { background: #fff; border: 1px dashed var(--vac-muted); color: var(--vac-muted); }
|
||||
.vac-bar-submit { background: var(--vac-ghost); border: 1px solid var(--vac-muted); color: var(--vac-black); }
|
||||
.vac-bar-approve, a.vac-bar-approve:link, a.vac-bar-approve:visited { background: var(--vac-black); color: #fff; }
|
||||
.vac-bar-reject { background: #fff; border: 1px solid var(--vac-red); color: var(--vac-red); text-decoration: line-through; }
|
||||
.vac-bar-cancel { background: #fff; border: 1px dashed var(--vac-ash); color: #bbb; opacity: 0.7; }
|
||||
|
||||
/* 범례 */
|
||||
.vac-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.vac-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vac-muted); }
|
||||
.vac-dot { width: 14px; height: 12px; border-radius: 4px; display: inline-block; }
|
||||
|
||||
/* ── 선택일 리스트 ── */
|
||||
.vac-list-card {
|
||||
padding: 16px; min-height: 0; overflow-y: auto; /* 긴 목록은 패널 내부 스크롤 */
|
||||
}
|
||||
.vac-list-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
|
||||
.vac-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.vac-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.vac-list-link {
|
||||
display: block; text-decoration: none; color: var(--vac-black);
|
||||
border: 1px solid var(--vac-ash); border-radius: 10px; padding: 10px 12px;
|
||||
}
|
||||
.vac-list-link:hover { background: var(--vac-ghost); }
|
||||
.vac-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vac-list-meta { font-size: 12px; margin-top: 4px; }
|
||||
.vac-list-reason { font-size: 12px; margin-top: 4px; }
|
||||
.vac-empty { padding: 12px 0; }
|
||||
|
||||
/* ── 신청 폼 ── */
|
||||
.vac-form-card { padding: 16px; max-width: 760px; }
|
||||
.vac-form-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px; margin-bottom: 12px;
|
||||
}
|
||||
.vac-full { grid-column: 1 / -1; }
|
||||
.vac-days-preview { margin: 12px 0; font-size: 14px; }
|
||||
.vac-days-preview strong { font-size: 16px; font-weight: 600; }
|
||||
.vac-form-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* ── 상세 ── */
|
||||
.vac-detail-card { padding: 16px; max-width: 760px; }
|
||||
.vac-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vac-detail-head h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.vac-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 24px; margin: 16px 0;
|
||||
}
|
||||
.vac-detail-grid dt { font-size: 12px; color: var(--vac-muted); margin-bottom: 2px; }
|
||||
.vac-detail-grid dd { margin: 0; font-size: 14px; }
|
||||
.vac-detail-block { margin: 12px 0; }
|
||||
.vac-detail-label { font-size: 12px; color: var(--vac-muted); margin-bottom: 4px; }
|
||||
.vac-reject-block p { color: var(--vac-red); }
|
||||
.vac-approve-box {
|
||||
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
background: var(--vac-ghost); border-radius: 10px; padding: 12px; margin: 12px 0;
|
||||
}
|
||||
.vac-reject-form { display: flex; gap: 6px; align-items: center; }
|
||||
.vac-inline-form { display: inline; }
|
||||
.vac-detail-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
|
||||
.vac-push-right { margin-left: auto; }
|
||||
|
||||
/* ── 승인 대기 ── */
|
||||
.vac-pending-table .vac-act-col { width: 280px; }
|
||||
.vac-pending-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.vac-reason-cell { max-width: 260px; }
|
||||
.erp-btn-sm { padding: 2px 10px; font-size: 12px; }
|
||||
.erp-input-sm { padding: 2px 8px; font-size: 12px; min-width: 140px; }
|
||||
|
||||
/* ── 설정 ── */
|
||||
.vac-settings-head { margin-bottom: 4px; }
|
||||
.vac-year-form { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.vac-year-field { max-width: 140px; }
|
||||
.vac-settings-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) { .vac-settings-grid { grid-template-columns: 1fr; } }
|
||||
.vac-set-card { padding: 16px; }
|
||||
.vac-card-head h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
|
||||
.vac-holiday-form, .vac-balance-form {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px; margin-bottom: 12px; align-items: end;
|
||||
}
|
||||
.vac-holiday-form button, .vac-balance-form button { grid-column: 1 / -1; }
|
||||
.vac-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.vac-set-scroll { max-height: 420px; overflow: auto; }
|
||||
.vac-set-note { font-size: 12px; margin-top: 8px; }
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 휴가 신청 폼 — 종류에 따른 필드 토글 + 예상 일수 미리보기.
|
||||
서버(store.compute_days)가 공휴일 포함 정확히 재계산한다. 여기선 주말만 제외한 근사치. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var form = document.getElementById("vac-form");
|
||||
if (!form) return;
|
||||
|
||||
var type = document.getElementById("vac-type");
|
||||
var start = document.getElementById("vac-start");
|
||||
var startHalf = document.getElementById("vac-start-half");
|
||||
var endField = document.getElementById("vac-end-field");
|
||||
var end = document.getElementById("vac-end");
|
||||
var endHalfField = document.getElementById("vac-end-half-field");
|
||||
var endHalf = document.getElementById("vac-end-half");
|
||||
var startHalfField = document.getElementById("vac-start-half-field");
|
||||
var out = document.getElementById("vac-days-out");
|
||||
|
||||
function isHalfType(t) { return t === "오전반차" || t === "오후반차"; }
|
||||
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var p = s.split("-");
|
||||
if (p.length !== 3) return null;
|
||||
return new Date(+p[0], +p[1] - 1, +p[2]);
|
||||
}
|
||||
|
||||
function workingDays(sd, ed) {
|
||||
var n = 0;
|
||||
var d = new Date(sd.getTime());
|
||||
while (d <= ed) {
|
||||
var wd = d.getDay(); // 0=일, 6=토
|
||||
if (wd !== 0 && wd !== 6) n++;
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function isWorking(d) {
|
||||
var wd = d.getDay();
|
||||
return wd !== 0 && wd !== 6;
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
var t = type.value;
|
||||
var half = isHalfType(t);
|
||||
|
||||
// 반차 종류면 종료일/구분 숨김, 단일 0.5
|
||||
endField.style.display = half ? "none" : "";
|
||||
endHalfField.style.display = half ? "none" : "";
|
||||
startHalfField.style.display = half ? "none" : "";
|
||||
|
||||
if (half) {
|
||||
out.textContent = "0.5";
|
||||
return;
|
||||
}
|
||||
|
||||
var sd = parseDate(start.value);
|
||||
var ed = parseDate(end.value) || sd;
|
||||
if (!sd || !ed || ed < sd) { out.textContent = "—"; return; }
|
||||
|
||||
if (sd.getTime() === ed.getTime()) {
|
||||
out.textContent = (startHalf.value === "am" || startHalf.value === "pm") ? "0.5" : "1";
|
||||
return;
|
||||
}
|
||||
|
||||
var n = workingDays(sd, ed);
|
||||
var total = n;
|
||||
if ((startHalf.value === "am" || startHalf.value === "pm") && isWorking(sd)) total -= 0.5;
|
||||
if ((endHalf.value === "am" || endHalf.value === "pm") && isWorking(ed)) total -= 0.5;
|
||||
out.textContent = total > 0 ? String(total) : "0";
|
||||
}
|
||||
|
||||
// 시작일 변경 시 종료일이 비었거나 더 빠르면 맞춰줌
|
||||
start.addEventListener("change", function () {
|
||||
if (!end.value || parseDate(end.value) < parseDate(start.value)) {
|
||||
end.value = start.value;
|
||||
}
|
||||
recalc();
|
||||
});
|
||||
|
||||
[type, end, startHalf, endHalf].forEach(function (el) {
|
||||
if (el) el.addEventListener("change", recalc);
|
||||
});
|
||||
|
||||
recalc();
|
||||
})();
|
||||
+69
-5
@@ -10,19 +10,34 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .timezone import now_kst_iso
|
||||
|
||||
# 슈퍼 관리자 — 강등/삭제 불가
|
||||
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
||||
|
||||
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신
|
||||
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation")
|
||||
# 시스템에서 지원하는 권한 키.
|
||||
# - 접근권한: 모듈 페이지 진입 허용
|
||||
# - 승인자: 결재 워크플로에서 승인/반려 가능
|
||||
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
|
||||
MODULE_KEYS: tuple[str, ...] = (
|
||||
"corm",
|
||||
"order",
|
||||
"expense",
|
||||
"vacation",
|
||||
"cupang",
|
||||
"expense_approver",
|
||||
"vacation_approver",
|
||||
)
|
||||
|
||||
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
|
||||
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return now_kst_iso()
|
||||
|
||||
|
||||
class UserStore:
|
||||
@@ -88,6 +103,42 @@ class UserStore:
|
||||
data = self._read()
|
||||
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
name: str = "",
|
||||
role: str = "user",
|
||||
modules: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
|
||||
email = email.lower().strip()
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일 형식이 아닙니다.")
|
||||
if role not in ("admin", "user"):
|
||||
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||
is_super = email == SUPER_ADMIN_EMAIL
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if email in data["users"]:
|
||||
raise ValueError(f"이미 등록된 사용자입니다: {email}")
|
||||
now = _now_iso()
|
||||
final_role = "admin" if is_super else role
|
||||
rec = {
|
||||
"email": email,
|
||||
"name": name or email.split("@")[0],
|
||||
"picture": "",
|
||||
"role": final_role,
|
||||
"modules": self._normalize_modules(
|
||||
modules, is_admin=(final_role == "admin")
|
||||
),
|
||||
"created_at": now,
|
||||
"last_login": "",
|
||||
}
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
||||
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
||||
email = email.lower().strip()
|
||||
@@ -179,7 +230,20 @@ def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
||||
if is_admin(user_rec):
|
||||
return True
|
||||
mods = user_rec.get("modules") or {}
|
||||
return bool(mods.get(module))
|
||||
if mods.get(module):
|
||||
return True
|
||||
# 승인자(<module>_approver) 권한이 있으면 해당 모듈 접근도 허용 —
|
||||
# 본인 경비 등록/제출도 가능해야 하므로.
|
||||
if module in APPROVER_KEYS:
|
||||
return False
|
||||
if mods.get(f"{module}_approver"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
|
||||
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
|
||||
return has_module(user_rec, f"{kind}_approver")
|
||||
|
||||
|
||||
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
||||
|
||||
+110
-40
@@ -5,21 +5,44 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>사용자/권한 관리 — DBX ERP</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
<style>
|
||||
.erp-admin-toolbar {
|
||||
display: flex; align-items: center; gap: var(--sp-12);
|
||||
margin: var(--sp-16) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
|
||||
|
||||
.erp-add-form {
|
||||
display: flex; gap: var(--sp-8); align-items: center;
|
||||
margin-left: auto; flex-wrap: wrap;
|
||||
}
|
||||
.erp-add-form input, .erp-add-form select {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 6px 10px; font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; }
|
||||
.erp-mod-cell { text-align: center; padding: 4px; }
|
||||
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
|
||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||
font-size: 10px; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="erp-body">
|
||||
|
||||
<div class="erp-shell">
|
||||
|
||||
<!-- ── 상단 네비게이션 ── -->
|
||||
<nav class="erp-nav">
|
||||
<div class="erp-nav-left">
|
||||
<a href="/" class="erp-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||
<span class="erp-brand-divider"></span>
|
||||
<span class="erp-brand-system">ERP 시스템</span>
|
||||
<span class="erp-brand-system">DBX ERP System</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-nav-right">
|
||||
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
||||
<div class="erp-user-chip">
|
||||
@@ -37,39 +60,45 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="erp-main" style="max-width: 1100px;">
|
||||
<main class="erp-main" style="max-width: 1280px;">
|
||||
|
||||
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
||||
<p class="erp-greeting-sub">
|
||||
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
|
||||
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
|
||||
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
|
||||
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
|
||||
</p>
|
||||
|
||||
<!-- 이메일 등록 폼 + 검색 -->
|
||||
<div class="erp-admin-toolbar">
|
||||
<input type="search"
|
||||
id="user-search"
|
||||
class="erp-input"
|
||||
placeholder="이메일 또는 이름으로 검색…" />
|
||||
<input type="search" id="user-search" class="erp-input"
|
||||
placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
|
||||
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
||||
|
||||
<form id="add-user-form" class="erp-add-form">
|
||||
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
|
||||
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
|
||||
<select name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 28%;">사용자</th>
|
||||
<th style="width: 11%;">역할</th>
|
||||
<th style="width: 22%;">사용자</th>
|
||||
<th style="width: 80px;">역할</th>
|
||||
{% for key in module_keys %}
|
||||
<th style="text-align:center; width: 9%;">
|
||||
{% if key == 'corm' %}CORM
|
||||
{% elif key == 'order' %}Order
|
||||
{% elif key == 'expense' %}개인경비
|
||||
{% elif key == 'vacation' %}휴가
|
||||
{% else %}{{ key }}{% endif %}
|
||||
<th class="erp-mod-th">
|
||||
{{ module_labels.get(key, key) }}
|
||||
{% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th style="width: 13%;">최근 로그인</th>
|
||||
<th style="width: 12%; text-align: right;">동작</th>
|
||||
<th style="width: 120px;">최근 로그인</th>
|
||||
<th style="width: 130px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -102,7 +131,7 @@
|
||||
</select>
|
||||
</td>
|
||||
{% for key in module_keys %}
|
||||
<td style="text-align:center;">
|
||||
<td class="erp-mod-cell">
|
||||
<label class="erp-switch">
|
||||
<input type="checkbox"
|
||||
data-field="module"
|
||||
@@ -114,11 +143,15 @@
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
||||
{{ (u.last_login or '')[:16].replace('T', ' ') }}
|
||||
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-primary" data-action="save"
|
||||
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
||||
{% if not u.is_super_admin %}
|
||||
<button class="erp-btn erp-btn-outline" data-action="delete"
|
||||
style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -127,8 +160,9 @@
|
||||
</div>
|
||||
|
||||
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
|
||||
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
|
||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
|
||||
admin 역할은 모든 모듈 권한 자동 부여.
|
||||
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
|
||||
</p>
|
||||
|
||||
</main>
|
||||
@@ -141,6 +175,7 @@
|
||||
const searchInput = document.getElementById("user-search");
|
||||
const userCountEl = document.getElementById("user-count");
|
||||
const toastEl = document.getElementById("toast");
|
||||
const addForm = document.getElementById("add-user-form");
|
||||
|
||||
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
||||
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
||||
@@ -150,20 +185,16 @@
|
||||
toastEl.dataset.type = type || "info";
|
||||
toastEl.dataset.visible = "true";
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => {
|
||||
toastEl.dataset.visible = "false";
|
||||
}, 2400);
|
||||
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
|
||||
}
|
||||
|
||||
// 역할 변경시 모듈 토글 활성/비활성 갱신
|
||||
function syncModuleSwitches(tr) {
|
||||
const isSuper = tr.dataset.super === "true";
|
||||
const role = tr.querySelector("select[data-field='role']").value;
|
||||
const adminRole = role === "admin";
|
||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||
if (isSuper || adminRole) {
|
||||
cb.checked = true;
|
||||
cb.disabled = true;
|
||||
cb.checked = true; cb.disabled = true;
|
||||
} else {
|
||||
cb.disabled = false;
|
||||
}
|
||||
@@ -173,25 +204,23 @@
|
||||
tbody.addEventListener("change", (e) => {
|
||||
const tr = e.target.closest("tr");
|
||||
if (!tr) return;
|
||||
if (e.target.dataset.field === "role") {
|
||||
syncModuleSwitches(tr);
|
||||
}
|
||||
if (e.target.dataset.field === "role") syncModuleSwitches(tr);
|
||||
markDirty(tr);
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button[data-action='save']");
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const email = tr.dataset.email;
|
||||
|
||||
if (btn.dataset.action === "save") {
|
||||
const role = tr.querySelector("select[data-field='role']").value;
|
||||
const modules = {};
|
||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||
modules[cb.dataset.module] = cb.checked;
|
||||
});
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "저장 중…";
|
||||
btn.disabled = true; btn.textContent = "저장 중…";
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||
method: "PUT",
|
||||
@@ -208,12 +237,53 @@
|
||||
} catch (err) {
|
||||
showToast(`저장 실패: ${err.message}`, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "저장";
|
||||
btn.disabled = false; btn.textContent = "저장";
|
||||
}
|
||||
} else if (btn.dataset.action === "delete") {
|
||||
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||
method: "DELETE", credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
tr.remove();
|
||||
showToast(`${email} 삭제 완료`, "info");
|
||||
} catch (err) {
|
||||
showToast(`삭제 실패: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 사용자 추가
|
||||
addForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(addForm);
|
||||
const payload = {
|
||||
email: fd.get("email").trim().toLowerCase(),
|
||||
name: fd.get("name").trim(),
|
||||
role: fd.get("role"),
|
||||
};
|
||||
try {
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} catch (err) {
|
||||
showToast(`등록 실패: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 검색
|
||||
searchInput.addEventListener("input", () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
<link rel="stylesheet" href="/static/erp-shell.css" />
|
||||
<link rel="stylesheet" href="/static/erp.css?v=20260530q" />
|
||||
<link rel="stylesheet" href="/static/erp-shell.css?v=20260530q" />
|
||||
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body class="erp-body erp-app-body">
|
||||
@@ -18,7 +19,7 @@
|
||||
<div class="erp-sidebar-head">
|
||||
<a href="/" class="erp-sidebar-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
|
||||
<span class="erp-sidebar-system">ERP</span>
|
||||
<span class="erp-sidebar-system">DBX ERP System</span>
|
||||
</a>
|
||||
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
@@ -159,6 +160,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/static/erp-attach-viewer.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="/" class="erp-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||
<span class="erp-brand-divider"></span>
|
||||
<span class="erp-brand-system">ERP 시스템</span>
|
||||
<span class="erp-brand-system">DBX ERP System</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""프로젝트 공통 시간대 — 모든 시간은 한국 시간(KST, UTC+9)으로 표시/계산한다.
|
||||
|
||||
대한민국은 서머타임(DST)이 없으므로 고정 오프셋 +9 로 충분하다.
|
||||
zoneinfo/tzdata 의존 없이 어디서나 동일하게 동작한다.
|
||||
|
||||
- DB 의 TIMESTAMPTZ 는 UTC 로 저장되고 psycopg 가 aware datetime(UTC)로 돌려준다.
|
||||
표시 직전에 `to_kst_iso()` 로 KST 문자열로 변환한다.
|
||||
- "오늘"/"지금" 판정은 `today_kst()` / `now_kst()` 를 쓴다(서버 로컬 TZ 무관).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
KST = timezone(timedelta(hours=9), name="KST")
|
||||
|
||||
|
||||
def now_kst() -> datetime:
|
||||
"""현재 시각(KST, tz-aware)."""
|
||||
return datetime.now(KST)
|
||||
|
||||
|
||||
def today_kst() -> date:
|
||||
"""오늘 날짜(KST 기준)."""
|
||||
return now_kst().date()
|
||||
|
||||
|
||||
def now_kst_iso() -> str:
|
||||
return now_kst().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def to_kst_iso(dt: datetime | None, *, timespec: str = "seconds") -> str | None:
|
||||
"""datetime → KST ISO 문자열. naive 는 UTC 로 간주 후 변환."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(KST).isoformat(timespec=timespec)
|
||||
+11
-1
@@ -10,10 +10,20 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 사용자/권한 JSON 저장소 — 볼륨에 영구 보관
|
||||
# 사용자/권한 JSON 저장소 + 첨부 업로드 — 볼륨에 영구 보관
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- dbx-main-data:/data
|
||||
networks:
|
||||
- default
|
||||
- postgres_default # postgres-db 컨테이너와 통신 (DSN host=postgres-db)
|
||||
|
||||
volumes:
|
||||
dbx-main-data:
|
||||
|
||||
networks:
|
||||
# main_default — compose 자동 생성, 동일 프로젝트 내부 통신용
|
||||
default:
|
||||
# postgres_default — postgres-db 가 속한 외부 네트워크 (별도 compose 가 만듦)
|
||||
postgres_default:
|
||||
external: true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
|
||||
| `return_db` | 반품·교환·CS 데이터 |
|
||||
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
|
||||
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
|
||||
|
||||
---
|
||||
|
||||
@@ -105,10 +106,54 @@ DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱
|
||||
| `amount` | BIGINT | 원 단위, ≥ 0 |
|
||||
| `memo` | TEXT | 비고 |
|
||||
| `status` | TEXT | 작성중/제출/승인/반려/정산완료 |
|
||||
| `approver_email` | TEXT | 결재자 email (승인/반려/정산 시 기록) |
|
||||
| `decided_at` | TIMESTAMPTZ | 결재 시점 |
|
||||
| `reject_reason` | TEXT | 반려 사유 |
|
||||
| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 |
|
||||
|
||||
인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`.
|
||||
|
||||
### 테이블 `expense_attachments`
|
||||
|
||||
영수증/기타파일 메타데이터. 실제 파일은 `DATA_DIR/uploads/expense/{item_id}/` 에 저장.
|
||||
|
||||
| 컬럼 | 타입 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | 12자 hex |
|
||||
| `item_id` | TEXT FK | `expense_items(id)` ON DELETE CASCADE |
|
||||
| `owner` | TEXT | 업로드한 사용자 email |
|
||||
| `kind` | TEXT | `receipt` 또는 `other` (CHECK) |
|
||||
| `filename` | TEXT | 원본 파일명 |
|
||||
| `stored_path` | TEXT | 디스크 경로 (절대) |
|
||||
| `content_type` | TEXT | MIME |
|
||||
| `size_bytes` | BIGINT | 바이트 |
|
||||
| `uploaded_at` | TIMESTAMPTZ | 업로드 시각 |
|
||||
|
||||
인덱스: `(item_id)`.
|
||||
|
||||
### 결재 워크플로
|
||||
|
||||
```
|
||||
작성중 ─submit──▶ 제출 ─approve──▶ 승인 ─settle──▶ 정산완료
|
||||
▲ │
|
||||
└─revert/reject──┴─reject──▶ 반려 ─revert──▶ 작성중
|
||||
```
|
||||
|
||||
- `submit`/`revert`: owner 본인
|
||||
- `approve`/`reject`/`settle`: `expense_approver` 또는 `admin`
|
||||
- 첨부 추가/항목 수정/삭제: `작성중` 또는 `반려` 상태에서만
|
||||
|
||||
### 마이그레이션
|
||||
|
||||
기존 운영 DB 에 신규 컬럼/테이블 적용:
|
||||
|
||||
```bash
|
||||
docker exec -i postgres-db psql -U postgres -d expense_db \
|
||||
< scripts/sql/expense_db_002_workflow_attachments.sql
|
||||
```
|
||||
|
||||
신규 설치는 `expense_db_init.sql` 하나로 충분 (둘 다 멱등).
|
||||
|
||||
### 운영 서버 초기화 (1회)
|
||||
|
||||
```bash
|
||||
@@ -142,6 +187,75 @@ docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \
|
||||
|
||||
---
|
||||
|
||||
## cupang_db 스키마 / 초기화
|
||||
|
||||
DDL: `scripts/sql/cupang_db_init.sql` (멱등). DB·역할(`cupang_app`)·테이블·인덱스·트리거·센터 seed 를 한 번에 생성. **JSON 폴백 없음** — `CUPANG_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
|
||||
|
||||
테이블:
|
||||
|
||||
| 테이블 | 용도 |
|
||||
| --- | --- |
|
||||
| `cupang_centers` | 입고센터. `active=false` 로 비활성화(사용 중이면 hard delete 금지) |
|
||||
| `cupang_box_rules` | 제품코드별 박스당 입수량(`units_per_box`). `product_code` UNIQUE |
|
||||
| `cupang_shipments` | 출고 묶음 헤더 (작성일/출고일/센터입고일/센터/출고방식/상태/작업자/메모) |
|
||||
| `cupang_shipment_lines` | 출고 라인. `shipment_id` FK ON DELETE CASCADE. `UNIQUE(shipment_id, line_no)` |
|
||||
|
||||
`status` 허용값: `작성중`, `출고준비`, `출고완료`, `센터입고완료`, `취소`. 삭제는 기본 soft delete(`status='취소'`).
|
||||
|
||||
박스 계산은 서버(`store.compute_boxes`)에서 재계산: `required_boxes = ceil(quantity / units_per_box)`. 클라이언트 계산은 미리보기용.
|
||||
|
||||
상품은 `cupang_db` 에 복제 저장하지 않는다. 라인에는 `product_code` + `product_name_snapshot` 만 보존(과거 명칭 보존). 상품 검색은 `itemcode_db` **읽기 전용**(`ITEMCODE_DB_URL`, 미설정 시 수동 입력).
|
||||
|
||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||
|
||||
```bash
|
||||
read -s -p "cupang_app password: " APP_PWD; echo
|
||||
docker exec -i postgres-db psql -U postgres \
|
||||
-v app_password="$APP_PWD" \
|
||||
< scripts/sql/cupang_db_init.sql
|
||||
# main-app .env 에 추가:
|
||||
# CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
|
||||
cd /opt/www/main && docker compose up -d --build
|
||||
```
|
||||
|
||||
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
|
||||
|
||||
---
|
||||
|
||||
## vacation_db 스키마 / 초기화
|
||||
|
||||
DDL: `scripts/sql/vacation_db_init.sql` (멱등). DB·역할(`vacation_app`)·테이블·인덱스·트리거·2026 공휴일 seed 를 한 번에 생성. **JSON 폴백 없음** — `VACATION_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
|
||||
|
||||
테이블:
|
||||
|
||||
| 테이블 | 용도 |
|
||||
| --- | --- |
|
||||
| `vacation_requests` | 휴가 신청(헤더). 종류/기간/시작·종료 구분(full/am/pm)/일수/사유/상태/승인자/반려사유 |
|
||||
| `vacation_holidays` | 공휴일(`holiday_date` UNIQUE). `is_red=true` 면 달력 빨강 + 일수 계산 제외. 관리자가 settings 에서 추가/수정/삭제 |
|
||||
| `vacation_balances` | 사용자별 연차(`UNIQUE(user_email, year)`). `total_days` 설정, 사용일수는 승인 휴가 합계로 자동 계산 |
|
||||
|
||||
`status` 허용값: `작성중`, `제출`, `승인`, `반려`, `취소`. 워크플로: 작성중/반려 → 제출 → 승인|반려. 삭제는 기본 soft delete(`status='취소'`). 수정은 작성중/반려 상태에서 본인만.
|
||||
|
||||
휴가 일수는 서버(`store.compute_days`)에서 재계산: 주말 + `vacation_holidays(is_red)` 제외, 오전/오후 반차 0.5일, 시작/종료 반차는 각 0.5 차감. 클라이언트 계산은 미리보기(주말만 제외)용.
|
||||
|
||||
권한: `vacation`(접근) / `vacation_approver`(승인·반려). admin 은 항상 통과. 공휴일·연차 설정은 admin 전용.
|
||||
|
||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||
|
||||
```bash
|
||||
read -s -p "vacation_app password: " APP_PWD; echo
|
||||
docker exec -i postgres-db psql -U postgres \
|
||||
-v app_password="$APP_PWD" \
|
||||
< scripts/sql/vacation_db_init.sql
|
||||
# main-app .env 에 추가:
|
||||
# VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
|
||||
cd /opt/www/main && docker compose up -d --build
|
||||
```
|
||||
|
||||
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 공휴일은 연도별로 다르므로 settings 화면에서 추가/수정.
|
||||
|
||||
---
|
||||
|
||||
## 백업 / 복구 (안전 절차)
|
||||
|
||||
### 백업
|
||||
|
||||
@@ -116,6 +116,10 @@ docker compose down # 컨테이너 제거 (볼륨 유지)
|
||||
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
|
||||
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
|
||||
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
|
||||
| `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내) |
|
||||
| `VACATION_DB_URL` | 휴가 관리 DB DSN (예: `postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내). 권한키 `vacation`/`vacation_approver` |
|
||||
| `ITEMCODE_DB_URL` | 상품 검색용 itemcode_db 읽기 전용 DSN. 미설정 시 검색 비활성(수동 입력). 테이블/컬럼은 `ITEMCODE_TABLE`/`ITEMCODE_CODE_COL`/`ITEMCODE_NAME_COL`/`ITEMCODE_TYPE_COL` 또는 `ITEMCODE_SEARCH_SQL` 로 지정 |
|
||||
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,9 +17,21 @@
|
||||
| CS관리 | 문의/응대 이력, 발주 업무 트리거 |
|
||||
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
|
||||
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
|
||||
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) |
|
||||
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
||||
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
|
||||
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
||||
|
||||
### 권한 키 (`MODULE_KEYS`)
|
||||
|
||||
| 키 | 종류 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| `corm` / `order` | 접근 | 외부 모듈 진입 |
|
||||
| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 |
|
||||
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
|
||||
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
|
||||
|
||||
`admin` 역할은 모든 권한 자동 부여. 신규 사용자는 관리자 페이지(`/admin`)에서 이메일만으로 등록 가능.
|
||||
|
||||
---
|
||||
|
||||
## 모듈 디렉토리 규약
|
||||
@@ -75,12 +87,8 @@ app/modules/<name>/
|
||||
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
|
||||
| `return_db` | 반품·교환·CS 데이터 |
|
||||
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
|
||||
|
||||
### DB 후보
|
||||
|
||||
| 모듈 | 현재 저장소 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 휴가 | (미개발) | `vacation_db` (승인 후 생성) |
|
||||
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
|
||||
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
|
||||
|
||||
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
|
||||
|
||||
|
||||
@@ -6,3 +6,6 @@ jinja2>=3.1
|
||||
itsdangerous>=2.2
|
||||
python-dotenv>=1.0
|
||||
psycopg[binary,pool]>=3.2
|
||||
python-multipart>=0.0.20
|
||||
openpyxl>=3.1
|
||||
pillow>=10.0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =====================================================================
|
||||
-- cupang_db 마이그레이션 002 — cupang_shipments.document_no 컬럼 제거
|
||||
-- =====================================================================
|
||||
-- 사유: 출고 폼에서 "문서번호" 항목 제거(미사용).
|
||||
-- 멱등: IF EXISTS. 운영 적용 전 백업 권장(DROP COLUMN 은 되돌릴 수 없음).
|
||||
--
|
||||
-- 실행:
|
||||
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||
-- < scripts/sql/cupang_db_002_drop_document_no.sql
|
||||
--
|
||||
-- 주의: 이 컬럼에 보관된 값이 있으면 함께 삭제된다. 현재 폼에서 입력받지
|
||||
-- 않으므로 값이 없거나 NULL 일 가능성이 높다. 확인 후 실행:
|
||||
-- SELECT count(*) FROM cupang_shipments WHERE document_no IS NOT NULL AND document_no <> '';
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
\connect cupang_db
|
||||
|
||||
ALTER TABLE cupang_shipments DROP COLUMN IF EXISTS document_no;
|
||||
|
||||
SELECT 'cupang_db 002 done' AS status;
|
||||
@@ -0,0 +1,199 @@
|
||||
-- =====================================================================
|
||||
-- cupang_db 초기화 스크립트 (PostgreSQL) — 쿠팡 밀크런 모듈
|
||||
-- =====================================================================
|
||||
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
|
||||
--
|
||||
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
|
||||
--
|
||||
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||
--
|
||||
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
|
||||
-- read -s -p "cupang_app password: " APP_PWD; echo
|
||||
-- docker exec -i postgres-db psql -U postgres \
|
||||
-- -v app_password="$APP_PWD" \
|
||||
-- < scripts/sql/cupang_db_init.sql
|
||||
--
|
||||
-- 2) main-app .env 에 연결 정보 등록
|
||||
-- CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
|
||||
--
|
||||
-- 3) main-app 재기동
|
||||
-- cd /opt/www/main && docker compose up -d --build
|
||||
--
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||
-- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품은 읽기 전용 참조).
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- DB 가 없을 때만 생성
|
||||
SELECT 'CREATE DATABASE cupang_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cupang_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할 (expense_app 패턴과 동일)
|
||||
SELECT 'CREATE ROLE cupang_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cupang_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE cupang_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE cupang_db TO cupang_app;
|
||||
|
||||
-- cupang_db 컨텍스트로 전환
|
||||
\connect cupang_db
|
||||
|
||||
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
|
||||
CREATE OR REPLACE FUNCTION cupang_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 1) 입고센터
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_centers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_centers_updated ON cupang_centers;
|
||||
CREATE TRIGGER trg_cupang_centers_updated
|
||||
BEFORE UPDATE ON cupang_centers
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2) 박스 입수량 규칙
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_box_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_code TEXT NOT NULL UNIQUE,
|
||||
product_name_snapshot TEXT,
|
||||
box_name TEXT NOT NULL DEFAULT '쿠팡박스',
|
||||
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_box_rules_code ON cupang_box_rules (product_code);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_box_rules_updated ON cupang_box_rules;
|
||||
CREATE TRIGGER trg_cupang_box_rules_updated
|
||||
BEFORE UPDATE ON cupang_box_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2-b) 제품명 카탈로그 (itemcode_db 에서 가져와 등록 → 폼 드롭다운 소스)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_products (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_code TEXT NOT NULL UNIQUE,
|
||||
product_name TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_products_name ON cupang_products (product_name);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_products_updated ON cupang_products;
|
||||
CREATE TRIGGER trg_cupang_products_updated
|
||||
BEFORE UPDATE ON cupang_products
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 3) 출고 묶음 (헤더)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_shipments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_by TEXT NOT NULL,
|
||||
document_date DATE NOT NULL,
|
||||
ship_date DATE NOT NULL,
|
||||
center_arrival_date DATE NOT NULL,
|
||||
center_id BIGINT REFERENCES cupang_centers(id),
|
||||
center_name_snapshot TEXT NOT NULL DEFAULT '',
|
||||
ship_method TEXT NOT NULL DEFAULT '택배',
|
||||
outbound_summary TEXT NOT NULL DEFAULT '',
|
||||
worker TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_doc_date ON cupang_shipments (document_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_ship_date ON cupang_shipments (ship_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_arr_date ON cupang_shipments (center_arrival_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_status ON cupang_shipments (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_center ON cupang_shipments (center_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_shipments_updated ON cupang_shipments;
|
||||
CREATE TRIGGER trg_cupang_shipments_updated
|
||||
BEFORE UPDATE ON cupang_shipments
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 4) 출고 라인
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_shipment_lines (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
shipment_id BIGINT NOT NULL REFERENCES cupang_shipments(id) ON DELETE CASCADE,
|
||||
line_no INTEGER NOT NULL,
|
||||
product_code TEXT NOT NULL,
|
||||
product_name_snapshot TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
box_rule_id BIGINT REFERENCES cupang_box_rules(id),
|
||||
units_per_box INTEGER,
|
||||
calculated_boxes INTEGER,
|
||||
remainder_units INTEGER,
|
||||
manual_box_text TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (shipment_id, line_no)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_lines_shipment ON cupang_shipment_lines (shipment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_lines_code ON cupang_shipment_lines (product_code);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_lines_updated ON cupang_shipment_lines;
|
||||
CREATE TRIGGER trg_cupang_lines_updated
|
||||
BEFORE UPDATE ON cupang_shipment_lines
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 5) 초기 입고센터 seed (멱등: ON CONFLICT DO NOTHING)
|
||||
-- sort_order 는 목록 순서대로 부여.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
INSERT INTO cupang_centers (name, sort_order) VALUES
|
||||
('대구3', 1), ('인천32', 2), ('이천1', 3), ('인천42', 4), ('인천26', 5),
|
||||
('인천16', 6), ('인천28', 7), ('안성8', 8), ('천안8(RC)', 9), ('시흥2', 10),
|
||||
('인천36', 11), ('MGMH5', 12), ('XRC10(RC)', 13), ('인천14', 14), ('경기광주5', 15),
|
||||
('경기광주3', 16), ('XRC06(RC)', 17), ('용인1', 18), ('인천30', 19), ('마장1', 20),
|
||||
('안성4', 21), ('대구6', 22), ('전라광주2', 23), ('창원1', 24), ('고양1', 25),
|
||||
('동탄1', 26), ('이천4', 27), ('XRC09(RC)', 28)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 6) 권한 (cupang_app: CRUD only)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
GRANT USAGE ON SCHEMA public TO cupang_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||
cupang_centers, cupang_box_rules, cupang_products,
|
||||
cupang_shipments, cupang_shipment_lines
|
||||
TO cupang_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cupang_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO cupang_app;
|
||||
|
||||
SELECT 'cupang_db ready' AS status;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================
|
||||
-- expense_db 마이그레이션 #002 — 결재 워크플로 + 첨부파일
|
||||
-- =====================================================================
|
||||
-- 실행:
|
||||
-- docker exec -i postgres-db psql -U postgres -d expense_db \
|
||||
-- < scripts/sql/expense_db_002_workflow_attachments.sql
|
||||
--
|
||||
-- 멱등. init.sql 도 동일 변경을 포함하므로 신규 설치는 init 하나로 충분.
|
||||
-- 이미 운영 중인 DB 에만 별도로 적용한다.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- expense_items 컬럼 추가
|
||||
ALTER TABLE expense_items
|
||||
ADD COLUMN IF NOT EXISTS approver_email TEXT,
|
||||
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
|
||||
|
||||
-- 첨부파일 테이블
|
||||
CREATE TABLE IF NOT EXISTS expense_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
|
||||
owner TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
|
||||
|
||||
SELECT 'migration 002 applied' AS status;
|
||||
@@ -24,14 +24,16 @@ WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'expense_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'expense_app') THEN
|
||||
EXECUTE format('CREATE ROLE expense_app LOGIN PASSWORD %L', :'app_password');
|
||||
ELSE
|
||||
EXECUTE format('ALTER ROLE expense_app WITH LOGIN PASSWORD %L', :'app_password');
|
||||
END IF;
|
||||
END$$;
|
||||
-- psql 변수(:'app_password')는 dollar-quoted 블록 안에서 치환되지 않으므로
|
||||
-- DO 블록을 쓰지 않고 \gexec 로 동적 SQL 을 생성·실행한다.
|
||||
|
||||
SELECT 'CREATE ROLE expense_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'expense_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE expense_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE expense_db TO expense_app;
|
||||
|
||||
@@ -48,14 +50,37 @@ CREATE TABLE IF NOT EXISTS expense_items (
|
||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
approver_email TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
reject_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 기존 DB 호환: 컬럼이 없으면 추가
|
||||
ALTER TABLE expense_items
|
||||
ADD COLUMN IF NOT EXISTS approver_email TEXT,
|
||||
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC);
|
||||
|
||||
-- 첨부파일 (영수증/기타). 실제 파일은 파일시스템 저장, 메타만 DB.
|
||||
CREATE TABLE IF NOT EXISTS expense_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
|
||||
owner TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
|
||||
|
||||
-- updated_at 자동 갱신 트리거
|
||||
CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
@@ -72,6 +97,7 @@ CREATE TRIGGER trg_expense_set_updated_at
|
||||
-- 권한
|
||||
GRANT USAGE ON SCHEMA public TO expense_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =====================================================================
|
||||
-- vacation_db 초기화 스크립트 (PostgreSQL) — 휴가 관리 모듈
|
||||
-- =====================================================================
|
||||
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
|
||||
--
|
||||
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
|
||||
--
|
||||
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||
--
|
||||
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
|
||||
-- read -s -p "vacation_app password: " APP_PWD; echo
|
||||
-- docker exec -i postgres-db psql -U postgres \
|
||||
-- -v app_password="$APP_PWD" \
|
||||
-- < scripts/sql/vacation_db_init.sql
|
||||
--
|
||||
-- 2) main-app .env 에 연결 정보 등록
|
||||
-- VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
|
||||
--
|
||||
-- 3) main-app 재기동
|
||||
-- cd /opt/www/main && docker compose up -d --build
|
||||
--
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- DB 가 없을 때만 생성
|
||||
SELECT 'CREATE DATABASE vacation_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'vacation_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할 (expense_app / cupang_app 패턴과 동일)
|
||||
SELECT 'CREATE ROLE vacation_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vacation_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE vacation_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE vacation_db TO vacation_app;
|
||||
|
||||
-- vacation_db 컨텍스트로 전환
|
||||
\connect vacation_db
|
||||
|
||||
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
|
||||
CREATE OR REPLACE FUNCTION vacation_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 1) 휴가 신청 (헤더)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
owner_name TEXT NOT NULL DEFAULT '',
|
||||
vacation_type TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
start_half TEXT NOT NULL DEFAULT 'full',
|
||||
end_half TEXT NOT NULL DEFAULT 'full',
|
||||
days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
approver_email TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
reject_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_owner_start ON vacation_requests (owner, start_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_status ON vacation_requests (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_range ON vacation_requests (start_date, end_date);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_requests_updated ON vacation_requests;
|
||||
CREATE TRIGGER trg_vacation_requests_updated
|
||||
BEFORE UPDATE ON vacation_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2) 공휴일 (달력 빨강 표시 + 휴가일수 계산 제외 기준)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_holidays (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
holiday_date DATE NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'public',
|
||||
is_red BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_holidays_date ON vacation_holidays (holiday_date);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_holidays_updated ON vacation_holidays;
|
||||
CREATE TRIGGER trg_vacation_holidays_updated
|
||||
BEFORE UPDATE ON vacation_holidays
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 3) 사용자별 연차 잔여 (연도 단위)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_balances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
total_days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
used_days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_email, year)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_balances_user_year ON vacation_balances (user_email, year);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_balances_updated ON vacation_balances;
|
||||
CREATE TRIGGER trg_vacation_balances_updated
|
||||
BEFORE UPDATE ON vacation_balances
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 4) 공휴일 seed — 2026년 대한민국 공휴일 (멱등: ON CONFLICT DO NOTHING)
|
||||
-- 연도별로 달라지므로 settings 화면에서 추가/수정/삭제 가능.
|
||||
-- KASI(한국천문연구원) 발표 기준. 새 연도는 settings 또는 본 seed 추가.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
INSERT INTO vacation_holidays (holiday_date, name, kind) VALUES
|
||||
('2026-01-01', '신정', 'public'),
|
||||
('2026-02-16', '설날 연휴', 'lunar'),
|
||||
('2026-02-17', '설날', 'lunar'),
|
||||
('2026-02-18', '설날 연휴', 'lunar'),
|
||||
('2026-03-01', '삼일절', 'public'),
|
||||
('2026-03-02', '삼일절 대체', 'substitute'),
|
||||
('2026-05-05', '어린이날', 'public'),
|
||||
('2026-05-24', '부처님오신날', 'lunar'),
|
||||
('2026-05-25', '부처님오신날 대체', 'substitute'),
|
||||
('2026-06-06', '현충일', 'public'),
|
||||
('2026-08-15', '광복절', 'public'),
|
||||
('2026-08-17', '광복절 대체', 'substitute'),
|
||||
('2026-09-24', '추석 연휴', 'lunar'),
|
||||
('2026-09-25', '추석', 'lunar'),
|
||||
('2026-09-26', '추석 연휴', 'lunar'),
|
||||
('2026-09-28', '추석 대체', 'substitute'),
|
||||
('2026-10-03', '개천절', 'public'),
|
||||
('2026-10-05', '개천절 대체', 'substitute'),
|
||||
('2026-10-09', '한글날', 'public'),
|
||||
('2026-12-25', '성탄절', 'public')
|
||||
ON CONFLICT (holiday_date) DO NOTHING;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 5) 권한 (vacation_app: CRUD only)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
GRANT USAGE ON SCHEMA public TO vacation_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||
vacation_requests, vacation_holidays, vacation_balances
|
||||
TO vacation_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vacation_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vacation_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO vacation_app;
|
||||
|
||||
SELECT 'vacation_db ready' AS status;
|
||||
Reference in New Issue
Block a user