4 Commits

Author SHA1 Message Date
VPN SaaS Dev
9fe172702f docker-deploy-smoke
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 19:35:07 +09:00
VPN SaaS Dev
8efac3a844 frontend-roles-ux 2026-05-16 19:35:04 +09:00
VPN SaaS Dev
b03b63a5cc work-order-hardening 2026-05-16 19:35:01 +09:00
VPN SaaS Dev
4ee83690f6 audit/entity-map 2026-05-16 19:34:56 +09:00
15 changed files with 217 additions and 14 deletions

View File

@@ -20,7 +20,5 @@ SECRET_KEY=change-this-long-random-secret
REDIS_URL=redis://redis:6379/0
OCR_PROVIDER=tesseract
OCR_LANGUAGES=eng+rus+kor
LLM_BASE_URL=
LLM_MODEL=
ADMIN_TELEGRAM_IDS=
ADMIN_BOOTSTRAP_TOKEN=

View File

@@ -19,7 +19,7 @@ Edit `.env` and set real secrets:
- `INTERNAL_API_TOKEN`
- `SECRET_KEY`
- `REDIS_URL` if Redis is external
- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` when browser push is enabled
- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` only when browser push beta is enabled
- `ADMIN_TELEGRAM_IDS`
Production must use public HTTPS URLs and `ALLOW_DEV_AUTH=false`.
@@ -34,6 +34,7 @@ curl -fsS http://127.0.0.1:8000/ready
```
The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation.
Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled.
## Git-Based Update
@@ -51,7 +52,7 @@ The script runs:
- Docker build/up
- `alembic upgrade head`
- Python smoke compile
- `/ready` health check
- `/health`, `/ready` and `/metrics` smoke checks
Do not use rsync as the primary deploy mechanism.
@@ -75,12 +76,24 @@ Create a compressed custom-format dump before risky deploys:
BACKUP_DIR=/opt/carpass/backups ./scripts/backup_db.sh
```
Compatibility wrapper:
```bash
BACKUP_DIR=/opt/carpass/backups ./scripts/backup.sh
```
Restore only during a maintenance window:
```bash
./scripts/restore_db.sh /opt/carpass/backups/carpass-drivers-YYYYMMDDTHHMMSSZ.dump
```
Compatibility wrapper:
```bash
./scripts/restore.sh /opt/carpass/backups/carpass-drivers-YYYYMMDDTHHMMSSZ.dump
```
For volume-level recovery, back up the Docker named volumes `pgdata` and `redisdata` according to the host backup policy.
## Logs

View File

@@ -63,6 +63,8 @@ CarPass создает рекомендации обслуживания из д
Уведомления имеют статусы `pending`, `processing`, `sent`, `failed`, `retrying`, `abandoned`, `read`, счетчик повторов и idempotency key, чтобы не плодить дубли.
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
## Безопасность данных
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.

View File

@@ -88,6 +88,13 @@ async def get_work_order_with_items(session: AsyncSession, work_order_id: int) -
return visit
async def get_work_order_correction(session: AsyncSession, correction_id: int) -> WorkOrderCorrection:
correction = await session.get(WorkOrderCorrection, correction_id)
if correction is None:
raise HTTPException(status_code=404, detail="Work order correction not found")
return correction
async def ensure_work_order_sto_access(
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
) -> None:
@@ -539,6 +546,26 @@ async def request_vehicle_profile_details(
return visit
@router.get("/{work_order_id}/corrections", response_model=list[WorkOrderCorrectionRead])
async def list_work_order_corrections(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[WorkOrderCorrection]:
visit = await get_work_order(session, work_order_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
await ensure_work_order_sto_access(session, visit, current_user)
result = await session.execute(
select(WorkOrderCorrection)
.where(WorkOrderCorrection.service_visit_id == visit.id)
.order_by(WorkOrderCorrection.created_at.desc(), WorkOrderCorrection.id.desc())
)
return list(result.scalars())
@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_correction(
work_order_id: int,
@@ -559,6 +586,19 @@ async def create_work_order_correction(
created_version=visit.version or 1,
)
session.add(correction)
vehicle = await session.get(Car, visit.vehicle_id)
if payload.owner_approval_required and vehicle is not None:
await create_service_notification(
session,
recipient_user_id=vehicle.owner_id,
service_center_id=visit.service_center_id,
notification_type="work_order.correction_waiting_owner_approval",
title="СТО просит согласовать правку заказ-наряда",
body=payload.reason,
idempotency_key=f"work_order:{visit.id}:correction:{visit.version or 1}:{payload.reason[:80]}",
web_app_url=work_order_webapp_url(visit.id),
button_text="Открыть заказ-наряд",
)
await log_audit(
session,
actor=current_user,
@@ -572,6 +612,60 @@ async def create_work_order_correction(
return correction
@router.post("/corrections/{correction_id}/approve", response_model=WorkOrderCorrectionRead)
async def approve_work_order_correction(
correction_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCorrection:
correction = await get_work_order_correction(session, correction_id)
visit = await get_work_order(session, correction.service_visit_id)
await ensure_work_order_owner_access(session, visit, current_user)
if correction.status != "pending":
raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "approved"
correction.resolved_at = datetime.now(UTC)
await log_audit(
session,
actor=current_user,
action="work_order.correction.approve",
target_type="work_order_correction",
target_id=correction.id,
metadata={"comment": payload.comment},
)
await session.commit()
await session.refresh(correction)
return correction
@router.post("/corrections/{correction_id}/reject", response_model=WorkOrderCorrectionRead)
async def reject_work_order_correction(
correction_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCorrection:
correction = await get_work_order_correction(session, correction_id)
visit = await get_work_order(session, correction.service_visit_id)
await ensure_work_order_owner_access(session, visit, current_user)
if correction.status != "pending":
raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "rejected"
correction.resolved_at = datetime.now(UTC)
await log_audit(
session,
actor=current_user,
action="work_order.correction.reject",
target_type="work_order_correction",
target_id=correction.id,
metadata={"comment": payload.comment},
)
await session.commit()
await session.refresh(correction)
return correction
@router.get("/{work_order_id}/status-history", response_model=list[WorkOrderStatusHistoryRead])
async def work_order_status_history(
work_order_id: int,

View File

@@ -22,8 +22,6 @@ class Settings(BaseSettings):
allow_dev_auth: bool = False
ocr_provider: str = "tesseract"
ocr_languages: str = "eng+rus+kor"
llm_base_url: str = ""
llm_model: str = ""
admin_telegram_ids: str = ""
admin_bootstrap_token: str = ""

View File

@@ -25,7 +25,7 @@ dp = Dispatcher()
api = ApiClient()
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
STO_WORKPLACE_ROLES = {"owner", "mechanic"}
STO_WORKPLACE_ROLES = {"owner", "manager", "receptionist", "mechanic"}
def webapp_url(path: str = "") -> str:

View File

@@ -54,8 +54,6 @@ services:
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
SECRET_KEY: ${SECRET_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
@@ -91,8 +89,6 @@ services:
APP_ENV: ${APP_ENV:-development}
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
SECRET_KEY: ${SECRET_KEY:-}
depends_on:

View File

@@ -0,0 +1,47 @@
# CarPass Production Pilot Entity Map
This map captures the closed-pilot domain model as implemented in the codebase.
## Owner Domain
- `User` owns `Car` records and authenticates through Telegram.
- `Car` is the vehicle/passport entity. It owns `FuelEntry`, `ServiceEntry`, `ExpenseEntry`, `OdometerHistory`, `CarServiceLink`, `ServiceAppointment`, `ServiceVisit`, and `MaintenanceRecommendation`.
- `FuelEntry`, `ServiceEntry`, and `ExpenseEntry` update ownership analytics and can update the vehicle odometer through `OdometerHistory`.
- `OwnershipAnalytics` is computed on demand from entries, expenses, depreciation settings, and loan settings.
- `VehicleAccess` grants non-owner user access to a vehicle for selected backend flows.
## STO Domain
- `ServiceCenter` is the STO profile and tenant boundary.
- `ServiceCenterVerification` stores moderation applications and review decisions.
- `ServiceEmployee` links users to a service center with one of `owner`, `manager`, `receptionist`, or `mechanic`.
- `CarServiceLink` is the owner-approved connection between a vehicle and an STO.
- `ServiceCenterBookingSettings` and `ServiceCenterHoliday` define the appointment calendar.
- `ServiceAppointment` is the customer booking request and can become one `ServiceVisit`.
- `ServiceCenterReview` and `ServiceCenterReviewComment` store public feedback.
## Work Order Domain
- `ServiceVisit` is the work order. It links `ServiceCenter`, `Car`, owner user, employee, appointment, labor items, product items, status history, and corrections.
- `ServiceWorkItem` stores labor/repair work and next-service intervals.
- `ServiceProductItem` stores parts, fluids, materials, SKU and quantity.
- `WorkOrderCatalogItem` is the STO price/catalog item source.
- `InventoryTransaction` records consumed products on work-order completion.
- `WorkOrderStatusHistory` audits status transitions.
- `WorkOrderCorrection` records post-completion correction requests and owner decisions.
- Completion creates immutable owner records: `ServiceEntry`, `ExpenseEntry`, `OdometerHistory`, `MaintenanceRecommendation` when applicable, `ServiceNotification`, and `AuditLog`.
## Trust, Notifications, And Exchange
- `MaintenanceRecommendation` is connected to a vehicle and optionally a service center/appointment.
- `ServiceNotification` is the internal notification queue with best-effort Telegram delivery and idempotency keys.
- `AuditLog` records sensitive and operational actions.
- `Achievement`, `UserAchievement`, `VehicleScore`, `ServiceCenterScore`, and `EngagementEvent` power passport quality, trust, and timeline features.
- Import/export is synchronous API work, not a persisted `ImportExportJob`; schema is `carpass.exchange.v1`.
- OCR returns preview candidates through API responses; no persisted `OCRResult` table exists and OCR never writes vehicle data directly.
## Pilot Gaps To Keep Explicit
- Browser Web Push stores subscriptions and has a service worker listener, but server-side Web Push delivery is beta and not part of the pilot-critical notification path.
- Inventory exists as consumption transactions and catalog products, not as full stock-level management.
- Correction requests record decisions; they do not mutate completed work-order snapshots automatically.

4
scripts/backup.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(dirname "$0")/backup_db.sh" "$@"

View File

@@ -44,6 +44,7 @@ for attempt in {1..30}; do
if curl -fsS "$HEALTH_URL" >/tmp/carpass-ready.json; then
cat /tmp/carpass-ready.json
echo
BASE_URL="${BASE_URL:-${HEALTH_URL%/ready}}" ./scripts/smoke_test.sh
$COMPOSE ps
exit 0
fi

4
scripts/restore.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(dirname "$0")/restore_db.sh" "$@"

16
scripts/smoke_test.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
echo "Checking health..."
curl -fsS "$BASE_URL/health"
echo
echo "Checking readiness..."
curl -fsS "$BASE_URL/ready"
echo
echo "Checking metrics..."
curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total"
echo "Smoke test passed."

View File

@@ -50,6 +50,19 @@ async def test_employee_invite_activation_revoked_and_expired(
dashboard = await client.get(f"/api/sto/dashboard?service_center_id={center['id']}", headers=other_auth_headers)
assert dashboard.status_code == 200
other_center = await create_verified_center(
client,
auth_headers,
admin_auth_headers,
internal_headers,
"Other Tenant Service",
)
cross_tenant_dashboard = await client.get(
f"/api/sto/dashboard?service_center_id={other_center['id']}",
headers=other_auth_headers,
)
assert cross_tenant_dashboard.status_code == 403
revoked_invite = (
await client.post(
f"/api/service-centers/{center['id']}/employees/invite",
@@ -193,6 +206,22 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
)
assert correction.status_code == 201
assert correction.json()["created_version"] == completed.json()["version"]
corrections = await client.get(f"/api/work-orders/{work_order['id']}/corrections", headers=other_auth_headers)
assert corrections.status_code == 200
assert corrections.json()[0]["id"] == correction.json()["id"]
approved_correction = await client.post(
f"/api/work-orders/corrections/{correction.json()['id']}/approve",
headers=other_auth_headers,
json={"comment": "Correction accepted"},
)
assert approved_correction.status_code == 200
assert approved_correction.json()["status"] == "approved"
repeated_correction_decision = await client.post(
f"/api/work-orders/corrections/{correction.json()['id']}/reject",
headers=other_auth_headers,
json={"comment": "Too late"},
)
assert repeated_correction_decision.status_code == 409
service_history = await client.get(
f"/api/my/vehicles/{vehicle['id']}/service-history",

View File

@@ -602,7 +602,7 @@ function hideAuthOverlay() {
}
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "manager", "receptionist", "mechanic"]);
const STO_CALENDAR_ROLES = new Set(["owner", "manager", "receptionist"]);
function isPlatformAdmin() {

View File

@@ -3,7 +3,7 @@ tg?.ready();
tg?.expand();
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "manager", "receptionist", "mechanic"]);
const state = {
user: null,
@@ -250,7 +250,8 @@ function renderDashboard(dashboard) {
}
function renderAppointments() {
const canManage = (activeCenter()?.employee_role || "owner") === "owner";
const role = activeCenter()?.employee_role || "owner";
const canManage = ["owner", "manager", "receptionist"].includes(role);
document.querySelector("#appointmentsList").innerHTML = state.appointments.length
? state.appointments.map((item) => `
<div class="stack-item work-order-card">