494 lines
20 KiB
Python
494 lines
20 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc, text, func
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
import os, uuid
|
|
from database import get_db
|
|
import models
|
|
from auth import require_admin, get_current_user, get_optional_user
|
|
from utils import save_upload, UPLOAD_DIR
|
|
|
|
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
|
|
|
def _fmt_report(r: models.Report, db: Session):
|
|
c = r.charger
|
|
repair_id = None
|
|
mechanic_name = None
|
|
mechanic_company = None
|
|
if r.repair_links:
|
|
repair_id = r.repair_links[0].repair_id
|
|
rep = r.repair_links[0].repair
|
|
if rep and rep.mechanic:
|
|
mechanic_name = rep.mechanic.name
|
|
mechanic_company = rep.mechanic.company
|
|
return {
|
|
"id": r.id, "charger_id": r.charger_id,
|
|
"charger_name": c.name if c else None,
|
|
"station_name": c.station_name if c else None,
|
|
"cpo_name": c.cpo_name if c else None,
|
|
"charger_type": c.charger_type.name if c and c.charger_type else None,
|
|
"installed_at": str(c.installed_at) if c and c.installed_at else None,
|
|
"issue_types": r.issue_types, "issue_detail": r.issue_detail,
|
|
"error_code": r.error_code, "contact": r.contact,
|
|
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
|
|
"reported_at": r.reported_at.isoformat() if r.reported_at else None,
|
|
"gps_lat": r.gps_lat, "gps_lng": r.gps_lng,
|
|
"charger_lat": c.gps_lat if c else None,
|
|
"charger_lng": c.gps_lng if c else None,
|
|
"location_detail": c.location_detail if c else None,
|
|
"status": r.status,
|
|
"ocpp_log": r.ocpp_log,
|
|
"source": r.source or "qr",
|
|
"reported_by_name": r.reporter.name if r.reporter else None,
|
|
"photos": [{"id": p.id, "path": p.file_path} for p in r.photos],
|
|
"repair_id": repair_id,
|
|
"mechanic_name": mechanic_name,
|
|
"mechanic_company": mechanic_company,
|
|
"closure_type": r.closure_type,
|
|
"closure_note": r.closure_note,
|
|
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
|
"closed_by_name": r.closer.name if r.closer else None,
|
|
"re_dispatch_count": r.re_dispatch_count or 0,
|
|
"report_scope": r.report_scope or "single",
|
|
"scope_charger_count": r.scope_charger_count or 1,
|
|
"charger_ids": r.charger_ids or [],
|
|
}
|
|
|
|
@router.post("")
|
|
async def create_report(
|
|
charger_id: str = Form(...),
|
|
issue_types: str = Form(...), # JSON 배열 문자열
|
|
issue_detail: str = Form(""),
|
|
error_code: str = Form(""),
|
|
occurred_at: Optional[str] = Form(None),
|
|
contact: str = Form(""),
|
|
consent: bool = Form(False),
|
|
gps_lat: Optional[float] = Form(None),
|
|
gps_lng: Optional[float] = Form(None),
|
|
ocpp_log: Optional[str] = Form(None),
|
|
source: Optional[str] = Form(None),
|
|
photos: List[UploadFile] = File(default=[]),
|
|
db: Session = Depends(get_db),
|
|
current_user: Optional[models.User] = Depends(get_optional_user)
|
|
):
|
|
import json
|
|
charger = db.query(models.Charger).filter_by(id=charger_id).first()
|
|
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
|
|
|
# 신고 공개 정책 확인
|
|
setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first()
|
|
policy = setting.value if setting else "immediate"
|
|
initial_status = "pending_approval" if policy == "admin_approval" else "pending"
|
|
|
|
if current_user:
|
|
source_value = source if source in ("admin", "dashboard") else "admin"
|
|
else:
|
|
source_value = "qr"
|
|
|
|
issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types
|
|
r = models.Report(
|
|
charger_id=charger_id, issue_types=issue_list,
|
|
issue_detail=issue_detail or None, error_code=error_code or None,
|
|
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
|
|
contact=contact or None, consent=consent,
|
|
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status,
|
|
ocpp_log=ocpp_log or None,
|
|
source=source_value,
|
|
reported_by=current_user.id if current_user else None,
|
|
)
|
|
db.add(r); db.commit(); db.refresh(r)
|
|
|
|
for photo in photos:
|
|
if photo.filename:
|
|
path = save_upload(photo, f"reports/{r.id}")
|
|
db.add(models.ReportPhoto(report_id=r.id, file_path=path))
|
|
db.commit()
|
|
return {"id": r.id, "status": r.status}
|
|
|
|
@router.post("/batch")
|
|
async def create_batch_report(
|
|
charger_id: str = Form(...),
|
|
scope: str = Form("single"), # single | station | type | multi
|
|
charger_ids: Optional[str] = Form(None), # JSON: ["id1","id2",...] for multi scope
|
|
issue_types: str = Form(...),
|
|
issue_detail: str = Form(""),
|
|
error_code: str = Form(""),
|
|
occurred_at: Optional[str] = Form(None),
|
|
contact: str = Form(""),
|
|
consent: bool = Form(False),
|
|
gps_lat: Optional[float] = Form(None),
|
|
gps_lng: Optional[float] = Form(None),
|
|
ocpp_log: Optional[str] = Form(None),
|
|
source: Optional[str] = Form(None),
|
|
photos: List[UploadFile] = File(default=[]),
|
|
db: Session = Depends(get_db),
|
|
current_user: Optional[models.User] = Depends(get_optional_user)
|
|
):
|
|
import json
|
|
charger = db.query(models.Charger).filter_by(id=charger_id).first()
|
|
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
|
|
|
selected_ids = None
|
|
if scope == "multi" and charger_ids:
|
|
selected_ids = json.loads(charger_ids)
|
|
all_targets = [charger]
|
|
report_scope = "multi"
|
|
scope_charger_count = len(selected_ids)
|
|
elif scope == "station":
|
|
all_targets = db.query(models.Charger).filter_by(
|
|
station_name=charger.station_name, is_active=True).all()
|
|
report_scope = "station"
|
|
scope_charger_count = len(all_targets)
|
|
elif scope == "type" and charger.charger_type_id:
|
|
all_targets = db.query(models.Charger).filter_by(
|
|
charger_type_id=charger.charger_type_id, is_active=True).all()
|
|
report_scope = "type"
|
|
scope_charger_count = len(all_targets)
|
|
else:
|
|
all_targets = [charger]
|
|
report_scope = "single"
|
|
scope_charger_count = 1
|
|
|
|
targets = [charger] # 항상 단일 신고 생성
|
|
|
|
setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first()
|
|
policy = setting.value if setting else "immediate"
|
|
initial_status = "pending_approval" if policy == "admin_approval" else "pending"
|
|
issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types
|
|
|
|
if current_user:
|
|
source_value = source if source in ("admin", "dashboard") else "admin"
|
|
else:
|
|
source_value = "qr"
|
|
|
|
photo_data = []
|
|
for photo in photos:
|
|
if photo.filename:
|
|
photo_data.append((photo.filename, photo.file.read()))
|
|
|
|
created_ids = []
|
|
for target in targets:
|
|
r = models.Report(
|
|
charger_id=target.id, issue_types=issue_list,
|
|
issue_detail=issue_detail or None, error_code=error_code or None,
|
|
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
|
|
contact=contact or None, consent=consent,
|
|
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status,
|
|
ocpp_log=ocpp_log or None,
|
|
source=source_value,
|
|
reported_by=current_user.id if current_user else None,
|
|
report_scope=report_scope,
|
|
scope_charger_count=scope_charger_count,
|
|
charger_ids=selected_ids,
|
|
)
|
|
db.add(r); db.commit(); db.refresh(r)
|
|
|
|
for fname, content in photo_data:
|
|
ext = os.path.splitext(fname)[1].lower() or ".jpg"
|
|
sub = f"reports/{r.id}"
|
|
folder = os.path.join(UPLOAD_DIR, sub)
|
|
os.makedirs(folder, exist_ok=True)
|
|
dest = os.path.join(folder, f"{uuid.uuid4().hex}{ext}")
|
|
with open(dest, "wb") as f:
|
|
f.write(content)
|
|
db.add(models.ReportPhoto(report_id=r.id, file_path=f"/uploads/{sub}/{os.path.basename(dest)}"))
|
|
db.commit()
|
|
created_ids.append(r.id)
|
|
|
|
return {"ids": created_ids, "count": len(created_ids), "primary_id": created_ids[0] if created_ids else None}
|
|
|
|
@router.get("")
|
|
def list_reports(
|
|
status: Optional[str] = None,
|
|
charger_id: Optional[str] = None,
|
|
station_name: Optional[str] = None,
|
|
active_only: bool = False,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user)
|
|
):
|
|
# 전체 신고 기준 순번 (삭제 gap 없이, 오래된 것=1)
|
|
seq_subq = db.query(
|
|
models.Report.id.label("rid"),
|
|
func.row_number().over(
|
|
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
|
|
).label("seq")
|
|
).subquery()
|
|
|
|
q = (db.query(models.Report, seq_subq.c.seq)
|
|
.join(seq_subq, models.Report.id == seq_subq.c.rid)
|
|
.order_by(desc(models.Report.reported_at)))
|
|
if status == "pending_all":
|
|
q = q.filter(models.Report.status.in_(["pending", "pending_approval"]))
|
|
elif status:
|
|
q = q.filter(models.Report.status == status)
|
|
elif active_only:
|
|
q = q.filter(models.Report.status.in_(
|
|
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
|
|
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
|
|
if station_name:
|
|
q = (q.join(models.Charger, models.Report.charger_id == models.Charger.id, isouter=True)
|
|
.filter(models.Charger.station_name == station_name))
|
|
if current_user.role == "mechanic":
|
|
q = q.filter(models.Report.status != "pending_approval")
|
|
|
|
result = []
|
|
for r, seq in q.all():
|
|
fmt = _fmt_report(r, db)
|
|
fmt["seq"] = seq
|
|
result.append(fmt)
|
|
return result
|
|
|
|
@router.get("/{report_id}")
|
|
def get_report(report_id: int, db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user)):
|
|
r = db.query(models.Report).filter_by(id=report_id).first()
|
|
if not r: raise HTTPException(404)
|
|
result = _fmt_report(r, db)
|
|
result["re_dispatch_count"] = r.re_dispatch_count or 0
|
|
# 전체 기준 순번 계산
|
|
seq_subq = db.query(
|
|
models.Report.id.label("rid"),
|
|
func.row_number().over(
|
|
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
|
|
).label("seq")
|
|
).subquery()
|
|
row = db.query(seq_subq.c.seq).filter(seq_subq.c.rid == report_id).scalar()
|
|
result["seq"] = row
|
|
# 수리 정보 포함 — repair_links를 id 내림차순(최신 우선)으로 정렬
|
|
def _fmt_one_repair(repair, include_cost=True):
|
|
cost = repair.cost
|
|
return {
|
|
"id": repair.id,
|
|
"mechanic_name": repair.mechanic.name if repair.mechanic else None,
|
|
"mechanic_company": repair.mechanic.company if repair.mechanic else None,
|
|
"repair_types": repair.repair_types,
|
|
"description": repair.description,
|
|
"started_at": repair.started_at.isoformat(),
|
|
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
|
|
"result_status": repair.result_status,
|
|
"mechanic_lat": repair.mechanic_lat,
|
|
"mechanic_lng": repair.mechanic_lng,
|
|
"charger_lat": r.charger.gps_lat if r.charger else None,
|
|
"charger_lng": r.charger.gps_lng if r.charger else None,
|
|
"approved_at": repair.approved_at.isoformat() if repair.approved_at else None,
|
|
"approved_by_name": repair.approver.name if repair.approved_by and repair.approver else None,
|
|
"re_dispatch_requested": repair.re_dispatch_requested or False,
|
|
"re_dispatch_requested_at": repair.re_dispatch_requested_at.isoformat() if repair.re_dispatch_requested_at else None,
|
|
"photos_before": [p.file_path for p in repair.photos if p.photo_type == "before"],
|
|
"photos_after": [p.file_path for p in repair.photos if p.photo_type == "after"],
|
|
"cost": {
|
|
"root_cause": cost.root_cause,
|
|
"admin_note": cost.admin_note,
|
|
"cost_party_type": cost.cost_party_type,
|
|
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
|
|
"cost_party_custom": cost.cost_party_custom,
|
|
"cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None,
|
|
"recv_party_type": cost.recv_party_type,
|
|
"recv_party_manufacturer_id": cost.recv_party_manufacturer_id,
|
|
"recv_party_custom": cost.recv_party_custom,
|
|
"recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None,
|
|
"cost_amount": cost.cost_amount,
|
|
"cost_status": cost.cost_status,
|
|
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
|
|
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
|
|
} if cost else None,
|
|
"linked_improvements": _get_linked_improvements(repair, db) if include_cost else [],
|
|
}
|
|
|
|
if r.repair_links:
|
|
sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True)
|
|
result["repair"] = _fmt_one_repair(sorted_links[0].repair)
|
|
result["prev_repairs"] = [
|
|
_fmt_one_repair(link.repair, include_cost=True)
|
|
for link in sorted_links[1:]
|
|
if link.repair
|
|
]
|
|
return result
|
|
|
|
def _get_linked_improvements(repair, db):
|
|
rids = [lk.report_id for lk in repair.report_links]
|
|
if not rids:
|
|
return []
|
|
imp_links = db.query(models.ImprovementReport).filter(
|
|
models.ImprovementReport.report_id.in_(rids)
|
|
).all()
|
|
seen, result = set(), []
|
|
for il in imp_links:
|
|
if il.improvement_id not in seen:
|
|
seen.add(il.improvement_id)
|
|
imp = db.query(models.Improvement).filter_by(id=il.improvement_id).first()
|
|
if imp:
|
|
result.append({"id": imp.id, "title": imp.title,
|
|
"category": imp.category, "status": imp.status})
|
|
return result
|
|
|
|
@router.delete("/bulk")
|
|
def bulk_delete_reports(
|
|
ids: List[int] = Body(...),
|
|
db: Session = Depends(get_db),
|
|
_=Depends(require_admin)
|
|
):
|
|
if not ids:
|
|
raise HTTPException(400, "삭제할 항목을 선택하세요.")
|
|
db.execute(text("DELETE FROM repair_reports WHERE report_id = ANY(:ids)"), {"ids": ids})
|
|
result = db.execute(text("DELETE FROM reports WHERE id = ANY(:ids)"), {"ids": ids})
|
|
db.commit()
|
|
return {"deleted": result.rowcount}
|
|
|
|
@router.patch("/{report_id}")
|
|
async def update_report(
|
|
report_id: int,
|
|
charger_id: Optional[str] = Form(None),
|
|
scope: Optional[str] = Form(None),
|
|
issue_types: Optional[str] = Form(None),
|
|
issue_detail: Optional[str] = Form(None),
|
|
error_code: Optional[str] = Form(None),
|
|
contact: Optional[str] = Form(None),
|
|
occurred_at: Optional[str] = Form(None),
|
|
status: Optional[str] = Form(None),
|
|
ocpp_log: Optional[str] = Form(None),
|
|
photos: List[UploadFile] = File(default=[]),
|
|
db: Session = Depends(get_db),
|
|
_=Depends(require_admin)
|
|
):
|
|
import json
|
|
r = db.query(models.Report).filter_by(id=report_id).first()
|
|
if not r: raise HTTPException(404)
|
|
if charger_id is not None and charger_id.strip():
|
|
ch = db.query(models.Charger).filter_by(id=charger_id.strip()).first()
|
|
if not ch: raise HTTPException(400, "충전기를 찾을 수 없습니다")
|
|
r.charger_id = charger_id.strip()
|
|
if scope is not None and scope in ("single", "station", "type"):
|
|
ref = db.query(models.Charger).filter_by(id=r.charger_id).first()
|
|
if scope == "station" and ref:
|
|
count = db.query(models.Charger).filter_by(
|
|
station_name=ref.station_name, is_active=True).count()
|
|
elif scope == "type" and ref and ref.charger_type_id:
|
|
count = db.query(models.Charger).filter_by(
|
|
charger_type_id=ref.charger_type_id, is_active=True).count()
|
|
else:
|
|
count = 1
|
|
r.report_scope = scope
|
|
r.scope_charger_count = count
|
|
if scope != "multi":
|
|
r.charger_ids = None
|
|
if issue_types is not None:
|
|
r.issue_types = json.loads(issue_types)
|
|
if issue_detail is not None:
|
|
r.issue_detail = issue_detail or None
|
|
if error_code is not None:
|
|
r.error_code = error_code or None
|
|
if contact is not None:
|
|
r.contact = contact or None
|
|
if occurred_at is not None:
|
|
r.occurred_at = datetime.fromisoformat(occurred_at) if occurred_at else None
|
|
if status is not None:
|
|
r.status = status
|
|
if ocpp_log is not None:
|
|
r.ocpp_log = ocpp_log or None
|
|
db.commit()
|
|
for photo in photos:
|
|
if photo.filename:
|
|
path = save_upload(photo, f"reports/{report_id}")
|
|
db.add(models.ReportPhoto(report_id=report_id, file_path=path))
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
@router.delete("/{report_id}/photos/{photo_id}")
|
|
def delete_report_photo(
|
|
report_id: int, photo_id: int,
|
|
db: Session = Depends(get_db),
|
|
_=Depends(require_admin)
|
|
):
|
|
photo = db.query(models.ReportPhoto).filter_by(id=photo_id, report_id=report_id).first()
|
|
if not photo: raise HTTPException(404)
|
|
db.delete(photo)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
@router.patch("/{report_id}/approve")
|
|
def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
r = db.query(models.Report).filter_by(id=report_id).first()
|
|
if not r: raise HTTPException(404)
|
|
r.status = "pending"; db.commit()
|
|
return {"ok": True}
|
|
|
|
CLOSURE_TYPES = {"natural", "remote_reset", "false_alarm", "other"}
|
|
|
|
@router.patch("/{report_id}/close")
|
|
def close_report(
|
|
report_id: int,
|
|
closure_type: str = Form(...),
|
|
closure_note: str = Form(""),
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(require_admin)
|
|
):
|
|
if closure_type not in CLOSURE_TYPES:
|
|
raise HTTPException(400, "유효하지 않은 상황종료 사유입니다.")
|
|
r = db.query(models.Report).filter_by(id=report_id).first()
|
|
if not r: raise HTTPException(404)
|
|
from datetime import datetime
|
|
r.status = "closed"
|
|
r.closure_type = closure_type
|
|
r.closure_note = closure_note.strip() or None
|
|
r.closed_at = datetime.now()
|
|
r.closed_by = current_user.id
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
@router.patch("/{report_id}/status")
|
|
def update_status(report_id: int, status: str = Form(...),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
r = db.query(models.Report).filter_by(id=report_id).first()
|
|
if not r: raise HTTPException(404)
|
|
r.status = status; db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 공개 엔드포인트 — 인증 없이 특정 충전기의 진행 중 신고 조회 ──
|
|
# QR 신고 페이지에서 기존 접수 현황을 사용자에게 보여줄 때 사용
|
|
@router.get("/public/{charger_id}")
|
|
def public_charger_reports(charger_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
해당 충전기에서 아직 해결되지 않은 신고 목록을 반환.
|
|
완료(done) / 면제 · 정산 상태는 제외하고 진행 중인 것만 반환.
|
|
개인정보(연락처) 는 반환하지 않음.
|
|
"""
|
|
active_statuses = ["pending_approval", "pending", "in_progress", "waiting", "revisit"]
|
|
rows = (
|
|
db.query(models.Report)
|
|
.filter(
|
|
models.Report.charger_id == charger_id,
|
|
models.Report.status.in_(active_statuses),
|
|
)
|
|
.order_by(models.Report.reported_at.desc())
|
|
.limit(20)
|
|
.all()
|
|
)
|
|
|
|
STATUS_LABEL = {
|
|
"pending_approval": "검토 대기",
|
|
"pending": "접수 완료",
|
|
"in_progress": "처리 중",
|
|
"waiting": "부품 대기",
|
|
"revisit": "재방문 예정",
|
|
}
|
|
|
|
result = []
|
|
for r in rows:
|
|
repair = r.repair_links[0].repair if r.repair_links else None
|
|
result.append({
|
|
"id": r.id,
|
|
"issue_types": r.issue_types,
|
|
"issue_detail": r.issue_detail or "",
|
|
"status": r.status,
|
|
"status_label": STATUS_LABEL.get(r.status, r.status),
|
|
"reported_at": r.reported_at.isoformat() if r.reported_at else "",
|
|
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else "",
|
|
"photo_count": len(r.photos),
|
|
"mechanic_name": repair.mechanic.name if repair and repair.mechanic else None,
|
|
"started_at": repair.started_at.isoformat() if repair and repair.started_at else None,
|
|
})
|
|
return result
|