У 1С:Предприятия два HTTP-входа для внешних систем. Стандартный интерфейс OData — автоматический REST по протоколу OData 3.0 ко всем включённым в его состав объектам: без программирования, но «широкий» и привязанный к структуре конфигурации. HTTP-сервисы — собственный REST, который программист 1С описывает в конфигурации: узкий контракт с бизнес-логикой внутри 1С. Чтение справочников удобно брать из OData, а заказы и операции с правилами — через HTTP-сервис по согласованному контракту.
После материала вы сможете
- выбрать между стандартным интерфейсом 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С
- Публикация и авторизация
- URL и параметры OData: $filter, $select, $expand, $top
- Клиент на Node: чтение, запись и проведение
- Кириллица в именах объектов
- HTTP-сервисы 1С: свой REST и контракт
- Конфликты, источник правды и альтернативы
- Проверка на моке, ограничения и упражнения
Что видит веб-разработчик со стороны 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С или администратор публикует базу из конфигуратора и включает поддержку OData; HTTP-сервисы публикуются так же, как веб-сервисы. Результат — адрес публикации, от него строятся все URL.
Состав интерфейса
Объекты, доступные через OData, задаются методом глобального контекста
УстановитьСоставСтандартногоИнтерфейсаOData. В конфигурациях на Библиотеке стандартных подсистем для этого есть обработка «Настройка автоматического REST-сервиса». Включайте только нужные объекты; 1С:Фреш отдельно не рекомендует открывать планы обмена.Служебный пользователь
Отдельный пользователь 1С только с нужными правами. В приложениях 1С:Фреш ему назначается роль
УдаленныйДоступOData. Не используйте учётную запись живого сотрудника.Аутентификация
Способы аутентификации OData-клиентов совпадают с веб-сервисами; в примерах 1С:Фреш — заголовок
Authorization: Basicс логином и паролем в base64. Basic передаёт пароль фактически открытым текстом, поэтому публикация должна работать только по HTTPS.Проверка
Простой
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.
// Литералы для $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}`;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("&")}`;
}$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 с пустым телом.
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");
}Прикладной слой переводит строки 1С в модель сайта и решает, что синхронизировать. Номенклатурой владеет 1С, сайт только читает: раз в N минут проходим справочник постранично и сравниваем DataVersion с сохранённой. По нашему опыту, это поле меняется при каждой записи объекта, поэтому годится как дешёвый признак изменения; проверьте это на своей конфигурации до запуска.
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С — обработчики, а файл контракта становится общим источником правды о форматах. Ниже — шаблон: остатки по складу с меткой изменений и приём заказа с ключом идемпотентности.
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' }redocly lint — без ошибок)// Клиент собственного 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 }[] };
}
}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, табличные части и регистры мок не эмулирует.
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 или из браузера{
"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"]
}.ts без сборки — импорты с расширением .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)`);
}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`,
);
});
});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 }]);
});
});Результат: 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.
Источники
- 1С:Предприятие — REST интерфейс (v8.1c.ru)
- 1С:Предприятие — HTTP-сервисы (v8.1c.ru)
- 1С:Фреш — Работа с данными приложения через стандартный интерфейс OData
- 1С:Фреш на ИТС — использование сервиса OData для интеграции
- OData Version 3.0 — URL Conventions
- OData Version 4.01 — URL Conventions (string literals)
