manual-deploy 2026-06-07 04:13

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-07 04:13:36 +09:00
parent 9b52cc8941
commit 9f2344604e
4 changed files with 729 additions and 2 deletions

120
models.py
View File

@ -7153,3 +7153,123 @@ class TmuxCommand(Base):
command = Column(Text)
sent_by = Column(String(100))
created_at = Column(DateTime, default=func.now())
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능 백엔드 모델 (Mobile API) ─────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
class AlertRule(Base):
"""알림 규칙 — 서버/서비스/SR 지표 임계치 기반 노코드 알림 정의 (#45)."""
__tablename__ = "tb_alert_rule"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(String(50), nullable=False, index=True)
target_type = Column(String(20), nullable=False) # server | service | sr
target_id = Column(String(100))
metric = Column(String(30), nullable=False) # cpu | memory | disk | sla
threshold = Column(Float, nullable=False)
operator = Column(String(5), default=">") # > | < | =
channel = Column(String(20), default="inapp") # push | inapp | sms
enabled = Column(Boolean, default=True)
created_by = Column(String(100))
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class InventoryPart(Base):
"""부품 재고 — 현장 서비스 교체 부품 관리 (#62)."""
__tablename__ = "tb_inventory_part"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(String(50), nullable=False, index=True)
name = Column(String(200), nullable=False)
model = Column(String(100))
quantity = Column(Integer, default=0)
min_quantity = Column(Integer, default=1)
location = Column(String(200))
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class SRChatMessage(Base):
"""SR 채팅 메시지 — SR별 실시간 협업 대화 (#98)."""
__tablename__ = "tb_sr_chat_message"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String(30), nullable=False, index=True) # SRRequest.sr_id
sender_id = Column(String(100), nullable=False)
content = Column(Text, nullable=False)
msg_type = Column(String(20), default="text") # text | image | sr_update
read_by = Column(Text) # JSON list of usernames who read
created_at = Column(DateTime, default=func.now(), index=True)
class SRSubscription(Base):
"""SR 구독/팔로우 — 사용자가 SR 변경 알림을 받도록 구독 (#14)."""
__tablename__ = "tb_sr_subscription"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String(30), nullable=False, index=True) # SRRequest.sr_id
username = Column(String(100), nullable=False, index=True)
created_at = Column(DateTime, default=func.now())
class SRRating(Base):
"""SR 완료 만족도 평가 (#53)."""
__tablename__ = "tb_sr_rating"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String(30), nullable=False, index=True)
rater = Column(String(100), nullable=False)
score = Column(Integer, nullable=False) # 1~5
comment = Column(Text)
created_at = Column(DateTime, default=func.now())
class SRSignature(Base):
"""현장 전자서명 — 작업 완료 현장 확인 서명 (#70)."""
__tablename__ = "tb_sr_signature"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String(30), nullable=False, index=True)
signed_by = Column(String(100), nullable=False)
signature_b64 = Column(Text, nullable=False) # base64 PNG 서명 데이터
created_at = Column(DateTime, default=func.now())
class SRCheckin(Base):
"""현장 체크인 — 엔지니어 현장 도착 GPS 기록 (#93)."""
__tablename__ = "tb_sr_checkin"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String(30), nullable=False, index=True)
username = Column(String(100), nullable=False)
lat = Column(Float)
lng = Column(Float)
created_at = Column(DateTime, default=func.now())
class UserDevice(Base):
"""등록 디바이스 — 모바일 앱 디바이스/푸시 토큰 관리 (#33)."""
__tablename__ = "tb_user_device"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), nullable=False, index=True)
device_name = Column(String(150))
device_type = Column(String(30)) # android | ios | web
push_token = Column(String(300))
last_seen_at = Column(DateTime, default=func.now())
created_at = Column(DateTime, default=func.now())
class LoginEvent(Base):
"""보안 이벤트 로그 — 로그인 성공/실패/디바이스 등록 등 (#34)."""
__tablename__ = "tb_login_event"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), index=True)
event_type = Column(String(40), nullable=False) # LOGIN_SUCCESS | LOGIN_FAIL | DEVICE_ADDED | ...
detail = Column(Text)
ip_addr_hash= Column(String(64)) # SHA-256, 원본 미저장
created_at = Column(DateTime, default=func.now(), index=True)

View File

@ -34,6 +34,66 @@ async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, de
))
class DelegateByUsernameRequest(BaseModel):
delegate_to: str # 대리 결재자 username
from_date: str # ISO date
to_date: str # ISO date
reason: Optional[str] = None
@router.post("/delegate")
async def delegate_global(
body: DelegateByUsernameRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""기간 기반 대리 결재 위임 — 현재 사용자의 대기 결재를 일괄 위임 (#65).
주의: 라우트는 POST /{sr_id} 보다 먼저 정의되어야 'delegate'
sr_id 경로 변수로 잘못 매칭되지 않는다.
"""
try:
from_dt = datetime.fromisoformat(body.from_date)
to_dt = datetime.fromisoformat(body.to_date)
except ValueError:
raise HTTPException(422, "from_date / to_date는 ISO 날짜 형식이어야 합니다.")
if to_dt < from_dt:
raise HTTPException(422, "to_date는 from_date 이후여야 합니다.")
delegate_user = (await db.execute(
select(User).where(User.username == body.delegate_to)
)).scalars().first()
if not delegate_user:
raise HTTPException(404, f"대리 결재자 '{body.delegate_to}'를 찾을 수 없습니다.")
pending = (await db.execute(
select(ApprovalFlow).where(
ApprovalFlow.approver == current_user.username,
ApprovalFlow.result == ApprovalResult.PENDING,
)
)).scalars().all()
delegated = 0
for apv in pending:
apv.delegate_to = delegate_user.id
apv.delegate_until = to_dt
delegated += 1
if pending:
await _write_audit(
db, pending[0].sr_id, current_user.username, "APPROVAL_DELEGATED_BULK",
f"대리 결재 → {delegate_user.username} ({body.from_date}~{body.to_date}) "
f"| {delegated}건 | 사유: {body.reason or ''}"
)
await db.commit()
return {
"delegate_to": delegate_user.username,
"from_date": body.from_date,
"to_date": body.to_date,
"delegated_count": delegated,
"message": f"{delegated}건의 결재가 위임되었습니다.",
}
@router.get("/{sr_id}", response_model=List[ApprovalOut])
async def list_approvals(sr_id: str, db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
@ -245,3 +305,99 @@ async def extend_deadline(
"new_deadline": body.new_deadline,
"message": "승인 마감이 연장되었습니다.",
}
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능: 다단계 승인 현황 ────────────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
@router.get("/{approval_id}/stages")
async def approval_stages(
approval_id: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""다단계 승인 현황 — 동일 SR의 모든 승인 단계를 순서대로 반환 (#67)."""
apv = await db.get(ApprovalFlow, approval_id)
if not apv:
raise HTTPException(404, "승인 레코드를 찾을 수 없습니다.")
rows = (await db.execute(
select(ApprovalFlow).where(ApprovalFlow.sr_id == apv.sr_id)
.order_by(ApprovalFlow.created_at, ApprovalFlow.id)
)).scalars().all()
stages = []
for level, r in enumerate(rows, start=1):
stages.append({
"level": level,
"approval_id": r.id,
"approver": r.approver,
"status": r.result,
"approved_at": r.decided_at.isoformat() if r.decided_at else None,
"delegate_to": r.delegate_to,
"signed": bool(r.signature),
})
return {"sr_id": apv.sr_id, "stages": stages, "total_stages": len(stages)}
# ════════════════════════════════════════════════════════════════════════════════
# ── 변경 달력 (#68) — /api/changes/calendar ─────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
changes_router = APIRouter(prefix="/api/changes", tags=["changes"])
@changes_router.get("/calendar")
async def changes_calendar(
month: str, # YYYY-MM
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 달력 — 해당 월 날짜별 변경(SR) 목록 (#68)."""
try:
year, mon = month.split("-")
year_i, mon_i = int(year), int(mon)
if not (1 <= mon_i <= 12):
raise ValueError()
except (ValueError, AttributeError):
raise HTTPException(422, "month는 YYYY-MM 형식이어야 합니다.")
from datetime import date as _date
start = _date(year_i, mon_i, 1)
end = _date(year_i + (1 if mon_i == 12 else 0),
1 if mon_i == 12 else mon_i + 1, 1)
start_dt = datetime(start.year, start.month, start.day)
end_dt = datetime(end.year, end.month, end.day)
# CHANGE / DEPLOY 유형 SR을 변경으로 취급, created_at 기준 그룹핑
from models import SRType
q = select(SRRequest).where(
SRRequest.created_at >= start_dt,
SRRequest.created_at < end_dt,
)
# CUSTOMER 기관 필터
from models import UserRole
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
from models import Institution as _Inst
inst = (await db.execute(
select(_Inst).where(_Inst.inst_code == current_user.inst_code)
)).scalars().first()
q = q.where(SRRequest.inst_id == (inst.id if inst else -1))
srs = (await db.execute(q.order_by(SRRequest.created_at))).scalars().all()
by_date: dict = {}
for s in srs:
if not s.created_at:
continue
key = s.created_at.date().isoformat()
by_date.setdefault(key, []).append({
"sr_id": s.sr_id,
"title": s.title,
"sr_type": s.sr_type,
"status": s.status,
"priority": s.priority,
})
return {"month": month, "calendar": by_date, "total": len(srs)}

View File

@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import (create_access_token, get_current_user, hash_password,
verify_password, MAX_FAILED_ATTEMPTS, LOCKOUT_MINUTES)
from database import get_db
from models import User
from models import User, UserDevice, LoginEvent, Institution
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -453,6 +453,158 @@ async def admin_user_lock_status(
}
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능: 디바이스 / 보안 이벤트 / 네트워크 상태 / 기관 전환 ───────────
# ════════════════════════════════════════════════════════════════════════════════
class DeviceRegister(BaseModel):
device_name: Optional[str] = None
device_type: Optional[str] = None # android | ios | web
push_token: Optional[str] = None
class SwitchTenantRequest(BaseModel):
tenant_id: str # 전환 대상 inst_code
@router.get("/devices")
async def list_devices(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""등록 디바이스 목록 (#33)."""
rows = (await db.execute(
select(UserDevice).where(UserDevice.username == current_user.username)
.order_by(UserDevice.last_seen_at.desc())
)).scalars().all()
return [
{
"id": d.id,
"device_name": d.device_name,
"device_type": d.device_type,
"last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None,
"created_at": d.created_at.isoformat() if d.created_at else None,
}
for d in rows
]
@router.post("/devices", status_code=201)
async def register_device(
body: DeviceRegister,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""디바이스 등록/갱신 (#33)."""
dev = UserDevice(
username=current_user.username,
device_name=body.device_name,
device_type=body.device_type,
push_token=body.push_token,
last_seen_at=datetime.now(),
)
db.add(dev)
db.add(LoginEvent(username=current_user.username, event_type="DEVICE_ADDED",
detail=f"{body.device_type or '?'} / {body.device_name or '?'}"))
await db.commit()
await db.refresh(dev)
return {"id": dev.id, "message": "디바이스가 등록되었습니다."}
@router.delete("/devices/{device_id}", status_code=204)
async def unregister_device(
device_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""디바이스 등록 해제 (#33)."""
dev = await db.get(UserDevice, device_id)
if not dev or dev.username != current_user.username:
raise HTTPException(404, "디바이스를 찾을 수 없습니다.")
await db.delete(dev)
db.add(LoginEvent(username=current_user.username, event_type="DEVICE_REMOVED",
detail=f"device_id={device_id}"))
await db.commit()
@router.get("/events")
async def list_security_events(
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""보안 이벤트 로그 — 로그인 이력/실패/디바이스 변경 등 (#34)."""
rows = (await db.execute(
select(LoginEvent).where(LoginEvent.username == current_user.username)
.order_by(LoginEvent.created_at.desc())
.limit(min(limit, 200))
)).scalars().all()
return [
{
"id": e.id,
"event_type": e.event_type,
"detail": e.detail,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in rows
]
@router.get("/network-status")
async def network_status(
current_user: User = Depends(get_current_user),
):
"""접속 경로 상태 — VPN / 개방망 / 내부망 (#37)."""
import os as _os
mode = _os.environ.get("GUARDIA_NETWORK_MODE", "internal")
if mode == "open":
via, level = "opennet", 2
elif mode == "vpn":
via, level = "vpn", 2
else:
via, level = "internal", 3 # 내부망이 가장 신뢰 수준 높음
return {
"via": via,
"level": level,
"username": current_user.username,
"checked_at": datetime.now().isoformat(),
}
@router.post("/switch-tenant")
async def switch_tenant(
body: SwitchTenantRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""기관 전환 — 새 inst_code로 전환 후 새 JWT 발급 (#33/멀티기관)."""
inst = (await db.execute(
select(Institution).where(Institution.inst_code == body.tenant_id)
)).scalars().first()
if not inst:
raise HTTPException(404, "전환 대상 기관을 찾을 수 없습니다.")
# CUSTOMER는 자기 기관으로만 제한, ADMIN/PM/ENGINEER는 자유 전환
from models import UserRole
if current_user.role == UserRole.CUSTOMER and current_user.inst_code != body.tenant_id:
raise HTTPException(403, "해당 기관으로 전환할 권한이 없습니다.")
token = create_access_token({
"sub": current_user.username,
"role": current_user.role,
"tenant": body.tenant_id,
})
db.add(LoginEvent(username=current_user.username, event_type="TENANT_SWITCH",
detail=f"{body.tenant_id}"))
await db.commit()
return {
"access_token": token,
"token_type": "bearer",
"tenant_id": body.tenant_id,
"inst_name": inst.inst_name,
}
# ── OAuth2 소셜 로그인 ────────────────────────────────────────────────────────
@router.get("/oauth/providers")

View File

@ -13,7 +13,8 @@ from core.events import broadcast
from database import get_db
from models import (
AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus,
SRStatusUpdate, SRType, User, compute_log_hash
SRStatusUpdate, SRType, User, compute_log_hash,
SRSubscription, SRRating, SRSignature, SRCheckin,
)
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@ -507,3 +508,301 @@ async def bulk_sr_action(
"results": results,
}
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능: SR 액션 + 통계 ──────────────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
class EscalateRequest(BaseModel):
reason: Optional[str] = None
escalate_to: Optional[str] = None # 미지정 시 온콜 에스컬레이션 체인 사용
class RatingRequest(BaseModel):
score: int
comment: Optional[str] = None
class SignatureRequest(BaseModel):
signature_base64: str
class SlaExceptionRequest(BaseModel):
reason: str
new_deadline: str # ISO datetime/date
class CheckinRequest(BaseModel):
lat: float
lng: float
async def _get_sr(sr_id: str, db: AsyncSession) -> SRRequest:
sr = (await db.execute(
select(SRRequest).where(SRRequest.sr_id == sr_id)
)).scalars().first()
if not sr:
raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
return sr
@router.post("/{sr_id}/escalate")
async def escalate_task(
sr_id: str,
payload: EscalateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR 에스컬레이션 — 상위 담당자로 재배정 + 알림 (#8)."""
from models import UserRole
if current_user.role == UserRole.CUSTOMER:
raise HTTPException(403, "에스컬레이션 권한이 없습니다.")
sr = await _get_sr(sr_id, db)
target = payload.escalate_to
if not target:
# 온콜 에스컬레이션 체인에서 대상 도출 시도
try:
from core.oncall_rotate import get_current_oncall
sched = await get_current_oncall(db)
if sched:
target = sched.escalation_to or sched.backup_engineer or sched.engineer
except Exception:
target = None
sr.escalated_at = datetime.now()
sr.escalated_to = target
if target:
sr.assigned_to = target
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, current_user.username, "SR_ESCALATED",
f"에스컬레이션 → {target or '미정'} | 사유: {payload.reason or ''}")
await db.commit()
await broadcast("sla_escalated", {
"sr_id": sr_id, "escalated_to": target, "by": current_user.username,
})
return {
"sr_id": sr_id,
"escalated_to": target,
"escalated_at": sr.escalated_at.isoformat(),
"message": f"'{target or '대상 미정'}'(으)로 에스컬레이션되었습니다.",
}
@router.post("/{sr_id}/subscribe")
async def toggle_subscribe(
sr_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR 구독/팔로우 토글 (#14)."""
await _get_sr(sr_id, db)
existing = (await db.execute(
select(SRSubscription).where(
SRSubscription.task_id == sr_id,
SRSubscription.username == current_user.username,
)
)).scalars().first()
if existing:
await db.delete(existing)
await db.commit()
return {"sr_id": sr_id, "subscribed": False, "message": "구독을 해제했습니다."}
sub = SRSubscription(task_id=sr_id, username=current_user.username)
db.add(sub)
await db.commit()
return {"sr_id": sr_id, "subscribed": True, "message": "구독했습니다."}
@router.post("/{sr_id}/rating", status_code=201)
async def rate_task(
sr_id: str,
payload: RatingRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""완료 SR 만족도 평가 (#53)."""
if not (1 <= payload.score <= 5):
raise HTTPException(422, "score는 1~5 범위여야 합니다.")
sr = await _get_sr(sr_id, db)
if sr.status != SRStatus.COMPLETED:
raise HTTPException(400, "완료된 SR만 평가할 수 있습니다.")
rating = SRRating(
task_id=sr_id, rater=current_user.username,
score=payload.score, comment=payload.comment,
)
db.add(rating)
await _write_audit(db, sr_id, current_user.username, "SR_RATED",
f"만족도 {payload.score}")
await db.commit()
await db.refresh(rating)
return {"sr_id": sr_id, "score": payload.score, "rating_id": rating.id}
@router.post("/{sr_id}/signature", status_code=201)
async def save_signature(
sr_id: str,
payload: SignatureRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""현장 전자서명 저장 (#70)."""
if not payload.signature_base64 or len(payload.signature_base64) < 10:
raise HTTPException(422, "signature_base64 데이터가 올바르지 않습니다.")
await _get_sr(sr_id, db)
sig = SRSignature(
task_id=sr_id, signed_by=current_user.username,
signature_b64=payload.signature_base64,
)
db.add(sig)
await _write_audit(db, sr_id, current_user.username, "SR_SIGNED", "현장 서명 등록")
await db.commit()
await db.refresh(sig)
return {"sr_id": sr_id, "signature_id": sig.id, "signed_by": current_user.username}
@router.post("/{sr_id}/sla-exception")
async def request_sla_exception(
sr_id: str,
payload: SlaExceptionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SLA 예외 승인 요청 — SLA 마감 연장 요청 (#94)."""
sr = await _get_sr(sr_id, db)
try:
new_dl = datetime.fromisoformat(payload.new_deadline)
except ValueError:
raise HTTPException(422, "new_deadline은 ISO 날짜/시간 형식이어야 합니다.")
old_dl = sr.sla_deadline
sr.sla_deadline = new_dl
sr.sla_breached = False
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, current_user.username, "SLA_EXCEPTION_REQUESTED",
f"SLA 마감 {old_dl}{new_dl} | 사유: {payload.reason}")
await db.commit()
return {
"sr_id": sr_id,
"old_deadline": old_dl.isoformat() if old_dl else None,
"new_deadline": new_dl.isoformat(),
"reason": payload.reason,
"message": "SLA 예외(마감 연장)가 적용되었습니다.",
}
@router.post("/{sr_id}/checkin", status_code=201)
async def checkin_task(
sr_id: str,
payload: CheckinRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""현장 체크인 — GPS 좌표 기록 (#93)."""
if not (-90 <= payload.lat <= 90) or not (-180 <= payload.lng <= 180):
raise HTTPException(422, "좌표 값이 올바르지 않습니다.")
await _get_sr(sr_id, db)
chk = SRCheckin(
task_id=sr_id, username=current_user.username,
lat=payload.lat, lng=payload.lng,
)
db.add(chk)
await _write_audit(db, sr_id, current_user.username, "SR_CHECKIN",
f"현장 체크인 ({payload.lat}, {payload.lng})")
await db.commit()
await db.refresh(chk)
return {
"sr_id": sr_id,
"checkin_id": chk.id,
"lat": payload.lat,
"lng": payload.lng,
"checked_in_at": chk.created_at.isoformat() if chk.created_at else None,
}
@router.get("/stats/mine")
async def my_sr_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""내 SR 처리 통계 — total / closed / avg_resolve_hours / sla_met_rate (#3)."""
rows = (await db.execute(
select(SRRequest).where(SRRequest.assigned_to == current_user.username)
)).scalars().all()
total = len(rows)
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
closed_rows = [r for r in rows if r.status in terminal]
closed = len(closed_rows)
# 평균 해결 시간 (완료 건의 created_at→updated_at)
completed = [r for r in rows if r.status == SRStatus.COMPLETED
and r.created_at and r.updated_at]
avg_hours = 0.0
if completed:
secs = sum((r.updated_at - r.created_at).total_seconds() for r in completed)
avg_hours = round(secs / len(completed) / 3600, 2)
# SLA 준수율 (마감 시각이 있는 건 중 미위반 비율)
sla_rows = [r for r in rows if r.sla_deadline is not None]
sla_met = len([r for r in sla_rows if not r.sla_breached])
sla_met_rate = round(sla_met / len(sla_rows) * 100, 1) if sla_rows else 100.0
return {
"username": current_user.username,
"total": total,
"closed": closed,
"open": total - closed,
"avg_resolve_hours": avg_hours,
"sla_met_rate": sla_met_rate,
}
@router.get("/stats/by-institution")
async def stats_by_institution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""기관별 SR 현황 비교 (#4). CUSTOMER는 자기 기관만."""
from models import UserRole
q = select(SRRequest)
cust_inst_id = None
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
inst = (await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)).scalars().first()
cust_inst_id = inst.id if inst else -1
q = q.where(SRRequest.inst_id == cust_inst_id)
srs = (await db.execute(q)).scalars().all()
# 기관 이름 매핑
insts = (await db.execute(select(Institution))).scalars().all()
name_map = {i.id: i.inst_name for i in insts}
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
agg: dict = {}
for s in srs:
key = s.inst_id
bucket = agg.setdefault(key, {
"inst_id": key,
"inst_name": name_map.get(key, "미지정"),
"total": 0, "closed": 0, "open": 0, "sla_breached": 0,
})
bucket["total"] += 1
if s.status in terminal:
bucket["closed"] += 1
else:
bucket["open"] += 1
if s.sla_breached:
bucket["sla_breached"] += 1
result = sorted(agg.values(), key=lambda x: x["total"], reverse=True)
return {"institutions": result, "count": len(result)}