#Запись протокола в карту

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

Если режим доставки — push, реализуйте этот endpoint.

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

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

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

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

В текущем контракте smartica.ai вызывает только PUT. POST и PATCH smartica.ai не вызывает.

Адрес тот же, что у GET контекста. Отличается только метод:

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

#Что присылает smartica.ai

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
X-Smartica-Revision: 1

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

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

Тип value задаёт type в бланке. Число приходит числом. Дата приходит строкой YYYY-MM-DD. Флажок приходит как true или false.

Текст без format приходит с разметкой Markdown. Чтобы получить текст без разметки, передайте format со значением plain. Чтобы получить HTML, передайте format со значением html.

Полный список типов, массив skipped и примеры описаны в статье Типы полей бланка.

В заголовке X-Smartica-Revision приходит номер ревизии этого протокола. Заголовок не меняет тело запроса. Если ваша схема не знает заголовок, всё равно примите запрос. Номер нужен, только когда после правки врача вы отправляете сохранённую версию обратно. Порядок отправки описан в статье Протокол после правки врача.

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

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

  1. Найдите карточку приёма по ID из адреса.
  2. Если encounter_id в теле не совпадает с ID из адреса, верните 422.
  3. Для каждого элемента fields обновите поле с тем же id.
  4. Если поля нет в массиве fields, не трогайте его. Не очищайте это поле и не обнуляйте его.
  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.ai не присылает поля, для которых не нашлось содержательного значения. smartica.ai отбрасывает пустые строки и заглушки вроде «не указано» или «отсутствует».

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

#Не очищайте отсутствующие поля

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

#Сохраните value как текст

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

Если ваша МИС показывает value как HTML, экранируйте или очищайте значение на стороне МИС. Со стороны smartica.ai value — текст.

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

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

Повтор возникает в таких случаях:

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

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

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

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

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

Врачдописывает приём
Бэкенд МИСобработчик PUT, карточка приёма
smartica.aiзаполняет протокол
Первая доставка
1
smartica.ai → бэкенд МИС
Заполненный протокол
PUT /…/encounters/12345
fields: complaints · transcript
Приём 12345
complaintsДавящая боль за грудиной
diagnosis_primaryвписан врачом вручную
transcriptпервая запись
diagnosis_primary не пришёл в fields, поэтому остаётся как был.
Бэкенд МИС → smartica.ai
200 и JSON: сохранено
Повторврач дописал приём или нажал повтор отправки
2
Врач → smartica.ai
Открывает тот же приём и дописывает
тот же encounter_id
3
Протокол собран заново
4
smartica.ai → бэкенд МИС
Заполненный протокол
PUT /…/encounters/12345
тот же адрес, обновлённые fields
Тот же приём 12345
complaintsобновлено из fields
diagnosis_primaryбез изменений
transcriptзаменена полной версией
Найдите приём по encounter_id и обновите. Новая карточка на повтор — дубль в карте пациента.
Бэкенд МИС → smartica.ai
200 и JSON: сохранено

Отправьте один и тот же запрос через curl дважды. Убедитесь, что в МИС остался один приём с обновлёнными данными.

#Верните успешный ответ

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

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

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

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

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

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

#Ошибки

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

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

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

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

Не помещайте в 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. Частичный набор полей. Уберите одно поле из массива fields. Убедитесь, что в МИС поле не опустело.

#Аудиофайл

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

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

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

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