zioinfo-mail/workspace/guardia-itsm/routers/erp_connector.py
DESKTOP-TKLFCPR\ython fc0ba65e05 feat(expansion): GUARDiA v3 P3 완성 — 13 routers + 14 DB tables
라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py:      llava 이미지 분석 + 에러 자동 분류
- learning_loop.py:   Ollama 파인튜닝 + 품질 지표
- ai_insights.py:     주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py:          NCloud API (서버/LB/스토리지/비용)
- billing.py:         구독 플랜 + 사용량 측정 + 청구서
- servicenow.py:      ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py:   그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py:    카카오 알림톡 + 대량 발송
- auto_report.py:     Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py:       기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률

DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 06:06:59 +09:00

160 lines
6.3 KiB
Python

"""
ERP / 그룹웨어 연동 커넥터
기능:
- 그룹웨어 전자결재 연동 (결재 요청 → GUARDiA SR 생성)
- ERP HR 데이터 동기화 (사용자 조직 정보)
- 범용 REST API 커넥터 (설정 기반)
엔드포인트:
POST /api/erp/config — ERP 연동 설정
GET /api/erp/config — 설정 조회
POST /api/erp/test — 연결 테스트
POST /api/erp/webhook — ERP 웹훅 수신 (결재 알림)
POST /api/erp/sync-users — HR 사용자 동기화
GET /api/erp/org-chart — 조직도 조회
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, UserRole, SRRequest, SRStatus, ERPConfig # 신규
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/erp", tags=["ERP 연동"])
class ERPConfigCreate(BaseModel):
name: str = Field(..., max_length=100, description="시스템명 (예: 나라장터, 그룹웨어)")
base_url: str
api_key: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
erp_type: str = Field("generic", description="groupware | nara | hr | generic")
@router.post("/config")
async def save_erp_config(
req: ERPConfigCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
cfg = ERPConfig(
tenant_id=user.tenant_id, name=req.name, base_url=req.base_url,
api_key_enc=req.api_key, username=req.username, password_enc=req.password,
erp_type=req.erp_type, is_active=True, created_at=datetime.utcnow()
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return {"ok": True, "id": cfg.id}
@router.get("/config")
async def list_erp_configs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(ERPConfig).where(ERPConfig.tenant_id == user.tenant_id, ERPConfig.is_active == True))
cfgs = rows.scalars().all()
return [{"id": c.id, "name": c.name, "erp_type": c.erp_type, "base_url": c.base_url[:30] + "..."} for c in cfgs]
@router.post("/test/{config_id}")
async def test_erp(config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(ERPConfig).where(ERPConfig.id == config_id, ERPConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
try:
headers = {"Authorization": f"Bearer {cfg.api_key_enc}"} if cfg.api_key_enc else {}
async with httpx.AsyncClient(timeout=10, verify=False) as c:
r = await c.get(cfg.base_url, headers=headers)
return {"ok": r.status_code < 400, "status_code": r.status_code}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.post("/webhook")
async def erp_webhook(request: Request, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)):
"""ERP 웹훅 수신 — 결재 요청 → SR 자동 생성."""
body = await request.json()
event_type = body.get("event_type", "")
title = body.get("title") or body.get("subject") or "ERP 연동 요청"
description = body.get("description") or body.get("content") or json_to_str(body)
if event_type in ("APPROVAL_REQUEST", "WORK_ORDER", "MAINTENANCE_REQUEST"):
sr = SRRequest(
title=f"[ERP] {title[:100]}",
description=description[:1000],
category="ERP", priority="MEDIUM",
status=SRStatus.OPEN, created_at=datetime.utcnow(),
)
db.add(sr)
await db.commit()
return {"ok": True, "sr_id": sr.id}
return {"ok": True, "skipped": True}
@router.post("/sync-users/{config_id}")
async def sync_hr_users(
config_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""ERP HR → GUARDiA 사용자 동기화."""
row = await db.execute(select(ERPConfig).where(ERPConfig.id == config_id, ERPConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
try:
headers = {"Authorization": f"Bearer {cfg.api_key_enc}"} if cfg.api_key_enc else {}
async with httpx.AsyncClient(timeout=15, verify=False) as c:
r = await c.get(f"{cfg.base_url}/users", headers=headers)
if r.status_code != 200:
raise HTTPException(400, "HR API 응답 오류")
hr_users = r.json().get("users", r.json() if isinstance(r.json(), list) else [])
except Exception as e:
raise HTTPException(500, f"HR 연결 실패: {e}")
synced = 0
for hr_user in hr_users:
email = hr_user.get("email") or hr_user.get("mail")
name = hr_user.get("name") or hr_user.get("displayName")
if not email: continue
existing = await db.execute(select(User).where(User.email == email))
u = existing.scalar_one_or_none()
if u:
if name: u.name = name
synced += 1
await db.commit()
return {"ok": True, "synced": synced, "total_hr": len(hr_users)}
@router.get("/org-chart/{config_id}")
async def get_org_chart(
config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user),
):
row = await db.execute(select(ERPConfig).where(ERPConfig.id == config_id, ERPConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
try:
headers = {"Authorization": f"Bearer {cfg.api_key_enc}"} if cfg.api_key_enc else {}
async with httpx.AsyncClient(timeout=15, verify=False) as c:
r = await c.get(f"{cfg.base_url}/org-chart", headers=headers)
return r.json() if r.status_code == 200 else {"departments": []}
except Exception:
return {"departments": []}
def json_to_str(data: dict) -> str:
import json
return json.dumps(data, ensure_ascii=False)[:500]