KNOTTA research & development

Отчёты, шаблоны, вложения и доставки

Модели Report, ReportSurveyTemplate, FileAttachment, ReportDelivery — структура отчётности и история доставок.

Отчёты, шаблоны, вложения и доставки

ReportSurveyTemplate

Префикс: rprts_ Таблица: report_survey_template Файл: garden/modules/report/models/report.py

Шаблон отчёта в формате SurveyJS.

ПолеТипОписание
idKSUID, rprts_*
nameCharField(255)Название шаблона
versionPositiveIntegerFieldВерсия (например, 1, 2, 3)
descriptionTextFieldПроизвольное описание
template_jsonJSONFieldSurveyJS-схема — целиком
is_defaultBooleanFieldФлаг «шаблон по умолчанию»

Constraints:

  • uq_template_name_version — одно сочетание (name, version) уникально.
  • uq_only_one_default_template — ровно один шаблон может иметь is_default=True (partial unique constraint).

Если при сохранении шаблону ставится is_default=True, метод save() автоматически снимает флаг с остальных.

Дефолтный шаблон создаётся командой:

python manage.py seed_report_templates

Report

Префикс: rprt_ Таблица: report Файл: garden/modules/report/models/report.py

Отчёт по наряду. Связь с нарядом — 1:1 через OneToOneField.

ПолеТипОписание
idKSUID, rprt_*
service_visitOneToOne → ServiceVisitНа какой наряд (PROTECT)
authorFK → UserБригадир — автор (limit_choices role=brigadier)
reviewerFK → User, nullableСотрудник — рецензент (limit_choices role IN (admin, staff))
templateFK → ReportSurveyTemplateШаблон, по которому собран отчёт
template_name_snapshotCharField(255)Снимок имени шаблона
template_version_snapshotPositiveIntegerFieldСнимок версии
template_json_snapshotJSONFieldПолный снимок схемы на момент создания
statusReportStatusСм. ниже
rejection_commentTextFieldКомментарий рецензента при отклонении
submitted_at, approved_at, rejected_atDateTimeFieldТаймстемпы переходов
submission_attemptsPositiveSmallIntegerFieldСколько раз подавался
sent_to_clientBooleanFieldОтправлен клиенту хоть раз?
survey_dataJSONField(nullable)Заполненные ответы по шаблону

History подключена с excluded_fields=["survey_data", "template_json_snapshot"] — JSON-поля не пишутся в historical_report, чтобы не раздувать историю.

Снимок шаблона

В момент создания отчёта (когда Report._state.adding истинный, а template_json_snapshot пустой) метод save() копирует весь шаблон в snapshot-поля:

self.template_name_snapshot = tpl.name
self.template_version_snapshot = tpl.version
self.template_json_snapshot = tpl.template_json

Зачем это нужно. Если потом изменить шаблон в ReportSurveyTemplate.template_json — уже созданные отчёты продолжат использовать ту версию схемы, по которой их собирали. Это:

  1. Гарантирует валидность данныхsurvey_data всегда соответствует своему шаблону.
  2. Поддерживает обратную совместимость — старые отчёты со старыми полями (*-person-count / *-hours-count) корректно отображаются и считаются.
  3. Делает шаблоны иммутабельными для исторических данных.

Статусная машина

Загрузка диаграммы…
СтатусТриггерЭффекты
draftСозданиеСтартовая позиция, обычно создаётся автоматически вместе с нарядом
submittedsurvey_completesubmitted_at = now. submission_attempts += 1. Отправляется REPORT_SUBMITTED сотрудникам.
approvedapproveapproved_at = now. Запускает sync_visit_members_from_survey. Отправляется REPORT_APPROVED бригадиру.
rejectedreject (с комментарием)rejected_at = now. Требуется rejection_comment (валидация в clean()). Отправляется REPORT_REJECTED бригадиру.

Поле sent_to_client поднимается отдельно — через POST /reports/{id}/survey/send-to-client/. Это действие отправляет REPORT_READY контактным лицам.

Эндпоинты отчёта

МетодПутьНазначение
GET/api/reports/Список (поддерживает фильтры)
GET/api/reports/{id}/Детали
POST/api/reports/{id}/survey/Сохранить черновик survey_data
POST/api/reports/{id}/survey/complete/`draft
POST/api/reports/{id}/survey/approve/submitted → approved
POST/api/reports/{id}/survey/reject/submitted → rejected (требует rejection_comment)
POST/api/reports/{id}/survey/send-to-client/Поднимает sent_to_client, отправляет REPORT_READY
GET/api/reports/{id}/deliveries/История попыток доставки
GET/api/reports/{id}/report-summary/Аггрегированный отчёт для публичной страницы (расчёт чел.-час)

FileAttachment

Префикс: file_ Таблица: file_attachment

Файл, прикреплённый к отчёту.

ПолеТипОписание
idKSUID, file_*
reportFK → ReportК какому отчёту
bucketCharField(255)Имя бакета в Supabase Storage
object_keyCharField(1024)Путь к файлу внутри бакета
original_filenameCharField(255)Исходное имя
content_typeCharField(128)MIME-тип
sizeBigIntegerField(nullable)Размер в байтах
checksum_sha256CharField(64)SHA-256 хеш
width, heightIntegerField(nullable)Для изображений
uploaded_atDateTimeField(auto_now_add)Когда загружен

URL не хранится. Метод build_presigned_url(expires_seconds=3600) генерирует подписанную ссылку на лету. Это даёт короткоживущие ссылки и не требует публичности bucket.

ReportDelivery

Префикс: rprtdel_ Таблица: report_delivery Файл: garden/modules/report/models/delivery.py

Запись о попытке доставки уведомления — фиксирует канал, статус, время, ошибки и payload.

ПолеТипОписание
idKSUID, rprtdel_*
reportFK → Report, nullableДля топиков report_*
service_visitFK → ServiceVisit, nullableДля топиков work_*
topicNotificationTopic, nullableКакой топик
channelDeliveryChanneltelegram / email
telegram_chat_idCharField(128, nullable)Для канала telegram
recipient_emailEmailField(nullable)Для канала email
statusDeliveryStatusСм. ниже
error_code, error_messageCharField / TextFieldПри ошибках
payload_snapshotJSONFieldПолезная нагрузка на момент отправки (для отладки)
attemptsPositiveSmallIntegerFieldСколько раз пытались
sent_at, delivered_at, opened_at, failed_atDateTimeFieldТаймстемпы переходов

Constraint delivery_has_report_or_service_visit:

CheckConstraint(
    check=Q(report__isnull=False) | Q(service_visit__isnull=False),
    name="delivery_has_report_or_service_visit",
)

Хотя бы одно из двух полей должно быть заполнено. Для большинства топиков заполнены оба (через report.service_visit); для work_started / work_completed — только service_visit (отчёта ещё / уже нет в фокусе).

Жизненный цикл доставки

Загрузка диаграммы…

opened достижим напрямую из sent, потому что Resend tracking pixel может сработать до (или вместо) события delivered.

Эндпоинт report-summary

GET /api/reports/{id}/report-summary/ — публичный (для авторизованных и для держателей прямой ссылки) — возвращает агрегированный отчёт:

{
  "service_visit": {...},
  "object": {...},
  "client": {...},
  "blocks": {
    "standard_care": {
      "is_active": true,
      "people": 3,
      "hours": 10.0,
      "comment": "..."
    },
    "topiary_cut": {...},
    "materials": [...],
    "services": [...]
  },
  "files": [...]
}

Расчёт людей и часов — через утилиту calculate_man_hours(survey_data, work_type):

def calculate_man_hours(survey_data, work_type):
    workers_key      = f"{work_type}-workers"
    person_count_key = f"{work_type}-person-count"
    hours_count_key  = f"{work_type}-hours-count"

    if workers_key in survey_data:
        # Новый формат — массив {worker_id, hours}
        workers = survey_data[workers_key]
        return len(workers), sum(w.get("hours", 0) for w in workers)
    elif person_count_key in survey_data:
        # Старый формат — people × hours
        p = survey_data.get(person_count_key, 0)
        h = survey_data.get(hours_count_key, 0)
        return p, p * h
    return 0, 0.0

Это поддерживает обе версии формы (со слайдерами и с matrixdynamic) одновременно, без миграции данных.

На странице