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
|
REDIS_URL=redis://redis:6379/0
|
||||||
OCR_PROVIDER=tesseract
|
OCR_PROVIDER=tesseract
|
||||||
OCR_LANGUAGES=eng+rus+kor
|
OCR_LANGUAGES=eng+rus+kor
|
||||||
LLM_BASE_URL=
|
|
||||||
LLM_MODEL=
|
|
||||||
ADMIN_TELEGRAM_IDS=
|
ADMIN_TELEGRAM_IDS=
|
||||||
ADMIN_BOOTSTRAP_TOKEN=
|
ADMIN_BOOTSTRAP_TOKEN=
|
||||||
|
|||||||
17
DEPLOY.md
17
DEPLOY.md
@@ -19,7 +19,7 @@ Edit `.env` and set real secrets:
|
|||||||
- `INTERNAL_API_TOKEN`
|
- `INTERNAL_API_TOKEN`
|
||||||
- `SECRET_KEY`
|
- `SECRET_KEY`
|
||||||
- `REDIS_URL` if Redis is external
|
- `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`
|
- `ADMIN_TELEGRAM_IDS`
|
||||||
|
|
||||||
Production must use public HTTPS URLs and `ALLOW_DEV_AUTH=false`.
|
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.
|
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
|
## Git-Based Update
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ The script runs:
|
|||||||
- Docker build/up
|
- Docker build/up
|
||||||
- `alembic upgrade head`
|
- `alembic upgrade head`
|
||||||
- Python smoke compile
|
- Python smoke compile
|
||||||
- `/ready` health check
|
- `/health`, `/ready` and `/metrics` smoke checks
|
||||||
|
|
||||||
Do not use rsync as the primary deploy mechanism.
|
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
|
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:
|
Restore only during a maintenance window:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/restore_db.sh /opt/carpass/backups/carpass-drivers-YYYYMMDDTHHMMSSZ.dump
|
./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.
|
For volume-level recovery, back up the Docker named volumes `pgdata` and `redisdata` according to the host backup policy.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ CarPass создает рекомендации обслуживания из д
|
|||||||
|
|
||||||
Уведомления имеют статусы `pending`, `processing`, `sent`, `failed`, `retrying`, `abandoned`, `read`, счетчик повторов и idempotency key, чтобы не плодить дубли.
|
Уведомления имеют статусы `pending`, `processing`, `sent`, `failed`, `retrying`, `abandoned`, `read`, счетчик повторов и idempotency key, чтобы не плодить дубли.
|
||||||
|
|
||||||
|
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
|
||||||
|
|
||||||
## Безопасность данных
|
## Безопасность данных
|
||||||
|
|
||||||
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ async def get_work_order_with_items(session: AsyncSession, work_order_id: int) -
|
|||||||
return visit
|
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(
|
async def ensure_work_order_sto_access(
|
||||||
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
|
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -539,6 +546,26 @@ async def request_vehicle_profile_details(
|
|||||||
return visit
|
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)
|
@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_work_order_correction(
|
async def create_work_order_correction(
|
||||||
work_order_id: int,
|
work_order_id: int,
|
||||||
@@ -559,6 +586,19 @@ async def create_work_order_correction(
|
|||||||
created_version=visit.version or 1,
|
created_version=visit.version or 1,
|
||||||
)
|
)
|
||||||
session.add(correction)
|
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(
|
await log_audit(
|
||||||
session,
|
session,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
@@ -572,6 +612,60 @@ async def create_work_order_correction(
|
|||||||
return 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])
|
@router.get("/{work_order_id}/status-history", response_model=list[WorkOrderStatusHistoryRead])
|
||||||
async def work_order_status_history(
|
async def work_order_status_history(
|
||||||
work_order_id: int,
|
work_order_id: int,
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ class Settings(BaseSettings):
|
|||||||
allow_dev_auth: bool = False
|
allow_dev_auth: bool = False
|
||||||
ocr_provider: str = "tesseract"
|
ocr_provider: str = "tesseract"
|
||||||
ocr_languages: str = "eng+rus+kor"
|
ocr_languages: str = "eng+rus+kor"
|
||||||
llm_base_url: str = ""
|
|
||||||
llm_model: str = ""
|
|
||||||
admin_telegram_ids: str = ""
|
admin_telegram_ids: str = ""
|
||||||
admin_bootstrap_token: str = ""
|
admin_bootstrap_token: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ dp = Dispatcher()
|
|||||||
api = ApiClient()
|
api = ApiClient()
|
||||||
|
|
||||||
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
|
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
|
||||||
STO_WORKPLACE_ROLES = {"owner", "mechanic"}
|
STO_WORKPLACE_ROLES = {"owner", "manager", "receptionist", "mechanic"}
|
||||||
|
|
||||||
|
|
||||||
def webapp_url(path: str = "") -> str:
|
def webapp_url(path: str = "") -> str:
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ services:
|
|||||||
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
|
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
|
||||||
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
|
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
|
||||||
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
|
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}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
SECRET_KEY: ${SECRET_KEY:-}
|
SECRET_KEY: ${SECRET_KEY:-}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
@@ -91,8 +89,6 @@ services:
|
|||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
|
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
|
||||||
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
|
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}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
SECRET_KEY: ${SECRET_KEY:-}
|
SECRET_KEY: ${SECRET_KEY:-}
|
||||||
depends_on:
|
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
|
if curl -fsS "$HEALTH_URL" >/tmp/carpass-ready.json; then
|
||||||
cat /tmp/carpass-ready.json
|
cat /tmp/carpass-ready.json
|
||||||
echo
|
echo
|
||||||
|
BASE_URL="${BASE_URL:-${HEALTH_URL%/ready}}" ./scripts/smoke_test.sh
|
||||||
$COMPOSE ps
|
$COMPOSE ps
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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)
|
dashboard = await client.get(f"/api/sto/dashboard?service_center_id={center['id']}", headers=other_auth_headers)
|
||||||
assert dashboard.status_code == 200
|
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 = (
|
revoked_invite = (
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/service-centers/{center['id']}/employees/invite",
|
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.status_code == 201
|
||||||
assert correction.json()["created_version"] == completed.json()["version"]
|
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(
|
service_history = await client.get(
|
||||||
f"/api/my/vehicles/{vehicle['id']}/service-history",
|
f"/api/my/vehicles/{vehicle['id']}/service-history",
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ function hideAuthOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
|
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"]);
|
const STO_CALENDAR_ROLES = new Set(["owner", "manager", "receptionist"]);
|
||||||
|
|
||||||
function isPlatformAdmin() {
|
function isPlatformAdmin() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ tg?.ready();
|
|||||||
tg?.expand();
|
tg?.expand();
|
||||||
|
|
||||||
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
|
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 = {
|
const state = {
|
||||||
user: null,
|
user: null,
|
||||||
@@ -250,7 +250,8 @@ function renderDashboard(dashboard) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAppointments() {
|
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
|
document.querySelector("#appointmentsList").innerHTML = state.appointments.length
|
||||||
? state.appointments.map((item) => `
|
? state.appointments.map((item) => `
|
||||||
<div class="stack-item work-order-card">
|
<div class="stack-item work-order-card">
|
||||||
|
|||||||
Reference in New Issue
Block a user