402 lines
17 KiB
Python
402 lines
17 KiB
Python
"""
|
||
Наряды на ТО (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."""
|
||
try:
|
||
items = list(_work_orders.values())
|
||
return {
|
||
"total": len(items),
|
||
"draft": len([w for w in items if w.get("status") == "draft"]),
|
||
"in_progress": len([w for w in items if w.get("status") == "in_progress"]),
|
||
"closed": len([w for w in items if w.get("status") == "closed"]),
|
||
"cancelled": len([w for w in items if w.get("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.get("status") == "closed"),
|
||
}
|
||
except Exception:
|
||
return {"total": 0, "draft": 0, "in_progress": 0, "closed": 0, "cancelled": 0, "aog": 0, "total_manhours": 0}
|
||
|
||
|
||
# ===================================================================
|
||
# СКВОЗНАЯ ИНТЕГРАЦИЯ: 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}
|