Compare commits
4 Commits
78749bb4fe
...
9fe172702f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fe172702f | ||
|
|
8efac3a844 | ||
|
|
b03b63a5cc | ||
|
|
4ee83690f6 |
@@ -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=
|
||||
|
||||
17
DEPLOY.md
17
DEPLOY.md
@@ -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
|
||||
|
||||
@@ -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, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
47
docs/production_pilot_entity_map.md
Normal file
47
docs/production_pilot_entity_map.md
Normal 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
4
scripts/backup.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
exec "$(dirname "$0")/backup_db.sh" "$@"
|
||||
@@ -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
4
scripts/restore.sh
Executable 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
16
scripts/smoke_test.sh
Executable 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."
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user