klg-asutk-app/backend/app/api/routes/work_orders.py

399 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Наряды на ТО (Work Orders) — управление работами по ТО ВС.
Правовые основания:
- ФАП-145 п.145.A.50, A.55, A.60, A.65 — порядок выполнения ТО
- ФАП-148 п.3, п.4 — программы ТО эксплуатанта
- EASA Part-145.A.50-65 — Certification of maintenance
- EASA Part-M.A.801 — Aircraft certificate of release to service
- ICAO Annex 6 Part I 8.7 — Maintenance release
"""
import uuid, logging
from datetime import datetime, timezone
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.api.helpers import audit
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
_work_orders: dict = {}
class WorkOrderCreate(BaseModel):
wo_number: str = Field(..., description="Номер наряда")
aircraft_reg: str
wo_type: str = Field(..., description="scheduled | unscheduled | ad_compliance | sb_compliance | defect_rectification | modification")
title: str
description: str = ""
ata_chapters: List[str] = Field(default=[])
related_ad_id: Optional[str] = None
related_sb_id: Optional[str] = None
related_defect_id: Optional[str] = None
maintenance_program_ref: Optional[str] = None
priority: str = Field("normal", description="aog | urgent | normal | deferred")
planned_start: Optional[str] = None
planned_end: Optional[str] = None
estimated_manhours: float = 0
assigned_to: Optional[str] = Field(None, description="Специалист / бригада")
parts_required: List[dict] = Field(default=[], description="P/N, qty")
class WorkOrderClose(BaseModel):
actual_manhours: float = 0
findings: str = ""
parts_used: List[dict] = Field(default=[])
crs_signed_by: Optional[str] = Field(None, description="CRS подписант (ФАП-145 п.A.50)")
crs_date: Optional[str] = None
@router.get("/")
def list_work_orders(
status: Optional[str] = None, aircraft_reg: Optional[str] = None,
wo_type: Optional[str] = None, priority: Optional[str] = None,
page: int = Query(1, ge=1), per_page: int = Query(50, le=200),
user=Depends(get_current_user),
):
items = list(_work_orders.values())
if status: items = [w for w in items if w["status"] == status]
if aircraft_reg: items = [w for w in items if w["aircraft_reg"] == aircraft_reg]
if wo_type: items = [w for w in items if w["wo_type"] == wo_type]
if priority: items = [w for w in items if w["priority"] == priority]
total = len(items)
start = (page - 1) * per_page
return {"total": total, "page": page, "items": items[start:start + per_page],
"legal_basis": "ФАП-145 п.A.50-65; EASA Part-145; ICAO Annex 6 8.7"}
@router.post("/")
def create_work_order(data: WorkOrderCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
wid = str(uuid.uuid4())
wo = {"id": wid, **data.dict(), "status": "draft", "created_at": datetime.now(timezone.utc).isoformat()}
_work_orders[wid] = wo
if data.priority == "aog":
try:
from app.services.ws_manager import notify_wo_aog
asyncio.create_task(notify_wo_aog(data.wo_number, data.aircraft_reg))
except Exception:
pass
audit(db, user, "create", "work_order", entity_id=wid, description=f"WO {data.wo_number}: {data.title}")
db.commit()
return wo
@router.get("/{wo_id}")
def get_work_order(wo_id: str, user=Depends(get_current_user)):
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
return wo
@router.put("/{wo_id}/open")
def open_work_order(wo_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Открыть наряд → в работу."""
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
wo["status"] = "in_progress"
wo["opened_at"] = datetime.now(timezone.utc).isoformat()
audit(db, user, "open", "work_order", entity_id=wo_id)
db.commit()
return wo
@router.put("/{wo_id}/close")
def close_work_order(wo_id: str, data: WorkOrderClose, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Закрыть наряд + CRS (Certificate of Release to Service).
ФАП-145 п.145.A.50: после ТО оформляется свидетельство о допуске к эксплуатации.
"""
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
wo["status"] = "closed"
wo["closed_at"] = datetime.now(timezone.utc).isoformat()
wo["actual_manhours"] = data.actual_manhours
wo["findings"] = data.findings
wo["parts_used"] = data.parts_used
wo["crs_signed_by"] = data.crs_signed_by
wo["crs_date"] = data.crs_date or datetime.now(timezone.utc).isoformat()
try:
from app.services.ws_manager import notify_wo_closed
asyncio.create_task(notify_wo_closed(wo["wo_number"], wo["aircraft_reg"], data.crs_signed_by or ""))
except Exception:
pass
audit(db, user, "close", "work_order", entity_id=wo_id,
description=f"WO закрыт, CRS: {data.crs_signed_by}")
db.commit()
return wo
@router.put("/{wo_id}/cancel")
def cancel_work_order(wo_id: str, reason: str = "", db: Session = Depends(get_db), user=Depends(get_current_user)):
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
wo["status"] = "cancelled"
wo["cancel_reason"] = reason
audit(db, user, "cancel", "work_order", entity_id=wo_id)
db.commit()
return wo
@router.get("/stats/summary")
def work_order_stats(user=Depends(get_current_user)):
"""Статистика нарядов для Dashboard."""
items = list(_work_orders.values())
return {
"total": len(items),
"draft": len([w for w in items if w["status"] == "draft"]),
"in_progress": len([w for w in items if w["status"] == "in_progress"]),
"closed": len([w for w in items if w["status"] == "closed"]),
"cancelled": len([w for w in items if w["status"] == "cancelled"]),
"aog": len([w for w in items if w.get("priority") == "aog"]),
"total_manhours": sum(w.get("actual_manhours", 0) for w in items if w["status"] == "closed"),
}
# ===================================================================
# СКВОЗНАЯ ИНТЕГРАЦИЯ: AD → WO, Defect → WO, LL → WO
# ФАП-145 п.A.50: все работы по ТО оформляются нарядом
# ===================================================================
@router.post("/from-directive/{directive_id}")
def create_wo_from_directive(directive_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Создать наряд на выполнение ДЛГ."""
from app.api.routes.airworthiness_core import _directives
ad = _directives.get(directive_id)
if not ad:
raise HTTPException(404, "Directive not found")
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-AD-{ad['number'][:20]}",
"aircraft_reg": ", ".join(ad.get("aircraft_types", [])),
"wo_type": "ad_compliance",
"title": f"Выполнение ДЛГ {ad['number']}: {ad.get('title', '')}",
"description": ad.get("description", ""),
"related_ad_id": directive_id,
"priority": "urgent" if ad.get("compliance_type") == "mandatory" else "normal",
"status": "draft",
"estimated_manhours": 0,
"ata_chapters": [ad["ata_chapter"]] if ad.get("ata_chapter") else [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
audit(db, user, "create_from_ad", "work_order", entity_id=wid,
description=f"WO из ДЛГ {ad['number']}")
db.commit()
return wo
@router.post("/from-defect/{defect_id}")
def create_wo_from_defect(defect_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Создать наряд на устранение дефекта."""
from app.api.routes.defects import _defects
defect = _defects.get(defect_id)
if not defect:
raise HTTPException(404, "Defect not found")
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-DEF-{defect_id[:8].upper()}",
"aircraft_reg": defect["aircraft_reg"],
"wo_type": "defect_rectification",
"title": f"Устранение дефекта: {defect.get('description', '')[:80]}",
"description": defect.get("description", ""),
"related_defect_id": defect_id,
"priority": "aog" if defect.get("severity") == "critical" else "urgent" if defect.get("severity") == "major" else "normal",
"status": "draft",
"estimated_manhours": 0,
"ata_chapters": [defect["ata_chapter"]] if defect.get("ata_chapter") else [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
audit(db, user, "create_from_defect", "work_order", entity_id=wid,
description=f"WO из дефекта {defect['aircraft_reg']}")
db.commit()
return wo
@router.post("/from-bulletin/{bulletin_id}")
def create_wo_from_bulletin(bulletin_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Создать наряд на внедрение SB."""
from app.api.routes.airworthiness_core import _bulletins
sb = _bulletins.get(bulletin_id)
if not sb:
raise HTTPException(404, "Bulletin not found")
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-SB-{sb['number'][:20]}",
"aircraft_reg": ", ".join(sb.get("aircraft_types", [])),
"wo_type": "sb_compliance",
"title": f"Внедрение SB {sb['number']}: {sb.get('title', '')}",
"description": sb.get("description", ""),
"related_sb_id": bulletin_id,
"priority": "urgent" if sb.get("category") == "mandatory" else "normal",
"status": "draft",
"estimated_manhours": sb.get("estimated_manhours", 0),
"ata_chapters": [sb["ata_chapter"]] if sb.get("ata_chapter") else [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
audit(db, user, "create_from_sb", "work_order", entity_id=wid,
description=f"WO из SB {sb['number']}")
db.commit()
return wo
@router.get("/{wo_id}/report/pdf")
def generate_wo_pdf(wo_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Генерация PDF отчёта по наряду на ТО (включая CRS).
ФАП-145 п.145.A.55: документация о выполненном ТО.
"""
from io import BytesIO
from datetime import datetime as dt
from fastapi.responses import StreamingResponse
wo = _work_orders.get(wo_id)
if not wo:
raise HTTPException(404, "Work Order not found")
try:
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=30, bottomMargin=30)
styles = getSampleStyleSheet()
elements = []
# Title
elements.append(Paragraph(f"НАРЯД НА ТО / WORK ORDER", styles["Title"]))
elements.append(Paragraph(f"No: {wo.get('wo_number', '?')}", styles["Heading2"]))
elements.append(Spacer(1, 12))
# Main info table
data = [
["Борт / Aircraft:", wo.get("aircraft_reg", "")],
["Тип работ / Type:", wo.get("wo_type", "")],
["Наименование / Title:", wo.get("title", "")],
["Приоритет / Priority:", wo.get("priority", "")],
["Статус / Status:", wo.get("status", "")],
["План. ч/ч / Est. MH:", str(wo.get("estimated_manhours", 0))],
["Факт. ч/ч / Actual MH:", str(wo.get("actual_manhours", ""))],
]
t = Table(data, colWidths=[180, 340])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, -1), colors.Color(0.9, 0.9, 0.9)),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
elements.append(t)
elements.append(Spacer(1, 16))
# Description
if wo.get("description"):
elements.append(Paragraph("Описание работ:", styles["Heading4"]))
elements.append(Paragraph(wo["description"], styles["Normal"]))
elements.append(Spacer(1, 12))
# Findings
if wo.get("findings"):
elements.append(Paragraph("Замечания / Findings:", styles["Heading4"]))
elements.append(Paragraph(wo["findings"], styles["Normal"]))
elements.append(Spacer(1, 12))
# CRS block
if wo.get("crs_signed_by"):
elements.append(Spacer(1, 20))
elements.append(Paragraph("CERTIFICATE OF RELEASE TO SERVICE (CRS)", styles["Heading3"]))
elements.append(Paragraph(
f"Certifies that the work specified was carried out in accordance with "
f"Part-145 and the aircraft/component is considered ready for release to service.",
styles["Normal"]
))
elements.append(Spacer(1, 8))
crs_data = [
["CRS подписал / Signed by:", wo["crs_signed_by"]],
["Дата / Date:", wo.get("crs_date", "")],
["Основание / Ref:", "ФАП-145 п.145.A.50; EASA Part-145.A.50"],
]
ct = Table(crs_data, colWidths=[180, 340])
ct.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, -1), colors.Color(0.85, 0.95, 0.85)),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 9),
]))
elements.append(ct)
# Footer
elements.append(Spacer(1, 30))
elements.append(Paragraph(
f"Сформировано: {dt.now().strftime('%d.%m.%Y %H:%M')} | АСУ ТК КЛГ",
styles["Normal"]
))
doc.build(elements)
buf.seek(0)
audit(db, user, "pdf_export", "work_order", entity_id=wo_id,
description=f"PDF WO {wo.get('wo_number', '?')}")
db.commit()
return StreamingResponse(
buf,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=WO_{wo.get('wo_number', 'report')}.pdf"},
)
except ImportError:
raise HTTPException(500, "ReportLab not installed")
@router.post("/batch-from-program/{program_id}")
def batch_create_from_program(
program_id: str,
aircraft_reg: str = "",
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""
Массовое создание нарядов из программы ТО.
Для каждой задачи в программе создаётся отдельный WO.
ФАП-148 п.3: программа ТО → наряды на выполнение.
"""
from app.api.routes.airworthiness_core import _maint_programs
mp = _maint_programs.get(program_id)
if not mp:
raise HTTPException(404, "Maintenance Program not found")
tasks = mp.get("tasks", [])
if not tasks:
raise HTTPException(400, "Program has no tasks")
created = []
for task in tasks:
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-MP-{task.get('task_id', wid[:6])}",
"aircraft_reg": aircraft_reg or mp.get("aircraft_type", ""),
"wo_type": "scheduled",
"title": task.get("description", task.get("task_id", "Task")),
"description": f"Из программы ТО: {mp['name']} ({mp['revision']})",
"maintenance_program_ref": f"{program_id}:{task.get('task_id', '')}",
"priority": "normal",
"status": "draft",
"estimated_manhours": task.get("manhours", 0),
"ata_chapters": [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
created.append(wo)
audit(db, user, "batch_create", "work_order", entity_id=program_id,
description=f"Batch WO из MP {mp['name']}: {len(created)} нарядов")
db.commit()
return {"program": mp["name"], "created_count": len(created), "work_orders": created}