From 81c3428aa16d6512c22619b337cc92e28aeea70f Mon Sep 17 00:00:00 2001 From: byun Date: Mon, 1 Jun 2026 09:58:50 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=AC=EC=A1=B0=EC=B9=98=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?(=EC=A1=B0=EC=B9=98=20=EA=B8=B0=EB=A1=9D=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=20=EC=9E=AC=EC=B6=9C=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정비사 조치 완료 후 동일 문제 재발 시 관리자가 기존 기록을 보존한 채 재조치를 요청할 수 있는 기능 추가. - DB: repairs.re_dispatch_requested/at, reports.re_dispatch_count 컬럼 추가 - 재조치 요청 엔드포인트 (POST /repairs/{id}/re-dispatch): 기존 repair에 플래그, 연결 신고를 pending으로 복원, re_dispatch_count 증가 - pending 상태 신고는 새 조치 생성으로 분기 (in_progress만 기존 수정 모드) - report-detail: 조치승인·취소 사이에 "🔁 재조치 요청" 버튼, 이전 조치 이력 카드 - 정비사 대시보드: 재조치 건에 🔁 뱃지 및 강조 버튼색 표시 - 엑셀 export: 재조치횟수 컬럼 추가 Co-Authored-By: Claude Sonnet 4.6 --- backend/models.py | 3 ++ backend/routers/export.py | 6 ++- backend/routers/repairs.py | 28 ++++++++++- backend/routers/reports.py | 24 +++++++--- .../static/pages/admin/report-detail.html | 47 ++++++++++++++++++- frontend/static/pages/mechanic/dashboard.html | 4 +- 6 files changed, 99 insertions(+), 13 deletions(-) diff --git a/backend/models.py b/backend/models.py index 4a1657b..498c274 100644 --- a/backend/models.py +++ b/backend/models.py @@ -63,6 +63,7 @@ class Report(Base): closure_note = Column(Text) closed_at = Column(TIMESTAMP) closed_by = Column(Integer, ForeignKey("users.id"), nullable=True) + re_dispatch_count = Column(Integer, default=0) charger = relationship("Charger", back_populates="reports") photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan") repair_links = relationship("RepairReport", back_populates="report") @@ -90,6 +91,8 @@ class Repair(Base): mechanic_lng = Column(Float) approved_at = Column(TIMESTAMP) approved_by = Column(Integer, ForeignKey("users.id")) + re_dispatch_requested = Column(Boolean, default=False) + re_dispatch_requested_at = Column(TIMESTAMP) mechanic = relationship("User", foreign_keys=[mechanic_id]) approver = relationship("User", foreign_keys=[approved_by]) report_links = relationship("RepairReport", back_populates="repair", cascade="all, delete-orphan") diff --git a/backend/routers/export.py b/backend/routers/export.py index 7d82545..02953a1 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -88,13 +88,14 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): "조치시작","조치완료","작업소요시간","신고→완료소요시간", "문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태", "처리담당자","처리일시","연결개선항목번호", - "상황종료사유","상황종료메모","상황종료일시","상황종료처리자" + "상황종료사유","상황종료메모","상황종료일시","상황종료처리자", + "재조치횟수" ] style_header(ws, headers) 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] + 18,24,16,14,10] for i, w in enumerate(col_widths, 1): ws.column_dimensions[ws.cell(1, i).column_letter].width = w @@ -148,6 +149,7 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): r.closure_note or "", fmt_dt(r.closed_at), r.closer.name if r.closer else "", + r.re_dispatch_count or 0, ] for col, val in enumerate(row_data, 1): ws.cell(row=row_num, column=col, value=val) diff --git a/backend/routers/repairs.py b/backend/routers/repairs.py index 8f8d752..e1270df 100644 --- a/backend/routers/repairs.py +++ b/backend/routers/repairs.py @@ -50,6 +50,8 @@ def _fmt_repair(repair: models.Repair) -> dict: "completed_at": repair.completed_at.isoformat() if repair.completed_at 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": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "before"], "photos_after": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "after"], "reports": reports, @@ -67,9 +69,9 @@ def pending_reports(db: Session = Depends(get_db), result = [] for r in q.all(): c = r.charger - # in_progress 신고는 연결된 repair_id 포함 → 편집 모드로 연결 + # in_progress 신고만 기존 repair 편집 모드; pending은 재조치 포함 새 조치 생성 repair_id = None - if r.repair_links: + if r.status == "in_progress" and r.repair_links: repair_id = r.repair_links[0].repair_id result.append({ "id": r.id, "charger_id": r.charger_id, @@ -80,6 +82,7 @@ def pending_reports(db: Session = Depends(get_db), "reported_at": r.reported_at.isoformat(), "occurred_at": r.occurred_at.isoformat() if r.occurred_at else None, "repair_id": repair_id, + "re_dispatch_count": r.re_dispatch_count or 0, "gps_lat": c.gps_lat if c else None, "gps_lng": c.gps_lng if c else None, "location_detail": c.location_detail if c else None, @@ -263,6 +266,27 @@ def approve_repair( return {"ok": True, "improvement_id": target_imp_id} +@router.post("/{repair_id}/re-dispatch") +def re_dispatch_repair( + repair_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(require_admin) +): + """기존 조치 기록을 유지하며 재조치 요청 — 신고를 pending으로 되돌림""" + repair = db.query(models.Repair).filter_by(id=repair_id).first() + if not repair: raise HTTPException(404) + if repair.approved_at: + raise HTTPException(400, "이미 승인된 조치는 재조치 요청할 수 없습니다.") + repair.re_dispatch_requested = True + repair.re_dispatch_requested_at = datetime.now() + for link in repair.report_links: + if link.report: + link.report.status = "pending" + link.report.re_dispatch_count = (link.report.re_dispatch_count or 0) + 1 + db.commit() + return {"ok": True} + + @router.delete("/{repair_id}") def cancel_repair( repair_id: int, diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 59c58fb..323d493 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -203,11 +203,11 @@ def get_report(report_id: int, db: Session = Depends(get_db), r = db.query(models.Report).filter_by(id=report_id).first() if not r: raise HTTPException(404) result = _fmt_report(r, db) - # 수리 정보 포함 - if r.repair_links: - repair = r.repair_links[0].repair + result["re_dispatch_count"] = r.re_dispatch_count or 0 + # 수리 정보 포함 — repair_links를 id 내림차순(최신 우선)으로 정렬 + def _fmt_one_repair(repair, include_cost=True): cost = repair.cost - result["repair"] = { + return { "id": repair.id, "mechanic_name": repair.mechanic.name if repair.mechanic else None, "mechanic_company": repair.mechanic.company if repair.mechanic else None, @@ -222,6 +222,8 @@ def get_report(report_id: int, db: Session = Depends(get_db), "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": { @@ -232,9 +234,19 @@ def get_report(report_id: int, db: Session = Depends(get_db), "cost_amount": cost.cost_amount, "cost_status": cost.cost_status, "manufacturer_name": cost.manufacturer.name if cost.manufacturer else None, - } if cost else None, - "linked_improvements": _get_linked_improvements(repair, db), + } if (cost and include_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) + for link in sorted_links[1:] + if link.repair + ] return result def _get_linked_improvements(repair, db): diff --git a/frontend/static/pages/admin/report-detail.html b/frontend/static/pages/admin/report-detail.html index 9f0feaa..1a67973 100644 --- a/frontend/static/pages/admin/report-detail.html +++ b/frontend/static/pages/admin/report-detail.html @@ -206,6 +206,7 @@ async function load() { ]); const repair = r.repair; const cost = repair?.cost; + const prevRepairs = r.prev_repairs || []; document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`; @@ -529,8 +530,13 @@ async function load() {
${repair.approved_at ? `✅ 승인완료 · ${repair.approved_by_name||''}` - : `` + : repair.re_dispatch_requested + ? `🔁 재조치 요청됨 · ${Auth.fmtDt(repair.re_dispatch_requested_at)}` + : `` } + ${!repair.approved_at && !repair.re_dispatch_requested + ? `` + : ''}
` : ''} @@ -543,6 +549,8 @@ async function load() { 시작시각${Auth.fmtDt(repair.started_at)} 완료시각${Auth.fmtDt(repair.completed_at)} 처리결과${Auth.statusBadge(repair.result_status)} + ${repair.re_dispatch_requested ? ` + 재조치요청🔁 재조치 요청됨 (${Auth.fmtDt(repair.re_dispatch_requested_at)})` : ''}
@@ -661,6 +669,35 @@ async function load() {
+ ${prevRepairs.length ? ` +
+
📋 이전 조치 이력 (재조치 전 기록 ${prevRepairs.length}건)
+ ${prevRepairs.map((pr, idx) => ` +
+ + #${pr.id} · ${pr.mechanic_name||'?'} · ${Auth.fmtDt(pr.completed_at)} + 🔁 재조치 요청됨 + +
+ + + + + + + +
정비사${pr.mechanic_name||'-'} (${pr.mechanic_company||'-'})
조치유형${(pr.repair_types||[]).join(', ')||'-'}
조치내용${pr.description||'-'}
완료시각${Auth.fmtDt(pr.completed_at)}
처리결과${Auth.statusBadge(pr.result_status)}
재조치요청${Auth.fmtDt(pr.re_dispatch_requested_at)}
+ ${(pr.photos_before||[]).length || (pr.photos_after||[]).length ? ` +
+ ${(pr.photos_before||[]).length ? `
조치 전
+
${(pr.photos_before||[]).map(p=>``).join('')}
` : ''} + ${(pr.photos_after||[]).length ? `
조치 후
+
${(pr.photos_after||[]).map(p=>``).join('')}
` : ''} +
` : ''} +
+
`).join('')} +
` : ''} + ${costHtml} `; @@ -914,6 +951,14 @@ async function cancelRepair(repairId, isApproved) { } catch(e) { alert('조치취소 오류: ' + e.message); } } +async function requestRedispatch(repairId) { + if (!confirm('재조치를 요청합니다.\n\n기존 조치 기록은 유지되며,\n연결된 신고가 정비사 목록에 다시 표시됩니다.\n\n계속하시겠습니까?')) return; + try { + await API.post('/repairs/' + repairId + '/re-dispatch', new FormData()); + load(); + } catch(e) { alert('재조치 요청 오류: ' + e.message); } +} + async function approveReport(id) { if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return; await API.patch(`/reports/${id}/approve`); diff --git a/frontend/static/pages/mechanic/dashboard.html b/frontend/static/pages/mechanic/dashboard.html index 885668a..4f544d3 100644 --- a/frontend/static/pages/mechanic/dashboard.html +++ b/frontend/static/pages/mechanic/dashboard.html @@ -157,14 +157,14 @@ function renderList() { : `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`; return ` - #${r.id} + #${r.id}${r.re_dispatch_count > 0 ? ' 🔁재조치' : ''} ${r.charger_id}
${r.charger_name||''} ${r.station_name||'-'} ${r.charger_type||'-'} ${(r.issue_types||[]).join(', ')} ${Auth.fmtDt(r.reported_at)} ${Auth.statusBadge(r.status)} - 조치 + 조치 `; }).join('');