Система тарифів на транспортні перевезення (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)
Пріоритети пошуку специфікації¶
Від найконкретнішої до загальної:
- Точний маршрут + тип ТЗ + вантаж → score ~140
- Точний маршрут + тип ТЗ → score ~130
- Точний маршрут → score ~100
- Точки origin+destination + тип ТЗ → score ~130
- Точки origin+destination → score ~100
- Тип ТЗ + distance_scale → score ~30
- Загальний 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¶
Створює для кожного з 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"
}