149 lines
6.3 KiB
Python
149 lines
6.3 KiB
Python
"""
|
|
Import/Export Excel (XLSX) — массовая загрузка и выгрузка данных.
|
|
Поддерживает: компоненты, директивы, персонал ПЛГ, дефекты.
|
|
"""
|
|
import io
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from app.api.deps import get_db, get_current_user
|
|
from app.api.helpers import audit
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/import-export", tags=["import-export"])
|
|
|
|
|
|
@router.get("/export/{entity_type}")
|
|
def export_xlsx(entity_type: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
"""
|
|
Экспорт в XLSX. Типы: components, directives, bulletins, specialists, defects, work_orders.
|
|
"""
|
|
try:
|
|
import openpyxl
|
|
except ImportError:
|
|
raise HTTPException(500, "openpyxl not installed")
|
|
|
|
data_map = {
|
|
"components": ("airworthiness_core", "_components", ["name", "part_number", "serial_number", "ata_chapter", "manufacturer", "condition", "current_hours", "current_cycles"]),
|
|
"directives": ("airworthiness_core", "_directives", ["number", "title", "issuing_authority", "effective_date", "compliance_type", "status"]),
|
|
"bulletins": ("airworthiness_core", "_bulletins", ["number", "title", "manufacturer", "category", "issued_date", "status"]),
|
|
"specialists": ("personnel_plg", "_specialists", ["full_name", "personnel_number", "position", "category", "license_number", "status"]),
|
|
"defects": ("defects", "_defects", ["aircraft_reg", "ata_chapter", "description", "severity", "discovered_during", "status"]),
|
|
"work_orders": ("work_orders", "_work_orders", ["wo_number", "aircraft_reg", "wo_type", "title", "priority", "status", "estimated_manhours"]),
|
|
}
|
|
|
|
if entity_type not in data_map:
|
|
raise HTTPException(400, f"Unknown entity: {entity_type}. Supported: {list(data_map.keys())}")
|
|
|
|
module_name, store_name, columns = data_map[entity_type]
|
|
mod = __import__(f"app.api.routes.{module_name}", fromlist=[store_name])
|
|
items = list(getattr(mod, store_name).values())
|
|
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = entity_type
|
|
ws.append(columns)
|
|
|
|
for item in items:
|
|
row = [str(item.get(col, "")) for col in columns]
|
|
ws.append(row)
|
|
|
|
# Style header
|
|
from openpyxl.styles import Font, PatternFill
|
|
for cell in ws[1]:
|
|
cell.font = Font(bold=True)
|
|
cell.fill = PatternFill(start_color="DBEAFE", end_color="DBEAFE", fill_type="solid")
|
|
|
|
for col in ws.columns:
|
|
max_len = max(len(str(cell.value or "")) for cell in col)
|
|
ws.column_dimensions[col[0].column_letter].width = min(max_len + 2, 40)
|
|
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
|
|
audit(db, user, "export_xlsx", entity_type, description=f"XLSX export: {entity_type} ({len(items)} rows)")
|
|
db.commit()
|
|
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f"attachment; filename={entity_type}_{datetime.now().strftime('%Y%m%d')}.xlsx"},
|
|
)
|
|
|
|
|
|
@router.post("/import/{entity_type}")
|
|
async def import_xlsx(
|
|
entity_type: str,
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
user=Depends(get_current_user),
|
|
):
|
|
"""Импорт из XLSX. Первая строка — заголовки."""
|
|
try:
|
|
import openpyxl
|
|
except ImportError:
|
|
raise HTTPException(500, "openpyxl not installed")
|
|
|
|
if not file.filename.endswith(('.xlsx', '.xls')):
|
|
raise HTTPException(400, "Only .xlsx files accepted")
|
|
|
|
content = await file.read()
|
|
wb = openpyxl.load_workbook(io.BytesIO(content))
|
|
ws = wb.active
|
|
|
|
rows = list(ws.iter_rows(values_only=True))
|
|
if len(rows) < 2:
|
|
raise HTTPException(400, "File must have header + at least 1 data row")
|
|
|
|
headers = [str(h).strip().lower() if h else "" for h in rows[0]]
|
|
imported = 0
|
|
errors = []
|
|
|
|
for i, row in enumerate(rows[1:], start=2):
|
|
try:
|
|
item = {headers[j]: (str(v).strip() if v is not None else "") for j, v in enumerate(row) if j < len(headers)}
|
|
item["id"] = str(uuid.uuid4())
|
|
item["created_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
if entity_type == "components":
|
|
from app.api.routes.airworthiness_core import _components
|
|
if not item.get("name") or not item.get("part_number") or not item.get("serial_number"):
|
|
errors.append(f"Row {i}: missing required fields (name, part_number, serial_number)")
|
|
continue
|
|
item.setdefault("condition", "serviceable")
|
|
item["current_hours"] = float(item.get("current_hours", 0) or 0)
|
|
item["current_cycles"] = int(item.get("current_cycles", 0) or 0)
|
|
_components[item["id"]] = item
|
|
elif entity_type == "specialists":
|
|
from app.api.routes.personnel_plg import _specialists
|
|
if not item.get("full_name") or not item.get("personnel_number"):
|
|
errors.append(f"Row {i}: missing full_name or personnel_number")
|
|
continue
|
|
item.setdefault("status", "active")
|
|
item.setdefault("specializations", [])
|
|
_specialists[item["id"]] = item
|
|
elif entity_type == "directives":
|
|
from app.api.routes.airworthiness_core import _directives
|
|
if not item.get("number") or not item.get("title"):
|
|
errors.append(f"Row {i}: missing number or title")
|
|
continue
|
|
item.setdefault("status", "open")
|
|
item.setdefault("aircraft_types", [])
|
|
_directives[item["id"]] = item
|
|
else:
|
|
errors.append(f"Import not supported for: {entity_type}")
|
|
break
|
|
|
|
imported += 1
|
|
except Exception as e:
|
|
errors.append(f"Row {i}: {str(e)}")
|
|
|
|
audit(db, user, "import_xlsx", entity_type, description=f"XLSX import: {entity_type} ({imported} imported, {len(errors)} errors)")
|
|
db.commit()
|
|
|
|
return {"imported": imported, "errors": errors[:20], "total_rows": len(rows) - 1}
|