1차완료
This commit is contained in:
@@ -542,22 +542,52 @@ def stats_top_chargers(limit: int = 10):
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/stats/charger-error-codes")
|
||||
def stats_charger_error_codes(code_limit: int = 10):
|
||||
"""에러코드별 누적 건수 Top N (단순 순위)."""
|
||||
@app.get("/api/stats/top-stations")
|
||||
def stats_top_stations(limit: int = 10):
|
||||
"""충전소별 누적 고장 신고 건수 Top N."""
|
||||
from database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(text("""
|
||||
SELECT TRIM(error_code) AS error_code, COUNT(*) AS cnt
|
||||
SELECT COALESCE(c.station_name, rep.charger_id) AS station_name,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE rep.status = 'done') AS done_cnt,
|
||||
COUNT(*) FILTER (WHERE rep.status != 'done') AS active_cnt
|
||||
FROM reports rep
|
||||
LEFT JOIN chargers c ON c.id = rep.charger_id
|
||||
GROUP BY COALESCE(c.station_name, rep.charger_id)
|
||||
ORDER BY total DESC
|
||||
LIMIT :lim
|
||||
"""), {"lim": limit}).fetchall()
|
||||
return [
|
||||
{
|
||||
"station_name": row[0],
|
||||
"total": int(row[1]),
|
||||
"done": int(row[2]),
|
||||
"active": int(row[3]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/stats/charger-error-codes")
|
||||
def stats_charger_error_codes(code_limit: int = 10):
|
||||
"""에러코드별 누적 건수 Top N (에러코드 없음 포함)."""
|
||||
from database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(text("""
|
||||
SELECT COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음') AS error_code,
|
||||
COUNT(*) AS cnt
|
||||
FROM reports
|
||||
WHERE error_code IS NOT NULL AND TRIM(error_code) != ''
|
||||
GROUP BY TRIM(error_code)
|
||||
GROUP BY COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음')
|
||||
ORDER BY cnt DESC
|
||||
LIMIT :limit
|
||||
"""), {"limit": code_limit}).fetchall()
|
||||
# 역순: 차트 Y축에서 1위가 맨 위
|
||||
result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)]
|
||||
return {"error_codes": result}
|
||||
finally:
|
||||
|
||||
@@ -64,6 +64,9 @@ class Report(Base):
|
||||
closed_at = Column(TIMESTAMP)
|
||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
re_dispatch_count = Column(Integer, default=0)
|
||||
report_scope = Column(String(20), default="single") # single | station | type | multi
|
||||
scope_charger_count = Column(Integer, default=1)
|
||||
charger_ids = Column(ARRAY(Text), nullable=True) # multi 범위일 때 선택된 충전기 ID 목록
|
||||
charger = relationship("Charger", back_populates="reports")
|
||||
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
|
||||
repair_links = relationship("RepairReport", back_populates="report")
|
||||
@@ -121,16 +124,20 @@ class RepairCost(Base):
|
||||
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True)
|
||||
root_cause = Column(Text)
|
||||
admin_note = Column(Text)
|
||||
cost_party_type = Column(String(20))
|
||||
cost_party_manufacturer_id = Column(Integer, ForeignKey("users.id"))
|
||||
cost_party_custom = Column(String(100))
|
||||
cost_party_type = Column(String(50))
|
||||
cost_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||
cost_party_custom = Column(String(200))
|
||||
recv_party_type = Column(String(50))
|
||||
recv_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||
recv_party_custom = Column(String(200))
|
||||
cost_amount = Column(Integer, default=0)
|
||||
cost_status = Column(String(20), default="pending")
|
||||
reviewed_by = Column(Integer, ForeignKey("users.id"))
|
||||
reviewed_at = Column(TIMESTAMP)
|
||||
repair = relationship("Repair", back_populates="cost")
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||
manufacturer = relationship("User", foreign_keys=[cost_party_manufacturer_id])
|
||||
cost_manufacturer = relationship("Manufacturer", foreign_keys=[cost_party_manufacturer_id])
|
||||
recv_manufacturer = relationship("Manufacturer", foreign_keys=[recv_party_manufacturer_id])
|
||||
|
||||
class Improvement(Base):
|
||||
__tablename__ = "improvements"
|
||||
@@ -141,13 +148,13 @@ class Improvement(Base):
|
||||
priority = Column(String(10), default="normal")
|
||||
part_name = Column(String(100))
|
||||
status = Column(String(20), default="registered")
|
||||
manufacturer_id = Column(Integer, ForeignKey("users.id"))
|
||||
manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
sw_deploy_target = Column(Date)
|
||||
sw_deployed_at = Column(Date)
|
||||
manufacturer_memo = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
manufacturer = relationship("User", foreign_keys=[manufacturer_id])
|
||||
manufacturer = relationship("Manufacturer", foreign_keys=[manufacturer_id])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan")
|
||||
attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan")
|
||||
|
||||
@@ -40,9 +40,14 @@ def list_costs(
|
||||
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
|
||||
"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,
|
||||
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
|
||||
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
|
||||
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
|
||||
})
|
||||
@@ -79,6 +84,9 @@ def upsert_cost(
|
||||
cost_party_type: str = Form(...),
|
||||
cost_party_manufacturer_id: Optional[int] = Form(None),
|
||||
cost_party_custom: str = Form(""),
|
||||
recv_party_type: str = Form(""),
|
||||
recv_party_manufacturer_id: Optional[int] = Form(None),
|
||||
recv_party_custom: str = Form(""),
|
||||
cost_amount: int = Form(0),
|
||||
cost_status: str = Form("pending"),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -93,6 +101,9 @@ def upsert_cost(
|
||||
cost.cost_party_type = cost_party_type
|
||||
cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None
|
||||
cost.cost_party_custom = cost_party_custom or None
|
||||
cost.recv_party_type = recv_party_type or None
|
||||
cost.recv_party_manufacturer_id = recv_party_manufacturer_id or None
|
||||
cost.recv_party_custom = recv_party_custom or None
|
||||
cost.cost_amount = cost_amount; cost.cost_status = cost_status
|
||||
cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now()
|
||||
else:
|
||||
@@ -101,6 +112,9 @@ def upsert_cost(
|
||||
cost_party_type=cost_party_type,
|
||||
cost_party_manufacturer_id=cost_party_manufacturer_id or None,
|
||||
cost_party_custom=cost_party_custom or None,
|
||||
recv_party_type=recv_party_type or None,
|
||||
recv_party_manufacturer_id=recv_party_manufacturer_id or None,
|
||||
recv_party_custom=recv_party_custom or None,
|
||||
cost_amount=cost_amount, cost_status=cost_status,
|
||||
reviewed_by=current_user.id, reviewed_at=datetime.now()
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
from typing import Optional
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from database import get_db
|
||||
@@ -13,19 +14,29 @@ from auth import require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
|
||||
NAVY = "0B1E3D"
|
||||
LIGHT = "D6EAF8"
|
||||
NAVY = "0B1E3D"
|
||||
LIGHT = "D6EAF8"
|
||||
GREEN = "1B5E20"
|
||||
ORANGE = "E65100"
|
||||
PURPLE = "4A148C"
|
||||
TEAL = "004D40"
|
||||
|
||||
def style_header(ws, headers, row=1):
|
||||
|
||||
def _hdr_cell(ws, row, col, value, color=NAVY):
|
||||
bd = Side(style="thin", color="AAAAAA")
|
||||
c = ws.cell(row=row, column=col, value=value)
|
||||
c.font = Font(bold=True, color="FFFFFF", size=10)
|
||||
c.fill = PatternFill("solid", fgColor=color)
|
||||
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
c.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||
|
||||
|
||||
def style_header(ws, headers, row=1, color=NAVY):
|
||||
for col, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=h)
|
||||
cell.font = Font(bold=True, color="FFFFFF", size=11)
|
||||
cell.fill = PatternFill("solid", fgColor=NAVY)
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||
_hdr_cell(ws, row, col, h, color)
|
||||
ws.row_dimensions[row].height = 20
|
||||
|
||||
|
||||
def style_row(ws, row_num, num_cols, even=True):
|
||||
bd = Side(style="thin", color="DDDDDD")
|
||||
for col in range(1, num_cols + 1):
|
||||
@@ -35,51 +46,357 @@ def style_row(ws, row_num, num_cols, even=True):
|
||||
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
|
||||
|
||||
def fmt_dt(dt):
|
||||
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
||||
|
||||
|
||||
def fmt_d(d):
|
||||
return str(d) if d else ""
|
||||
|
||||
|
||||
def elapsed(start, end):
|
||||
if not start or not end: return ""
|
||||
diff = end - start
|
||||
total = int(diff.total_seconds())
|
||||
if total < 0: return ""
|
||||
h, m = divmod(total // 60, 60)
|
||||
return f"{h}시간 {m}분"
|
||||
|
||||
|
||||
def set_col_widths(ws, widths):
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
|
||||
|
||||
def make_response(wb: openpyxl.Workbook, korean_name: str) -> StreamingResponse:
|
||||
"""엑셀 파일을 StreamingResponse로 반환 — 한글 파일명 URL 인코딩 처리"""
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
date_str = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
filename = f"{korean_name}_{date_str}.xlsx"
|
||||
encoded = quote(filename, safe="") # 한글 URL 인코딩
|
||||
cd_header = f"attachment; filename*=UTF-8''{encoded}"
|
||||
date_str = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
filename = f"{korean_name}_{date_str}.xlsx"
|
||||
encoded = quote(filename, safe="")
|
||||
cd = f"attachment; filename*=UTF-8''{encoded}"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": cd_header},
|
||||
headers={"Content-Disposition": cd},
|
||||
)
|
||||
|
||||
|
||||
def _parse_dates(date_from, date_to):
|
||||
try:
|
||||
dt_from = datetime.strptime(date_from, "%Y-%m-%d") if date_from else None
|
||||
dt_to = (datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)) if date_to else None
|
||||
except ValueError:
|
||||
raise HTTPException(400, "날짜 형식 오류 (YYYY-MM-DD)")
|
||||
return dt_from, dt_to
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. AS 신고 목록
|
||||
# 시트 빌더 — 공용
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CLOSURE_LABEL = {
|
||||
"natural": "증상자연소거",
|
||||
"remote_reset": "원격리셋후증상소거",
|
||||
"false_alarm": "인지오류",
|
||||
"other": "기타",
|
||||
}
|
||||
SOURCE_LABEL = {
|
||||
"qr": "QR스캔",
|
||||
"admin": "관리자접수",
|
||||
"dashboard": "대시보드접수",
|
||||
}
|
||||
COST_STATUS_LABEL = {
|
||||
"pending": "미처리", "billed": "청구완료", "waived": "면제", "settled": "정산완료",
|
||||
}
|
||||
PARTY_LABEL = {
|
||||
"cpo": "CPO(운영사)", "manufacturer": "업체", "self": "자체부담",
|
||||
"user": "사용자과실", "other": "기타",
|
||||
}
|
||||
IMP_STATUS_LABEL = {
|
||||
"registered": "등록", "reviewing": "검토중", "developing": "개발중",
|
||||
"deployed": "배포완료", "done": "완료",
|
||||
}
|
||||
RESULT_LABEL = {
|
||||
"done": "완료", "in_progress": "진행중", "waiting": "부품대기", "revisit": "재방문",
|
||||
}
|
||||
|
||||
|
||||
def _ws_reports(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("AS신고이력")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"신고번호", "충전기ID", "충전기종류", "충전기명", "충전소명", "CPO",
|
||||
"문제유형", "에러코드", "상세설명",
|
||||
"신고자연락처", "문제발생시각", "신고일시", "신고출처", "신고자",
|
||||
"처리상태", "담당정비사", "소속",
|
||||
"조치유형", "조치내용", "조치시작", "조치완료", "작업소요시간",
|
||||
"재조치횟수", "문제원인", "비고",
|
||||
"출장비부담", "출장비금액(원)", "출장비상태",
|
||||
"상황종료사유", "상황종료일시",
|
||||
]
|
||||
style_header(ws, headers, color=NAVY)
|
||||
set_col_widths(ws, [10,14,14,14,18,12,22,12,26,14,16,16,10,14,12,12,12,16,26,16,16,12,8,24,24,14,12,12,18,16])
|
||||
|
||||
q = db.query(models.Report)
|
||||
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
|
||||
rows = q.order_by(desc(models.Report.reported_at)).all()
|
||||
|
||||
for rn, r in enumerate(rows, 2):
|
||||
c = r.charger
|
||||
repair = r.repair_links[0].repair if r.repair_links else None
|
||||
cost = repair.cost if repair else None
|
||||
ws.append([
|
||||
r.id,
|
||||
r.charger_id,
|
||||
c.charger_type.name if c and c.charger_type else "",
|
||||
c.name if c else "",
|
||||
c.station_name if c else "",
|
||||
c.cpo_name if c else "",
|
||||
", ".join(r.issue_types or []),
|
||||
r.error_code or "",
|
||||
r.issue_detail or "",
|
||||
r.contact or "",
|
||||
fmt_dt(r.occurred_at),
|
||||
fmt_dt(r.reported_at),
|
||||
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
|
||||
r.reporter.name if r.reporter else "",
|
||||
r.status,
|
||||
repair.mechanic.name if repair and repair.mechanic else "",
|
||||
repair.mechanic.company if repair and repair.mechanic else "",
|
||||
", ".join(repair.repair_types or []) if repair else "",
|
||||
repair.description if repair else "",
|
||||
fmt_dt(repair.started_at) if repair else "",
|
||||
fmt_dt(repair.completed_at) if repair else "",
|
||||
elapsed(repair.started_at, repair.completed_at) if repair else "",
|
||||
r.re_dispatch_count or 0,
|
||||
cost.root_cause if cost else "",
|
||||
cost.admin_note if cost else "",
|
||||
PARTY_LABEL.get(cost.cost_party_type, cost.cost_party_type or "") if cost else "",
|
||||
cost.cost_amount if cost else "",
|
||||
COST_STATUS_LABEL.get(cost.cost_status, cost.cost_status or "") if cost else "",
|
||||
CLOSURE_LABEL.get(r.closure_type or "", ""),
|
||||
fmt_dt(r.closed_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_repairs(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("조치이력")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"조치번호", "연결신고번호", "충전기ID", "충전소명", "충전기종류",
|
||||
"정비사", "소속",
|
||||
"조치유형", "조치내용",
|
||||
"시작시각", "완료시각", "소요시간",
|
||||
"처리결과", "재조치요청",
|
||||
"승인완료", "승인자", "승인일시",
|
||||
]
|
||||
style_header(ws, headers, color=GREEN)
|
||||
set_col_widths(ws, [10,16,14,18,14,12,14,18,30,16,16,12,10,10,10,12,16])
|
||||
|
||||
q = db.query(models.Repair)
|
||||
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
|
||||
rows = q.order_by(desc(models.Repair.completed_at)).all()
|
||||
|
||||
for rn, rep in enumerate(rows, 2):
|
||||
rids = [rr.report_id for rr in rep.report_links]
|
||||
charger_id = station = ctype = ""
|
||||
if rids:
|
||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||
if r and r.charger:
|
||||
charger_id = r.charger_id
|
||||
station = r.charger.station_name or ""
|
||||
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
ws.append([
|
||||
rep.id,
|
||||
", ".join(str(i) for i in rids),
|
||||
charger_id, station, ctype,
|
||||
rep.mechanic.name if rep.mechanic else "",
|
||||
rep.mechanic.company if rep.mechanic else "",
|
||||
", ".join(rep.repair_types or []),
|
||||
rep.description or "",
|
||||
fmt_dt(rep.started_at),
|
||||
fmt_dt(rep.completed_at),
|
||||
elapsed(rep.started_at, rep.completed_at),
|
||||
RESULT_LABEL.get(rep.result_status or "", rep.result_status or ""),
|
||||
"예" if rep.re_dispatch_requested else "아니오",
|
||||
"예" if rep.approved_at else "아니오",
|
||||
rep.approver.name if rep.approver else "",
|
||||
fmt_dt(rep.approved_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_improvements(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("개선항목")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"번호", "제목", "분류", "우선순위", "개선내용", "관련부품",
|
||||
"담당업체", "담당자(대표)", "연락처",
|
||||
"연결AS건수", "연결AS번호",
|
||||
"진행상태", "SW배포목표일", "SW실제배포일",
|
||||
"제조사메모", "등록자", "등록일시",
|
||||
]
|
||||
style_header(ws, headers, color=PURPLE)
|
||||
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
|
||||
|
||||
q = db.query(models.Improvement)
|
||||
if dt_from: q = q.filter(models.Improvement.created_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Improvement.created_at < dt_to)
|
||||
rows = q.order_by(desc(models.Improvement.created_at)).all()
|
||||
|
||||
for rn, imp in enumerate(rows, 2):
|
||||
rids = [ir.report_id for ir in imp.report_links]
|
||||
ws.append([
|
||||
imp.id, imp.title, imp.category, imp.priority,
|
||||
imp.description, imp.part_name or "",
|
||||
imp.manufacturer.name if imp.manufacturer else "",
|
||||
imp.manufacturer.representative_name if imp.manufacturer else "",
|
||||
imp.manufacturer.phone if imp.manufacturer else "",
|
||||
len(rids),
|
||||
", ".join(str(i) for i in rids),
|
||||
IMP_STATUS_LABEL.get(imp.status, imp.status),
|
||||
fmt_d(imp.sw_deploy_target),
|
||||
fmt_d(imp.sw_deployed_at),
|
||||
imp.manufacturer_memo or "",
|
||||
imp.creator.name if imp.creator else "",
|
||||
fmt_dt(imp.created_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_costs(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("출장비정산")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"신고번호", "충전기ID", "충전소명", "충전기종류",
|
||||
"정비사", "소속", "조치완료일",
|
||||
"문제원인", "비고",
|
||||
"부담주체유형", "부담업체명", "부담기타",
|
||||
"수급주체유형", "수급업체명", "수급기타",
|
||||
"금액(원)", "처리상태",
|
||||
"처리담당자", "처리일시",
|
||||
]
|
||||
style_header(ws, headers, color=ORANGE)
|
||||
set_col_widths(ws, [14,14,18,14,12,14,16,26,26,12,16,16,12,16,16,12,12,12,16])
|
||||
|
||||
q = db.query(models.RepairCost).join(models.Repair)
|
||||
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
|
||||
rows = q.order_by(desc(models.Repair.completed_at)).all()
|
||||
|
||||
for rn, cost in enumerate(rows, 2):
|
||||
repair = cost.repair
|
||||
rids = [rr.report_id for rr in repair.report_links]
|
||||
charger_id = station = ctype = ""
|
||||
if rids:
|
||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||
if r and r.charger:
|
||||
charger_id = r.charger_id
|
||||
station = r.charger.station_name or ""
|
||||
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
ws.append([
|
||||
", ".join(str(i) for i in rids),
|
||||
charger_id, station, ctype,
|
||||
repair.mechanic.name if repair.mechanic else "",
|
||||
repair.mechanic.company if repair.mechanic else "",
|
||||
fmt_dt(repair.completed_at),
|
||||
cost.root_cause or "",
|
||||
cost.admin_note or "",
|
||||
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
|
||||
cost.cost_manufacturer.name if cost.cost_manufacturer else "",
|
||||
cost.cost_party_custom or "",
|
||||
PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""),
|
||||
cost.recv_manufacturer.name if cost.recv_manufacturer else "",
|
||||
cost.recv_party_custom or "",
|
||||
cost.cost_amount or 0,
|
||||
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""),
|
||||
cost.reviewer.name if cost.reviewer else "",
|
||||
fmt_dt(cost.reviewed_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_summary(wb, counts, dt_from, dt_to, date_from, date_to):
|
||||
ws = wb.create_sheet("요약", 0) # 맨 앞에 삽입
|
||||
ws.sheet_view.showGridLines = False
|
||||
|
||||
period = f"{date_from or '전체'} ~ {date_to or '전체'}"
|
||||
|
||||
def lbl(row, col, text, bold=False, size=11, color="1A2B4A"):
|
||||
c = ws.cell(row=row, column=col, value=text)
|
||||
c.font = Font(bold=bold, size=size, color=color)
|
||||
c.alignment = Alignment(vertical="center")
|
||||
|
||||
def val(row, col, text, color="1A2B4A", bold=True):
|
||||
c = ws.cell(row=row, column=col, value=text)
|
||||
c.font = Font(bold=bold, size=13, color=color)
|
||||
c.alignment = Alignment(vertical="center", horizontal="center")
|
||||
|
||||
ws.row_dimensions[1].height = 16
|
||||
ws.row_dimensions[2].height = 36
|
||||
ws.row_dimensions[3].height = 14
|
||||
|
||||
c = ws.cell(row=2, column=2, value="EV AS 관리 통합 이력")
|
||||
c.font = Font(bold=True, size=18, color=NAVY)
|
||||
c.alignment = Alignment(vertical="center")
|
||||
ws.cell(row=2, column=5, value=f"조회 기간: {period}").font = Font(size=11, color="666666")
|
||||
|
||||
sheet_names = ["AS신고이력", "조치이력", "개선항목", "출장비정산"]
|
||||
colors = [NAVY, GREEN, PURPLE, ORANGE]
|
||||
labels = ["AS 신고", "조치 이력", "개선항목", "출장비 정산"]
|
||||
|
||||
for i, (name, color, label, cnt) in enumerate(zip(sheet_names, colors, labels, counts)):
|
||||
row = 5 + i * 3
|
||||
ws.row_dimensions[row].height = 24
|
||||
ws.row_dimensions[row+1].height = 22
|
||||
cell = ws.cell(row=row, column=2, value=f"● {label}")
|
||||
cell.font = Font(bold=True, size=12, color=color)
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
ws.cell(row=row, column=3, value=f"{cnt}건").font = Font(bold=True, size=14, color=color)
|
||||
ws.cell(row=row+1, column=2,
|
||||
value=f'자세한 내용은 "{name}" 시트를 확인하세요').font = Font(size=9, color="888888")
|
||||
|
||||
ws.column_dimensions["A"].width = 4
|
||||
ws.column_dimensions["B"].width = 22
|
||||
ws.column_dimensions["C"].width = 14
|
||||
ws.column_dimensions["D"].width = 10
|
||||
ws.column_dimensions["E"].width = 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. AS 신고 목록 (개별)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/reports")
|
||||
def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
def export_reports(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "AS신고목록"
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
CLOSURE_LABEL = {
|
||||
"natural": "증상자연소거",
|
||||
"remote_reset": "원격리셋후증상소거",
|
||||
"false_alarm": "인지오류",
|
||||
"other": "기타",
|
||||
}
|
||||
headers = [
|
||||
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
|
||||
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
|
||||
@@ -92,14 +409,15 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
"재조치횟수"
|
||||
]
|
||||
style_header(ws, headers)
|
||||
set_col_widths(ws, [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
|
||||
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
|
||||
18,24,16,14,10])
|
||||
|
||||
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
|
||||
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
|
||||
18,24,16,14,10]
|
||||
for i, w in enumerate(col_widths, 1):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
q = db.query(models.Report)
|
||||
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
|
||||
reports = q.order_by(desc(models.Report.reported_at)).all()
|
||||
|
||||
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
|
||||
for row_num, r in enumerate(reports, 2):
|
||||
c = r.charger
|
||||
repair = r.repair_links[0].repair if r.repair_links else None
|
||||
@@ -108,10 +426,8 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
ir.improvement_id
|
||||
for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all()
|
||||
]
|
||||
seq_no = row_num - 1 # 순차번호 (1부터 시작)
|
||||
|
||||
row_data = [
|
||||
seq_no,
|
||||
r.id,
|
||||
r.charger_id,
|
||||
c.charger_type.name if c and c.charger_type else "",
|
||||
c.name if c else "",
|
||||
@@ -120,18 +436,18 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
fmt_d(c.installed_at) if c else "",
|
||||
r.gps_lat or "",
|
||||
r.gps_lng or "",
|
||||
", ".join(r.issue_types) if r.issue_types else "",
|
||||
", ".join(r.issue_types or []),
|
||||
r.error_code or "",
|
||||
r.issue_detail or "",
|
||||
r.contact or "",
|
||||
fmt_dt(r.occurred_at),
|
||||
fmt_dt(r.reported_at),
|
||||
{"qr": "QR스캔", "admin": "관리자접수", "dashboard": "대시보드접수"}.get(r.source or "qr", r.source or "qr"),
|
||||
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
|
||||
r.reporter.name if r.reporter else "",
|
||||
r.status,
|
||||
repair.mechanic.name if repair and repair.mechanic else "",
|
||||
repair.mechanic.company if repair and repair.mechanic else "",
|
||||
", ".join(repair.repair_types) if repair and repair.repair_types else "",
|
||||
", ".join(repair.repair_types or []) if repair else "",
|
||||
repair.description if repair else "",
|
||||
fmt_dt(repair.started_at) if repair else "",
|
||||
fmt_dt(repair.completed_at) if repair else "",
|
||||
@@ -139,13 +455,13 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
|
||||
cost.root_cause if cost else "",
|
||||
cost.admin_note if cost else "",
|
||||
cost.cost_party_type if cost else "",
|
||||
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or "") if cost else "",
|
||||
cost.cost_amount if cost else "",
|
||||
cost.cost_status if cost else "",
|
||||
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or "") if cost else "",
|
||||
cost.reviewer.name if cost and cost.reviewer else "",
|
||||
fmt_dt(cost.reviewed_at) if cost else "",
|
||||
", ".join(str(i) for i in imp_ids) if imp_ids else "",
|
||||
CLOSURE_LABEL.get(r.closure_type, "") if r.closure_type else "",
|
||||
CLOSURE_LABEL.get(r.closure_type or "", ""),
|
||||
r.closure_note or "",
|
||||
fmt_dt(r.closed_at),
|
||||
r.closer.name if r.closer else "",
|
||||
@@ -160,10 +476,16 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 2. 출장비 목록
|
||||
# 2. 출장비 목록 (개별)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/costs")
|
||||
def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
def export_costs(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "출장비목록"
|
||||
@@ -172,39 +494,45 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
headers = [
|
||||
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
|
||||
"정비사","소속","문제원인","비고",
|
||||
"출장비부담주체","제조사명","금액(원)","처리상태",
|
||||
"처리담당자","처리일시"
|
||||
"부담주체유형","부담업체명","부담기타",
|
||||
"수급주체유형","수급업체명","수급기타",
|
||||
"금액(원)","처리상태","처리담당자","처리일시"
|
||||
]
|
||||
style_header(ws, headers)
|
||||
for i, w in enumerate([10,14,14,18,16,12,14,24,24,16,16,12,12,12,16], 1):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
set_col_widths(ws, [14,14,14,18,16,12,14,26,26,12,16,16,12,16,16,12,12,12,16])
|
||||
|
||||
costs = db.query(models.RepairCost).join(models.Repair).order_by(
|
||||
desc(models.RepairCost.reviewed_at)).all()
|
||||
q = db.query(models.RepairCost).join(models.Repair)
|
||||
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
|
||||
costs = q.order_by(desc(models.Repair.completed_at)).all()
|
||||
|
||||
for row_num, cost in enumerate(costs, 2):
|
||||
repair = cost.repair
|
||||
rids = [rr.report_id for rr in repair.report_links]
|
||||
charger_id = station_name = charger_type = ""
|
||||
charger_id = station = ctype = ""
|
||||
if rids:
|
||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||
if r and r.charger:
|
||||
charger_id = r.charger_id
|
||||
station_name = r.charger.station_name
|
||||
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
charger_id = r.charger_id
|
||||
station = r.charger.station_name or ""
|
||||
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
|
||||
row_data = [
|
||||
", ".join(str(i) for i in rids),
|
||||
charger_id, charger_type, station_name,
|
||||
charger_id, ctype, station,
|
||||
fmt_dt(repair.completed_at),
|
||||
repair.mechanic.name if repair.mechanic else "",
|
||||
repair.mechanic.company if repair.mechanic else "",
|
||||
cost.root_cause or "",
|
||||
cost.admin_note or "",
|
||||
cost.cost_party_type or "",
|
||||
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom or ""),
|
||||
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
|
||||
cost.cost_manufacturer.name if cost.cost_manufacturer else "",
|
||||
cost.cost_party_custom or "",
|
||||
PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""),
|
||||
cost.recv_manufacturer.name if cost.recv_manufacturer else "",
|
||||
cost.recv_party_custom or "",
|
||||
cost.cost_amount or 0,
|
||||
cost.cost_status or "",
|
||||
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""),
|
||||
cost.reviewer.name if cost.reviewer else "",
|
||||
fmt_dt(cost.reviewed_at),
|
||||
]
|
||||
@@ -217,10 +545,16 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 3. 개선항목 목록
|
||||
# 3. 개선항목 목록 (개별)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/improvements")
|
||||
def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
def export_improvements(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "개선항목목록"
|
||||
@@ -228,40 +562,29 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
|
||||
|
||||
headers = [
|
||||
"번호","제목","분류","우선순위","개선내용","관련부품",
|
||||
"담당제조사","담당자","연락처","연결AS건수","연결AS번호","연결AS신고자",
|
||||
"담당업체","담당자(대표)","연락처","연결AS건수","연결AS번호",
|
||||
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
|
||||
"등록관리자","등록일시"
|
||||
]
|
||||
style_header(ws, headers)
|
||||
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,24,12,14,14,24,12,16], 1):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
|
||||
|
||||
q = db.query(models.Improvement)
|
||||
if dt_from: q = q.filter(models.Improvement.created_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Improvement.created_at < dt_to)
|
||||
imps = q.order_by(desc(models.Improvement.created_at)).all()
|
||||
|
||||
imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all()
|
||||
for row_num, imp in enumerate(imps, 2):
|
||||
rids = [ir.report_id for ir in imp.report_links]
|
||||
|
||||
reporters = []
|
||||
for ir in imp.report_links:
|
||||
r = ir.report
|
||||
if not r:
|
||||
continue
|
||||
if r.source == "admin" and r.reporter:
|
||||
reporters.append(f"#{r.id} {r.reporter.name}(관리자)")
|
||||
elif r.contact:
|
||||
reporters.append(f"#{r.id} {r.contact}(QR)")
|
||||
else:
|
||||
reporters.append(f"#{r.id} 익명(QR)")
|
||||
|
||||
row_data = [
|
||||
imp.id, imp.title, imp.category, imp.priority,
|
||||
imp.description, imp.part_name or "",
|
||||
imp.manufacturer.company if imp.manufacturer else "",
|
||||
imp.manufacturer.name if imp.manufacturer else "",
|
||||
imp.manufacturer.phone if imp.manufacturer else "",
|
||||
imp.manufacturer.name if imp.manufacturer else "",
|
||||
imp.manufacturer.representative_name if imp.manufacturer else "",
|
||||
imp.manufacturer.phone if imp.manufacturer else "",
|
||||
len(rids),
|
||||
", ".join(str(i) for i in rids),
|
||||
"\n".join(reporters),
|
||||
imp.status,
|
||||
IMP_STATUS_LABEL.get(imp.status, imp.status),
|
||||
fmt_d(imp.sw_deploy_target),
|
||||
fmt_d(imp.sw_deployed_at),
|
||||
imp.manufacturer_memo or "",
|
||||
@@ -274,3 +597,28 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
|
||||
ws.row_dimensions[row_num].height = 16
|
||||
|
||||
return make_response(wb, "개선항목목록")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 4. 통합 다운로드 (멀티 시트)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/full")
|
||||
def export_full(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active) # 기본 빈 시트 제거
|
||||
|
||||
c1 = _ws_reports(wb, db, dt_from, dt_to)
|
||||
c2 = _ws_repairs(wb, db, dt_from, dt_to)
|
||||
c3 = _ws_improvements(wb, db, dt_from, dt_to)
|
||||
c4 = _ws_costs(wb, db, dt_from, dt_to)
|
||||
_ws_summary(wb, [c1, c2, c3, c4], dt_from, dt_to, date_from, date_to)
|
||||
|
||||
period = f"{date_from or '전체'}~{date_to or '전체'}"
|
||||
return make_response(wb, f"EV_AS_통합이력_{period}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, text
|
||||
from sqlalchemy import desc, text, func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from database import get_db
|
||||
@@ -18,7 +18,7 @@ def _fmt(imp: models.Improvement):
|
||||
"part_name": imp.part_name, "status": imp.status,
|
||||
"manufacturer_id": imp.manufacturer_id,
|
||||
"manufacturer_name": imp.manufacturer.name if imp.manufacturer else None,
|
||||
"manufacturer_company": imp.manufacturer.company if imp.manufacturer else None,
|
||||
"manufacturer_company": None,
|
||||
"created_by_name": imp.creator.name if imp.creator else None,
|
||||
"sw_deploy_target": str(imp.sw_deploy_target) if imp.sw_deploy_target else None,
|
||||
"sw_deployed_at": str(imp.sw_deployed_at) if imp.sw_deployed_at else None,
|
||||
@@ -27,7 +27,7 @@ def _fmt(imp: models.Improvement):
|
||||
"report_ids": [ir.report_id for ir in imp.report_links],
|
||||
"report_count": len(imp.report_links),
|
||||
"attachments": [{"path": a.file_path, "name": a.file_name} for a in imp.attachments],
|
||||
"logs": [{"old": l.old_status, "new": l.new_status, "memo": l.memo,
|
||||
"logs": [{"old_status": l.old_status, "new_status": l.new_status, "memo": l.memo,
|
||||
"changed_at": l.changed_at.isoformat(),
|
||||
"by": l.changer.name if l.changer else None} for l in imp.logs],
|
||||
}
|
||||
@@ -52,7 +52,22 @@ def get_improvement(imp_id: int, db: Session = Depends(get_db),
|
||||
if not imp: raise HTTPException(404)
|
||||
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
|
||||
raise HTTPException(403)
|
||||
return _fmt(imp)
|
||||
result = _fmt(imp)
|
||||
rids = [ir.report_id for ir in imp.report_links]
|
||||
if rids:
|
||||
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()
|
||||
seqs = {row.rid: row.seq for row in
|
||||
db.query(seq_subq.c.rid, seq_subq.c.seq)
|
||||
.filter(seq_subq.c.rid.in_(rids)).all()}
|
||||
result["report_links"] = [{"id": rid, "seq": seqs.get(rid, rid)} for rid in rids]
|
||||
else:
|
||||
result["report_links"] = []
|
||||
return result
|
||||
|
||||
@router.post("")
|
||||
async def create_improvement(
|
||||
|
||||
@@ -36,6 +36,16 @@ def _fmt_repair(repair: models.Repair) -> dict:
|
||||
"issue_types": r.issue_types,
|
||||
"status": r.status,
|
||||
})
|
||||
|
||||
# 같은 신고에 연결된 조치 목록에서 현재 조치의 순번 계산 (오래된 것=1차)
|
||||
attempt = 1
|
||||
if repair.report_links:
|
||||
first_report = repair.report_links[0].report
|
||||
if first_report and first_report.repair_links:
|
||||
all_repair_ids = sorted(rl.repair_id for rl in first_report.repair_links)
|
||||
if repair.id in all_repair_ids:
|
||||
attempt = all_repair_ids.index(repair.id) + 1
|
||||
|
||||
return {
|
||||
"id": repair.id,
|
||||
"charger_id": charger_id,
|
||||
@@ -56,6 +66,7 @@ def _fmt_repair(repair: models.Repair) -> dict:
|
||||
"photos_after": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "after"],
|
||||
"reports": reports,
|
||||
"report_count": len(reports),
|
||||
"attempt": attempt,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ def _fmt_report(r: models.Report, db: Session):
|
||||
"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("")
|
||||
@@ -105,7 +109,8 @@ async def create_report(
|
||||
@router.post("/batch")
|
||||
async def create_batch_report(
|
||||
charger_id: str = Form(...),
|
||||
scope: str = Form("single"), # single | station | type
|
||||
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(""),
|
||||
@@ -124,14 +129,28 @@ async def create_batch_report(
|
||||
charger = db.query(models.Charger).filter_by(id=charger_id).first()
|
||||
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
||||
|
||||
if scope == "station":
|
||||
targets = db.query(models.Charger).filter_by(
|
||||
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:
|
||||
targets = db.query(models.Charger).filter_by(
|
||||
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:
|
||||
targets = [charger]
|
||||
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"
|
||||
@@ -143,7 +162,6 @@ async def create_batch_report(
|
||||
else:
|
||||
source_value = "qr"
|
||||
|
||||
# Read all photo bytes upfront so they can be written for each target
|
||||
photo_data = []
|
||||
for photo in photos:
|
||||
if photo.filename:
|
||||
@@ -160,6 +178,9 @@ async def create_batch_report(
|
||||
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)
|
||||
|
||||
@@ -181,6 +202,7 @@ async def create_batch_report(
|
||||
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)
|
||||
@@ -196,12 +218,17 @@ def list_reports(
|
||||
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:
|
||||
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")
|
||||
|
||||
@@ -254,20 +281,26 @@ def get_report(report_id: int, db: Session = Depends(get_db),
|
||||
"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,
|
||||
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
|
||||
} if (cost and include_cost) else None,
|
||||
"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)
|
||||
# 재조치로 인한 이전 조치 이력 (최신 제외, re_dispatch_requested=True인 것)
|
||||
result["prev_repairs"] = [
|
||||
_fmt_one_repair(link.repair, include_cost=False)
|
||||
_fmt_one_repair(link.repair, include_cost=True)
|
||||
for link in sorted_links[1:]
|
||||
if link.repair
|
||||
]
|
||||
@@ -306,6 +339,8 @@ def bulk_delete_reports(
|
||||
@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),
|
||||
@@ -320,6 +355,24 @@ async def update_report(
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user