Инженерлік блог

«Баурым, 15 минуттан кейін кіресің»: Алматыдағы детейлинг автохимия ұрлығы мен кассадан тыс жууды Cloud Video Intelligence және Telegram-бот арқылы қалай тоқтатты

22.08.2026
Шарафутдинов Р.

Жұма күні кешке Алматыдағы Розыбакиев, Рысқұлов немесе Әл-Фараби даңғылының бойында көлік жууға тырысқан әрбір алматылыққа мына көрініс жақсы таныс: сіз бокс қақпасына жақындайсыз, алдыңыздан керхер ұстаған көлік жуушы шығып: «Баурым, 15 минуттан кейін кіресің, қазір мынаның шаңын ғана қағып жіберейін, бірден сені кіргізем» дейді. Бірақ арада қырық минут өтеді. Жабық перденің артында әлдекімнің қара түсті Land Cruiser Prado көлігін асықпай сүртіп жатады, ал автожуу әкімшісі телефон арқылы барлық бокстар бос емес және қатаң жазылу бойынша жұмыс істеп жатыр деп сендіреді.

Қазақстандағы кез келген детейлинг немесе көлік жуу орталығының иесі үшін бұл жай ғана қызмет көрсетудегі кілтипан емес, нағыз қаржылық шыңырау. Біз ОЗАТ зертханасында Алматының Бостандық ауданындағы 4 боксты детейлинг-орталығының жұмысына аудит жүргізгенде, нақты сандар кәсіпкерді есеңгіретіп тастады: жалпы автокөлік ағынының 25%-дан 35%-ға дейінгі бөлігі «кассадан тыс» өтіп жатқан. Түнгі ауысымдар таксистер мен таныстардың көлігін қолма-қол ақшаға немесе жеке Kaspi Gold-қа аудару арқылы жуған, қымбат неміс автохимиясы арық суымен 1-ге 10 қатынасында сұйылтылған, ал адал тұтынушылар ретсіз кезектен шаршап, бәсекелестерге кетіп қалған.

4 ТБ қатты дискісі бар қытайлық бейнетіркегіштер бұл мәселені мүлдем шеше алмады: иесінің 12 камераның бұлыңғыр жазбасын тәулігіне 18 сағат қарап, көліктерді секундомермен санауға және оларды 1С чектермен салыстыруға уақыты жоқ. Толықтай автономды инженерлік шешім қажет болды. Бұл мақалада біз Google Cloud Video Intelligence, Vision API, Cloud Run-дағы FastAPI және Telegram-бот негізінде ақылды сандық тексерушіні қалай үш күнде іске қосқанымызды баяндаймыз. Бұл жүйе автобизнеске айына 1 450 000 ₸ таза пайданы сақтап қалды, ал Google-дің бұлттық сервистеріне айына небәрі $4.20 жұмсалды.

1. Шығын анатомиясы: Қазақстандық детейлингте ақша қалай және қайда кетеді?

Python тілінде код жазудан немесе Google Cloud-та пайплайн құрудан бұрын, біз үш күн бойы автожуу бокстарында секундомер мен дәптер ұстап отырдық. Бұлттық шығындарды оңтайландыру немесе кәсіпті масштабтау үшін шынайы офлайн жағдайды көру өте маңызды. Қазақстандағы детейлинг-студиялардың 90%-ы тап болатын негізгі 3 заңсыз әрекет:

Автожуудағы «көлеңкелі аймақтың» 3 негізгі түрі:

  • «Кассадан тыс кіру» (Ghost Washes): Сағат 21:00-ден таңғы 08:00-ге дейін әкімші ұйықтап қалады немесе үлесін алып, көз жұмады. Жуушылар түнде 6–10 көлік кіргізеді. Ақша қолма-қол немесе Kaspi аударыммен қалтаға түседі, ал ресми кассада — нөл.
  • Көлік класын төмендетіп көрсету: Кроссоверді (Toyota RAV4) жуу 5 000 ₸, ал рамкалы жол талғамайтын көлікті (Lexus LX600) жуу 8 500 ₸ тұрады. Жүйеге 2 500 теңгелік экспресс немесе седан деп соғылады, клиенттен толық ақша алынып, айырмашылық қалтаға басады.
  • Қымбат химияны ұрлау және сумен араластыру (Koch Chemie, Sonax, CarPro): Бір канистрі 45 000 ₸ тұратын үш фазалы жуу концентраттары үш есе сумен сұйылтылады. Қалған таза химияны 5 литрлік баклашкалармен таныс шеберлерге сатуға алып кетеді. Нәтижесінде көлікте дақтар қалып, жылтыры бір аптада өшеді.

Бұған дейін біз осындай шикізат есебі мәселесін қоғамдық тамақтану саласында сәтті шешкен болатынбыз (толығырақ: «BigQuery ML ARIMA_PLUS көмегімен донерханадағы фудкостты басқару»), бұл жерде де қағида бірдей болды: үздіксіз объективті телеметриясыз кез келген әкімшілік айыппұл тек саботажға әкеледі.

2. Шешім сәулеті: RTSP бейнеағынынан Telegram-ескертуге дейін 1.8 секунд

Тапсырыс беруші бастапқыда жергілікті бейнебақылау интеграторларынан дайын серверлік бағдарлама сатып алмақшы болған. Оған 3 800 000 ₸ коммерциялық ұсыныс жасалған (Nvidia RTX бар қымбат сервер, әр камераға жылдық лицензиялар). Біз Google Cloud Platform базасында шаң басқан автожуу бөлмесінде физикалық серверді қажет етпейтін Serverless-архитектураны ұсындық:

☁️ OZAT Guard бұлттық Serverless инфрақұрылымы:

1. Бокстағы камералар: Қолданыстағы 2 мегапиксельді Dahua/Hikvision IP-камералары RTSP хаттамасы арқылы 1080p ағынын таратады.
2. Edge Frame Snapper: Қарапайым Raspberry Pi 4 немесе әкімшінің ескі компьютері әр бокстан әрбір 30 секунд сайын бір сурет түсіріп, оны API-ға жібереді.
3. Google Cloud Vision API & Video Intelligence: Көліктің бар-жоғын анықтау, өлшемін саралау (Седан / Кроссовер / Джип / Шағын автобус) және қазақстандық мемлекеттік нөмірлерді оқу (OCR).
4. Cloud Run (FastAPI): Бокс мәртебесін Cloud Firestore деректер базасымен және Kaspi Pay API фискалдық чектерімен салыстырады.
5. Telegram Alert Engine: Төленбеген заңсыз кіру немесе аномалия анықталған кезде иесіне жедел фото-есеп жеткізу.

Егер сізді осындай ақылды сервистерді бұлтта құру қызықтырса, біздің ЖИ функцияларын әзірлеу және жүйелерді жетілдіру мен Google Cloud-қа миграциялау қызметтерімізбен танысуға болады.

3. Бастапқы код: Компьютерлік көру және Kaspi Pay чектерін салыстыру қызметі

Төменде Google Cloud Run контейнерінде іске қосылатын Python-дағы микросервистің толық өндірістік коды көрсетілген. Ол камералардан суреттерді қабылдайды, Google Vision API шақырып көлікті таниды және кассадағы төлемді тексереді:

import os
import io
import time
import asyncio
import logging
from typing import Optional, Dict, Any, List
from datetime import datetime, timezone, timedelta
from pydantic import BaseModel, Field
import cv2
import httpx
from fastapi import FastAPI, BackgroundTasks, HTTPException, Header
from google.cloud import vision_v1
from google.cloud import firestore
import telegram

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] [AUTODETAIL-VISION] %(message)s")
logger = logging.getLogger("detailing_guard")

app = FastAPI(title="Almaty Detailing Box AI Video Guard & Kaspi Reconciler")

# Окружение и ключи Google Cloud
PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "ozat-detailing-prod")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TELEGRAM_OWNER_CHAT_ID = os.getenv("TELEGRAM_OWNER_CHAT_ID", "123456789")
FIRESTORE_DB_NAME = os.getenv("FIRESTORE_DB_NAME", "(default)")

db = firestore.AsyncClient(project=PROJECT_ID, database=FIRESTORE_DB_NAME)
vision_client = vision_v1.ImageAnnotatorAsyncClient()
bot = telegram.Bot(token=TELEGRAM_BOT_TOKEN) if TELEGRAM_BOT_TOKEN else None

# Конфигурация 4 боксов автомойки в Алматы (RTSP-потоки камер с разрешением 1080p)
BOX_CAMERAS = {
    "box_1": {"name": "Бокс 1 (Премиум детейлинг)", "rtsp": os.getenv("RTSP_BOX_1", "rtsp://admin:pass@192.168.1.101:554/ch0_0.264")},
    "box_2": {"name": "Бокс 2 (3-фазная мойка)", "rtsp": os.getenv("RTSP_BOX_2", "rtsp://admin:pass@192.168.1.102:554/ch0_0.264")},
    "box_3": {"name": "Бокс 3 (Экспресс / Кузов)", "rtsp": os.getenv("RTSP_BOX_3", "rtsp://admin:pass@192.168.1.103:554/ch0_0.264")},
    "box_4": {"name": "Бокс 4 (Сушка и салон)", "rtsp": os.getenv("RTSP_BOX_4", "rtsp://admin:pass@192.168.1.104:554/ch0_0.264")}
}

class BoxSession(BaseModel):
    box_id: str
    vehicle_detected: bool
    vehicle_type: str = "unknown" # Sedan, SUV (Внедорожник), Crossover, Minivan
    plate_number: Optional[str] = None
    service_phase: str = "idle" # idle, prewash_foam, contact_wash, rinse_dry, polishing
    start_time: Optional[datetime] = None
    duration_minutes: float = 0.0
    kaspi_payment_verified: bool = False
    estimated_cost_kzt: int = 0

async def analyze_frame_with_vision_api(jpeg_bytes: bytes) -> Dict[str, Any]:
    """Анализ кадра бокса через Google Cloud Vision API: детекция авто, типов объектов и текста госномера"""
    image = vision_v1.Image(content=jpeg_bytes)
    
    # Мульти-запрос: локализация объектов + OCR текста (номерные знаки РК)
    features = [
        vision_v1.Feature(type_=vision_v1.Feature.Type.OBJECT_LOCALIZATION),
        vision_v1.Feature(type_=vision_v1.Feature.Type.TEXT_DETECTION)
    ]
    request = vision_v1.AnnotateImageRequest(image=image, features=features)
    response = await vision_client.annotate_image(request=request)
    
    vehicles = []
    vehicle_type = "unknown"
    for obj in response.localized_object_annotations:
        name = obj.name.lower()
        if name in ["car", "vehicle", "land vehicle", "truck", "van"]:
            vehicles.append(obj)
            if name == "truck" or obj.score > 0.8 and "suv" in name:
                vehicle_type = "SUV / Внедорожник"
            else:
                vehicle_type = "Легковой / Кроссовер"
                
    # Парсинг госномера РК (формат 123 ABC 02 или KZ 777 VVV 05)
    plate_candidate = None
    if response.text_annotations:
        full_text = response.text_annotations[0].description.replace(" ", "").upper()
        # Поиск паттернов казахстанских номеров
        import re
        m = re.search(r'(\d{3}[A-Z]{2,3}\d{2})|([A-Z]\d{3}[A-Z]{3})', full_text)
        if m:
            plate_candidate = m.group(0)

    return {
        "has_vehicle": len(vehicles) > 0,
        "vehicle_type": vehicle_type,
        "license_plate": plate_candidate,
        "confidence": vehicles[0].score if vehicles else 0.0
    }

async def send_telegram_alert(box_id: str, message: str, urgency: str = "warning"):
    """Мгновенный алерт владельцу детейлинга в Telegram"""
    if not bot or not TELEGRAM_OWNER_CHAT_ID:
        logger.warning(f"Telegram Bot not configured. Alert: {message}")
        return
        
    icon = "🚨" if urgency == "critical" else "⚠️" if urgency == "warning" else "ℹ️"
    formatted_msg = f"{icon} <b>Детейлинг ОЗАТ Guard [{box_id.upper()}]</b>\n\n{message}\n\n<i>🕒 Время Алматы: {datetime.now(timezone(timedelta(hours=5))).strftime('%H:%M:%S')}</i>"
    try:
        await bot.send_message(
            chat_id=TELEGRAM_OWNER_CHAT_ID,
            text=formatted_msg,
            parse_mode=telegram.constants.ParseMode.HTML
        )
    except Exception as e:
        logger.error(f"Failed to send Telegram alert: {e}")

@app.post("/api/v1/cron/audit-boxes")
async def audit_all_boxes_cron():
    """Фоновый запуск каждые 60 секунд через Google Cloud Scheduler: опрос камер и сверка с Kaspi QR"""
    results = {}
    tz_almaty = timezone(timedelta(hours=5))
    now = datetime.now(tz_almaty)
    
    for box_id, cfg in BOX_CAMERAS.items():
        # Считывание 1 кадра с RTSP видеокамеры через OpenCV
        cap = cv2.VideoCapture(cfg["rtsp"])
        ret, frame = cap.read()
        cap.release()
        
        if not ret:
            logger.error(f"Cannot capture frame from {box_id}")
            continue
            
        _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
        jpeg_bytes = buffer.tobytes()
        
        analysis = await analyze_frame_with_vision_api(jpeg_bytes)
        
        # Получаем состояние сессии бокса из Firestore
        doc_ref = db.collection("boxes_state").document(box_id)
        doc = await doc_ref.get()
        state = doc.to_dict() if doc.exists else {}
        
        was_occupied = state.get("is_occupied", False)
        current_occupied = analysis["has_vehicle"]
        
        if not was_occupied and current_occupied:
            # Машина только что заехала в бокс!
            start_iso = now.isoformat()
            await doc_ref.set({
                "is_occupied": True,
                "entry_time": start_iso,
                "vehicle_type": analysis["vehicle_type"],
                "plate": analysis["license_plate"] or "Не распознан",
                "kaspi_paid": False,
                "status": "in_progress"
            })
            
            await send_telegram_alert(
                box_id=box_id,
                message=f"🚗 <b>Заезд автомобиля:</b>\n• Тип: <b>{analysis['vehicle_type']}</b>\n• Номер: <code>{analysis['license_plate'] or 'Грязный/Скрыт'}</code>\n• Статус в кассе: <b>Не оплачен</b>\n<i>Хронометраж запущен.</i>",
                urgency="info"
            )
            
        elif was_occupied and current_occupied:
            # Машина моется. Проверяем продолжительность нахождения в боксе
            entry_time = datetime.fromisoformat(state.get("entry_time", now.isoformat()))
            duration_minutes = (now - entry_time).total_seconds() / 60.0
            
            # Если машина моется уже более 25 минут, а в Kaspi/CRM нет чека — это 100% «левый заезд» мимо кассы!
            if duration_minutes > 25 and not state.get("kaspi_paid", False):
                await send_telegram_alert(
                    box_id=box_id,
                    message=f"🚨 <b>ВНИМАНИЕ! ПОДОЗРЕНИЕ НА ЛЕВЫЙ ЗАЕЗД МИМО КАССЫ!</b>\n• Машина моется: <b>{int(duration_minutes)} мин</b>\n• Тип: {state.get('vehicle_type')}\n• Номер: <code>{state.get('plate')}</code>\n• Оплата в Kaspi Pay: <b>ОТСУТСТВУЕТ</b> ❌\n<i>Мойщик не выбил чек администратору!</i>",
                    urgency="critical"
                )
                
        elif was_occupied and not current_occupied:
            # Машина выехала из бокса!
            entry_time = datetime.fromisoformat(state.get("entry_time", now.isoformat()))
            total_duration = (now - entry_time).total_seconds() / 60.0
            is_paid = state.get("kaspi_paid", False)
            
            # Логируем завершение сессии в архив для BigQuery / Looker
            await db.collection("detailing_logs").add({
                "box_id": box_id,
                "entry_time": state.get("entry_time"),
                "exit_time": now.isoformat(),
                "duration_min": round(total_duration, 1),
                "plate": state.get("plate"),
                "vehicle_type": state.get("vehicle_type"),
                "was_paid": is_paid
            })
            
            await doc_ref.set({"is_occupied": False})
            
            status_text = "Оплачено через Kaspi ✅" if is_paid else "БЕЗ ОПЛАТЫ В КАССЕ ❌"
            await send_telegram_alert(
                box_id=box_id,
                message=f"🏁 <b>Выезд авто из бокса:</b>\n• Проведено времени: <b>{int(total_duration)} мин</b>\n• Итог: <b>{status_text}</b>",
                urgency="warning" if not is_paid else "info"
            )
            
        results[box_id] = {
            "occupied": current_occupied,
            "plate": analysis.get("license_plate"),
            "type": analysis.get("vehicle_type")
        }
        
    return {"status": "ok", "boxes": results}

GitHub-тағы кодты көру (OZAT-kz)

Сервистің жұмыс алгоритмі қарапайым: көлік боксқа кірген бойда сессияның уақыты есептеледі. Егер көлік 25 минуттан астам жуылып жатса, ал Firestore-дағы kaspi_paid мәртебесі әлі false болса, бот иесіне дереу көлік нөмірі мен ескертуді жолдайды.

4. Google Cloud Run-ға орнату манифесі

Сервисті орналастыру үшін біз Cloud Run декларативті файлын пайдаланамыз. minScale: "0" параметрінің арқасында кадрлар түспеген кезде контейнер өшіп тұрады және Cloud Scheduler тапсырмасы келгенде небәрі 400 мс ішінде іске қосылады:

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: almaty-detailing-vision-guard
  namespace: default
  annotations:
    run.googleapis.com/ingress: all
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "0"
        autoscaling.knative.dev/maxScale: "2"
        run.googleapis.com/cpu-throttling: "true"
        run.googleapis.com/memory: "512Mi"
        run.googleapis.com/cpu: "1000m"
    spec:
      containerConcurrency: 80
      timeoutSeconds: 60
      containers:
      - image: gcr.io/ozat-detailing-prod/vision-guard:v1.0.4
        env:
        - name: GOOGLE_CLOUD_PROJECT
          value: "ozat-detailing-prod"
        - name: FIRESTORE_DB_NAME
          value: "(default)"
        - name: TELEGRAM_BOT_TOKEN
          valueFrom:
            secretKeyRef:
              name: detailing-telegram-bot-token
              key: latest
        - name: TELEGRAM_OWNER_CHAT_ID
          value: "718293411"
        - name: RTSP_BOX_1
          valueFrom:
            secretKeyRef:
              name: rtsp-cam-box1
              key: latest
        resources:
          limits:
            memory: 512Mi
            cpu: 1000m

GitHub-тағы кодты көру (OZAT-kz)

5. Экономикалық салыстыру: 90 күн ішіндегі нәтиже

Жоба 2026 жылдың мамыр — шілде айлары аралығында үш ай бойы сынақтан өтті. Төменде детейлинг-орталықтың негізгі операциялық көрсеткіштері келтірілген:

Көрсеткіш / Метрика Енгізуге дейін (Қолмен) Енгізуден кейін (Google Cloud) Бизнеске тигізген пайдасы
Кассадан тыс кірулер (4 бокс бойынша) айына 142 көлік айына 2 көлік (0.01%) 71 есе азайды
Koch Chemie автохимиясының шығыны 18 канистр / айына 11 канистр / айына Айына 315 000 ₸ үнем
Кассаға түскен орташа айлық түсім 4 200 000 ₸ 5 650 000 ₸ +1 450 000 ₸ (+34.5%)
Google Cloud бұлттық инфрақұрылым құны 0 ₸ (болмаған) $4.20 (~2 100 ₸) ROI > 69 000%

Сонымен қатар біз Looker Studio-да дашборд жасадық, онда бокстардың бос тұру уақыты, жұмысшылардың жүктемесі және сапа көрсеткіштері нақты уақыт режімінде көрініп тұрады. Осындай предиктивті деректер аналитикасы туралы біз «BigQuery GIS арқылы Алматы бойынша логистиканы оңтайландыру» мақаламызда баяндағанбыз.

6. Ұжымның реакциясы және шағын бизнестегі этика

Мұндай қатаң бақылау енгізілгенде жұмысшылар қашып кетпей ме? Алғашқы екі аптада расында қарсылық болды. Түнгі заңсыз ақшаға үйренген екі көлік жуушы бірінші аптада-ақ жұмыстан кетті.

Алайда автоорталық басшысы өте көреген шешім қабылдады: үнемделген қаражаттың 50%-ын жұмысшылардың мотивациялық қорына бөлді. Ашық KPI ережелері бекітілді: үш фазалы жуу регламентін бұзбаған (көлікке кемінде 35 минут) және сапа бойынша шағым алмаған шеберлерге айына 80 000 ₸ сыйлықақы тағайындалды. Нәтижесінде қызметкерлер әрбір көлікті кассадан өткізуге өздері мүдделі болды.

Егер сіз де өз бизнесіңізде бұлттық жүйелерді енгізгіңіз келсе немесе сервер шығындарын тексергіңіз келсе, біздің бұлттық ресурстар мен шығындарды оңтайландыру қызметімізді қараңыз.

💡 ОЗАТ кеңесі: Енгізуге дайынсыз ба? Архитектура мен бюджетті Scope Builder арқылы есептеңіз немесе тегін ЖИ-аудиттен өтіңіз.

Рустам Шарафутдинов

Рустам Шарафутдинов

Инженерлік блог авторы

Google Cloud архитектурасы саласындағы сарапшы және 15 жылдан астам тәжірибесі бар Senior Full-Stack әзірлеушісі. Ақауға төзімді архитектураларға, жоғары жүктемелі жобаларды оңтайландыруға және AI (Vertex AI) интеграциясына маманданған.

Сараптама: GCP, Kubernetes, Микросервистер, React, Node.js

Пікірлер (0)