arc/hesabixAPI/app/services/repair_shop_service.py

841 lines
28 KiB
Python
Executable file

"""
سرویس اصلی افزونه مدیریت تعمیرگاه
"""
from __future__ import annotations
import secrets
import logging
from datetime import datetime, date
from typing import Any, Dict, List, Optional
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.repair_shop import (
RepairShopSettings,
RepairTechnician,
RepairOrder,
RepairOrderPart,
RepairOrderStatus,
RepairOrderAttachment,
RepairInvoice,
)
from adapters.db.models.business import Business
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
from adapters.db.repositories.repair_shop_repository import (
RepairShopSettingsRepository,
RepairTechnicianRepository,
RepairOrderRepository,
RepairOrderPartRepository,
RepairOrderStatusRepository,
RepairOrderAttachmentRepository,
RepairInvoiceRepository,
)
from app.core.responses import ApiError
from app.core.repair_shop_plugin_dependency import check_repair_shop_plugin_active
logger = logging.getLogger(__name__)
# الفبای برای تولید کدهای رندوم
BASE62_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
# وضعیت‌های مجاز
VALID_STATUSES = [
"received",
"assigned",
"in_progress",
"waiting_parts",
"testing",
"completed_fixed",
"completed_unfixable",
"ready_for_pickup",
"delivered",
"cancelled"
]
# ========== Helper Functions ==========
def _generate_random_code(length: int = 8) -> str:
"""تولید کد رندوم"""
return "".join(secrets.choice(BASE62_ALPHABET) for _ in range(length))
def _check_plugin_active(db: Session, business_id: int) -> None:
"""بررسی فعال بودن افزونه"""
if not check_repair_shop_plugin_active(db, business_id):
raise ApiError(
"PLUGIN_NOT_ACTIVE",
"افزونه مدیریت تعمیرگاه فعال نیست",
http_status=403
)
def _person_display_name(person: Person) -> str:
"""نام نمایشی شخص (مدل Person فیلد name ندارد)."""
parts = [person.first_name, person.last_name]
full = " ".join(p for p in parts if p)
return (
person.alias_name
or full.strip()
or person.company_name
or ""
)
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
"""دریافت سال مالی جاری"""
today = date.today()
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.start_date <= today,
FiscalYear.end_date >= today
)
).first()
if not fiscal_year:
raise ApiError(
"NO_ACTIVE_FISCAL_YEAR",
"سال مالی فعالی یافت نشد",
http_status=400
)
return fiscal_year
# ========== تنظیمات تعمیرگاه ==========
def get_repair_shop_settings(db: Session, business_id: int) -> Dict[str, Any]:
"""دریافت تنظیمات تعمیرگاه"""
_check_plugin_active(db, business_id)
repo = RepairShopSettingsRepository(db)
settings = repo.get_by_business(business_id)
if not settings:
# ایجاد تنظیمات پیش‌فرض
settings = repo.create_or_update(business_id, {})
return {
"id": settings.id,
"business_id": settings.business_id,
"receipt_code_format": settings.receipt_code_format,
"receipt_code_prefix": settings.receipt_code_prefix,
"auto_send_sms_on_receive": settings.auto_send_sms_on_receive,
"auto_send_sms_on_status_change": settings.auto_send_sms_on_status_change,
"auto_send_email_on_receive": settings.auto_send_email_on_receive,
"auto_send_email_on_status_change": settings.auto_send_email_on_status_change,
"sms_templates": settings.sms_templates or {},
"email_templates": settings.email_templates or {},
"default_service_product_id": settings.default_service_product_id,
"default_warehouse_id": settings.default_warehouse_id,
"extra_settings": settings.extra_settings or {},
}
def update_repair_shop_settings(
db: Session,
business_id: int,
settings_data: Dict[str, Any]
) -> Dict[str, Any]:
"""به‌روزرسانی تنظیمات تعمیرگاه"""
_check_plugin_active(db, business_id)
repo = RepairShopSettingsRepository(db)
settings = repo.create_or_update(business_id, settings_data)
db.commit()
return get_repair_shop_settings(db, business_id)
# ========== تعمیرکاران ==========
def list_technicians(
db: Session,
business_id: int,
only_active: bool = True,
offset: int = 0,
limit: int = 100
) -> Dict[str, Any]:
"""لیست تعمیرکاران"""
_check_plugin_active(db, business_id)
repo = RepairTechnicianRepository(db)
technicians = repo.list_by_business(business_id, only_active, offset, limit)
items = []
for tech in technicians:
# دریافت اطلاعات Person
person = db.query(Person).filter(Person.id == tech.person_id).first()
items.append({
"id": tech.id,
"business_id": tech.business_id,
"person_id": tech.person_id,
"person_name": _person_display_name(person) if person else "",
"code": tech.code,
"commission_type": tech.commission_type,
"commission_value": float(tech.commission_value),
"is_active": tech.is_active,
"extra_info": tech.extra_info or {},
})
return {
"items": items,
"total": len(items),
}
def get_technician(
db: Session,
business_id: int,
technician_id: int
) -> Dict[str, Any]:
"""دریافت اطلاعات یک تعمیرکار"""
_check_plugin_active(db, business_id)
repo = RepairTechnicianRepository(db)
tech = repo.get_by_id(technician_id, business_id)
if not tech:
raise ApiError("TECHNICIAN_NOT_FOUND", "تعمیرکار یافت نشد", http_status=404)
# دریافت اطلاعات Person
person = db.query(Person).filter(Person.id == tech.person_id).first()
return {
"id": tech.id,
"business_id": tech.business_id,
"person_id": tech.person_id,
"person_name": _person_display_name(person) if person else "",
"code": tech.code,
"commission_type": tech.commission_type,
"commission_value": float(tech.commission_value),
"is_active": tech.is_active,
"extra_info": tech.extra_info or {},
}
def create_technician(
db: Session,
business_id: int,
data: Dict[str, Any],
user_id: int
) -> Dict[str, Any]:
"""ایجاد تعمیرکار جدید"""
_check_plugin_active(db, business_id)
# بررسی Person
person_id = data.get("person_id")
if not person_id:
raise ApiError("PERSON_ID_REQUIRED", "شناسه Person الزامی است", http_status=400)
person = db.query(Person).filter(
and_(
Person.id == person_id,
Person.business_id == business_id
)
).first()
if not person:
raise ApiError("PERSON_NOT_FOUND", "Person یافت نشد", http_status=404)
# تولید کد (اگر ارسال نشده باشد)
code = data.get("code")
if not code:
# تولید کد اتوماتیک
repo = RepairTechnicianRepository(db)
counter = 1
while True:
code = f"TECH-{counter:04d}"
existing = repo.get_by_code(code, business_id)
if not existing:
break
counter += 1
# بررسی تکراری بودن کد
repo = RepairTechnicianRepository(db)
existing = repo.get_by_code(code, business_id)
if existing:
raise ApiError("CODE_ALREADY_EXISTS", "کد تعمیرکار تکراری است", http_status=409)
# ایجاد
tech_data = {
"business_id": business_id,
"person_id": person_id,
"code": code,
"commission_type": data.get("commission_type", "percentage"),
"commission_value": Decimal(str(data.get("commission_value", 0))),
"is_active": data.get("is_active", True),
"extra_info": data.get("extra_info"),
}
tech = repo.create(tech_data)
db.commit()
return get_technician(db, business_id, tech.id)
def update_technician(
db: Session,
business_id: int,
technician_id: int,
data: Dict[str, Any],
user_id: int
) -> Dict[str, Any]:
"""به‌روزرسانی تعمیرکار"""
_check_plugin_active(db, business_id)
repo = RepairTechnicianRepository(db)
tech = repo.get_by_id(technician_id, business_id)
if not tech:
raise ApiError("TECHNICIAN_NOT_FOUND", "تعمیرکار یافت نشد", http_status=404)
# بررسی تکراری بودن کد (اگر تغییر کرده باشد)
if "code" in data and data["code"] != tech.code:
existing = repo.get_by_code(data["code"], business_id)
if existing:
raise ApiError("CODE_ALREADY_EXISTS", "کد تعمیرکار تکراری است", http_status=409)
# به‌روزرسانی
update_data = {}
if "code" in data:
update_data["code"] = data["code"]
if "commission_type" in data:
update_data["commission_type"] = data["commission_type"]
if "commission_value" in data:
update_data["commission_value"] = Decimal(str(data["commission_value"]))
if "is_active" in data:
update_data["is_active"] = data["is_active"]
if "extra_info" in data:
update_data["extra_info"] = data["extra_info"]
tech = repo.update(tech, update_data)
db.commit()
return get_technician(db, business_id, technician_id)
def delete_technician(
db: Session,
business_id: int,
technician_id: int,
user_id: int
) -> Dict[str, Any]:
"""حذف تعمیرکار"""
_check_plugin_active(db, business_id)
repo = RepairTechnicianRepository(db)
tech = repo.get_by_id(technician_id, business_id)
if not tech:
raise ApiError("TECHNICIAN_NOT_FOUND", "تعمیرکار یافت نشد", http_status=404)
# بررسی اینکه آیا تعمیرکار سفارش فعالی دارد
active_orders = db.query(RepairOrder).filter(
and_(
RepairOrder.assigned_technician_id == technician_id,
RepairOrder.status.in_([
"received", "assigned", "in_progress", "waiting_parts", "testing"
])
)
).count()
if active_orders > 0:
raise ApiError(
"TECHNICIAN_HAS_ACTIVE_ORDERS",
f"این تعمیرکار {active_orders} سفارش فعال دارد. ابتدا سفارشات را تکمیل کنید.",
http_status=409
)
# غیرفعال کردن به جای حذف (Soft Delete)
tech.is_active = False
db.commit()
return {"message": "تعمیرکار با موفقیت غیرفعال شد"}
# ========== سفارشات تعمیر ==========
def _generate_repair_code(
db: Session,
business_id: int,
format_type: str,
prefix: str
) -> str:
"""تولید کد یکتا برای سفارش تعمیر"""
repo = RepairOrderRepository(db)
if format_type == "random":
# تولید کد رندوم
while True:
code = f"{prefix}-{_generate_random_code(8)}"
existing = repo.get_by_code(code, business_id)
if not existing:
return code
elif format_type == "sequential":
# تولید کد ترتیبی
year = datetime.utcnow().year
next_num = repo.get_next_sequential_number(business_id, prefix, year)
return f"{prefix}-{year}-{next_num:04d}"
else:
# پیش‌فرض: sequential
year = datetime.utcnow().year
next_num = repo.get_next_sequential_number(business_id, prefix, year)
return f"{prefix}-{year}-{next_num:04d}"
def list_repair_orders(
db: Session,
business_id: int,
filters: Optional[Dict[str, Any]] = None,
offset: int = 0,
limit: int = 50
) -> Dict[str, Any]:
"""لیست سفارشات تعمیر"""
from sqlalchemy.orm import selectinload, joinedload
_check_plugin_active(db, business_id)
repo = RepairOrderRepository(db)
orders, total = repo.list_by_business(business_id, filters, offset, limit)
# دریافت ارز پیش‌فرض کسب‌وکار برای همه سفارشات
business = db.query(Business).filter(Business.id == business_id).first()
default_currency_symbol = "تومان"
if business and business.default_currency_id:
currency = db.query(Currency).filter(Currency.id == business.default_currency_id).first()
if currency:
default_currency_symbol = currency.symbol
# Eager load relationships برای جلوگیری از N+1 queries
# دریافت IDs برای batch loading
customer_ids = {order.customer_person_id for order in orders}
technician_ids = {order.assigned_technician_id for order in orders if order.assigned_technician_id}
currency_ids = {order.currency_id for order in orders}
# Load همه customers به صورت batch
customers_map = {}
if customer_ids:
customers = db.query(Person).filter(Person.id.in_(customer_ids)).all()
customers_map = {c.id: c for c in customers}
# Load همه technicians و persons مرتبط
technicians_map = {}
tech_persons_map = {}
if technician_ids:
technicians = db.query(RepairTechnician).filter(
RepairTechnician.id.in_(technician_ids)
).all()
technicians_map = {t.id: t for t in technicians}
tech_person_ids = {t.person_id for t in technicians}
if tech_person_ids:
tech_persons = db.query(Person).filter(Person.id.in_(tech_person_ids)).all()
tech_persons_map = {p.id: p for p in tech_persons}
# Load همه currencies
currencies_map = {}
if currency_ids:
currencies = db.query(Currency).filter(Currency.id.in_(currency_ids)).all()
currencies_map = {c.id: c for c in currencies}
items = []
for order in orders:
# دریافت اطلاعات مشتری از cache
customer = customers_map.get(order.customer_person_id)
# دریافت اطلاعات تعمیرکار از cache
technician_name = None
if order.assigned_technician_id:
technician = technicians_map.get(order.assigned_technician_id)
if technician:
tech_person = tech_persons_map.get(technician.person_id)
technician_name = _person_display_name(tech_person) if tech_person else ""
# دریافت ارز این سفارش از cache
currency = currencies_map.get(order.currency_id)
currency_symbol = currency.symbol if currency else default_currency_symbol
items.append({
"id": order.id,
"code": order.code,
"customer_person_id": order.customer_person_id,
"customer_name": _person_display_name(customer) if customer else "",
"customer_phone": customer.mobile if customer else None,
"product_name": order.product_name,
"product_serial": order.product_serial,
"status": order.status,
"problem_description": order.problem_description,
"assigned_technician_id": order.assigned_technician_id,
"technician_name": technician_name,
"final_cost": float(order.final_cost),
"currency_id": order.currency_id,
"currency_symbol": currency_symbol,
"received_at": order.received_at,
"estimated_delivery_at": order.estimated_delivery_at,
"completed_at": order.completed_at,
})
return {
"items": items,
"total": total,
"offset": offset,
"limit": limit,
}
def get_repair_order(
db: Session,
business_id: int,
order_id: int
) -> Dict[str, Any]:
"""دریافت اطلاعات کامل یک سفارش تعمیر"""
_check_plugin_active(db, business_id)
repo = RepairOrderRepository(db)
order = repo.get_by_id(order_id, business_id)
if not order:
raise ApiError("REPAIR_ORDER_NOT_FOUND", "سفارش تعمیر یافت نشد", http_status=404)
# دریافت اطلاعات مشتری
customer = db.query(Person).filter(Person.id == order.customer_person_id).first()
# دریافت اطلاعات تعمیرکار
technician_name = None
if order.assigned_technician_id:
technician = db.query(RepairTechnician).filter(
RepairTechnician.id == order.assigned_technician_id
).first()
if technician:
tech_person = db.query(Person).filter(Person.id == technician.person_id).first()
technician_name = _person_display_name(tech_person) if tech_person else ""
# دریافت اطلاعات ارز
currency = db.query(Currency).filter(Currency.id == order.currency_id).first()
currency_symbol = currency.symbol if currency else "تومان"
currency_code = currency.code if currency else "IRR"
# دریافت قطعات
parts_repo = RepairOrderPartRepository(db)
parts = parts_repo.list_by_order(order_id)
parts_list = []
for part in parts:
product = db.query(Product).filter(Product.id == part.product_id).first()
parts_list.append({
"id": part.id,
"product_id": part.product_id,
"product_name": product.name if product else "",
"quantity": float(part.quantity),
"unit_price": float(part.unit_price),
"total_price": float(part.total_price),
"warehouse_id": part.warehouse_id,
"description": part.description,
})
# دریافت تاریخچه وضعیت‌ها
status_repo = RepairOrderStatusRepository(db)
statuses = status_repo.list_by_order(order_id)
status_history = []
for status in statuses:
status_history.append({
"id": status.id,
"status": status.status,
"notes": status.notes,
"created_at": status.created_at,
"sms_sent": status.sms_sent,
"email_sent": status.email_sent,
})
return {
"id": order.id,
"code": order.code,
"business_id": order.business_id,
"customer_person_id": order.customer_person_id,
"customer_name": _person_display_name(customer) if customer else "",
"customer_phone": customer.mobile if customer else None,
"customer_email": customer.email if customer else None,
"product_id": order.product_id,
"product_name": order.product_name,
"product_serial": order.product_serial,
"warranty_code_id": order.warranty_code_id,
"status": order.status,
"problem_description": order.problem_description,
"customer_notes": order.customer_notes,
"technician_notes": order.technician_notes,
"assigned_technician_id": order.assigned_technician_id,
"technician_name": technician_name,
"estimated_cost": float(order.estimated_cost) if order.estimated_cost else None,
"final_cost": float(order.final_cost),
"parts_cost": float(order.parts_cost),
"labor_cost": float(order.labor_cost),
"technician_commission": float(order.technician_commission),
"currency_id": order.currency_id,
"currency_symbol": currency_symbol,
"currency_code": currency_code,
"received_at": order.received_at,
"estimated_delivery_at": order.estimated_delivery_at,
"completed_at": order.completed_at,
"delivered_at": order.delivered_at,
"extra_info": order.extra_info or {},
"parts": parts_list,
"status_history": status_history,
}
def create_repair_order(
db: Session,
business_id: int,
data: Dict[str, Any],
user_id: int,
calendar_type: str = "jalali",
) -> Dict[str, Any]:
"""ایجاد سفارش تعمیر جدید"""
_check_plugin_active(db, business_id)
# دریافت تنظیمات
settings_repo = RepairShopSettingsRepository(db)
settings = settings_repo.get_by_business(business_id)
if not settings:
settings = settings_repo.create_or_update(business_id, {})
# بررسی مشتری
customer_person_id = data.get("customer_person_id")
if not customer_person_id:
raise ApiError("CUSTOMER_REQUIRED", "مشتری الزامی است", http_status=400)
customer = db.query(Person).filter(
and_(
Person.id == customer_person_id,
Person.business_id == business_id
)
).first()
if not customer:
raise ApiError("CUSTOMER_NOT_FOUND", "مشتری یافت نشد", http_status=404)
# بررسی کالا (اختیاری)
product_id = data.get("product_id")
product_name = data.get("product_name")
if product_id:
product = db.query(Product).filter(
and_(
Product.id == product_id,
Product.business_id == business_id
)
).first()
if not product:
raise ApiError("PRODUCT_NOT_FOUND", "کالا یافت نشد", http_status=404)
if not product_name:
product_name = product.name
if not product_name:
raise ApiError("PRODUCT_NAME_REQUIRED", "نام کالا الزامی است", http_status=400)
# دریافت سال مالی و ارز
fiscal_year = _get_current_fiscal_year(db, business_id)
currency_id = data.get("currency_id")
if not currency_id:
# استفاده از ارز پیش‌فرض کسب‌وکار
business = db.query(Business).filter(Business.id == business_id).first()
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
if not business.default_currency_id:
raise ApiError(
"NO_DEFAULT_CURRENCY",
"ارز پیش‌فرض برای این کسب‌وکار تنظیم نشده است",
http_status=400
)
currency_id = business.default_currency_id
# تولید کد
code = _generate_repair_code(
db,
business_id,
settings.receipt_code_format,
settings.receipt_code_prefix
)
# ایجاد سفارش
order_data = {
"business_id": business_id,
"code": code,
"customer_person_id": customer_person_id,
"product_id": product_id,
"product_name": product_name,
"product_serial": data.get("product_serial"),
"warranty_code_id": data.get("warranty_code_id"),
"status": "received",
"problem_description": data.get("problem_description", ""),
"customer_notes": data.get("customer_notes"),
"estimated_cost": Decimal(str(data["estimated_cost"])) if data.get("estimated_cost") else None,
"fiscal_year_id": fiscal_year.id,
"currency_id": currency_id,
"created_by_user_id": user_id,
"received_at": datetime.utcnow(),
"estimated_delivery_at": data.get("estimated_delivery_at"),
}
repo = RepairOrderRepository(db)
order = repo.create(order_data)
# ثبت وضعیت اولیه
status_repo = RepairOrderStatusRepository(db)
status_repo.create({
"repair_order_id": order.id,
"status": "received",
"notes": "دریافت کالا",
"created_by_user_id": user_id,
})
db.commit()
# ارسال پیامک/ایمیل (اگر فعال باشد)
if settings.auto_send_sms_on_receive or settings.auto_send_email_on_receive:
from app.services.repair_shop_notification import send_repair_notification
try:
send_repair_notification(
db=db,
business_id=business_id,
repair_order=order,
event_type="repair_shop.received",
triggered_by_user_id=user_id,
calendar_type=calendar_type,
)
except Exception as e:
logger.error(f"خطا در ارسال نوتیفیکیشن دریافت سفارش: {e}")
# ادامه می‌دهیم حتی اگر نوتیفیکیشن ارسال نشد
return get_repair_order(db, business_id, order.id)
def update_repair_order(
db: Session,
business_id: int,
order_id: int,
data: Dict[str, Any],
user_id: int
) -> Dict[str, Any]:
"""به‌روزرسانی سفارش تعمیر"""
_check_plugin_active(db, business_id)
repo = RepairOrderRepository(db)
order = repo.get_by_id(order_id, business_id)
if not order:
raise ApiError("REPAIR_ORDER_NOT_FOUND", "سفارش تعمیر یافت نشد", http_status=404)
# فقط سفارشات در وضعیت‌های خاص قابل ویرایش هستند
if order.status in ["delivered", "cancelled"]:
raise ApiError(
"ORDER_NOT_EDITABLE",
f"سفارش در وضعیت {order.status} قابل ویرایش نیست",
http_status=400
)
# به‌روزرسانی فیلدهای مجاز
update_data = {}
if "product_serial" in data:
update_data["product_serial"] = data["product_serial"]
if "problem_description" in data:
update_data["problem_description"] = data["problem_description"]
if "customer_notes" in data:
update_data["customer_notes"] = data["customer_notes"]
if "technician_notes" in data:
update_data["technician_notes"] = data["technician_notes"]
if "estimated_cost" in data:
update_data["estimated_cost"] = Decimal(str(data["estimated_cost"])) if data["estimated_cost"] else None
if "estimated_delivery_at" in data:
update_data["estimated_delivery_at"] = data["estimated_delivery_at"]
if update_data:
update_data["updated_at"] = datetime.utcnow()
order = repo.update(order, update_data)
db.commit()
return get_repair_order(db, business_id, order_id)
def delete_repair_order(
db: Session,
business_id: int,
order_id: int,
user_id: int,
reason: Optional[str] = None
) -> Dict[str, Any]:
"""حذف (لغو) سفارش تعمیر"""
_check_plugin_active(db, business_id)
repo = RepairOrderRepository(db)
order = repo.get_by_id(order_id, business_id)
if not order:
raise ApiError("REPAIR_ORDER_NOT_FOUND", "سفارش تعمیر یافت نشد", http_status=404)
# بررسی وضعیت - فقط سفارشات خاص قابل لغو هستند
if order.status in ["delivered"]:
raise ApiError(
"ORDER_NOT_DELETABLE",
"سفارش تحویل داده شده قابل لغو نیست",
http_status=400
)
if order.status == "cancelled":
raise ApiError(
"ORDER_ALREADY_CANCELLED",
"این سفارش قبلاً لغو شده است",
http_status=400
)
# بررسی فاکتور - اگر فاکتور صادر شده، نمی‌توان لغو کرد
invoice_repo = RepairInvoiceRepository(db)
existing_invoice = invoice_repo.get_by_order(order_id)
if existing_invoice:
raise ApiError(
"ORDER_HAS_INVOICE",
"این سفارش دارای فاکتور است و قابل لغو نیست. ابتدا فاکتور را حذف کنید.",
http_status=409
)
# Soft delete: تغییر وضعیت به cancelled
order.status = "cancelled"
order.updated_at = datetime.utcnow()
# ثبت در تاریخچه
status_repo = RepairOrderStatusRepository(db)
status_repo.create({
"repair_order_id": order.id,
"status": "cancelled",
"notes": reason or "لغو سفارش توسط کاربر",
"created_by_user_id": user_id,
})
db.commit()
return {
"message": "سفارش تعمیر با موفقیت لغو شد",
"order_id": order.id,
"code": order.code
}