#Ваш API: запись протокола (PUT)

Этот endpoint реализует ваша МИС. Smartica вызывает его, когда протокол готов: присылает заполненные поля и полную стенограмму.

Это самый важный endpoint интеграции — ради него всё и затевается. Если у вас режим доставки push, реализовать его обязательно.

#Когда этот endpoint нужен

Режим доставки Нужен ли PUT
push Да. Это единственный способ получить результат
pull Нет. Вы забираете результат сами, см. Import

Режим фиксируется при подключении и одинаков для сценариев Launch и Import.

#Метод и адрес

Используется только PUT. POST и PATCH текущий адаптер не вызывает.

Адрес — тот же, что и у GET контекста, различаются только методы:

PUT https://mis.example.ru/api/for-smartica/encounters/{encounter_id}

#Что присылает Smartica

PUT /api/for-smartica/encounters/12345 HTTP/1.1
Host: mis.example.ru
Authorization: Bearer <токен>
Content-Type: application/json; charset=utf-8
Accept: application/json

{
  "encounter_id": "12345",
  "fields": [
    {
      "id": "complaints",
      "value": "Жалобы на давящую боль за грудиной..."
    },
    {
      "id": "diagnosis_primary",
      "value": "Гипертоническая болезнь I ст."
    }
  ],
  "transcript": "Полная стенограмма разговора врача и пациента..."
}
Поле Есть всегда Тип Описание
encounter_id да строка ID приёма — тот же, что в SSO, в адресе и в GET
fields да массив Поля, для которых нашлось содержание. Может быть пустым
fields[].id да строка Точный id, который вы сами передали ранее
fields[].value да строка Непустой текст поля
transcript да строка Непустая полная стенограмма

Аудиофайл в текущем контракте не передаётся.

#Что делать с этими данными

Обрабатывайте запрос как обновление существующего приёма по encounter_id:

  1. Найдите карточку приёма по ID из адреса.
  2. Проверьте, что encounter_id в теле совпадает с ID из адреса. Не совпало — верните 422.
  3. Для каждого элемента fields обновите поле с соответствующим id.
  4. Поля, которых нет в массиве, не трогайте. Не очищайте, не обнуляйте.
  5. Сохраните transcript как актуальную полную версию, заменив предыдущую.
  6. Зафиксируйте всё в одной транзакции, если ваша МИС это поддерживает.

#Пример обработчика

Ниже — та же логика на разных стеках. Код намеренно без привязки к фреймворку: repository — ваш слой доступа к данным, urlId приходит из маршрута, raw — сырое тело запроса. Авторизацию считаем уже проверенной.

$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);

if (($data['encounter_id'] ?? null) !== $urlId) {
    return jsonResponse(422, ['message' => 'ID в адресе и в теле не совпадают']);
}

$encounter = $repository->findByExternalId($urlId);

if ($encounter === null) {
    return jsonResponse(404, ['message' => 'Приём не найден']);
}

$repository->transaction(function () use ($repository, $encounter, $data): void {
    // Обновляем только пришедшие поля. Остальные не трогаем.
    foreach ($data['fields'] ?? [] as $field) {
        $repository->upsertProtocolField($encounter->id, $field['id'], $field['value']);
    }

    // Стенограмма заменяется полной версией.
    $repository->saveTranscript($encounter->id, $data['transcript']);
});

return jsonResponse(200, ['encounter_id' => $urlId, 'status' => 'saved']);
const data = JSON.parse(raw);

if (data.encounter_id !== urlId) {
  return res.status(422).json({ message: 'ID в адресе и в теле не совпадают' });
}

const encounter = await repository.findByExternalId(urlId);

if (!encounter) {
  return res.status(404).json({ message: 'Приём не найден' });
}

await repository.transaction(async (tx) => {
  // Обновляем только пришедшие поля. Остальные не трогаем.
  for (const field of data.fields ?? []) {
    await tx.upsertProtocolField(encounter.id, field.id, field.value);
  }

  // Стенограмма заменяется полной версией.
  await tx.saveTranscript(encounter.id, data.transcript);
});

res.json({ encounter_id: urlId, status: 'saved' });
data = json.loads(raw)

if data.get("encounter_id") != url_id:
    return json_response(422, {"message": "ID в адресе и в теле не совпадают"})

encounter = repository.find_by_external_id(url_id)

if encounter is None:
    return json_response(404, {"message": "Приём не найден"})

with repository.transaction():
    # Обновляем только пришедшие поля. Остальные не трогаем.
    for field in data.get("fields", []):
        repository.upsert_protocol_field(encounter.id, field["id"], field["value"])

    # Стенограмма заменяется полной версией.
    repository.save_transcript(encounter.id, data["transcript"])

return json_response(200, {"encounter_id": url_id, "status": "saved"})
var data = JsonSerializer.Deserialize<ExportPayload>(raw)!;

if (data.EncounterId != urlId)
{
    return Results.Json(new { message = "ID в адресе и в теле не совпадают" }, statusCode: 422);
}

var encounter = await repository.FindByExternalIdAsync(urlId);

if (encounter is null)
{
    return Results.Json(new { message = "Приём не найден" }, statusCode: 404);
}

await using var transaction = await db.Database.BeginTransactionAsync();

// Обновляем только пришедшие поля. Остальные не трогаем.
foreach (var field in data.Fields ?? [])
{
    await repository.UpsertProtocolFieldAsync(encounter.Id, field.Id, field.Value);
}

// Стенограмма заменяется полной версией.
await repository.SaveTranscriptAsync(encounter.Id, data.Transcript);
await transaction.CommitAsync();

return Results.Json(new { encounter_id = urlId, status = "saved" });
ExportPayload data = objectMapper.readValue(raw, ExportPayload.class);

if (!urlId.equals(data.encounterId())) {
    return ResponseEntity.unprocessableEntity()
            .body(Map.of("message", "ID в адресе и в теле не совпадают"));
}

Encounter encounter = repository.findByExternalId(urlId);

if (encounter == null) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(Map.of("message", "Приём не найден"));
}

transactionTemplate.executeWithoutResult(status -> {
    // Обновляем только пришедшие поля. Остальные не трогаем.
    for (ProtocolField field : data.fields()) {
        repository.upsertProtocolField(encounter.getId(), field.id(), field.value());
    }

    // Стенограмма заменяется полной версией.
    repository.saveTranscript(encounter.getId(), data.transcript());
});

return ResponseEntity.ok(Map.of("encounter_id", urlId, "status", "saved"));
Функция EncounterPUT(Запрос)

    ИдентификаторИзURL = Запрос.ПараметрыURL["id"];

    Чтение = Новый ЧтениеJSON;
    Чтение.УстановитьСтроку(Запрос.ПолучитьТелоКакСтроку());
    Данные = ПрочитатьJSON(Чтение, Истина);
    Чтение.Закрыть();

    Если Данные["encounter_id"] <> ИдентификаторИзURL Тогда
        Возврат ОтветСОшибкой(422, "ID в адресе и в теле не совпадают");
    КонецЕсли;

    Приём = Приёмы.НайтиПоВнешнемуИдентификатору(ИдентификаторИзURL);

    Если Приём = Неопределено Тогда
        Возврат ОтветСОшибкой(404, "Приём не найден");
    КонецЕсли;

    НачатьТранзакцию();
    Попытка
        // Обновляем только пришедшие поля. Остальные не трогаем.
        Для Каждого Поле Из Данные["fields"] Цикл
            Протоколы.ЗаписатьЗначениеПоля(Приём, Поле["id"], Поле["value"]);
        КонецЦикла;

        // Стенограмма заменяется полной версией.
        Протоколы.ЗаписатьСтенограмму(Приём, Данные["transcript"]);

        ЗафиксироватьТранзакцию();
    Исключение
        ОтменитьТранзакцию();
        Возврат ОтветСОшибкой(500, "Не удалось сохранить протокол");
    КонецПопытки;

    Ответ = Новый HTTPСервисОтвет(200);
    Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
    Ответ.УстановитьТелоИзСтроки(
        "{""encounter_id"": """ + ИдентификаторИзURL + """, ""status"": ""saved""}");

    Возврат Ответ;

КонецФункции

Идемпотентность здесь обеспечивается двумя решениями: приём ищется по существующему encounter_id, а не создаётся заново, и поля пишутся через upsert по паре «приём + id поля». Повторный запрос с теми же данными просто перезапишет их теми же значениями.

#Почему fields может прийти пустым

Smartica не присылает поля, для которых не нашлось содержательного значения. Пустые строки и заглушки вроде «не указано» или «отсутствует» отбрасываются на нашей стороне.

Поэтому "fields": []валидный запрос, а не ошибка. Такое бывает, если запись очень короткая. Стенограмму в этом случае всё равно нужно сохранить: она и есть основной результат.

#Почему нельзя очищать отсутствующие поля

Массив fields — это не «новое состояние бланка целиком», а «вот что удалось заполнить». Если врач или медсестра уже что-то вписали в карту руками, а Smartica для этого поля ничего не нашла, — затирать введённое нельзя.

#Про содержимое value

value — обычный текст. Он может содержать переводы строк и разметку списков. Сохраняйте Unicode и многострочность без потерь.

Если ваша МИС отображает содержимое как HTML, экранируйте или очищайте значение на своей стороне: со стороны Smartica это просто текст.

#Идемпотентность

Один и тот же запрос может прийти повторно. Это штатная ситуация, а не сбой.

Когда это происходит:

  • врач нажал повтор отправки после ошибки;
  • врач дописал приём, и протокол собрался заново;
  • результат перегенерировали;
  • изменился механизм доставки в будущих версиях.

Отдельного заголовка вроде Idempotency-Key в контракте нет. Ключ операции — encounter_id.

Требуемое поведение:

  • одинаковый запрос не создаёт новую карточку, второй протокол или дубликат вложения;
  • изменившиеся значения переданных полей обновляются;
  • стенограмма заменяется полной версией из последнего успешного запроса;
  • ответ остаётся успешным, даже если данные уже были сохранены раньше.

Не рассчитывайте на то, что запрос придёт ровно один раз.

Как проверить: отправьте один и тот же запрос курлом дважды и убедитесь, что в МИС остался один приём с обновлёнными данными.

#Успешный ответ

Верните 200 OK и JSON-объект:

{
  "encounter_id": "12345",
  "status": "saved"
}

Smartica проверяет код ответа и то, что тело разбирается как JSON. Значения полей сейчас не используются, но рекомендуемый формат сильно упрощает разбор проблем.

Не возвращайте:

  • 204 No Content;
  • пустое тело;
  • HTML-страницу;
  • текст, который не декодируется как JSON.

Всё перечисленное считается ошибкой интеграции, даже если код относится к 2xx. Частая причина — прокси или балансировщик, который подменяет ответ приложения своей страницей.

#Ошибки

Верните подходящий код не из 2xx и безопасный JSON:

{
  "message": "Приём закрыт для изменения"
}
Код Когда использовать
400 Тело не является корректным запросом
401 Учётные данные неверны
403 Запись в этот ресурс запрещена
404 Приём не найден
409 Состояние приёма не допускает обновление — например, он уже закрыт
422 Ошибка в полях тела, включая несовпадение ID
5xx Временная ошибка на вашей стороне

При любом ответе не из 2xx, timeout или невалидном JSON отправка помечается ошибкой. Врач увидит это в интерфейсе Smartica и сможет повторить вручную.

Автоматический повтор на каждую ошибку не гарантирован — см. Лимиты и ограничения.

Не помещайте в message стектрейсы, SQL, учётные данные и сведения о пациенте: текст сохраняется для диагностики и может быть показан пользователю.

#Проверка через curl

curl --fail-with-body --silent --show-error \
  --request PUT \
  --header 'Authorization: Bearer ЗАМЕНИТЕ-НА-ТОКЕН' \
  --header 'Content-Type: application/json; charset=utf-8' \
  --header 'Accept: application/json' \
  --data '{
    "encounter_id": "12345",
    "fields": [
      {
        "id": "complaints",
        "value": "Тестовые жалобы"
      },
      {
        "id": "diagnosis_primary",
        "value": "Тестовый диагноз"
      }
    ],
    "transcript": "Тестовая стенограмма..."
  }' \
  'https://mis.example.ru/api/for-smartica/encounters/12345'

Проверьте три сценария:

  1. Обычное сохранение. Данные попали в нужный приём.
  2. Повторная отправка. Выполните команду дважды — в МИС должен остаться один приём.
  3. Частичный набор полей. Уберите одно поле из массива и убедитесь, что в МИС оно не опустело.

#Аудиофайл

Аудиозапись в текущем контракте не передаётся.

Если она нужна вашей МИС, согласуйте отдельное расширение при онбординге. Не добавляйте поле audio в базовый payload самостоятельно — оно не появится.

#Связанные статьи

← Все статьи: Интеграция с МИС Поиск по базе знаний