Перейти до змісту

Система тарифів на транспортні перевезення (Fleet)

Бізнес-контекст

В Україні ціноутворення на транспортні перевезення рідко базується на ідеальній формулі "тариф за тонно-кілометр". Реальні сценарії:

# Кейс Частота Приклад
1 Фіксований рейс Найчастіший "Миколаїв–Одеса = 1200 грн" — домовленість сторін
2 Контракт + специфікація Довгострокові Контракт з клієнтом: маршрут + вантаж + тип ТЗ → ціна
3 Сітка відстаней Спецтранспорт Цементовоз: 0–20 км = 800, 20–50 км = 1500...
4 Тонно-кілометр Рідкий X грн/т·км — ідеальний, але майже не зустрічається

Архітектура моделей

Нові моделі

ContractSpecification (essentials)

Файл: backend/essentials/models/contract_specification.py

Специфікація тарифу — підтаблиця (inline) до Contract. Визначає ціну перевезення за комбінацією параметрів.

ContractSpecification
├── contract (FK Contract) — батьківський контракт
├── name (CharField) — опис
├── [Matching — за чим шукати]
│   ├── route (FK Route, nullable) — точний маршрут
│   ├── point_from (FK LocationPoint, nullable) — або окрема точка відправлення
│   ├── point_to (FK LocationPoint, nullable) — або окрема точка призначення
│   ├── cargo_description (CharField) — опис вантажу
│   ├── cargo_weight_min / cargo_weight_max (Decimal, nullable) — діапазон ваги
│   └── vehicle_type (CharField: truck, cement_mixer, refrigerator, tanker...)
├── [Pricing — як рахувати]
│   ├── tariff_type: fixed_route | per_ton_km | per_km | distance_scale
│   ├── price (Decimal) — фіксована ціна
│   ├── price_per_ton_km (Decimal) — ставка за т·км
│   ├── price_per_km (Decimal) — ставка за км
│   └── currency (FK Currency)
├── [Validity — коли діє]
│   ├── effective_from / effective_to (Date)
│   ├── priority (int) — вищий = перевіряється першим
│   └── is_active (bool)
└── _subtables → DistanceScaleLine

DistanceScaleLine (essentials)

Рядок шкали відстаней — для тарифу типу distance_scale.

DistanceScaleLine
├── specification (FK ContractSpecification)
├── distance_from (Decimal km)
├── distance_to (Decimal km)
└── price (Decimal)

Змінені моделі

Модель Поле Тип Призначення
WaybillWorkResult tariff_price Decimal(15,2) Розрахована ціна за тарифом
tariff_currency FK Currency Валюта тарифу
tariff_source CharField contract_spec / order / manual
tariff_spec FK ContractSpecification Посилання на специфікацію
tariff_details JSONField Деталі розрахунку (audit trail)
Waybill total_revenue Decimal(15,2) Сума tariff_price по work_results
Order contract FK Contract Зв'язок замовлення з контрактом
Contract _subtables Додано subtable specifications

Алгоритм розрахунку

Кнопка "Розрахувати" у Waybill

POST /api/v1/fleet/waybills/{id}/calculate/
├── 1. FuelCalculator — розрахунок витрати пального (як раніше)
└── 2. TariffCalculator — розрахунок тарифів (НОВЕ)
      Для кожного WaybillTask:
      ├── A. Отримати параметри:
      │     task.order → client, contract, route
      │     task.point_from, task.point_to
      │     task.weight, task.distance_planned
      │     waybill.vehicle.vehicle_type
      ├── B. Пошук ContractSpecification:
      │     1. Якщо order.contract задано → шукати в його специфікаціях
      │     2. Інакше → шукати серед усіх контрактів client + organization
      │     Фільтри:
      │       • is_active = True
      │       • effective_from <= date <= effective_to
      │       • cargo_weight_min <= weight <= cargo_weight_max
      ├── C. Scoring (пріоритет matching):
      │     Route match:           +100 балів
      │     Point from match:       +50 балів
      │     Point to match:         +50 балів
      │     Vehicle type match:     +30 балів
      │     Cargo description:      +10 балів
      │     Якщо required field не збігається → score = -1 (виключення)
      ├── D. Розрахунок ціни за типом тарифу:
      │     fixed_route   → spec.price
      │     per_ton_km    → spec.price_per_ton_km × weight × distance
      │     per_km        → spec.price_per_km × distance
      │     distance_scale → lookup in DistanceScaleLine by distance range
      ├── E. Fallback:
      │     Якщо специфікація не знайдена → Order.price (ручна ціна)
      │     Якщо і ціни нема → tariff_price = null
      └── F. Запис у WaybillWorkResult:
            tariff_price, tariff_currency, tariff_source,
            tariff_spec, tariff_details (JSON audit)

      Waybill.total_revenue = SUM(work_results.tariff_price)

Пріоритети пошуку специфікації

Від найконкретнішої до загальної:

  1. Точний маршрут + тип ТЗ + вантаж → score ~140
  2. Точний маршрут + тип ТЗ → score ~130
  3. Точний маршрут → score ~100
  4. Точки origin+destination + тип ТЗ → score ~130
  5. Точки origin+destination → score ~100
  6. Тип ТЗ + distance_scale → score ~30
  7. Загальний per_ton_km → score ~0

При однаковому score — вибирається специфікація з вищим priority.


Структура файлів

Backend

backend/
├── essentials/
│   ├── models/
│   │   ├── contract.py                    ← +_subtables (specifications)
│   │   └── contract_specification.py      ← NEW: ContractSpecification + DistanceScaleLine
│   ├── serializers/
│   │   └── master_data.py                 ← +ContractSpecificationSerializer, DistanceScaleLineSerializer
│   ├── views/
│   │   └── master_data.py                 ← +ContractSpecificationViewSet, DistanceScaleLineViewSet
│   ├── apps.py                            ← +registry: contractSpecifications, distanceScaleLines
│   └── urls.py                            ← +router: contract-specifications, distance-scale-lines
├── fleet/
│   ├── models/
│   │   ├── waybill.py                     ← +total_revenue
│   │   ├── waybill_work_result.py         ← +tariff_price/currency/source/spec/details
│   │   └── order.py                       ← +contract FK
│   ├── services/
│   │   └── tariff_calculator.py           ← NEW: TariffCalculator
│   └── views.py                           ← calculate() тепер рахує fuel + tariffs
└── core/
    ├── management/commands/seed.py        ← +target contract_specifications
    └── seeders/essentials.py              ← +seed_contract_specifications()

Frontend

frontend/erp/src/
├── config/
│   └── essentials.ts                      ← contracts.subtables: contractSpecifications → distanceScaleLines
├── i18n/locales/
│   ├── en.json                            ← +17 field keys (Tariff Price, Total Revenue...)
│   └── ua.json                            ← +17 field keys (Ціна за тарифом, Загальний дохід...)

API Endpoints

CRUD специфікацій

GET    /api/v1/essentials/contract-specifications/?contract=ID
POST   /api/v1/essentials/contract-specifications/
GET    /api/v1/essentials/contract-specifications/{id}/
PUT    /api/v1/essentials/contract-specifications/{id}/
DELETE /api/v1/essentials/contract-specifications/{id}/

GET    /api/v1/essentials/distance-scale-lines/?specification=ID
POST   /api/v1/essentials/distance-scale-lines/
PUT    /api/v1/essentials/distance-scale-lines/{id}/
DELETE /api/v1/essentials/distance-scale-lines/{id}/

Розрахунок

POST /api/v1/fleet/waybills/{id}/calculate/
→ Повертає повний Waybill з оновленими:
  - fuel_records (розрахунок пального)
  - work_results (розрахунок тарифів: tariff_price, tariff_source, tariff_details)
  - total_revenue (сума тарифів)
  - _calc_warnings (масив попереджень, якщо є)

Seed Data

python manage.py seed contract_specifications

Створює для кожного з 5 тестових контрактів: - fixed_route — фіксована ціна за маршрут (priority 10) - per_ton_km — загальний тариф за тонно-км (priority 1) - distance_scale для cement_mixer — 5 діапазонів (priority 5) - per_km для refrigerator — ціна за км (priority 3)


Приклад tariff_details (JSON audit)

{
  "spec_id": 42,
  "spec_name": "Route: Mykolaiv — Odesa — fixed price",
  "tariff_type": "fixed_route",
  "distance_km": "120.00",
  "weight_t": "20.000",
  "calculation": "Fixed route price: 1200.00"
}
{
  "spec_id": 45,
  "spec_name": "Cement mixer — distance scale",
  "tariff_type": "distance_scale",
  "distance_km": "35.00",
  "weight_t": "8.000",
  "scale_range": "20–50 km",
  "calculation": "Distance scale: 35.00km in range 20–50 = 1500.00"
}

Міграції

essentials/migrations/0014_contractspecification_distancescaleline.py
fleet/migrations/0027_order_contract_waybill_total_revenue_and_more.py