Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

View File

@@ -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())
)

View File

@@ -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

View File

@@ -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
View 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},
)
)

View File

@@ -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
View 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
View 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