1차완료

This commit is contained in:
byun
2026-06-02 19:34:36 +09:00
parent 9f0f4326fe
commit b6863cd260
28 changed files with 1667 additions and 460 deletions

View File

@@ -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:

View File

@@ -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")

View File

@@ -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()
)

View File

@@ -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}")

View File

@@ -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(

View File

@@ -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,
}

View File

@@ -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: