This commit is contained in:
@@ -260,6 +260,7 @@ async def expense_period_totals(
|
||||
.where(
|
||||
ExpenseEntry.car_id == car_id,
|
||||
ExpenseEntry.entry_date <= date_to,
|
||||
ExpenseEntry.service_visit_id.is_(None),
|
||||
)
|
||||
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
|
||||
)
|
||||
|
||||
@@ -1,27 +1,70 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.car import ServiceNotification
|
||||
from app.models.user import User
|
||||
|
||||
MODERATOR_ROLES = {"admin", "verifier", "moderator"}
|
||||
|
||||
|
||||
async def notify_user(user: User, text: str) -> None:
|
||||
async def notify_user(user: User, text: str) -> bool:
|
||||
if not settings.bot_token or settings.app_env == "test":
|
||||
return
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
await client.post(
|
||||
response = await client.post(
|
||||
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||
data={"chat_id": str(user.telegram_id), "text": text},
|
||||
)
|
||||
return response.status_code < 400
|
||||
except Exception:
|
||||
return
|
||||
return False
|
||||
|
||||
|
||||
async def notify_platform_moderators(session: AsyncSession, text: str) -> None:
|
||||
result = await session.execute(select(User).where(User.platform_role.in_(MODERATOR_ROLES)))
|
||||
for user in result.scalars():
|
||||
await notify_user(user, text)
|
||||
|
||||
|
||||
async def retry_failed_notifications(session: AsyncSession, *, limit: int = 50) -> int:
|
||||
return await process_notification_queue(session, limit=limit)
|
||||
|
||||
|
||||
async def process_notification_queue(session: AsyncSession, *, limit: int = 50) -> int:
|
||||
now = datetime.now(UTC)
|
||||
result = await session.execute(
|
||||
select(ServiceNotification)
|
||||
.where(
|
||||
ServiceNotification.status.in_(["pending", "failed", "retrying"]),
|
||||
ServiceNotification.retry_count < 5,
|
||||
)
|
||||
.order_by(ServiceNotification.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
delivered = 0
|
||||
for notification in result.scalars():
|
||||
if notification.status == "retrying" and notification.created_at > now - timedelta(seconds=30):
|
||||
continue
|
||||
notification.status = "processing"
|
||||
user = await session.get(User, notification.recipient_user_id)
|
||||
if user is None:
|
||||
notification.status = "abandoned"
|
||||
notification.last_error = "recipient_not_found"
|
||||
continue
|
||||
ok = await notify_user(user, f"{notification.title}\n{notification.body}" if notification.body else notification.title)
|
||||
notification.retry_count += 1
|
||||
if ok:
|
||||
notification.status = "sent"
|
||||
notification.sent_at = datetime.now(UTC)
|
||||
notification.last_error = None
|
||||
delivered += 1
|
||||
else:
|
||||
notification.status = "abandoned" if notification.retry_count >= 5 else "retrying"
|
||||
notification.last_error = "telegram_delivery_failed"
|
||||
await session.commit()
|
||||
return delivered
|
||||
|
||||
@@ -50,17 +50,35 @@ class TesseractOCRProvider:
|
||||
def _recognize_sync(self, content: bytes) -> str:
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
except ImportError:
|
||||
return ""
|
||||
try:
|
||||
image = Image.open(BytesIO(content))
|
||||
except Exception:
|
||||
return ""
|
||||
candidates = [image]
|
||||
try:
|
||||
return pytesseract.image_to_string(image, lang=settings.ocr_languages)
|
||||
grayscale = ImageOps.grayscale(image)
|
||||
resized = grayscale.resize((grayscale.width * 2, grayscale.height * 2))
|
||||
contrast = ImageEnhance.Contrast(resized).enhance(1.8)
|
||||
threshold = contrast.point(lambda pixel: 255 if pixel > 165 else 0)
|
||||
candidates.extend([grayscale, contrast, threshold])
|
||||
except Exception:
|
||||
return pytesseract.image_to_string(image)
|
||||
candidates = [image]
|
||||
recognized: list[str] = []
|
||||
for candidate in candidates:
|
||||
for config in ("--psm 6", "--psm 11"):
|
||||
try:
|
||||
text = pytesseract.image_to_string(candidate, lang=settings.ocr_languages, config=config)
|
||||
except Exception:
|
||||
try:
|
||||
text = pytesseract.image_to_string(candidate, config=config)
|
||||
except Exception:
|
||||
text = ""
|
||||
if text.strip():
|
||||
recognized.append(text)
|
||||
return "\n".join(recognized)
|
||||
|
||||
|
||||
class CompositeOCRProvider:
|
||||
|
||||
124
app/services/rate_limit.py
Normal file
124
app/services/rate_limit.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Hashable
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
BucketKey = tuple[str, Hashable]
|
||||
|
||||
_buckets: dict[BucketKey, deque[float]] = defaultdict(deque)
|
||||
_redis_client = None
|
||||
|
||||
|
||||
def reset_rate_limit_state() -> None:
|
||||
_buckets.clear()
|
||||
|
||||
|
||||
async def check_rate_limit(
|
||||
*,
|
||||
scope: str,
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
request: Request | None = None,
|
||||
user: User | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
identifiers: list[Hashable] = []
|
||||
if user is not None:
|
||||
identifiers.append(f"user:{user.id}")
|
||||
identifiers.append(f"telegram:{user.telegram_id}")
|
||||
if request is not None and request.client is not None:
|
||||
identifiers.append(f"ip:{request.client.host}")
|
||||
if not identifiers:
|
||||
identifiers.append("anonymous")
|
||||
|
||||
if settings.redis_url:
|
||||
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
|
||||
if not allowed:
|
||||
await log_rate_limit_event(session, scope=scope, identifier="redis")
|
||||
raise_rate_limit(scope, window_seconds)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
for identifier in identifiers:
|
||||
key = (scope, identifier)
|
||||
bucket = _buckets[key]
|
||||
while bucket and now - bucket[0] > window_seconds:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= limit:
|
||||
await log_rate_limit_event(session, scope=scope, identifier=str(identifier))
|
||||
raise_rate_limit(scope, window_seconds)
|
||||
for identifier in identifiers:
|
||||
_buckets[(scope, identifier)].append(now)
|
||||
|
||||
|
||||
def raise_rate_limit(scope: str, window_seconds: int) -> None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={
|
||||
"code": "rate_limit_exceeded",
|
||||
"message": "Слишком много запросов. Попробуйте чуть позже.",
|
||||
"scope": scope,
|
||||
"retry_after_seconds": window_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def get_redis_client():
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
try:
|
||||
from redis.asyncio import Redis
|
||||
except ImportError:
|
||||
return None
|
||||
_redis_client = Redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def check_redis_rate_limit(
|
||||
scope: str,
|
||||
identifiers: list[Hashable],
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
) -> bool:
|
||||
client = await get_redis_client()
|
||||
if client is None:
|
||||
return True
|
||||
now_window = int(time.time() // window_seconds)
|
||||
keys = [f"rl:{scope}:{identifier}:{now_window}" for identifier in identifiers]
|
||||
pipe = client.pipeline()
|
||||
for key in keys:
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, window_seconds * 2)
|
||||
results = await pipe.execute()
|
||||
counts = [int(results[index]) for index in range(0, len(results), 2)]
|
||||
return all(count <= limit for count in counts)
|
||||
|
||||
|
||||
async def log_rate_limit_event(
|
||||
session: AsyncSession | None,
|
||||
*,
|
||||
scope: str,
|
||||
identifier: str,
|
||||
) -> None:
|
||||
if session is None:
|
||||
return
|
||||
from app.models.car import AuditLog
|
||||
|
||||
session.add(
|
||||
AuditLog(
|
||||
actor_user_id=None,
|
||||
actor_role="system",
|
||||
action="rate_limit.exceeded",
|
||||
target_type=scope,
|
||||
target_id=identifier[:80],
|
||||
metadata_json={"scope": scope, "identifier": identifier},
|
||||
)
|
||||
)
|
||||
@@ -21,7 +21,7 @@ from app.models.expense import ServiceEntry
|
||||
from app.models.user import User
|
||||
from app.services.notifications import notify_user
|
||||
|
||||
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "proposed_new_time"}
|
||||
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "confirmed_by_sto", "proposed_new_time"}
|
||||
DEFAULT_SERVICE_DURATIONS = {
|
||||
"oil_change": 60,
|
||||
"diagnostics": 60,
|
||||
@@ -190,7 +190,16 @@ async def create_service_notification(
|
||||
service_center_id: int | None = None,
|
||||
appointment_id: int | None = None,
|
||||
send_telegram: bool = True,
|
||||
idempotency_key: str | None = None,
|
||||
) -> ServiceNotification:
|
||||
if idempotency_key:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ServiceNotification).where(ServiceNotification.idempotency_key == idempotency_key)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
notification = ServiceNotification(
|
||||
recipient_user_id=recipient_user_id,
|
||||
service_center_id=service_center_id,
|
||||
@@ -198,12 +207,21 @@ async def create_service_notification(
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
body=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
session.add(notification)
|
||||
if send_telegram:
|
||||
user = await session.get(User, recipient_user_id)
|
||||
if user is not None:
|
||||
await notify_user(user, f"{title}\n{body}" if body else title)
|
||||
notification.status = "processing"
|
||||
delivered = await notify_user(user, f"{title}\n{body}" if body else title)
|
||||
if delivered:
|
||||
notification.status = "sent"
|
||||
notification.sent_at = datetime.now(UTC)
|
||||
else:
|
||||
notification.status = "retrying"
|
||||
notification.retry_count = 1
|
||||
notification.last_error = "telegram_delivery_failed"
|
||||
return notification
|
||||
|
||||
|
||||
|
||||
54
app/services/uploads.py
Normal file
54
app/services/uploads.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import PurePath
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
SAFE_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}
|
||||
SAFE_TEXT_TYPES = {"text/plain", "application/pdf"}
|
||||
BLOCKED_EXTENSIONS = {".exe", ".bat", ".cmd", ".sh", ".php", ".js", ".html", ".svg"}
|
||||
|
||||
|
||||
def sanitize_filename(filename: str | None) -> str:
|
||||
name = PurePath(filename or "upload.bin").name
|
||||
return "".join(char if char.isalnum() or char in {".", "-", "_"} else "_" for char in name)[:160]
|
||||
|
||||
|
||||
def validate_upload(
|
||||
*,
|
||||
content: bytes,
|
||||
filename: str | None,
|
||||
content_type: str | None,
|
||||
max_bytes: int,
|
||||
allowed_types: set[str],
|
||||
) -> str:
|
||||
safe_name = sanitize_filename(filename)
|
||||
suffix = PurePath(safe_name).suffix.lower()
|
||||
if len(content) > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
if suffix in BLOCKED_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail="Executable or unsafe file type is not allowed")
|
||||
detected_type = (content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream").lower()
|
||||
if detected_type not in allowed_types:
|
||||
raise HTTPException(status_code=415, detail="Unsupported file type")
|
||||
if detected_type in SAFE_IMAGE_TYPES:
|
||||
validate_image(content)
|
||||
return safe_name
|
||||
|
||||
|
||||
def validate_image(content: bytes) -> None:
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return
|
||||
try:
|
||||
with Image.open(__import__("io").BytesIO(content)) as image:
|
||||
width, height = image.size
|
||||
if width * height > 24_000_000:
|
||||
raise HTTPException(status_code=413, detail="Image dimensions are too large")
|
||||
image.verify()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=415, detail="Corrupted image file") from exc
|
||||
315
app/services/work_orders.py
Normal file
315
app/services/work_orders.py
Normal file
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.car import (
|
||||
Car,
|
||||
InventoryTransaction,
|
||||
MaintenanceRecommendation,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceProductItem,
|
||||
ServiceVisit,
|
||||
ServiceWorkItem,
|
||||
WorkOrderStatusHistory,
|
||||
)
|
||||
from app.models.expense import ExpenseCategory, ExpenseEntry, ServiceEntry, ServiceType
|
||||
from app.models.user import User
|
||||
from app.services.odometer import apply_odometer_from_record, validate_odometer_change
|
||||
from app.services.sto_booking import create_service_notification
|
||||
|
||||
WORK_ORDER_STATUSES = {
|
||||
"draft",
|
||||
"diagnosis",
|
||||
"waiting_owner_approval",
|
||||
"approved_by_owner",
|
||||
"rejected_by_owner",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"archived",
|
||||
}
|
||||
LOCKED_WORK_ORDER_STATUSES = {"completed", "cancelled", "archived"}
|
||||
|
||||
|
||||
def money(value: Decimal | int | float | None) -> Decimal:
|
||||
return Decimal(str(value or 0)).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def line_total(quantity: Decimal, unit_price: Decimal | None, discount: Decimal) -> Decimal:
|
||||
return max(Decimal("0"), Decimal(quantity) * money(unit_price) - money(discount)).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
async def add_status_history(
|
||||
session: AsyncSession,
|
||||
visit: ServiceVisit,
|
||||
*,
|
||||
to_status: str,
|
||||
actor: User | None,
|
||||
comment: str | None = None,
|
||||
) -> None:
|
||||
if to_status not in WORK_ORDER_STATUSES and to_status not in {"pending_owner_confirmation", "confirmed", "disputed"}:
|
||||
raise HTTPException(status_code=400, detail="Unsupported work order status")
|
||||
from_status = visit.status
|
||||
if from_status == to_status:
|
||||
return
|
||||
visit.status = to_status
|
||||
session.add(
|
||||
WorkOrderStatusHistory(
|
||||
service_visit_id=visit.id,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
changed_by_user_id=actor.id if actor else None,
|
||||
comment=comment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def ensure_work_order_editable(visit: ServiceVisit) -> None:
|
||||
if visit.status in LOCKED_WORK_ORDER_STATUSES:
|
||||
raise HTTPException(status_code=409, detail="Completed or archived work order cannot be changed")
|
||||
|
||||
|
||||
async def refresh_work_order_totals(session: AsyncSession, visit: ServiceVisit) -> None:
|
||||
work_items = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
product_items = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
labor_total = sum((money(item.total if item.total is not None else item.price) for item in work_items), Decimal("0"))
|
||||
product_total = sum((money(item.total) for item in product_items), Decimal("0"))
|
||||
discount_total = money(visit.discount_total)
|
||||
final_total = max(Decimal("0"), labor_total + product_total - discount_total).quantize(Decimal("0.01"))
|
||||
visit.labor_total = labor_total.quantize(Decimal("0.01"))
|
||||
visit.product_total = product_total.quantize(Decimal("0.01"))
|
||||
visit.final_total = final_total
|
||||
visit.total_cost = final_total
|
||||
if visit.status == "approved_by_owner" and visit.price_approved_total is not None and final_total != visit.price_approved_total:
|
||||
visit.status = "waiting_owner_approval"
|
||||
visit.approved_at = None
|
||||
|
||||
|
||||
async def assign_work_order_number(session: AsyncSession, visit: ServiceVisit) -> None:
|
||||
if visit.work_order_number:
|
||||
return
|
||||
await session.flush()
|
||||
visit.work_order_number = f"WO-{date.today():%Y%m%d}-{visit.id:06d}"
|
||||
|
||||
|
||||
async def add_labor_item(
|
||||
session: AsyncSession,
|
||||
visit: ServiceVisit,
|
||||
*,
|
||||
payload: dict,
|
||||
) -> ServiceWorkItem:
|
||||
await ensure_work_order_editable(visit)
|
||||
quantity = Decimal(str(payload.get("quantity") or 1))
|
||||
unit_price = payload.get("unit_price")
|
||||
legacy_price = payload.get("price")
|
||||
total = line_total(quantity, money(unit_price if unit_price is not None else legacy_price), Decimal(str(payload.get("discount") or 0)))
|
||||
item = ServiceWorkItem(**payload, service_visit_id=visit.id, total=total)
|
||||
if item.price is None:
|
||||
item.price = total
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
await refresh_work_order_totals(session, visit)
|
||||
return item
|
||||
|
||||
|
||||
async def add_product_item(
|
||||
session: AsyncSession,
|
||||
visit: ServiceVisit,
|
||||
*,
|
||||
payload: dict,
|
||||
) -> ServiceProductItem:
|
||||
await ensure_work_order_editable(visit)
|
||||
quantity = Decimal(str(payload.get("quantity") or 1))
|
||||
unit_price = Decimal(str(payload.get("unit_price") or 0))
|
||||
discount = Decimal(str(payload.get("discount") or 0))
|
||||
item = ServiceProductItem(**payload, service_visit_id=visit.id, total=line_total(quantity, unit_price, discount))
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
await refresh_work_order_totals(session, visit)
|
||||
return item
|
||||
|
||||
|
||||
async def close_work_order(
|
||||
session: AsyncSession,
|
||||
visit: ServiceVisit,
|
||||
*,
|
||||
actor: User,
|
||||
confirm_lower_odometer: bool = False,
|
||||
) -> tuple[ServiceEntry, ExpenseEntry]:
|
||||
if visit.status == "completed":
|
||||
service = (
|
||||
await session.execute(select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id))
|
||||
).scalar_one_or_none()
|
||||
expense = (
|
||||
await session.execute(select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id))
|
||||
).scalar_one_or_none()
|
||||
if service is not None and expense is not None:
|
||||
return service, expense
|
||||
raise HTTPException(status_code=409, detail="Completed work order is missing immutable records")
|
||||
if visit.status not in {"approved_by_owner", "in_progress", "diagnosis", "draft"}:
|
||||
raise HTTPException(status_code=409, detail="Work order must be approved or in progress before completion")
|
||||
if visit.approval_required and visit.status != "approved_by_owner":
|
||||
raise HTTPException(status_code=409, detail="Owner approval is required before completion")
|
||||
vehicle = await session.get(Car, visit.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
owner = await session.get(User, visit.owner_id or vehicle.owner_id)
|
||||
if owner is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle owner not found")
|
||||
validate_odometer_change(
|
||||
vehicle,
|
||||
visit.odometer,
|
||||
source_record_type="work_order",
|
||||
confirm_lower_odometer=confirm_lower_odometer,
|
||||
)
|
||||
await refresh_work_order_totals(session, visit)
|
||||
existing_service = (
|
||||
await session.execute(select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id))
|
||||
).scalar_one_or_none()
|
||||
existing_expense = (
|
||||
await session.execute(select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id))
|
||||
).scalar_one_or_none()
|
||||
if existing_service is not None or existing_expense is not None:
|
||||
raise HTTPException(status_code=409, detail="Work order completion records already exist")
|
||||
center = await session.get(ServiceCenter, visit.service_center_id)
|
||||
vendor_name = center.display_name or center.name if center else None
|
||||
service = ServiceEntry(
|
||||
car_id=vehicle.id,
|
||||
service_visit_id=visit.id,
|
||||
entry_date=visit.visit_date,
|
||||
odometer=visit.odometer,
|
||||
service_type=ServiceType.maintenance,
|
||||
title=f"Заказ-наряд {visit.work_order_number or visit.id}",
|
||||
category="sto_work_order",
|
||||
vendor=vendor_name,
|
||||
total_cost=visit.final_total,
|
||||
notes=visit.service_comment or visit.notes,
|
||||
)
|
||||
expense = ExpenseEntry(
|
||||
car_id=vehicle.id,
|
||||
service_visit_id=visit.id,
|
||||
entry_date=visit.visit_date,
|
||||
category=ExpenseCategory.maintenance,
|
||||
title=f"СТО: заказ-наряд {visit.work_order_number or visit.id}",
|
||||
vendor=vendor_name,
|
||||
total_cost=max(visit.final_total, Decimal("0.01")),
|
||||
currency=visit.currency,
|
||||
odometer=visit.odometer,
|
||||
metadata_json={
|
||||
"service_visit_id": visit.id,
|
||||
"work_order_number": visit.work_order_number,
|
||||
"labor_total": str(visit.labor_total),
|
||||
"product_total": str(visit.product_total),
|
||||
},
|
||||
)
|
||||
session.add_all([service, expense])
|
||||
await session.flush()
|
||||
await apply_odometer_from_record(
|
||||
session,
|
||||
vehicle,
|
||||
new_odometer=visit.odometer,
|
||||
source_record_type="work_order",
|
||||
source_record_id=visit.id,
|
||||
changed_by=actor.id,
|
||||
confirm_lower_odometer=confirm_lower_odometer,
|
||||
)
|
||||
visit.completed_at = datetime.now(UTC)
|
||||
await add_status_history(session, visit, to_status="completed", actor=actor, comment="Work order completed")
|
||||
appointment = (
|
||||
await session.execute(
|
||||
select(ServiceAppointment).where(ServiceAppointment.linked_work_order_id == visit.id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if appointment is not None:
|
||||
appointment.status = "completed"
|
||||
if appointment and appointment.source_recommendation_id:
|
||||
recommendation = await session.get(MaintenanceRecommendation, appointment.source_recommendation_id)
|
||||
if recommendation is not None:
|
||||
recommendation.status = "completed"
|
||||
work_items = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
product_items = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
for product in product_items:
|
||||
session.add(
|
||||
InventoryTransaction(
|
||||
service_center_id=visit.service_center_id,
|
||||
service_visit_id=visit.id,
|
||||
product_item_id=product.id,
|
||||
transaction_type="consume",
|
||||
sku=product.sku,
|
||||
title=product.title,
|
||||
quantity=product.quantity,
|
||||
unit=product.unit,
|
||||
actor_user_id=actor.id,
|
||||
metadata_json={"source": "work_order_completion"},
|
||||
)
|
||||
)
|
||||
for item in work_items:
|
||||
if item.next_due_date or item.next_due_odometer:
|
||||
session.add(
|
||||
MaintenanceRecommendation(
|
||||
vehicle_id=vehicle.id,
|
||||
recommendation_type=item.work_type or "maintenance",
|
||||
title=f"Следующее ТО: {item.title}",
|
||||
due_odometer_km=item.next_due_odometer,
|
||||
due_date=item.next_due_date,
|
||||
priority="medium",
|
||||
status="active",
|
||||
source="work_order",
|
||||
source_service_center_id=visit.service_center_id,
|
||||
source_appointment_id=appointment.id if appointment else None,
|
||||
)
|
||||
)
|
||||
visit.version = (visit.version or 1) + 1
|
||||
visit.completed_snapshot = {
|
||||
"work_order_number": visit.work_order_number,
|
||||
"vehicle_id": vehicle.id,
|
||||
"service_center_id": visit.service_center_id,
|
||||
"odometer": visit.odometer,
|
||||
"labor_total": str(visit.labor_total),
|
||||
"product_total": str(visit.product_total),
|
||||
"discount_total": str(visit.discount_total),
|
||||
"final_total": str(visit.final_total),
|
||||
"currency": visit.currency,
|
||||
"completed_at": visit.completed_at.isoformat() if visit.completed_at else None,
|
||||
}
|
||||
await create_service_notification(
|
||||
session,
|
||||
recipient_user_id=owner.id,
|
||||
service_center_id=visit.service_center_id,
|
||||
appointment_id=appointment.id if appointment else None,
|
||||
notification_type="work_order.completed",
|
||||
title="Работа по заказ-наряду завершена",
|
||||
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.",
|
||||
idempotency_key=f"work_order:{visit.id}:completed",
|
||||
)
|
||||
return service, expense
|
||||
Reference in New Issue
Block a user