#Ваш 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:
- Найдите карточку приёма по ID из адреса.
- Проверьте, что
encounter_idв теле совпадает с ID из адреса. Не совпало — верните422. - Для каждого элемента
fieldsобновите поле с соответствующимid. - Поля, которых нет в массиве, не трогайте. Не очищайте, не обнуляйте.
- Сохраните
transcriptкак актуальную полную версию, заменив предыдущую. - Зафиксируйте всё в одной транзакции, если ваша МИС это поддерживает.
#Пример обработчика
Ниже — та же логика на разных стеках. Код намеренно без привязки к фреймворку: 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'
Проверьте три сценария:
- Обычное сохранение. Данные попали в нужный приём.
- Повторная отправка. Выполните команду дважды — в МИС должен остаться один приём.
- Частичный набор полей. Уберите одно поле из массива и убедитесь, что в МИС оно не опустело.
#Аудиофайл
Аудиозапись в текущем контракте не передаётся.
Если она нужна вашей МИС, согласуйте отдельное расширение при онбординге. Не добавляйте поле audio в базовый payload самостоятельно — оно не появится.