Как делать11 октября 2026 г.10 мин чтения
API и интеграцииШаг 8 из 10

1С для разработчика: HTTP-сервисы и OData

Коротко

У 1С:Предприятия два HTTP-входа для внешних систем. Стандартный интерфейс OData — автоматический REST по протоколу OData 3.0 ко всем включённым в его состав объектам: без программирования, но «широкий» и привязанный к структуре конфигурации. HTTP-сервисы — собственный REST, который программист 1С описывает в конфигурации: узкий контракт с бизнес-логикой внутри 1С. Чтение справочников удобно брать из OData, а заказы и операции с правилами — через HTTP-сервис по согласованному контракту.

РуководствоУровень: СреднийИнтеграции с 1СПрактика: ~1–2 ч

После материала вы сможете

  • выбрать между стандартным интерфейсом OData и собственным HTTP-сервисом 1С под конкретный поток данных
  • составить URL OData-запроса к Catalog_Номенклатура с $filter, $select, $orderby, $top и $skip, правильно закодировав кириллицу и литералы guid'…' и datetime'…'
  • написать клиент на Node.js, который разбирает ответ value / odata.count и ошибки, читает справочник постранично и находит изменения по DataVersion
  • описать контракт HTTP-сервиса в OpenAPI и разделить работу с программистом 1С: кто источник правды и как повторять запись без дублей

Проверено на

1C:Enterprise 8.3 standard OData interface (OData v3, since 8.3.5) — not run on a real platformNode.js 24 LTS (24.18), built-in fetchTypeScript 7.0.2 (tsc --strict)Vitest 5.0.3, @redocly/cli 2.62.1 (OpenAPI 3.1)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что видит веб-разработчик со стороны 1С
  2. 02Публикация и авторизация
  3. 03URL и параметры OData: $filter, $select, $expand, $top
  4. 04Клиент на Node: чтение, запись и проведение
  5. 05Кириллица в именах объектов
  6. 06HTTP-сервисы 1С: свой REST и контракт
  7. 07Конфликты, источник правды и альтернативы
  8. 08Проверка на моке, ограничения и упражнения

Что видит веб-разработчик со стороны 1С

Бизнес-сторона выбора — какой поток через что передавать, файлы или очереди, кто главный по данным — разобрана в статье «Как интегрировать 1С с веб-приложением». Здесь техника: какие запросы отправляет сайт, как выглядят ответы и что попросить у программиста 1С.

Для внешней системы база 1С — это публикация на веб-сервере (Apache или IIS) с адресом вида https://1c.example.kz/trade. Через неё доступны два HTTP-интерфейса. По описанию на сайте платформы (v8.1c.ru), стандартный интерфейс OData — это автоматически формируемый REST-интерфейс всего прикладного решения по протоколу OData версии 3.0 с ответами в Atom/XML или JSON. HTTP-сервисы — объекты конфигурации, в которых разработчик сам на встроенном языке формирует ответ: код, заголовки и тело.

Кто делает

Стандартный интерфейс OData
включается при публикации, состав объектов задаёт администратор или программист 1С
Собственный HTTP-сервис
программист 1С пишет шаблоны URL и обработчики

Доработка конфигурации

Стандартный интерфейс OData
не нужна
Собственный HTTP-сервис
нужна (или расширение конфигурации)

Что доступно

Стандартный интерфейс OData
справочники, документы, регистры, планы обмена и другие объекты — всё, что включили в состав
Собственный HTTP-сервис
ровно то, что описано в контракте

Бизнес-логика

Стандартный интерфейс OData
на стороне клиента; 1С выполняет проверки прав и обработчики событий, но не проверку заполнения
Собственный HTTP-сервис
внутри 1С: валидация, подбор контрагента, проведение

Формат

Стандартный интерфейс OData
OData 3.0: имена ресурсов вида Catalog_Имя, поля Ref_Key, *_Key
Собственный HTTP-сервис
любой, обычно JSON по вашему контракту

Зависимость от структуры

Стандартный интерфейс OData
переименовали реквизит в конфигураторе — сломался клиент
Собственный HTTP-сервис
контракт стабилен, внутренности 1С меняются свободно

Публикация и авторизация

  1. Публикация на веб-сервере

    Программист 1С или администратор публикует базу из конфигуратора и включает поддержку OData; HTTP-сервисы публикуются так же, как веб-сервисы. Результат — адрес публикации, от него строятся все URL.

  2. Состав интерфейса

    Объекты, доступные через OData, задаются методом глобального контекста УстановитьСоставСтандартногоИнтерфейсаOData. В конфигурациях на Библиотеке стандартных подсистем для этого есть обработка «Настройка автоматического REST-сервиса». Включайте только нужные объекты; 1С:Фреш отдельно не рекомендует открывать планы обмена.

  3. Служебный пользователь

    Отдельный пользователь 1С только с нужными правами. В приложениях 1С:Фреш ему назначается роль УдаленныйДоступOData. Не используйте учётную запись живого сотрудника.

  4. Аутентификация

    Способы аутентификации OData-клиентов совпадают с веб-сервисами; в примерах 1С:Фреш — заголовок Authorization: Basic с логином и паролем в base64. Basic передаёт пароль фактически открытым текстом, поэтому публикация должна работать только по HTTPS.

  5. Проверка

    Простой GET к …/odata/standard.odata/Catalog_Имя/$count из браузера или curl: число в ответе означает, что публикация, состав и права настроены.

При чтении и записи через OData платформа выполняет обычные проверки прав и вызывает обработчики событий объектов, но не выполняет проверку заполнения. Если для части записей действуют ограничения доступа, запрос по умолчанию завершается ошибкой 401; параметр allowedOnly=true просит вернуть только доступные записи. Учитывайте это: «пустой» ответ может означать не отсутствие данных, а урезанные права служебного пользователя.

URL и параметры OData: $filter, $select, $expand, $top

URL запроса имеет вид адрес-публикации/odata/standard.odata/ресурс?параметры. Ресурс — тип и имя объекта конфигурации через подчёркивание; имя — как в свойстве «Имя» в конфигураторе. Без $format=json (или заголовка Accept: application/json) ответ придёт в XML.

Catalog_

Объект 1С
справочник
Пример
Catalog_Номенклатура(guid'…')

Document_

Объект 1С
документ
Пример
Document_ЗаказПокупателя

InformationRegister_

Объект 1С
регистр сведений
Пример
InformationRegister_КурсыВалют/SliceLast(…)

AccumulationRegister_

Объект 1С
регистр накопления
Пример
AccumulationRegister_Имя/Balance(…) — остатки

Constant_, ExchangePlan_, ChartOfAccounts_

Объект 1С
константа, план обмена, план счетов
Пример
Constant_Имя

суффикс _ИмяТабличнойЧасти

Объект 1С
строки табличной части
Пример
Catalog_Организации_КонтактнаяИнформация

$select=Ref_Key,Description,Артикул

Что делает
только нужные поля
Нюанс 1С
запрашивайте всегда: в объектах десятки реквизитов и табличные части

$filter=Артикул eq 'КФ-001' and DeletionMark eq false

Что делает
условие отбора
Нюанс 1С
операции eq ne gt ge lt le and or not, функции startswith, substringof, like, any/all по табличным частям

$orderby=Code desc

Что делает
сортировка
Нюанс 1С
обязательна для стабильной постраничной выборки

$top=500&$skip=1000

Что делает
страница
Нюанс 1С
$skip применяется раньше $top

$expand=Физлицо

Что делает
вложить связанный объект вместо Физлицо_Key
Нюанс 1С
только для списков; не раскрывает реквизиты табличных частей

$inlinecount=allpages

Что делает
общее число записей
Нюанс 1С
приходит строкой в odata.count

/$count

Что делает
только количество
Нюанс 1С
ответ — число без JSON

Литералы пишутся по правилам OData: строки — в апострофах, ссылки — guid'c3fbdcaf-…', даты — datetime'2026-10-01T00:00:00'. Апостроф внутри строки удваивается: спецификация OData 4.01 записывает это правило явно, а в версии 3.0, на которой работает 1С, отдельной формулировки нет — проверьте фильтр с апострофом на своей базе. Дату 1С хранит без часового пояса, поэтому передавайте её строкой «как на часах», а не через toISOString(), которая сдвинет время в UTC.

src/literals.ts
// Литералы для $filter и ключей стандартного интерфейса OData 1С (протокол OData v3)
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;

export const EMPTY_REF = "00000000-0000-0000-0000-000000000000"; // пустая ссылка в полях *_Key

// строка в апострофах; апостроф внутри удваивается: 'Д''Артаньян'
export const str = (value: string) => `'${value.replaceAll("'", "''")}'`;

export function guid(value: string): string {
  if (!GUID.test(value)) throw new Error(`not a GUID: ${value}`);
  return `guid'${value.toLowerCase()}'`;
}

// 1С хранит дату без часового пояса: передаём «настенное» время строкой, не через Date и toISOString()
export function datetime(value: string): string {
  if (!DATETIME.test(value)) throw new Error(`expected YYYY-MM-DDThh:mm:ss, got ${value}`);
  return `datetime'${value}'`;
}

// Catalog_Номенклатура, Document_ЗаказПокупателя — имя как в свойстве «Имя» в конфигураторе
export type ObjectKind =
  | "Catalog"
  | "Document"
  | "InformationRegister"
  | "AccumulationRegister"
  | "Constant"
  | "ChartOfCharacteristicTypes";
export const entity = (kind: ObjectKind, name: string) => `${kind}_${name}`;
Литералы и имена ресурсов: проверка формата до отправки запроса
src/query.ts
import { guid } from "./literals.ts";

export type ListOptions = {
  select?: string[];
  filter?: string;
  expand?: string[];
  orderby?: string;
  top?: number;
  skip?: number;
  count?: boolean; // $inlinecount=allpages → поле odata.count в ответе
  allowedOnly?: boolean; // не падать с 401 на записях, закрытых ограничением доступа
};

// Кириллица → UTF-8 (%D0%9D…), пробел → %20, & # + % → кодируются; «, / :» оставляем читаемыми,
// как их отправляет браузер. «$» в именах параметров не кодируем
const enc = (value: string) =>
  encodeURIComponent(value).replace(/%2C/g, ",").replace(/%2F/g, "/").replace(/%3A/g, ":");
const q = (name: string, value: string) => `${name}=${enc(value)}`;

export function listUrl(root: string, resource: string, o: ListOptions = {}): string {
  const params = [q("$format", "json")];
  if (o.select?.length) params.push(q("$select", o.select.join(",")));
  if (o.filter) params.push(q("$filter", o.filter));
  if (o.expand?.length) params.push(q("$expand", o.expand.join(",")));
  if (o.orderby) params.push(q("$orderby", o.orderby));
  if (o.top !== undefined) params.push(q("$top", String(o.top)));
  if (o.skip) params.push(q("$skip", String(o.skip)));
  if (o.count) params.push(q("$inlinecount", "allpages"));
  if (o.allowedOnly) params.push(q("allowedOnly", "true"));
  return `${root}/${encodeURIComponent(resource)}?${params.join("&")}`;
}

// Catalog_Номенклатура(guid'…') — обращение к одному объекту по ссылке
export function entityUrl(root: string, resource: string, ref: string, select?: string[]): string {
  const params = [q("$format", "json"), ...(select?.length ? [q("$select", select.join(","))] : [])];
  return `${root}/${encodeURIComponent(resource)}(${encodeURIComponent(guid(ref))})?${params.join("&")}`;
}
Сборка URL: кириллица и пробелы кодируются, $format=json — всегда

Ответ списка — объект с odata.metadata и массивом value. Стандартные реквизиты называются по-английски (Ref_Key, DataVersion, DeletionMark, IsFolder, Parent_Key, Code, Description), реквизиты конфигурации — как в конфигураторе, ссылочные поля приходят с суффиксом _Key и содержат GUID; пустая ссылка — 00000000-0000-0000-0000-000000000000. В примерах 1С:Фреш встречается и $format=json;odata=nometadata — вариант без служебного поля odata.metadata.

Клиент на Node: чтение, запись и проведение

Клиент держит в одном месте адрес …/odata/standard.odata, Basic-заголовок, таймаут и разбор ошибок. Создание — POST на коллекцию, изменение — PATCH с одними изменёнными полями (PUT требует все), проведение документа — POST …/Document_Имя(guid'…')/Post(); в ответ на успешное проведение 1С:Фреш показывает код 200 с пустым телом.

src/client.ts
import { guid } from "./literals.ts";
import { entityUrl, listUrl, type ListOptions } from "./query.ts";

export class OneCError extends Error {
  status: number;
  code: string | null;
  constructor(status: number, code: string | null, message: string) {
    super(`1C OData HTTP ${status}${code ? ` [${code}]` : ""}: ${message}`);
    this.status = status;
    this.code = code;
  }
}

type Options = {
  baseUrl: string; // https://1c.example.kz/trade — адрес публикации базы
  user: string; // служебный пользователь только с нужными правами
  password: string;
  timeoutMs?: number;
};

type ListResponse<T> = { "odata.metadata"?: string; "odata.count"?: string; value: T[] };

export class OneCODataClient {
  private root: string;
  private auth: string;
  private timeoutMs: number;

  constructor(o: Options) {
    this.root = `${o.baseUrl.replace(/\/$/, "")}/odata/standard.odata`;
    // Basic-аутентификация: только по HTTPS, иначе пароль уходит в открытом виде
    this.auth = `Basic ${Buffer.from(`${o.user}:${o.password}`, "utf8").toString("base64")}`;
    this.timeoutMs = o.timeoutMs ?? 30_000;
  }

  async list<T>(resource: string, o: ListOptions = {}): Promise<{ items: T[]; total: number | null }> {
    const body = await this.send<ListResponse<T>>("GET", listUrl(this.root, resource, o));
    return { items: body.value, total: body["odata.count"] ? Number(body["odata.count"]) : null };
  }

  // постранично через $top/$skip; без стабильного $orderby страницы могут пересекаться
  async *listAll<T>(resource: string, o: ListOptions & { orderby: string }, pageSize = 500): AsyncGenerator<T> {
    for (let skip = 0; ; skip += pageSize) {
      const { items } = await this.list<T>(resource, { ...o, top: pageSize, skip });
      yield* items;
      if (items.length < pageSize) return;
    }
  }

  get<T>(resource: string, ref: string, select?: string[]): Promise<T> {
    return this.send<T>("GET", entityUrl(this.root, resource, ref, select));
  }

  create<T>(resource: string, data: Record<string, unknown>): Promise<T> {
    return this.send<T>("POST", listUrl(this.root, resource), data);
  }

  // PATCH меняет только переданные реквизиты (PUT требует все)
  update<T>(resource: string, ref: string, data: Record<string, unknown>): Promise<T> {
    return this.send<T>("PATCH", entityUrl(this.root, resource, ref), data);
  }

  // проведение документа: POST …/Document_Имя(guid'…')/Post(); бизнес-логика 1С может отказать — HTTP 500
  async postDocument(resource: string, ref: string): Promise<void> {
    await this.send("POST", `${this.root}/${encodeURIComponent(resource)}(${guid(ref)})/Post()`);
  }

  private async send<T>(method: string, url: string, data?: unknown): Promise<T> {
    const res = await fetch(url, {
      method,
      headers: {
        Authorization: this.auth,
        Accept: "application/json",
        ...(data ? { "Content-Type": "application/json" } : {}),
      },
      body: data ? JSON.stringify(data) : undefined,
      signal: AbortSignal.timeout(this.timeoutMs),
    });
    const text = await res.text();
    if (!res.ok) throw toError(res.status, text);
    return (text ? JSON.parse(text) : undefined) as T;
  }
}

// Ошибка OData: { "odata.error" | "error": { code, message: { lang, value } } }; иначе — сырой текст
function toError(status: number, text: string): OneCError {
  try {
    const body = JSON.parse(text);
    const err = body["odata.error"] ?? body.error;
    if (err) return new OneCError(status, err.code ?? null, err.message?.value ?? String(err.message));
  } catch {
    // не JSON: страница веб-сервера, HTML с 401/403 и т. п.
  }
  return new OneCError(status, null, text.slice(0, 300) || "empty body");
}
OData-клиент 1С: список, постраничное чтение, объект по ссылке, запись, проведение, ошибки

Прикладной слой переводит строки 1С в модель сайта и решает, что синхронизировать. Номенклатурой владеет 1С, сайт только читает: раз в N минут проходим справочник постранично и сравниваем DataVersion с сохранённой. По нашему опыту, это поле меняется при каждой записи объекта, поэтому годится как дешёвый признак изменения; проверьте это на своей конфигурации до запуска.

src/products.ts
import type { OneCODataClient } from "./client.ts";
import { EMPTY_REF, entity, str } from "./literals.ts";

// Строка справочника «Номенклатура» так, как её отдаёт OData: служебные реквизиты — на английском,
// реквизиты конфигурации — как в конфигураторе, ссылки — с суффиксом _Key
export type NomenclatureRow = {
  Ref_Key: string;
  DataVersion: string;
  DeletionMark: boolean;
  IsFolder: boolean;
  Parent_Key: string;
  Code: string;
  Description: string;
  Артикул: string;
  ЕдиницаИзмерения_Key: string;
};

export type Product = {
  externalRef: string; // GUID из 1С — единственный устойчивый ключ
  sku: string;
  title: string;
  groupRef: string | null;
  version: string;
  archived: boolean;
};

export const NOMENCLATURE = entity("Catalog", "Номенклатура");
const FIELDS = ["Ref_Key", "DataVersion", "DeletionMark", "Parent_Key", "Code", "Description", "Артикул"];

export function toProduct(row: Pick<NomenclatureRow, "Ref_Key" | "DataVersion" | "DeletionMark" | "Parent_Key" | "Description" | "Артикул">): Product {
  return {
    externalRef: row.Ref_Key,
    sku: row.Артикул.trim(),
    title: row.Description.trim(),
    groupRef: row.Parent_Key === EMPTY_REF ? null : row.Parent_Key,
    version: row.DataVersion,
    archived: row.DeletionMark,
  };
}

// Полная сверка справочника: 1С — источник правды по товарам, сайт только читает.
// DataVersion сравниваем с сохранённой — переписываем на сайте только изменившиеся позиции
export async function syncProducts(
  client: OneCODataClient,
  known: Map<string, string>, // Ref_Key → DataVersion из прошлой синхронизации
  pageSize = 500,
): Promise<{ changed: Product[]; missing: string[] }> {
  const changed: Product[] = [];
  const seen = new Set<string>();
  const rows = client.listAll<NomenclatureRow>(
    NOMENCLATURE,
    { select: FIELDS, filter: "IsFolder eq false", orderby: "Code" },
    pageSize,
  );
  for await (const row of rows) {
    seen.add(row.Ref_Key);
    if (known.get(row.Ref_Key) !== row.DataVersion) changed.push(toProduct(row));
  }
  const missing = [...known.keys()].filter((ref) => !seen.has(ref)); // удалены в 1С напрямую
  return { changed, missing };
}

export async function findBySku(client: OneCODataClient, sku: string): Promise<Product | null> {
  const { items } = await client.list<NomenclatureRow>(NOMENCLATURE, {
    select: FIELDS,
    filter: `Артикул eq ${str(sku)} and DeletionMark eq false`,
    top: 2,
  });
  if (items.length > 1) throw new Error(`SKU ${sku} is not unique in 1C`); // артикул в 1С не обязан быть уникальным
  return items[0] ? toProduct(items[0]) : null;
}
Номенклатура: типы с кириллическими полями, поиск по артикулу и сверка по DataVersion

Ошибки: 4xx — ошибка клиента, 5xx — ошибка сервера; по описанию платформы для 4xx 1С может передать внутренний код ошибки и сообщение. Клиент достаёт их из объекта odata.error или error (поля code и message.value по спецификации OData), а если тело не JSON — например, страница веб-сервера с 401 — сохраняет начало текста. Отказ бизнес-логики при проведении документа, по примеру 1С:Фреш, приходит как 500 — не повторяйте такой запрос автоматически.

Кириллица в именах объектов

Имена ресурсов и реквизитов в OData совпадают с именами в конфигураторе, то есть почти всегда кириллические: Catalog_Номенклатура, Артикул, ЕдиницаИзмерения_Key. С этим можно спокойно жить, если соблюдать четыре правила.

  • В URL кириллица кодируется в UTF-8 percent-encoding — encodeURIComponent делает это сам; не собирайте URL склейкой строк без кодирования
  • Ключи JSON — тоже кириллица: в TypeScript это обычные свойства (row.Артикул), но опишите их типом, чтобы опечатка ловилась компилятором
  • Имя в 1С — часть контракта: переименование реквизита в конфигураторе молча ломает OData-клиент. Зафиксируйте список используемых полей и договоритесь, что их меняют только с уведомлением
  • Наружу модель сайта отдаёт свои латинские имена (sku, title) — кириллица остаётся внутри адаптера к 1С

HTTP-сервисы 1С: свой REST и контракт

HTTP-сервис — объект конфигурации в ветке «Общие — HTTP-сервисы». В нём программист 1С описывает шаблоны URL (с параметрами вида {id}) и для каждого шаблона — методы: обрабатываемый HTTP-метод и процедуру на встроенном языке. Платформа сопоставляет входящий URL с шаблонами; если совпадения нет, отвечает 404, если есть — передаёт обработчику объект HTTPСервисЗапрос и ждёт HTTPСервисОтвет с кодом, заголовками и телом. Публикация и аутентификация устроены так же, как у веб-сервисов.

По нашему опыту, в типовой публикации адрес сервиса имеет вид адрес-публикации/hs/корневой-URL/шаблон, например https://1c.example.kz/trade/hs/site/v1/orders. Точный адрес возьмите у программиста 1С после публикации: он зависит от настроек веб-сервера.

Главная ценность HTTP-сервиса — контракт, который не зависит от внутренностей конфигурации. Опишите его до разработки в OpenAPI: веб-разработчик пишет клиент и мок, программист 1С — обработчики, а файл контракта становится общим источником правды о форматах. Ниже — шаблон: остатки по складу с меткой изменений и приём заказа с ключом идемпотентности.

contract/site-api.yaml
openapi: 3.1.0
info:
  title: 1C HTTP service for the website
  version: 1.0.0
  description: >
    Контракт HTTP-сервиса 1С «site» (корневой URL site). Реализует программист 1С,
    вызывает сайт. Все ссылки — GUID 1С, даты — без часового пояса (время 1С).
servers:
  - url: https://1c.example.kz/trade/hs/site
security:
  - basicAuth: []
paths:
  /v1/stock:
    get:
      operationId: getStock
      summary: Остатки по складу, изменённые после метки
      parameters:
        - name: warehouse
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: changedSince
          in: query
          required: false
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$' }
      responses:
        '200':
          description: Остатки
          content:
            application/json:
              schema:
                type: object
                required: [items, asOf]
                properties:
                  asOf: { type: string, description: Метка для следующего запроса }
                  items:
                    type: array
                    items:
                      type: object
                      required: [productRef, quantity]
                      properties:
                        productRef: { type: string, format: uuid }
                        quantity: { type: number }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { description: Нет или неверные учётные данные }
  /v1/orders:
    post:
      operationId: createOrder
      summary: Принять заказ с сайта (идемпотентно по Idempotency-Key)
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: id заказа на сайте; повтор с тем же ключом возвращает тот же документ
          schema: { type: string, maxLength: 64 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Order' }
      responses:
        '201':
          description: Документ создан
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderAccepted' }
        '200':
          description: Повтор — документ уже был создан с этим ключом
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderAccepted' }
        '409': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
components:
  securitySchemes:
    basicAuth: { type: http, scheme: basic }
  schemas:
    Order:
      type: object
      required: [siteOrderId, customer, lines]
      properties:
        siteOrderId: { type: string }
        customer:
          type: object
          required: [name, phone]
          properties:
            name: { type: string }
            phone: { type: string, description: 'E.164, например +77011234567' }
            counterpartyRef: { type: string, format: uuid, description: если клиент уже сопоставлен }
        lines:
          type: array
          minItems: 1
          items:
            type: object
            required: [productRef, quantity, price]
            properties:
              productRef: { type: string, format: uuid }
              quantity: { type: number, exclusiveMinimum: 0 }
              price: { type: number, minimum: 0 }
    OrderAccepted:
      type: object
      required: [documentRef, number]
      properties:
        documentRef: { type: string, format: uuid }
        number: { type: string }
    Problem:
      type: object
      required: [code, message]
      properties:
        code: { type: string, description: 'PRODUCT_NOT_FOUND, PRICE_CHANGED, ...' }
        message: { type: string }
  responses:
    Problem:
      description: Ошибка бизнес-проверки
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Problem' }
Шаблон контракта HTTP-сервиса «site» в OpenAPI 3.1 (redocly lint — без ошибок)
src/hs-client.ts
// Клиент собственного HTTP-сервиса 1С по контракту contract/site-api.yaml
export type OrderLine = { productRef: string; quantity: number; price: number };
export type Order = {
  siteOrderId: string;
  customer: { name: string; phone: string; counterpartyRef?: string };
  lines: OrderLine[];
};
export type OrderAccepted = { documentRef: string; number: string; repeated: boolean };

export class OneCServiceError extends Error {
  status: number;
  code: string;
  constructor(status: number, code: string, message: string) {
    super(`1C service HTTP ${status} [${code}]: ${message}`);
    this.status = status;
    this.code = code;
  }
}

export class SiteServiceClient {
  private base: string;
  private auth: string;

  constructor(baseUrl: string, user: string, password: string) {
    this.base = baseUrl.replace(/\/$/, ""); // …/hs/site — корневой URL сервиса
    this.auth = `Basic ${Buffer.from(`${user}:${password}`, "utf8").toString("base64")}`;
  }

  async createOrder(order: Order): Promise<OrderAccepted> {
    const res = await fetch(`${this.base}/v1/orders`, {
      method: "POST",
      headers: {
        Authorization: this.auth,
        "Content-Type": "application/json; charset=utf-8",
        "Idempotency-Key": order.siteOrderId, // повтор после таймаута не создаст второй документ
      },
      body: JSON.stringify(order),
      signal: AbortSignal.timeout(30_000),
    });
    const body = await res.json().catch(() => ({}));
    if (res.status === 201 || res.status === 200) return { ...body, repeated: res.status === 200 };
    throw new OneCServiceError(res.status, body.code ?? "UNKNOWN", body.message ?? res.statusText);
  }

  async getStock(warehouseRef: string, changedSince?: string) {
    const params = new URLSearchParams({ warehouse: warehouseRef });
    if (changedSince) params.set("changedSince", changedSince);
    const res = await fetch(`${this.base}/v1/stock?${params}`, {
      headers: { Authorization: this.auth },
      signal: AbortSignal.timeout(30_000),
    });
    const body = await res.json().catch(() => ({}));
    if (!res.ok) throw new OneCServiceError(res.status, body.code ?? "UNKNOWN", body.message ?? res.statusText);
    return body as { asOf: string; items: { productRef: string; quantity: number }[] };
  }
}
Клиент HTTP-сервиса: Idempotency-Key = id заказа на сайте, повтор возвращает тот же документ

Контракт

Веб-разработчик
предлагает поля и коды ответов со стороны сайта
Программист 1С
сверяет с учётом в 1С: какие документы, справочники, проведение

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

Веб-разработчик
передаёт Idempotency-Key, повторяет при таймауте
Программист 1С
хранит ключ (например, в реквизите документа) и возвращает уже созданный документ

Ошибки

Веб-разработчик
различает 4xx (не повторять) и 5xx/таймаут (повторить)
Программист 1С
возвращает понятные code: PRODUCT_NOT_FOUND, PRICE_CHANGED

Тесты

Веб-разработчик
мок по контракту и автотесты клиента
Программист 1С
проверка обработчиков на копии базы

Конфликты, источник правды и альтернативы

Конфликт возникает, когда одно и то же поле меняют обе стороны. Лекарство — заранее назначить владельца каждой сущности и поля и писать только в своё.

Номенклатура, цены, остатки

Источник правды
1С
Как течёт
1С → сайт: OData или HTTP-сервис по расписанию, сайт только читает

Заказ с сайта

Источник правды
сайт до передачи, затем 1С
Как течёт
сайт → 1С один раз через HTTP-сервис; дальше статус читается из 1С

Контрагент

Источник правды
1С
Как течёт
сайт передаёт телефон и имя, сопоставление и создание — в 1С

Оплата онлайн

Источник правды
платёжный сервис → сайт
Как течёт
сайт → 1С отдельным вызовом с ключом идемпотентности
  • Сайт хранит GUID 1С (Ref_Key), а не код или наименование: код меняется при перенумерации, наименование правят руками
  • Запись через OData обходит проверку заполнения, поэтому обязательные поля проверяйте на своей стороне до отправки
  • У OData есть заголовок 1C_OData-DataLoadMode: true, который эмулирует запись в режиме обмена данными (ОбменДанными.Загрузка = Истина); в 1С:Фреш он не поддерживается. Используйте его только по согласованию с программистом 1С: в этом режиме часть проверок и обработчиков может не выполняться
  • Производительность: всегда $select, страницы по сотням записей с $orderby, $expand только на небольших выборках, остатки — через виртуальные таблицы регистров (Balance), а не чтением всех движений

Альтернатива HTTP — обмен файлами или очередями. Выгрузка XML (в том числе CommerceML) или CSV по расписанию через SFTP подходит, когда 1С нельзя опубликовать наружу или данные нужны раз в сутки; очередь сообщений — когда систем три и больше. Эти варианты и критерии выбора разобраны в статье «Как интегрировать 1С с веб-приложением»; техника идемпотентной доставки и повторов — в материале о событиях и очередях.

Проверка на моке, ограничения и упражнения

Экземпляра 1С в этой проверке не было, поэтому клиент проверен против локального мока на node:http. Мок отвечает в формате из примеров 1С:Фреш — odata.metadata, value, odata.count строкой, Ref_Key и DataVersion, — разбирает подмножество $filter (сравнения, and/or/not, скобки, startswith, substringof), поддерживает $select, $orderby, $top/$skip, создание, PATCH, Post() и HTTP-сервис «site» по контракту. Права, $metadata, табличные части и регистры мок не эмулирует.

bash
npm init -y && npm pkg set type=module
npm install -D typescript@7.0.2 @types/node@24 vitest@5.0.3 @redocly/cli@2.62.1
npx tsc -p tsconfig.json                 # типы, strict
npx vitest run                           # 13 тестов: URL, литералы, разбор ответов, HTTP-сервис
npx redocly lint contract/site-api.yaml  # контракт HTTP-сервиса валиден
node mock/onec-mock.ts                   # мок отдельно — для запросов curl или из браузера
Проект примера: зависимости и команды проверки
tsconfig.json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "mock", "test"]
}
tsconfig: Node 24 запускает .ts без сборки — импорты с расширением .ts, только стираемый синтаксис
mock/onec-mock.ts
// Мок публикации базы 1С: подмножество стандартного интерфейса OData (формат ответов — по примерам
// из документации 1С:Фреш) и HTTP-сервис «site» по нашему контракту. Не эмулирует права, $metadata,
// табличные части, регистры и $expand — только то, что нужно тестам клиента.
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

type Row = Record<string, unknown>;
type Lit = string | number | boolean;

const json = (res: ServerResponse, status: number, body?: unknown) => {
  res.writeHead(status, body === undefined ? {} : { "Content-Type": "application/json;charset=utf-8" });
  res.end(body === undefined ? undefined : JSON.stringify(body));
};
const odataError = (res: ServerResponse, status: number, code: string, value: string) =>
  json(res, status, { "odata.error": { code, message: { lang: "ru", value } } });
const readBody = async (req: IncomingMessage) => {
  let s = "";
  for await (const c of req) s += c;
  return s ? JSON.parse(s) : null;
};

/* ---------- мини-разбор $filter: сравнения, and/or/not, скобки, startswith/substringof ---------- */
const TOKEN = /\s*(guid'[^']*'|datetime'[^']*'|'(?:[^']|'')*'|-?\d+(?:\.\d+)?|\(|\)|,|[\p{L}_][\p{L}\p{N}_/]*)/uy;
function tokenize(src: string): string[] {
  const out: string[] = [];
  TOKEN.lastIndex = 0;
  while (TOKEN.lastIndex < src.length) {
    const m = TOKEN.exec(src);
    if (!m) throw new Error(`bad token at ${TOKEN.lastIndex}`);
    out.push(m[1]);
    if (/^\s*$/.test(src.slice(TOKEN.lastIndex))) break;
  }
  return out;
}
function literal(t: string): Lit {
  if (t.startsWith("guid'") || t.startsWith("datetime'")) return t.slice(t.indexOf("'") + 1, -1);
  if (t.startsWith("'")) return t.slice(1, -1).replaceAll("''", "'");
  if (t === "true" || t === "false") return t === "true";
  if (/^-?\d/.test(t)) return Number(t);
  throw new Error(`expected literal, got ${t}`);
}
function compileFilter(src: string): (row: Row) => boolean {
  const t = tokenize(src);
  let i = 0;
  const expect = (v: string) => {
    if (t[i++] !== v) throw new Error(`expected ${v}`);
  };
  const or = (): ((r: Row) => boolean) => {
    let left = and();
    while (t[i] === "or") {
      i++;
      const l = left, r = and();
      left = (row) => l(row) || r(row);
    }
    return left;
  };
  const and = (): ((r: Row) => boolean) => {
    let left = unary();
    while (t[i] === "and") {
      i++;
      const l = left, r = unary();
      left = (row) => l(row) && r(row);
    }
    return left;
  };
  const unary = (): ((r: Row) => boolean) => {
    if (t[i] === "not") {
      i++;
      const inner = unary();
      return (row) => !inner(row);
    }
    if (t[i] === "(") {
      i++;
      const inner = or();
      expect(")");
      return inner;
    }
    if (t[i] === "startswith" || t[i] === "substringof") {
      const fn = t[i++];
      expect("(");
      const a = t[i++];
      expect(",");
      const b = t[i++];
      expect(")");
      return fn === "startswith"
        ? (row) => String(row[a]).startsWith(String(literal(b)))
        : (row) => String(row[b]).includes(String(literal(a)));
    }
    const field = t[i++], op = t[i++], value = literal(t[i++]);
    const cmp: Record<string, (x: Lit, y: Lit) => boolean> = {
      eq: (x, y) => x === y, ne: (x, y) => x !== y, gt: (x, y) => x > y,
      ge: (x, y) => x >= y, lt: (x, y) => x < y, le: (x, y) => x <= y,
    };
    if (!cmp[op]) throw new Error(`unknown operator ${op}`);
    return (row) => cmp[op](row[field] as Lit, value);
  };
  const fn = or();
  if (i !== t.length) throw new Error(`unexpected ${t[i]}`);
  return fn;
}

/* ---------------------------------------- сервер ---------------------------------------- */
export async function startOneCMock(opts: { user?: string; password?: string; base?: string } = {}) {
  const base = opts.base ?? "/trade";
  const auth = `Basic ${Buffer.from(`${opts.user ?? "site-sync"}:${opts.password ?? "test-password"}`).toString("base64")}`;
  const data: Record<string, Row[]> = { "Catalog_Номенклатура": [], "Document_ЗаказПокупателя": [] };
  const orders = new Map<string, { documentRef: string; number: string }>();
  const requests: string[] = [];
  const version = () => Buffer.from(randomUUID().slice(0, 8)).toString("base64");

  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://mock");
    const path = decodeURIComponent(url.pathname);
    requests.push(`${req.method} ${path}${decodeURIComponent(url.search)}`);
    if (req.headers.authorization !== auth) {
      res.writeHead(401, { "WWW-Authenticate": 'Basic realm="1C"' }).end();
      return;
    }

    /* --- HTTP-сервис site по контракту --- */
    if (path === `${base}/hs/site/v1/orders` && req.method === "POST") {
      const key = String(req.headers["idempotency-key"] ?? "");
      if (!key) return json(res, 400, { code: "NO_IDEMPOTENCY_KEY", message: "Idempotency-Key is required" });
      const known = orders.get(key);
      if (known) return json(res, 200, known);
      const order = await readBody(req);
      const missing = (order?.lines ?? []).find(
        (l: { productRef: string }) => !data["Catalog_Номенклатура"].some((r) => r.Ref_Key === l.productRef),
      );
      if (!order?.lines?.length || missing)
        return json(res, 422, { code: "PRODUCT_NOT_FOUND", message: `unknown product ${missing?.productRef ?? "-"}` });
      const accepted = { documentRef: randomUUID(), number: `САЙТ-${String(orders.size + 1).padStart(6, "0")}` };
      orders.set(key, accepted);
      return json(res, 201, accepted);
    }
    if (path === `${base}/hs/site/v1/stock` && req.method === "GET") {
      if (!url.searchParams.get("warehouse")) return json(res, 400, { code: "NO_WAREHOUSE", message: "warehouse is required" });
      const items = data["Catalog_Номенклатура"].map((r) => ({ productRef: r.Ref_Key, quantity: 5 }));
      return json(res, 200, { asOf: "2026-10-11T12:00:00", items });
    }

    /* --- стандартный интерфейс OData --- */
    const prefix = `${base}/odata/standard.odata/`;
    if (!path.startsWith(prefix)) return json(res, 404);
    const m = /^([\p{L}_]+)(?:\(guid'([0-9a-f-]{36})'\))?(\/Post\(\))?$/u.exec(path.slice(prefix.length));
    if (!m || !data[m[1]]) return odataError(res, 404, "-1", `Сущность не найдена: ${path.slice(prefix.length)}`);
    const [, set, ref, action] = m;
    const rows = data[set];
    const metadata = `http://mock${prefix}$metadata#${set}`;

    if (ref) {
      const row = rows.find((r) => r.Ref_Key === ref);
      if (!row) return json(res, 404);
      if (action && req.method === "POST") {
        if (!row.Контрагент_Key) return odataError(res, 500, "-1", "Не заполнен контрагент: документ не может быть проведён");
        row.Posted = true;
        return json(res, 200);
      }
      if (req.method === "PATCH") {
        Object.assign(row, await readBody(req), { DataVersion: version() });
        return json(res, 200, { "odata.metadata": `${metadata}/@Element`, ...row });
      }
      return json(res, 200, { "odata.metadata": `${metadata}/@Element`, ...row });
    }

    if (req.method === "POST") {
      const row = { ...(await readBody(req)), Ref_Key: randomUUID(), DataVersion: version() };
      rows.push(row);
      return json(res, 201, { "odata.metadata": `${metadata}/@Element`, ...row });
    }

    let list = rows;
    const p = url.searchParams;
    try {
      const filter = p.get("$filter");
      if (filter) list = list.filter(compileFilter(filter));
    } catch (e) {
      return odataError(res, 400, "-1", `Неправильный формат параметра $filter: ${(e as Error).message}`);
    }
    const orderby = p.get("$orderby");
    if (orderby) {
      const [field, dir] = orderby.trim().split(/\s+/);
      list = [...list].sort((a, b) => String(a[field]).localeCompare(String(b[field])) * (dir === "desc" ? -1 : 1));
    }
    const total = list.length;
    const skip = Number(p.get("$skip") ?? 0);
    const top = p.get("$top") === null ? list.length : Number(p.get("$top"));
    const select = p.get("$select")?.split(",");
    const value = list
      .slice(skip, skip + top)
      .map((r) => (select ? Object.fromEntries(select.map((k) => [k, r[k]])) : r));
    return json(res, 200, {
      "odata.metadata": metadata,
      ...(p.get("$inlinecount") === "allpages" ? { "odata.count": String(total) } : {}),
      value,
    });
  });

  await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}${base}`,
    data,
    requests,
    addProduct(code: string, description: string, sku: string, extra: Row = {}): Row {
      const row: Row = {
        Ref_Key: randomUUID(),
        DataVersion: version(),
        DeletionMark: false,
        IsFolder: false,
        Parent_Key: "00000000-0000-0000-0000-000000000000",
        Code: code,
        Description: description,
        Артикул: sku,
        ЕдиницаИзмерения_Key: "00000000-0000-0000-0000-000000000000",
        ...extra,
      };
      data["Catalog_Номенклатура"].push(row);
      return row;
    },
    touch(row: Row, patch: Row) {
      Object.assign(row, patch, { DataVersion: version() });
    },
    close: () => new Promise<void>((r) => server.close(() => r())),
  };
}

if (import.meta.main) {
  const mock = await startOneCMock();
  mock.addProduct("00-00000001", "Кофе в зёрнах 1 кг", "КФ-001");
  console.log(`1C mock: ${mock.url}/odata/standard.odata  (Basic site-sync / test-password)`);
}
Мок публикации 1С для тестов — не замена реальной базе
test/query.test.ts
import { describe, expect, it } from "vitest";
import { datetime, entity, guid, str } from "../src/literals.ts";
import { entityUrl, listUrl } from "../src/query.ts";

const root = "https://1c.example.kz/trade/odata/standard.odata";

describe("литералы", () => {
  it("строка: апостроф удваивается", () => {
    expect(str("Д'Артаньян")).toBe("'Д''Артаньян'");
  });
  it("guid и datetime проверяются по формату", () => {
    expect(guid("C3FBDCAF-B8E1-11E4-8271-001E101F0864")).toBe("guid'c3fbdcaf-b8e1-11e4-8271-001e101f0864'");
    expect(() => guid("42")).toThrow(/not a GUID/);
    expect(datetime("2026-10-01T00:00:00")).toBe("datetime'2026-10-01T00:00:00'");
    expect(() => datetime("2026-10-01T00:00:00Z")).toThrow();
  });
  it("имя ресурса из вида и имени объекта", () => {
    expect(entity("Catalog", "Номенклатура")).toBe("Catalog_Номенклатура");
  });
});

describe("сборка URL", () => {
  it("список: $format, $select, $filter, $orderby, $top/$skip, кириллица в UTF-8", () => {
    const url = listUrl(root, "Catalog_Номенклатура", {
      select: ["Ref_Key", "Description", "Артикул"],
      filter: `Артикул eq ${str("КФ-001")} and DeletionMark eq false`,
      orderby: "Code",
      top: 50,
      skip: 100,
      count: true,
    });
    expect(url).toBe(
      root +
        "/Catalog_%D0%9D%D0%BE%D0%BC%D0%B5%D0%BD%D0%BA%D0%BB%D0%B0%D1%82%D1%83%D1%80%D0%B0" +
        "?$format=json" +
        "&$select=Ref_Key,Description,%D0%90%D1%80%D1%82%D0%B8%D0%BA%D1%83%D0%BB" +
        "&$filter=%D0%90%D1%80%D1%82%D0%B8%D0%BA%D1%83%D0%BB%20eq%20'%D0%9A%D0%A4-001'%20and%20DeletionMark%20eq%20false" +
        "&$orderby=Code&$top=50&$skip=100&$inlinecount=allpages",
    );
    // после декодирования — ровно то, что пишут в документации 1С
    expect(decodeURIComponent(url)).toContain("Catalog_Номенклатура?$format=json&$select=Ref_Key,Description,Артикул");
  });

  it("символы & и # в значении фильтра не ломают строку запроса", () => {
    const url = listUrl(root, "Catalog_Номенклатура", { filter: `Description eq ${str("Чай & кофе #1")}` });
    expect(url).toContain("%26");
    expect(url).toContain("%23");
    expect(new URL(url).searchParams.get("$filter")).toBe("Description eq 'Чай & кофе #1'");
  });

  it("объект по ссылке", () => {
    const url = entityUrl(root, "Document_ЗаказПокупателя", "f3f4dc08-4553-11ed-8d7b-005056892602", ["Number", "Posted"]);
    expect(decodeURIComponent(url)).toBe(
      `${root}/Document_ЗаказПокупателя(guid'f3f4dc08-4553-11ed-8d7b-005056892602')?$format=json&$select=Number,Posted`,
    );
  });
});
Тесты построения URL и литералов
test/client.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startOneCMock } from "../mock/onec-mock.ts";
import { OneCError, OneCODataClient } from "../src/client.ts";
import { NOMENCLATURE, findBySku, syncProducts, toProduct } from "../src/products.ts";
import { OneCServiceError, SiteServiceClient } from "../src/hs-client.ts";

let mock: Awaited<ReturnType<typeof startOneCMock>>;
let client: OneCODataClient;

beforeEach(async () => {
  mock = await startOneCMock();
  client = new OneCODataClient({ baseUrl: mock.url, user: "site-sync", password: "test-password" });
});
afterEach(() => mock.close());

describe("OData-клиент против мока 1С", () => {
  it("разбирает ответ: value, odata.count, кириллические реквизиты", async () => {
    mock.addProduct("00-001", "Кофе в зёрнах 1 кг", "КФ-001");
    mock.addProduct("00-002", "Чай зелёный", "ЧЙ-002");
    const { items, total } = await client.list<Record<string, unknown>>(NOMENCLATURE, {
      select: ["Description", "Артикул"],
      filter: "startswith(Description, 'Кофе')",
      count: true,
    });
    expect(items).toEqual([{ Description: "Кофе в зёрнах 1 кг", Артикул: "КФ-001" }]);
    expect(total).toBe(1);
  });

  it("поиск по артикулу с апострофом и защита от неуникального артикула", async () => {
    const row = mock.addProduct("00-003", "Сыр д'Аффинуа", "Д'А-1");
    expect(await findBySku(client, "Д'А-1")).toMatchObject({ externalRef: row.Ref_Key, sku: "Д'А-1", groupRef: null });
    expect(await findBySku(client, "нет-такого")).toBeNull();
    mock.addProduct("00-004", "Дубль", "Д'А-1");
    await expect(findBySku(client, "Д'А-1")).rejects.toThrow(/not unique/);
  });

  it("сверка по DataVersion: постранично, только изменённые и пропавшие", async () => {
    const rows = ["00-001", "00-002", "00-003", "00-004", "00-005"].map((code, i) => mock.addProduct(code, `Товар ${i}`, `SKU-${i}`));
    const first = await syncProducts(client, new Map(), 2);
    expect(first.changed).toHaveLength(5);
    expect(mock.requests.filter((r) => r.includes("$skip") || r.includes("$top=2"))).toHaveLength(3);

    const known = new Map(first.changed.map((p) => [p.externalRef, p.version]));
    mock.touch(rows[1], { Description: "Товар 1 (новая упаковка)" });
    mock.data[NOMENCLATURE].splice(4, 1); // объект удалён в 1С напрямую
    const second = await syncProducts(client, known, 2);
    expect(second.changed.map((p) => p.title)).toEqual(["Товар 1 (новая упаковка)"]);
    expect(second.missing).toEqual([rows[4].Ref_Key]);
  });

  it("создание, PATCH и проведение документа; отказ проведения — 500 с текстом", async () => {
    const doc = await client.create<{ Ref_Key: string }>("Document_ЗаказПокупателя", { Date: "2026-10-11T12:00:00" });
    await expect(client.postDocument("Document_ЗаказПокупателя", doc.Ref_Key)).rejects.toMatchObject({ status: 500 });
    await client.update("Document_ЗаказПокупателя", doc.Ref_Key, { Контрагент_Key: "c3fbdcaf-b8e1-11e4-8271-001e101f0864" });
    await client.postDocument("Document_ЗаказПокупателя", doc.Ref_Key);
    expect(await client.get<{ Posted: boolean }>("Document_ЗаказПокупателя", doc.Ref_Key, ["Posted"])).toMatchObject({ Posted: true });
  });

  it("ошибки: 400 с odata.error, 401 без JSON", async () => {
    const err = await client.list(NOMENCLATURE, { filter: "Артикул equals 'x'" }).catch((e) => e);
    expect(err).toBeInstanceOf(OneCError);
    expect(err).toMatchObject({ status: 400, code: "-1" });
    expect(err.message).toContain("$filter");

    const anonymous = new OneCODataClient({ baseUrl: mock.url, user: "site-sync", password: "wrong" });
    await expect(anonymous.list(NOMENCLATURE)).rejects.toMatchObject({ status: 401, code: null });
  });

  it("toProduct: пустая ссылка группы → null, пробелы обрезаются", () => {
    expect(
      toProduct({
        Ref_Key: "baebad03-b80e-11e4-826f-001e101fe696",
        DataVersion: "AAAAAAAAAAA=",
        DeletionMark: true,
        Parent_Key: "00000000-0000-0000-0000-000000000000",
        Description: " Таз 25 л ",
        Артикул: "8940 ",
      }),
    ).toEqual({ externalRef: "baebad03-b80e-11e4-826f-001e101fe696", sku: "8940", title: "Таз 25 л", groupRef: null, version: "AAAAAAAAAAA=", archived: true });
  });
});

describe("клиент HTTP-сервиса по контракту", () => {
  it("заказ идемпотентен по Idempotency-Key; неизвестный товар — 422", async () => {
    const hs = new SiteServiceClient(`${mock.url}/hs/site`, "site-sync", "test-password");
    const p = mock.addProduct("00-001", "Кофе", "КФ-001");
    const order = { siteOrderId: "web-1042", customer: { name: "Айгерим", phone: "+77011234567" }, lines: [{ productRef: String(p.Ref_Key), quantity: 2, price: 9900 }] };

    const a = await hs.createOrder(order);
    const b = await hs.createOrder(order);
    expect(a).toMatchObject({ number: "САЙТ-000001", repeated: false });
    expect(b).toEqual({ ...a, repeated: true });

    const bad = { ...order, siteOrderId: "web-1043", lines: [{ productRef: "c3fbdcaf-b8e1-11e4-8271-001e101f0864", quantity: 1, price: 1 }] };
    await expect(hs.createOrder(bad)).rejects.toMatchObject({ status: 422, code: "PRODUCT_NOT_FOUND" });
    await expect(hs.createOrder(bad)).rejects.toBeInstanceOf(OneCServiceError);

    const stock = await hs.getStock("de0977f8-b6af-11e4-bf8c-74d02b7dfd8c", "2026-10-11T00:00:00");
    expect(stock.items).toEqual([{ productRef: p.Ref_Key, quantity: 5 }]);
  });
});
Тесты клиента и HTTP-сервиса против мока

Результат: tsc без ошибок, 13 тестов Vitest зелёные, redocly lint признаёт контракт валидным (одно предупреждение — нет поля license). Против реальной 1С код не запускался: кодирование URL, разбор $filter, формат тела ошибки и поведение DataVersion проверьте на копии своей базы до запуска.

  • Запрос без $format=json — ответ приходит в XML, и JSON.parse падает
  • Служебный пользователь с полными правами — утечка пароля открывает всю базу
  • Публикация по HTTP с Basic-аутентификацией — пароль передаётся открытым текстом
  • Синхронизация по коду или наименованию вместо Ref_Key — дубли после перенумерации
  • Постраничное чтение без $orderby — записи пропускаются или повторяются
  • Дата через new Date().toISOString() — сдвиг на часовой пояс
Как проверено3 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    src/literals.ts, src/query.ts, src/client.ts, src/products.ts, src/hs-client.ts, mock/onec-mock.ts, tsconfig.json

    tsc -p tsconfig.json (TypeScript 7.0.2, strict, erasableSyntaxOnly) без ошибок во временном проекте scratchpad/kb/verify/1c-http-servisy-i-odata, Node.js 24.18

  • запущено, работает

    test/query.test.ts + test/client.test.ts — 13 тестов (литералы guid/datetime/строки с апострофом, сборка URL с кириллицей и спецсимволами, разбор value/odata.count, поиск по артикулу, сверка по DataVersion с пагинацией, создание/PATCH/Post() и отказ 500, ошибки 400 odata.error и 401, клиент HTTP-сервиса с Idempotency-Key и 422)

    vitest run (Vitest 5.0.3): 2 файла, 13 passed; мок запускался отдельно (node mock/onec-mock.ts + curl с Basic: 200 JSON с odata.count)

  • запущено, работает

    contract/site-api.yaml (OpenAPI 3.1, контракт HTTP-сервиса)

    redocly lint (@redocly/cli 2.62.1): valid, 1 warning info-license

  • не запускалось

    работа против реальной базы 1С (публикация, OData-запросы, HTTP-сервис, формат ошибок, DataVersion)

    нет экземпляра 1С:Предприятия; формат запросов и ответов взят из открытых официальных материалов 1С (v8.1c.ru, 1cfresh.com, открытый раздел ИТС 1С:Фреш)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.