Вначале я спросил людей.
Деревом в 1С это не сделать, как быть? Заказ горит. Денег вагон дают.
Мне последовал ответ: Поле HTML документа, html, js и json.
Дальше накидываю дипсику в своем терминале, в котором по апи он мне отвечает без ограничений, сделай мне шаблончик такой идеи, и получаю в ответ:
Ну дальше я вошел во вкус и двое суток накидывал по ТЗ и вспоминал JS и HTML из яркой молодости.
В итоге решение представляет собой расширение реализующее систему взаимодействия с АПИ поставщика под любую платформу и под любые формы.
Регламентное задание раз в сутки грузит данные с сайта поставщика по номенклатуре предварительно отобрав артикулы по регулярке (например, первые 2 буквы заглавные английские), затем загружает необходимую информацию в периодический регистр сведений плюс отчет по загрузке.
Основная общая форма представляет из себя поле HTML документа и 1 реквизит с типом бесконечная строка. Выводит список доступных позиций в таблицу по данным регистра с, так сказать, "необычными" группировками для 1С (видно на скриншоте).
Ну теперь самое сладкое, код.
&НаСервере
Процедура ОбновитьДанные()
Запрос = Новый Запрос;
Запрос.Текст = "ВЫБРАТЬ
| ИсторияОбновленияНоменклатурыСрезПоследних.Номенклатура.Артикул КАК ПартНомер,
| МАКСИМУМ(ИсторияОбновленияНоменклатурыСрезПоследних.Бренд) КАК Производитель,
| СУММА(ИсторияОбновленияНоменклатурыСрезПоследних.ДоступноеКоличество) КАК Количество,
| ИсторияОбновленияНоменклатурыСрезПоследних.Период КАК ДатаЗагрузки,
| ИсторияОбновленияНоменклатурыСрезПоследних.ОтветныйПартНомер КАК ОтветныйПартНомер,
| КОЛИЧЕСТВО(ИсторияОбновленияНоменклатурыСрезПоследних.ТНВЭД) КАК ТНВЭД,
| ИсторияОбновленияНоменклатурыСрезПоследних.ИсточникИД КАК ИсточникИД,
| СУММА(ИсторияОбновленияНоменклатурыСрезПоследних.КоличествоОт) КАК КоличествоОт,
| ИсторияОбновленияНоменклатурыСрезПоследних.ДатаКвотации КАК ДатаКвотации
|ИЗ
| РегистрСведений.ИсторияОбновленияНоменклатуры.СрезПоследних(&Дата, Бренд <> """") КАК ИсторияОбновленияНоменклатурыСрезПоследних
|
|СГРУППИРОВАТЬ ПО
| ИсторияОбновленияНоменклатурыСрезПоследних.Период,
| ИсторияОбновленияНоменклатурыСрезПоследних.ОтветныйПартНомер,
| ИсторияОбновленияНоменклатурыСрезПоследних.ИсточникИД,
| ИсторияОбновленияНоменклатурыСрезПоследних.Номенклатура.Артикул,
| ИсторияОбновленияНоменклатурыСрезПоследних.ДатаКвотации
|
|ИМЕЮЩИЕ
| СУММА(ИсторияОбновленияНоменклатурыСрезПоследних.ДоступноеКоличество) > 0
|
|УПОРЯДОЧИТЬ ПО
| ИсточникИД,
| ПартНомер
|АВТОУПОРЯДОЧИВАНИЕ";
Запрос.УстановитьПараметр("Дата", КонецДня(ТекущаяДата()));
РезультатЗапроса = Запрос.Выполнить().Выгрузить(); // Выгрузка в дерево значений!
РезультатЗапроса.Колонки.Добавить("Прайсы", Новый ОписаниеТипов("ТаблицаЗначений"));
Для Каждого Строка Из РезультатЗапроса Цикл
Запрос = Новый Запрос;
Запрос.Текст = "ВЫБРАТЬ
| ИсторияОбновленияНоменклатурыСрезПоследних.ВидПрайса КАК ГруппировкаВидПрайса,
| ИсторияОбновленияНоменклатурыСрезПоследних.Предложение КАК ГруппировкаНомерПозиции,
| ИсторияОбновленияНоменклатурыСрезПоследних.Цена КАК ЦенаПозиции,
| ИсторияОбновленияНоменклатурыСрезПоследних.ОтветныйПартНомер КАК ОтветныйПартНомер,
| ИсторияОбновленияНоменклатурыСрезПоследних.КоличествоОт КАК КоличествоОт,
| ИсторияОбновленияНоменклатурыСрезПоследних.Валюта КАК Валюта,
| ВЫРАЗИТЬ(ИсторияОбновленияНоменклатурыСрезПоследних.СрокПоставки КАК СТРОКА(250)) КАК СрокПоставки,
| ИсторияОбновленияНоменклатурыСрезПоследних.ДоступноеКоличество КАК Количество,
| "" "" КАК Цены,
| ИсторияОбновленияНоменклатурыСрезПоследних.Бренд КАК Бренд,
| ИсторияОбновленияНоменклатурыСрезПоследних.ТНВЭД КАК ТНВЭД,
| ИсторияОбновленияНоменклатурыСрезПоследних.Источник КАК Источник,
| ИсторияОбновленияНоменклатурыСрезПоследних.ИсточникИД КАК ИсточникИД,
| ИсторияОбновленияНоменклатурыСрезПоследних.ПозицияПрайса КАК ПозицияПрайса,
| ИсторияОбновленияНоменклатуры.Номенклатура.Артикул КАК ПартНомер
|ИЗ
| РегистрСведений.ИсторияОбновленияНоменклатуры.СрезПоследних(&Дата, Номенклатура.Артикул = &ПартНомер) КАК ИсторияОбновленияНоменклатурыСрезПоследних
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.ИсторияОбновленияНоменклатуры КАК ИсторияОбновленияНоменклатуры
| ПО ИсторияОбновленияНоменклатурыСрезПоследних.ДоступноеКоличество > ИсторияОбновленияНоменклатуры.МинимальноеКоличество
| И ИсторияОбновленияНоменклатурыСрезПоследних.Номенклатура = ИсторияОбновленияНоменклатуры.Номенклатура
| И ИсторияОбновленияНоменклатурыСрезПоследних.ВидПрайса = ИсторияОбновленияНоменклатуры.ВидПрайса
| И ИсторияОбновленияНоменклатурыСрезПоследних.Предложение = ИсторияОбновленияНоменклатуры.Предложение
| И ИсторияОбновленияНоменклатурыСрезПоследних.Период = ИсторияОбновленияНоменклатуры.Период
| И ИсторияОбновленияНоменклатурыСрезПоследних.ПозицияПрайса = ИсторияОбновленияНоменклатуры.ПозицияПрайса
|ГДЕ
| ИсторияОбновленияНоменклатурыСрезПоследних.ИсточникИД = &ИсточникИД
|
|УПОРЯДОЧИТЬ ПО
| ГруппировкаВидПрайса,
| ГруппировкаНомерПозиции,
| КоличествоОт
|ИТОГИ ПО
| ГруппировкаНомерПозиции,
| ГруппировкаВидПрайса
|АВТОУПОРЯДОЧИВАНИЕ";
Запрос.УстановитьПараметр("Дата", КонецДня(ТекущаяДата()));
Запрос.УстановитьПараметр("ПартНомер", Строка.ПартНомер);
Запрос.УстановитьПараметр("ИсточникИД", Строка.ИсточникИД);
ТаблицаПрайс = Запрос.Выполнить().Выгрузить();
ТаблицаПрайс.Очистить();
Выборка = Запрос.Выполнить().Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам); // Выгрузка в дерево значений!
Пока Выборка.Следующий() Цикл
ВыборкаВидПрайса = Выборка.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);
Пока ВыборкаВидПрайса.Следующий() Цикл
ВыборкаДетали = ВыборкаВидПрайса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);
СтрокаЦен = "";
СтрокаСрокПоставки = "";
Пока ВыборкаДетали.Следующий() Цикл
Если ВыборкаДетали.ЦенаПозиции = 0 Или ВыборкаДетали.Количество = 0 Тогда
Продолжить;;
КонецЕсли;
НоваяСтрока = ТаблицаПрайс.Добавить();
ЗаполнитьЗначенияСвойств(НоваяСтрока, ВыборкаДетали);
НоваяСтрока.ГруппировкаНомерПозиции = Выборка.ГруппировкаНомерПозиции;
НоваяСтрока.ГруппировкаВидПрайса = ВыборкаВидПрайса.ГруппировкаВидПрайса;
НоваяСтрока.Цены = СтрокаЦен + Строка(ВыборкаДетали.ЦенаПозиции) + " " + ВыборкаДетали.Валюта + " от " + Строка(ВыборкаДетали.КоличествоОт) + "<br>";
//НоваяСтрока.СрокПоставки = СтрокаСрокПоставки + СОКРЛП(ВыборкаДетали.СрокПоставки) + " доступно: " + Строка(ВыборкаДетали.ДоступноеКоличество) + "<br>";
КонецЦикла;
КонецЦикла;
КонецЦикла;
Строка.Прайсы = ТаблицаПрайс;
КонецЦикла;
Реквизит1 = ТаблицаВHTML(РезультатЗапроса);
КонецПроцедуры
&НаСервере
Функция ТаблицаВHTML(ТаблицаДанных) Экспорт
// Подготовка данных для JSON
ДанныеДляJSON = Новый Массив;
Для Каждого СтрокаТаблицы Из ТаблицаДанных Цикл
Элемент = Новый Структура;
Элемент.Вставить("ПартНомер", СтрокаТаблицы.ПартНомер);
Элемент.Вставить("ОтветныйПартНомер", СтрокаТаблицы.ОтветныйПартНомер);
//Элемент.Вставить("Предложение", СтрокаТаблицы.Предложение);
Элемент.Вставить("ТНВЭД", СтрокаТаблицы.ТНВЭД);
Элемент.Вставить("Количество", СтрокаТаблицы.Количество);
Элемент.Вставить("КоличествоОт", СтрокаТаблицы.КоличествоОт);
Элемент.Вставить("ДатаЗагрузки", Строка(Формат(СтрокаТаблицы.ДатаЗагрузки, "ДФ=dd.MM.yyyy")));
Элемент.Вставить("ДатаКвотации", Формат(СтрокаТаблицы.ДатаКвотации, "ДФ=dd.MM.yyyy"));
Если ЗначениеЗаполнено(СтрокаТаблицы.Прайсы) Тогда
Прайсы = Новый Массив;
Для Каждого СтрокаПрайса Из СтрокаТаблицы.Прайсы Цикл
Прайс = Новый Структура;
Прайс.Вставить("ПартНомер", СтрокаПрайса.ПартНомер);
Прайс.Вставить("ГруппировкаНомерПозиции", СтрокаПрайса.ГруппировкаНомерПозиции);
Прайс.Вставить("ГруппировкаВидПрайса", Строка(СтрокаПрайса.ГруппировкаВидПрайса));
Прайс.Вставить("Количество", СтрокаПрайса.Количество);
Прайс.Вставить("ЦенаПозиции", СтрокаПрайса.ЦенаПозиции);
Прайс.Вставить("Цены", СтрокаПрайса.Цены);
Прайс.Вставить("СрокПоставки", СтрокаПрайса.СрокПоставки);
Прайс.Вставить("Бренд", СтрокаПрайса.ИсточникИД);
Прайс.Вставить("ТНВЭД", СтрокаПрайса.ТНВЭД);
Прайс.Вставить("Источник", СтрокаПрайса.Источник);
Прайсы.Добавить(Прайс);
КонецЦикла;
Элемент.Вставить("Производитель", СтрокаПрайса.Бренд + "<br>" + СтрокаТаблицы.ИсточникИД);
Элемент.Вставить("Прайсы", Прайсы);
КонецЕсли;
ДанныеДляJSON.Добавить(Элемент);
КонецЦикла;
// Сериализация в JSON
JSON = КоннекторHTTP.ОбъектВJson(ДанныеДляJSON);
// Заменяем символы | на переводы строки
HTML = "
|<!DOCTYPE html>
|<html>
|<head>
| <title>Предложения</title>
| <style>
| :root {
| --primary: #4361ee;
| --secondary: #3f37c9;
| --accent: #4895ef;
| --light: #f8f9fa;
| --dark: #212529;
| --success: #4cc9f0;
| --warning: #f72585;
| --gray: #6c757d;
| }
|
| * {
| box-sizing: border-box;
| margin: 0;
| padding: 0;
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
| }
|
| body {
| background-color: #f5f7fa;
| color: var(--dark);
| line-height: 1.6;
| padding: 20px;
| }
|
| .container {
| max-width: 14000px;
| margin: 0 auto;
| background: white;
| border-radius: 12px;
| box-shadow: 0 4px 20px rgba(0,0,0,0.08);
| overflow: hidden;
| }
|
| header {
| background: linear-gradient(135deg, var(--gray), var(--secondary));
| color: black;
| padding: 20px 25px;
| display: flex;
| justify-content: space-between;
| align-items: center;
| }
|
| h1 {
| font-size: 0.8rem;
| font-weight: 600;
| }
|
| .controls {
| display: flex;
| gap: 15px;
| }
|
| .btn {
| padding: 8px 16px;
| border-radius: 6px;
| border: none;
| cursor: pointer;
| font-weight: 500;
| transition: all 0.2s ease;
| display: flex;
| align-items: center;
| gap: 8px;
| }
|
| .btn-primary {
| background-color: white;
| color: var(--primary);
| }
|
| .btn-outline {
| background: transparent;
| border: 1px solid rgba(255,255,255,0.3);
| color: white;
| }
|
| .btn:hover {
| transform: translateY(-2px);
| box-shadow: 0 4px 12px rgba(0,0,0,0.1);
| }
|
| .main-table {
| width: 100%;
| border-collapse: separate;
| border-spacing: 0;
| font-size: 0.8rem;
| }
|
| .main-table th {
| position: sticky;
| top: 0;
| background-color: white;
| font-size: 0.8rem;
| z-index: 10;
| box-shadow: 0 2px 0 #e9ecef;
| }
|
| .main-table th,
| .main-table td {
| font-size: 0.8rem;
| padding: 12px 15px;
| text-align: left;
| border-bottom: 1px solid #e9ecef;
| }
|
| .main-table tr:hover td {
| background-color: #f8f9fa;
| }
|
| .price-toggle {
| background-color: #f8f9fa;
| }
|
| .toggle-btn {
| cursor: pointer;
| background: var(--light);
| border: 1px;
| width: 48px;
| font-weight: 600;
| height: 48px;
| border-radius: 50%;
| display: flex;
| align-items: center;
| justify-content: center;
| transition: all 0.3s ease;
| }
|
| .toggle-btn:hover {
| background: var(--accent);
| color: white;
| }
|
| .price-badge {
| background: var(--light);
| padding: 4px 10px;
| }
|
| .price-details {
| max-height: 0;
| overflow: hidden;
| transition: max-height 0.4s ease-out;
| }
|
| .price-details.visible {
| max-height: 100000000px;
| }
|
| .price-group {
| margin: 15px 0;
| border-radius: 1px;
| font-size: 0.6rem;
| overflow: hidden;
| box-shadow: 0 1px 4px rgba(0,0,0,0.05);
| }
|
| .price-group-header {
| background: linear-gradient(90deg, #f8f9fa, #e9ecef);
| padding: 12px 15px;
| display: flex;
| justify-content: space-between;
| align-items: center;
| cursor: pointer;
| transition: all 0.2s ease;
| }
|
| .price-group-header:hover {
| background: linear-gradient(90deg, #e9ecef, #dee2e6);
| }
|
| .group-title {
| font-weight: 600;
| color: var(--dark);
| }
|
| .group-subtitle {
| font-size: 0.85rem;
| color: var(--gray);
| }
|
| .price-table {
| width: 100%;
| border-collapse: collapse;
| font-size: 0.6rem;
| }
|
| .price-table th {
| background-color: #f1f3f5;
| padding: 10px 15px;
| font-weight: 500;
| text-transform: uppercase;
| font-size: 0.6rem;
| letter-spacing: 0.1px;
| color: var(--gray);
| }
|
| .price-table td {
| padding: 12px 15px;
| border-bottom: 1px solid #f1f3f5;
| font-size: 0.6rem;
| }
|
| .price-table tr:last-child td {
| border-bottom: none;
| }
|
| .price-table tr:hover td {
| background-color: #f8f9fa;
| }
|
| .action-btn {
| padding: 6px 12px;
| border-radius: 4px;
| background: var(--primary);
| color: white;
| border: none;
| cursor: pointer;
| font-size: 0.85rem;
| transition: all 0.2s ease;
| }
|
| .action-btn:hover {
| background: var(--secondary);
| transform: translateY(-1px);
| }
|
| .search-box {
| padding: 15px;
| background: #f8f9fa;
| border-bottom: 1px solid #e9ecef;
| }
|
| .search-input {
| width: 100%;
| padding: 10px 15px;
| border: 1px solid #dee2e6;
| border-radius: 6px;
| font-size: 1rem;
| }
|
| .status-badge {
| display: inline-block;
| padding: 4px 8px;
| border-radius: 4px;
| font-size: 0.75rem;
| font-weight: 500;
| }
|
| .status-available {
| background: #e6f7ee;
| color: #00a854;
| }
|
| .status-warning {
| background: #fff7e6;
| color: #fa8c16;
| }
|
| @media (max-width: 568px) {
| .container {
| border-radius: 0;
| }
|
| header {
| flex-direction: column;
| gap: 15px;
| align-items: flex-start;
| }
|
| .controls {
| width: 100%;
| flex-wrap: wrap;
| }
|
| .btn {
| flex: 1;
| justify-content: center;
| }
| }
| </style>
|</head>
|<body>
| <div class='container'>
| <header>
| <h1>Анализ предложений поставщиков</h1>
| <div class='controls'>
| <button class='btn btn-primary'>
| <svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor'>
| <path d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'></path>
| </svg>
| Поиск
| </button>
| <button class='btn btn-outline'>
| <svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor'>
| <path d='M3 3v18h18M7 17l4-4 4 4 6-6m-6-6l-6 6'></path>
| </svg>
| Экспорт
| </button>
| </div>
| </header>
|
| <div class='search-box'>
| <input type='text' class='search-input' placeholder='Поиск по артикулу, производителю...'>
| </div>
|
| <div style='overflow-x: auto;'>
| <table class='main-table' id='mainTable'>
| <thead>
| <tr>
| <th>Партномер</th>
| <th>Производитель</th>
| <th>Доступно</th>
| <th>Предложения</th>
| <th>Обновлено</th>
| </tr>
| </thead>
| <tbody id='tableBody'>
| <!-- Данные будут вставлены через JavaScript -->
| </tbody>
| </table>
| </div>
| </div>
|
| <script>
| // Получаем данные из JSON
| const jsonData = %JSON%;
|
| // Функция для отображения данных
| function renderTable() {
| const tableBody = document.getElementById('tableBody'); //tableBody
| tableBody.innerHTML = '';
|
| jsonData.forEach(item => {
| const row = document.createElement('tr');
|
| // Основные поля
| row.innerHTML = `
| <td><strong>${item.ПартНомер || '—'}</strong></td>
| <td>${item.Производитель || '—'}</td>
| <td>
| <span class='status-badge' ${item.Количество > 0 ? 'status-available' : 'status-warning'}'>
| ${item.Количество || 0} шт.
| </span>
| </td>
| <td>
| <button class='action-btn' ${JSON.stringify(item.Производитель)}'>Обновить</button><span> Найдено; ${JSON.stringify(item.КоличествоОт)} позиций</span>
| <div class='price-toggle'>
| <button class='toggle-btn' </button>
| </div>
| <div class='price-details'>
| ${renderPriceGroups(item.Прайсы)}
| </div>
| </td>
| <td>${item.ДатаЗагрузки}</td>`
|
|
| tableBody.appendChild(row);
| });
| }
|
| // Функция для группировки прайсов
| function groupPrices(prices) {
| const groups = {};
|
| prices.forEach(price => {
| const groupKey = price.ПартНомер + price.ОтветныйПартНомер + '|' + price.ГруппировкаНомерПозиции;
|
| if (!groups[groupKey]) {
| groups[groupKey] = {
| ПартНомер: price.ПартНомер,
| ОтветныйПартНомер: price.ОтветныйПартНомер,
| ГруппировкаВидПрайса: price.ГруппировкаВидПрайса,
| ГруппировкаНомерПозиции: price.ГруппировкаНомерПозиции,
| items: []
| };
| }
|
| groups[groupKey].items.push(price);
| });
|
| return Object.values(groups);
| }
| // Функция для отрисовки строк прайсов
| function renderPriceRows(prices) {
| if (!prices) return '';
|
| return prices.map(price => `
| <tr>
| <td>${price.ГруппировкаВидПрайса || ''}</td>
| <td>${price.Источник || ''}</td>
| <td>${price.Количество || 0}</td>
| <td>${price.Цены || ''}</td>
| <td>${price.СрокПоставки || ''}</td>
| <td>${price.ЦенаПозиции || 0}</td>
| <td>
| <button class='action-btn'
| Заказать
| </button>
| </td>
| </tr>
| `).join('');
| }
| // Функция для отрисовки групп прайсов
| function renderPriceGroups(prices) {
| if (!prices || prices.length === 0) {
| return `
| <div style='padding: 20px; text-align: center; color: var(--gray);'>
| Нет данных о предложениях
| </div>
| `;
| }
|
| const groupedPrices = groupPrices(prices);
| let html = '';
|
| groupedPrices.forEach(group => {
| html += `
| <div class='price-group'>
| <div class='price-group-header'
| <div>
| <div class='group-title'>
| Предложение №${group.ГруппировкаНомерПозиции || '—'} ${group.Бренд || '—'}<br> Парт номер поставщика: ${group.ПартНомер || 'не указан'}
| </div>
| </div>
| <svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor'>
| <path d='M19 9l-7 7-7-7'></path>
| </svg>
| </div>
| <div class='price-group-content'>
| <table class='price-table'>
| <thead>
| <tr>
| <th>Вид прайса</th>
| <th>Источник</th>
| <th>Кол-во</th>
| <th>Цены по предложению</th>
| <th>Условия поставки</th>
| <th>Цена позиции</th>
| <th>Заказать</th>
| </tr>
| </thead>
| <tbody>
| ${renderPriceRows(group.items)}
| </tbody>
| </table>
| </div>
| </div>
| `;
| });
|
| return html;
| }
|
| function createOrder(priceData) {
| }
|
| function refreshPos(PartNumber, Brand) {
| }
|
| // Функция для переключения отображения прайсов
| function togglePrices(btn) {
| const details = btn.parentElement.nextElementSibling;
| details.classList.toggle('visible');
| btn.textContent = details.classList.contains('visible') ? 'W22;' : '+';
| }
|
| // Запускаем отрисовку при загрузке
| document.addEventListener('DOMContentLoaded', renderTable);
| </script>
|</body>
|</html>";
// //
// // Вставляем JSON в HTML
HTML = СтрЗаменить(HTML, "%JSON%", JSON);
Возврат HTML;
КонецФункции
Получилось что то такое, если смотреть с точки зрения будущего 1С:
Ну что хочу сказать в итоге. Реализация в прикрепленном расширении.
Чуть понимающий специалист сделает себе такое же, для своих поставщиков по апи воткнув токен, эндпоинт и чуть подправив код.
Под любую платформу. Под любые формы.
Проверено на следующих конфигурациях и релизах:
- 1С:Библиотека стандартных подсистем, редакция 3.1, релизы 3.1.11.228
Вступайте в нашу телеграмм-группу Инфостарт