В этой статье мы рассмотрим поэтапное создание одного вэб-проекта, начиная от 1С и заканчивая этой же 1С.
Вот уже сколько лет наблюдаю, как наши менеджеры предоставляют клиентам информацию о нашим товарах в виде excel-файлов. Огромные, почти по 8000 позиций, файлы с картинками. Сколько в 1С было написано вариантов сохранения этих прайсов... только мной - 2 версии. Постоянные проблемы у кого-то из клиентов в стиле "не открывается, не сохраняется, не получили письмо", поиски путей засунуть 8000 строк и картинок в файл так, чтобы они на выходе весили не больше 15 Мб. Разрастание почты каждого менеджера по 1 гигабайту в день. А как представляю себе этого несчастного покупателя-оптовика с той стороны, который делает заказ в 100 позиций разного товара листая длинную простыню из номенклатуры. Ни фильтров, ни поиска нормального, ничего. Сплошные страдания и плохой имидж для нашей компании - не способны продавать "удобно", "деревня" и прочее.
Попытки создания оптового онлайн-магазина предпринимались, один раз даже почти удалось, но разработчик сильно подвел халтурой. Что называется "не угадали с выбором компании". Деньги были потрачены в пустоту, остался неприятный осадок. В начале этого года вопрос поднялся снова. В очередной раз сохранение прайса в excel вдруг отказалось работать, опять некоторые клиенты не могут открыть, опять страдают планы продаж. Опять поиски подрядчика, шестизначные ценники, самый крупный не вникая в ТЗ запросил почти 600 тысяч. Руководство очень не хотело снова оказаться у разбитого корыта, проверенные разработчики продвигали свои продукты за космические суммы, остальные собирались по несколько недель продумывать дизайн, чтобы потом продать нам купленный за 5$ шаблон на wordpress.
В промежутках между заменой картриджей в офисе, телефонными звонками о пропавших кнопочках и потерявшихся письмах, эти годы я изучал самые разные языки для решения каких-то конкретных задач, своих или на работе. 1С, python, C#, php, js, способы работы с разными API в разных условиях, какие-то сайты разной направленности и сложности. Реализацию этого "Онлайн-прайса" я обдумывал уже давно и на тот момент произошло три вещи:
- Я видел фрустрацию руководства из-за прошлых провалов и нежелание рисковать.
- Мне окончательно надоело воевать с ветряными мельницами в 1С и решать очередную проблему со сломавшимся прайсом.
- Я мог сделать этот вэб-сервис сам.
В общем, я предложил - мне дали добро. Срок поставили - месяц. Справился за полтора, ибо замену картриджей никто не отменял.
И прежде, чем начать этот долгий рассказ, сразу хочу акцентировать ваше внимание на следующем:
- Статья не претендует на best-practice. Описано все на примере одного проекта со своими нюансами и допущениями.
- Да, можно сделать более технологично, улучшить часть моментов, что-то доавтоматизировать и т.д.
- Я пытаюсь показать, что иногда простую вещь не нужно делать сложно, как это часто нам пытаются навязать "конторы с мировым именем", томными вечерами изобретая дизайн и адаптируя верстку сайта-одностранички.
Схема
Если вы задавались целью найти себе разработчика на какой-то сайт - вы писали ТЗ (Техническое задание). Если у вас есть ТЗ, значит вы уже представляете себе, как это должно выглядеть, каким функционалом обладать, как оно должно вести себя в той или иной ситуации. И, в принципе, этого достаточно, осталось лишь определиться с инструментами.
Дано:
- Сервер 1С, расположенный в офисе компании.
- VPS, арендованный у хостинг-провайдера. На нем будет размещен итоговый сайт, БД сайта и бэкенд на DRF.
Если вы поднимаете VPS с нуля и он у вас тоже будет на debian 9, рекомендую мой мануал по развертыванию LEMP + DRF.
В нашем случае, было придумано так:
- Раз в день, в определенное время, из 1С выгружается информация о товарах, ценах и остатках + сервисные справочники. Она сохраняется локально на сервере в формате XML, рядом кладутся изображения номенклатуры.
- Получившиеся файлы загружаются на VPS по протоколу FTP, тем же регламентным заданием в 1С.
- Раз в день, в определенное время, на VPS происходит обработка новых XML файлов и загрузка информации из них в СУБД. Загрузка производится средствами python. СУБД - MariaDB 10.1
- К той же базе подключено приложение на Django + Django REST Framework. Задача DRF - по запросу отдавать на фронтенд нужную информацию из базы.
- Фронтенд на Vue.js. Его задачи - отрисовать сайт, общаться с бэкэндом на предмет: авторизации, получения информации о товарах и добавления новых заказов.
- DRF, получив новый заказ, отправляет ответственному за клиента менеджеру письмо с уникальным ID заказа.
- Менеджер открывает в 1С обработку, вставляет в нужное поле полученный ID и заказ загружается, создается документ в 1С.
- Менеджер управляет пользователями на сайте из 1С. Создает новых пользователей, назначает им тип цены и т.д. Общение 1С и DRF по части пользователей происходит через HTTP POST и GET запросы. Управление пользователями на самом сайте становится ненужно, а значит и админку для этого писать не придется.
Как видите, ни разу не прозвучало слово "дизайн". Это потому, что сайт у нас в первую очередь функциональный, и только потом - презентативный. Его красивость мы обеспечим бутстрапом, спецификой фреймворка и популярной оболочки для него - vuetify. Забавный факт вам скажу - попробовав эту связку один раз, вы будете видеть аналогичный дизайн везде.
Нарисуем нашу схему и приступим уже к реализации.

Часть 1. Выгрузка из 1С и загрузка на FTP
Основная задача, которая стоит перед этим этапом - выгрузить так, чтобы в дальнейшем не заниматься каким-либо изменением этих данных. Каждая дополнительная операция на других слоях разработки - увеличение времени обработки, увеличение нагрузки и размазывание области траблшутинга. Поэтому мы должны сформировать такие XML, чтобы потом их можно было просто взять и "вставить" в нашу БД на VPS.
На том, как формировать XML в файлы, останавливаться не буду. На эту тему есть достаточно материалов. Остановлюсь лишь на паре моментов.
Выгрузка изображений
Наверняка, картинок у вас много. У нас порядка 30 тысяч. Если положить их все в один каталог, вы его потом с трудом откроете. Возможно, вы уже замечали, как некоторые сервисы или CMS хранят картинки в подпапках из одной двух букв, по 100-1000 штук в каждой. Сделать такую структуру средствами 1С не сложно.
Функция ПолучитьПутьИзображения_Выгрузить(Номенклатура, Изображение)
ИмяИзображения = Номенклатура.УникальныйИдентификатор();
ДД = Изображение.Хранилище.Получить();
РасширениеФормат = ДД.Формат();
Расширение = мСоответствиеТиповФайловКартинок.Получить(РасширениеФормат);
ИмяИзображения = "" + ИмяИзображения + Расширение;
ИмяИзображенияURL = URLКаталогаИзображений;
КаталогИзображения_Локально = КаталогВыгрузкиIMAGES + "\" + ПолучитьПодкаталогИзображения(ИмяИзображения, "\");
ИмяИзображения_URL = ПолучитьПодкаталогИзображения(ИмяИзображения) + ИмяИзображения;
ПутьСохранения = "" + КаталогИзображения_Локально + ИмяИзображения;
// Проверим существование каталога
Если НЕ ПроверитьСуществованиеКаталога(КаталогИзображения_Локально) Тогда
Лог("Ошибка записи изображения на диск: " + Символы.ПС + " Не удалось создать каталог: " + КаталогИзображения_Локально);
ИмяИзображенияURL = ИмяИзображенияURL + "error-on-export.jpg";
Иначе
// Проверим, что файла там нет
Файл = Новый Файл(ПутьСохранения);
Если Файл.Существует() И НЕ ПерезаписыватьИзображения Тогда
ИмяИзображенияURL = ИмяИзображенияURL + ИмяИзображения_URL;
Иначе
Попытка
ДД.Записать(ПутьСохранения);
ИмяИзображенияURL = ИмяИзображенияURL + ИмяИзображения_URL;
Исключение
Лог("Ошибка записи изображения на диск: " + Символы.ПС + ОписаниеОшибки());
ИмяИзображенияURL = ИмяИзображенияURL + "error-on-export.jpg";
КонецПопытки;
КонецЕсли;
КонецЕсли;
Возврат ИмяИзображенияURL;
КонецФункции
Функция ПолучитьПодкаталогИзображения(ИмяИзображения, Разделитель = "/")
Если ПустаяСтрока(ИмяИзображения) Тогда
Возврат "";
КонецЕсли;
Корень = Лев(ИмяИзображения, 1);
Каталог = Сред(ИмяИзображения, 2, 1);
Путь = "" + Корень + Разделитель + Каталог + Разделитель;
Возврат Путь;
КонецФункции
Получается, что для номенклатуры с GUID bf0d2eb7-5caa-11e3-a144-001e6711eb85 картинка будет лежать сначала в папке b/, потом в папке f/ и только потом будет сама картинка bf0d2eb7-5caa-11e3-a144-001e6711eb85.jpg. С увеличением числа изображений мы просто увеличиваем в этом коде наш уровень вложенности до 3-4-5 каталогов. В XML же мы сразу пишем путь, по которому эта картинка будет доступна на сайте.
<ImagePath>/static/images/b/f/bf0d2eb7-5caa-11e3-a144-001e6711eb85.jpg</ImagePath>
Числа с плавающей точкой
В отличие от 1С, другие языки программирования разделяют целые и дробные части не запятой, а точкой. Это лучше сразу учесть на этапе выгрузки, чем заниматься преобразованием на этапе загрузки. Актуально, например, для цен товаров.
Процедура ВыгрузитьXML_Prices(ТипЦены, СП, ПутьДоФайлаЦен)
Лог("Получение данных выгрузки");
#Если Клиент Тогда
Состояние("Получение данных: Prices");
#КонецЕсли
Данные = ПолучитьДанныеЦен(ТипЦены, СП);
ЗаписатьСтандартныйXML_ID_Value(Данные, ПутьДоФайлаЦен, "ЧДЦ=2; ЧРД=.; ЧН=0.00; ЧГ=", Истина, "[Prices]",
"PriceValues", "Значения для типа цены - " + ТипЦены.Наименование, ТипЦены.Код);
Лог("Выгрузка цен завершена. Обработано строк: " + Данные.Количество());
КонецПроцедуры
Процедура ЗаписатьСтандартныйXML_ID_Value(Данные, ПутьДоФайла, ФорматЗначения, IDКакGUID,
Этап, InfoKey = "", InfoDescription = "", InfoArg = "")
XML = Новый ЗаписьXML;
XML.ОткрытьФайл(ПутьДоФайла, "UTF-8");
XML.ЗаписатьОбъявлениеXML();
XML.ЗаписатьНачалоЭлемента("Root");
ДобавитьЗаписьINFO(XML, InfoKey, InfoDescription, InfoArg);
XML.ЗаписатьНачалоЭлемента("Items");
#Если Клиент Тогда
МАКС_ИНД = Данные.Количество();
ИНД = 0;
Состояние("Обработка данных " + Этап + ": " + ИНД + "/" + МАКС_ИНД);
#КонецЕсли
// .Ссылка
// .Значение
Для Каждого СтрокаТЧ ИЗ Данные Цикл
#Если Клиент Тогда
ИНД = ИНД + 1;
Состояние("Обработка данных " + Этап + ": " + ИНД + "/" + МАКС_ИНД);
#КонецЕсли
// Формируем XML
XML.ЗаписатьНачалоЭлемента("Item");
Если IDКакGUID Тогда
ЗаписатьРеквизит(XML, "ID", СокрЛП(СтрокаТЧ.Ссылка.УникальныйИдентификатор()));
Иначе
ЗаписатьРеквизит(XML, "ID", СокрЛП(СтрокаТЧ.Ссылка.Код));
КонецЕсли;
ЗаписатьРеквизит(XML, "Value", Формат(СтрокаТЧ.Значение, ФорматЗначения));
XML.ЗаписатьКонецЭлемента(); //Item
КонецЦикла;
XML.ЗаписатьКонецЭлемента(); //Items
XML.ЗаписатьКонецЭлемента(); //Root
XML.Закрыть();
КонецПроцедуры
Процедура ЗаписатьРеквизит(ЗаписьXML, Имя, Значение)
ЗаписьXML.ЗаписатьНачалоЭлемента(Имя);
ЗаписьXML.ЗаписатьТекст(Строка(Значение));
ЗаписьXML.ЗаписатьКонецЭлемента();
КонецПроцедуры
Иерархические справочники
С одной стороны, структура справочника номенклатуры в самой СУБД в любом случае будет храниться двумерно, с другой - распарсить питоном иерархический XML в список, а потом этот список обратно преобразовать в иерархический объект - не сложно. Поэтому выгружаем иерархически. Получаем запросом список каталогов, формируем древо значений через Выгрузить(ОбходРезультатаЗапроса.ПоГруппировкамСИерархией) и обходим
Процедура ОбойтиУровеньДерева(ЗаписьXML, Строки, Уровень, Root = неопределено) Экспорт
#Если Клиент Тогда
ИНД = 0;
МАКС_ИНД = Строки.Количество();
#КонецЕсли
Для каждого Строка из Строки Цикл
#Если Клиент Тогда
ИНД = ИНД + 1;
Состояние("Обработка данных [Catalog: " + Уровень + "]: " + ИНД + "/" + МАКС_ИНД);
#КонецЕсли
Номенклатура = Строка.Номенклатура;
Если НЕ ЗначениеЗаполнено(Номенклатура) Тогда
Продолжить;
КонецЕсли;
Если НЕ Номенклатура.ЭтоГруппа Тогда
Продолжить;
КонецЕсли;
// Пропустить дубликат
Если Root <> неопределено И Root = Номенклатура Тогда
Продолжить;
КонецЕсли;
ЗаписьXML.ЗаписатьНачалоЭлемента("Item");
ЗаписатьРеквизит(ЗаписьXML, "ID", Номенклатура.УникальныйИдентификатор());
ЗаписатьРеквизит(ЗаписьXML, "Name", Номенклатура.Наименование);
ЗаписатьРеквизит(ЗаписьXML, "Level", Уровень);
Если Строка.Строки.Количество() > 0 Тогда
ЗаписьXML.ЗаписатьНачалоЭлемента("Childrens");
ОбойтиУровеньДерева(ЗаписьXML, Строка.Строки, Число(Уровень) + 1, Номенклатура);
ЗаписьXML.ЗаписатьКонецЭлемента(); //Childrens
КонецЕсли;
ЗаписьXML.ЗаписатьКонецЭлемента(); //Item
КонецЦикла;
КонецПроцедуры
Блок Info в каждом XML
Представим, что выгрузкой будет заниматься один программист, а загрузкой данных - другой. С одной стороны, они могут договориться, что будут сформированы вот такие файлы и с вот такими именами, с другой - такая договоренность может через года забыться, имена файлов вдруг изменятся и вообще. Поэтому, в каждый файл в самом начале будет неплохо добавить какие-то флаги, маркеры, объясняющие - что это и как с этим работать.
<?xml version="1.0" encoding="UTF-8"?>
<Root>
<Info>
<Key>PriceValues</Key>
<Description>Значения для типа цены - 1234</Description>
<Argument>000001234</Argument>
</Info>
<Items>
<Item>
<ID>bb5b71eb-3915-11e7-80b7-001e6711eb85</ID>
<Value>899.99</Value>
</Item>
...
</Items>
</Root>
И на той стороне уже увидят, что в каждом файле есть некий ключ Key, который можно использовать как идентификатор - что это за файл и как его обработать. А если появится программист, который будет что-то менять в выгрузке, он увидит этот блок и (будем надеяться) поймет, что это важно.
Выгрузка на FTP
По итогам формирования мы получили несколько файлов: один файл всех товаров с их реквизитами и параметрами, файл остатков, несколько файлов цен, по одному на каждый тип цены, а также небольшие файлы сопутствующих справочников, такие как производители, страны происхождения, единицы измерения и т.д.
Все это находится в двух корневых каталогах: IMAGES для картинок и XML для всех получившихся XML-файлов.
Сначала мы загрузим на VPS xml-файлы, потому что это быстро и критично, а уже потом включим синхронизацию изображений, потому что дожидаться завершения этого процесса для дальнейшей работы нам не нужно.
Сразу предостерегу вас - не используйте для этих задач встроенные механизмы FTP-клиента 1С. Он ужасен, он неэффективен, он ненадежен. Проверено. Вместо него в windows лучше воспользоваться сторонним приложением, вызывая его через COM-объект. Например, WinSCP.
Просто посмотрите, насколько лаконичнее и, поверьте, эффективнее, становится код в 1С. Приведу его полностью.
Выгрузка на FTP средствами WinSCP
// *********************** FTP ***********************
#Область Выгрузка_на_FTP
Процедура COM_ВыгрузитьНаFTP(КаталогВыгрузкиНаFTP, subdir="", МаскаФайловВыгрузки = "*.xml", ОчищатьFTPПередЗагрузкой = Истина)
session = ПолучитьОбъектSession();
Если session = неопределено Тогда
Возврат;
КонецЕсли;
НачатьСекциюЛога("ВЫГРУЗКА ФАЙЛОВ ИЗ: " + КаталогВыгрузкиНаFTP + "\" + subdir);
Если COM_ВыгрузитьXML_Sync(session, КаталогВыгрузкиНаFTP, subdir) Тогда
Лог(" --- Выгрузка завершена успешно.");
Иначе
Лог(" !!! Выгрузка завершена с ошибками.");
КонецЕсли;
// *************************************************
Если ВыгружатьИзображенияНаFTP Тогда
НачатьСекциюЛога("ВЫГРУЗКА ИЗОБРАЖЕНИЙ ИЗ: " + КаталогВыгрузкиIMAGES);
Если COM_ВыгрузитьIMG(session) Тогда
Лог(" --- Выгрузка IMG завершена успешно.");
Иначе
Лог(" !!! Выгрузка IMG завершена с ошибками.");
КонецЕсли;
КонецЕсли;
// *************************************************
session.Dispose();
КонецПроцедуры
Процедура COM_ВыгрузитьImagesНаFTP_Отладка() Экспорт
НачатьЛог("ВЫГРУЗКА НА FTP");
session = ПолучитьОбъектSession();
Если session = неопределено Тогда
Возврат;
КонецЕсли;
Если COM_ВыгрузитьIMG(session) Тогда
Лог(" --- Выгрузка IMG завершена успешно.");
Иначе
Лог(" !!! Выгрузка IMG завершена с ошибками.");
КонецЕсли;
ЗавершитьЛог();
КонецПроцедуры
Функция COM_УдалитьXML(session, subdir, Маска)
#Если Клиент Тогда
Состояние("Очищаем каталог /" + subdir + " на FTP...");
#КонецЕсли
Попытка
ПутьУдаленияНаФТП = КаталогXMLнаFTP + subdir + Маска;
transferResult = session.RemoveFiles(ПутьУдаленияНаФТП);
transferResult.Check();
//Обрабатываем результат выгрузки
Лог(" > Удалено файлов: " + transferResult.Removals.Count);
Для каждого УдаленныйФайл Из transferResult.Removals Цикл
Лог(" >>> Удален файл: " + УдаленныйФайл.FileName);
КонецЦикла;
Лог(" > Ошибок удаления: " + transferResult.Failures.Count);
Для каждого Ошибка Из transferResult.Failures Цикл
Лог(" >>> Описание ошибки: " + Ошибка.Message);
КонецЦикла;
Исключение
Лог(" >>> Ошибка удаления с FTP: " + Символы.ПС + " <<< " + ОписаниеОшибки());
Возврат Ложь;
КонецПопытки;
Возврат Истина;
КонецФункции
Функция COM_ВыгрузитьXML(session, subdir, КаталогВыгрузкиНаFTP, Маска)
#Если Клиент Тогда
Состояние("Выгружаем файлы на FTP...");
#КонецЕсли
Попытка
ПутьВыгрузки = КаталогВыгрузкиНаFTP + "\" + Маска;
ПутьЗагрузкиНаФТП = КаталогXMLнаFTP + subdir;
transferResult = session.PutFiles(ПутьВыгрузки, ПутьЗагрузкиНаФТП);
transferResult.Check();
//Обрабатываем результат выгрузки
Лог(" > Выгружено файлов: " + transferResult.Transfers.Count);
Для каждого ВыгруженныйФайл Из transferResult.Transfers Цикл
Лог(" >>> Выгружен файл: " + ВыгруженныйФайл.FileName);
КонецЦикла;
Лог(" > Ошибок выгрузки: " + transferResult.Failures.Count);
Для каждого Ошибка Из transferResult.Failures Цикл
Лог(" >>> Описание ошибки: " + Ошибка.Message);
КонецЦикла;
Исключение
Лог(" >>> Ошибка выгрузки XML: " + Символы.ПС + " <<< " + ОписаниеОшибки());
Возврат Ложь;
КонецПопытки;
Возврат Истина;
КонецФункции
Функция COM_ВыгрузитьXML_Sync(session, КаталогВыгрузкиНаFTP, subdir)
БезОшибок = Истина;
#Если Клиент Тогда
Состояние("Выгружаем XML на FTP. [Synchronization in progress...]");
#КонецЕсли
Попытка
ПутьВыгрузки = КаталогВыгрузкиНаFTP + "\" + ?(НЕ ПустаяСтрока(subdir), subdir + "\", "");
ПутьЗагрузкиНаФТП = КаталогXMLнаFTP + ?(НЕ ПустаяСтрока(subdir), subdir + "/", "");
//SynchronizationMode mode: 0 - local, 1 - remote, , 2 - both
//SynchronizationCriteria criteria: 0 - None, 1 - Time, 2 - Size, 3 - Either
//
//Public Function SynchronizeDirectories(
// mode As SynchronizationMode,
// localPath As String,
// remotePath As String,
// removeFiles As Boolean,
// Optional mirror As Boolean = False,
// Optional criteria As SynchronizationCriteria = 1,
// Optional options As TransferOptions = Nothing
//) As SynchronizationResult
transferResult = session.SynchronizeDirectories(1, ПутьВыгрузки, ПутьЗагрузкиНаФТП,
True, False);
transferResult.Check();
Лог(" > Выгружено файлов: " + transferResult.Uploads.Count);
Лог(" > Удалено файлов: " + transferResult.Removals.Count);
Лог(" > Ошибок: " + transferResult.Failures.Count);
Исключение
БезОшибок = Ложь;
Лог(" >>> Ошибка выгрузки IMG: " + Символы.ПС + " <<< " + ОписаниеОшибки());
КонецПопытки;
Возврат БезОшибок;
КонецФункции
Функция COM_ВыгрузитьIMG(session)
БезОшибок = Истина;
#Если Клиент Тогда
Состояние("Выгружаем IMAGES на FTP. [Synchronization in progress...]");
#КонецЕсли
// Тонко. Засинхроним корневые каталоги
Попытка
ПутьВыгрузки = КаталогВыгрузкиIMAGES + "\";
ПутьЗагрузкиНаФТП = КаталогIMAGESнаFTP;
//SynchronizationMode mode: 0 - local, 1 - remote, , 2 - both
//SynchronizationCriteria criteria: 0 - None, 1 - Time, 2 - Size, 3 - Either
//
//Public Function SynchronizeDirectories(
// mode As SynchronizationMode,
// localPath As String,
// remotePath As String,
// removeFiles As Boolean,
// Optional mirror As Boolean = False,
// Optional criteria As SynchronizationCriteria = 1,
// Optional options As TransferOptions = Nothing
//) As SynchronizationResult
transferResult = session.SynchronizeDirectories(1, ПутьВыгрузки, ПутьЗагрузкиНаФТП,
False, ПерезаписыватьИзображенияFTP);
transferResult.Check();
Лог(" > Выгружено файлов: " + transferResult.Uploads.Count);
Лог(" > Удалено файлов: " + transferResult.Removals.Count);
Лог(" > Ошибок: " + transferResult.Failures.Count);
Исключение
БезОшибок = Ложь;
Лог(" >>> Ошибка выгрузки IMG: " + Символы.ПС + " <<< " + ОписаниеОшибки());
КонецПопытки;
Возврат БезОшибок;
КонецФункции
// Служебные
Функция ПолучитьОбъектSession()
#Если Клиент Тогда
Состояние("Устанавливаем соединение...");
#КонецЕсли
Попытка
//Задаем параметры подключения
sessionOptions = Новый COMОбъект("WinSCP.SessionOptions"); //Создаем объект SessionOptions
sessionOptions.Protocol = 2; // FTP
//Sftp = 0,
//Scp = 1,
//Ftp = 2,
//Webdav = 3,
sessionOptions.HostName = FTP_Address;
sessionOptions.PortNumber = 21;
sessionOptions.UserName = FTP_User;
sessionOptions.Password = FTP_Password;
session = Новый COMОбъект("WinSCP.Session"); //Создаем объект Session
//TimeSpan Timeout
// default = 1 min
//на всякий случай
session.ExecutablePath = "C:\Program Files (x86)\WinSCP\winscp.exe";
// Подключаемся
session.Open(sessionOptions);
Возврат session;
Исключение
Лог(" >>> Ошибка соединения: " + Символы.ПС + " <<< " + ОписаниеОшибки());;
ЗавершитьЛог();
Возврат неопределено;
КонецПопытки;
КонецФункции
#КонецОбласти
Итог
На этом этап выгрузки данных завершен. Необходимые файлы сейчас находятся на удаленном сервере, где они через какое-то время, по заданию в crontab, будут обработаны.
Часть 2. Загрузка в СУБД
В принципе, вовсе не обязательно, чтобы данные из XML были загружены в СУБД именно с помощью pyhton. Этот этап полностью изолирован от всех остальных, он чисто сервисный. Я выбрал python потому, что понимал, как это нужно сделать. Основу скрипта составит библиотека SQLAlchemy. Для ее работы сначала мы создадим модели наших таблиц. Делать это мы будем декларативно, потому что мы не хотим заниматься ручным созданием таблиц в нашей базе, SQLAlchemy сделает это для нас. От нас на подготовительном этапе потребуется только создать саму базу и пользователя для работы с ней.
Пример модели Goods (Товары)
from sqlalchemy import Column, Index, Integer, DECIMAL, String, Text, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.mysql import INTEGER
Base = declarative_base()
class Goods(Base):
__tablename__ = 'main_goods'
id = Column('id', String(36),
primary_key = True,
comment = '1С - GUID')
name = Column('name', String(100),
nullable = True,
default = None,
comment = '1С - Наименование')
code = Column('code', String(11),
nullable = True,
default = None,
comment = '1С - Код элемента')
article = Column('article', String(25),
nullable = True,
default = None,
comment = '1C - Артикул')
description = Column('description', Text,
nullable = True,
default = None,
comment = '1С - Описание')
package_qty = Column('package_qty', INTEGER(display_width = 10),
default = 1,
comment = '1С - Штук в коробке')
id_parent = Column('id_parent', String(36),
# goods = relationship('Goods')
ForeignKey('main_catalog.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - GUID каталога (Родителя)')
name_parent_good = Column('name_parent_good', String(100),
nullable = True,
default = None,
comment = '1С - Имя каталога (Родителя)')
id_brand = Column('id_brand', String(9),
# goods = relationship('Goods')
ForeignKey('svc_brands.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - Производитель, Код')
name_brand = Column('name_brand', String(50),
nullable = True,
default = None,
comment = '1С - Производитель, Имя')
id_unit = Column('id_unit', String(4),
# goods = relationship('Goods')
ForeignKey('svc_units.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - Единица измерения, Код')
name_unit = Column('name_unit', String(25),
nullable = True,
default = None,
comment = '1С - Единица измерения, Имя')
id_country = Column('id_country', String(3),
# goods = relationship('Goods')
ForeignKey('svc_countries.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - Страна, Код')
name_country = Column('name_country', String(60),
nullable = True,
default = None,
comment = '1С - Страна, Имя')
image_path = Column('image_path', String(150),
default = '/static/images/noimage.jpg',
comment = 'URL до картинки')
sku_pcs = Column('sku_pcs', String(30),
nullable = True,
default = '',
comment = '1С - Штрихкод штуки')
sku_pkg = Column('sku_pkg', String(30),
nullable = True,
default = '',
comment = '1С - Штрихкод коробки')
category_new = Column('category_new', Boolean,
default = False,
index = True,
comment = '1С - Категория Новинка')
category_promo = Column('category_promo', Boolean,
default = False,
index = True,
comment = '1С - Категория Акция')
category_sale = Column('category_sale', Boolean,
default = False,
index = True,
comment = '1С - Категория Распродажа')
# Relationships
rests = relationship('Rests')
prices = relationship('Prices')
def __init__(self, *initial_data, **kwargs):
for dictionary in initial_data:
for key in dictionary:
value_to_init = dictionary[key]
if value_to_init == 'null':
value_to_init = None
elif value_to_init == 'True':
value_to_init = True
elif value_to_init == 'False':
value_to_init = False
if key == 'package_qty':
try:
value_to_init = int(dictionary[key])
except ValueError:
pass
setattr(self, key, value_to_init)
for key in kwargs:
setattr(self, key, kwargs[key])
self.set_defaults()
def set_defaults(self):
# Item attributes
if is_empty(self.name):
self.name = None
if is_empty(self.code):
self.code = None
if is_empty(self.article):
self.article = None
# Names of attributes
if is_empty(self.name_parent_good):
self.name_parent_good = None
if is_empty(self.name_brand):
self.name_brand = None
if is_empty(self.name_unit):
self.name_unit = None
if is_empty(self.name_country):
self.name_country = None
# Package QTY
if not isinstance(self.package_qty, int):
self.package_qty = 1
# Description
if is_empty(self.description):
self.description = None
# Image
if not isinstance(self.image_path, str):
self.image_path = '/static/images/noimage.jpg'
# SKU
if is_empty(self.sku_pcs):
self.sku_pcs = ''
if is_empty(self.sku_pkg):
self.sku_pkg = ''
# Categories
if not isinstance(self.category_new, bool):
self.category_new = False
if not isinstance(self.category_promo, bool):
self.category_promo = False
if not isinstance(self.category_sale, bool):
self.category_sale = False
def __repr__(self):
return '<Good object (ID: %s, Name: %s, Code: %s, Article: %s)>' % (
self.id, self.name, self.code, self.article)
Пример модели Prices (Цены)
from sqlalchemy import Column, Index, Integer, DECIMAL, String, Text, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.mysql import INTEGER
class Prices(Base):
__tablename__ = 'main_prices'
id = Column('id', INTEGER(display_width = 9),
primary_key = True,
autoincrement = True,
comment = 'AutoIncrement')
id_good = Column('id_good', String(36),
ForeignKey('main_goods.id', ondelete = 'CASCADE', onupdate = 'CASCADE'),
comment = '1С - GUID товара')
id_price = Column('id_price', String(9),
# prices = relationship('Prices')
ForeignKey('svc_pricetypes.id', ondelete = 'CASCADE', onupdate = 'CASCADE'),
comment = '1С - Код типа цены')
value = Column('value', DECIMAL(15,2),
default = 0.00,
comment = '1C - Число (15,2)')
__table_args__ = (Index('id_good_price', "id_good", "id_price"), )
def __init__(self, *initial_data, **kwargs):
for dictionary in initial_data:
for key in dictionary:
value_to_init = dictionary[key]
if value_to_init == 'null':
value_to_init = None
elif value_to_init == 'True':
value_to_init = True
elif value_to_init == 'False':
value_to_init = False
if key == 'value':
try:
value_to_init = float(dictionary[key])
except ValueError:
pass
setattr(self, key, value_to_init)
for key in kwargs:
setattr(self, key, kwargs[key])
self.set_defaults()
def set_defaults(self):
if not isinstance(self.value, float):
self.value = 0.00
def __repr__(self):
return '<Rest object (ID: %s, Good ID: %s, Value: %s)>' % (
self.id, self.id_good, self.value)
Обратите внимание на блоки init в каждой модели. Дело в том, что при чтении наших XML, False, True, Null будут представлены для python в виде текста и для дальнейшей работы с этими полями уже из самой БД нам нужно преобразовать их в типы, понятные python. Поэтому мы и проверяем, что если поле == 'null', то эквивалентом для python будет тип None и т.д. Если этого не сделать, мы столкнемся с проблемой при загрузке, когда попытаемся в поле БД с типом TINY_INT засунуть строку с текстом 'False'.
Описание этих моделей можно считать самым нудным этапом всей разработки. Это как писать техническую документацию на каждую колонку каждой таблицы всей базы данных.
Чтобы не заниматься прописыванием методов и полей в коде загрузки, сделаем некий словарь, где по ключу в XML будем определять, какие там поля и как они должны быть загружены в БД.
BULK_METHODS = ['PriceValues', 'RestValues']
# ------------- SQL<->XML RELATIONS -----------
# XML-Key -> SQL-Table | XML-Tags -> SQL-Columns
XML_SQL_RELATIONS = {
# --- MAIN ---
'GoodsCollection' : {
'table': 'main_goods',
'columns': {
'ID': 'id',
'Name': 'name',
'Code': 'code',
'Article': 'article',
'Description': 'description',
'PackageQty': 'package_qty',
'ParentID': 'id_parent',
'ParentName': 'name_parent_good',
'BrandID': 'id_brand',
'BrandName': 'name_brand',
'UnitID': 'id_unit',
'UnitName': 'name_unit',
'CountryID': 'id_country',
'CountryName': 'name_country',
'ImagePath': 'image_path',
'SKU_pcs': 'sku_pcs',
'SKU_pkg': 'sku_pkg',
'Category_New' : 'category_new',
'Category_Promo': 'category_promo',
'Category_Sale': 'category_sale',
},
},
'CatalogHierarchy' : {
'table': 'main_catalog',
'columns': {
'ID': 'id',
'Name': 'name',
'Level': 'level',
# parent -> ID : 'id_parent',
},
},
'PriceValues': {
'table': 'main_prices',
'columns': {
'ID': 'id_good',
'Value': 'value',
# info -> Argument : 'id_price',
},
},
'RestValues': {
'table': 'main_rests',
'columns': {
'ID': 'id_good',
'Value': 'value',
},
},
# --- SVC ---
'BrandsCollection' : {
'table': 'svc_brands',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
'CountriesCollection': {
'table': 'svc_countries',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
'ManagersCollection': {
'table': 'svc_managers',
'columns': {
'ID': 'id',
'Name': 'name',
'Code': 'code',
'Email': 'email',
'PhoneMobile': 'phone_mobile',
'PhoneOffice': 'phone_office',
},
},
'PricetypesCollection': {
'table': 'svc_pricetypes',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
'UnitsCollection': {
'table': 'svc_units',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
# --- TMP ---
'UsersUpdate': {
'table': 'main_users',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
}
Для того, чтобы получить данные из наших XML не сплошным текстом, а неким объектом, воспользуемся библиотекой lxml.
from glob import glob
from lxml import etree as ElementTree
def get_folder_files(directory, mask ='*.xml'):
files = glob('{directory}/{mask}'.format(directory = directory, mask = mask))
return files
def read_xml_file(file_path):
xml_content = ElementTree.parse(file_path)
xml_root = xml_content.getroot()
# INFO
info = dict(key = None, description = None, argument = None)
# we have INFO and ITEMS
child_info = xml_root.find('Info')
if child_info is not None:
read_xml_info(child_info, info)
child_items = xml_root.find('Items')
return dict(info = info, items = child_items)
def read_xml_info(node, info):
# node is an object of lxml Etree
info['key'] = node.find('Key').text if node.find('Key') is not None else None
info['description'] = node.find('Description').text if node.find('Description') is not None else None
info['argument'] = node.find('Argument').text if node.find('Argument') is not None else None
Когда механизм чтения готов, можно приступать к самой загрузке.
Главный метод, которым все и загружается
def main():
# Load all XMLs from main and svc folders
# '''
files = _xml.get_folder_files(ROOT_XML_SERVICE)
# Order is important
# first - we load catalog
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'catalog.xml'):
files.append(xml_file)
# Second - we load goods
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'goods.xml'):
files.append(xml_file)
# Third - we load rests
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'rests.xml'):
files.append(xml_file)
# Finally - we load prices
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'prices*'):
files.append(xml_file)
# '''
#files = _xml.get_folder_files(ROOT_XML_MAIN, mask = '*prices-*.xml')
_sql.init_connection()
# We clear db first.
# We don't need outdated information
delete_table_contents()
# Then we load Service files first
# And Main XMLs last
for xml_file in files:
log.info('\nParsing XML file: %s' % xml_file)
xml_content = _xml.read_xml_file(xml_file)
if xml_content['info']['key'] is not None and xml_content['info']['key'] in XML_SQL_RELATIONS.keys():
method = xml_content['info']['key']
log.info('Parsing XML by key: %s' % method)
xml_items = xml_content['items'].findall('.//Item')
if len(xml_items) > 0:
log.info(' > File contains: %s items' % len(xml_items))
max_trans_roll = MAX_TRANSACTIONS
if len(xml_items) > max_trans_roll * 10:
max_trans_roll *= 10
stack = 0
bulk_stack = []
for xml_item in xml_items:
try:
if method in BULK_METHODS:
bulk_stack.append(_sql.add_string(method, xml_item,
argument = xml_content['info']['argument']))
else:
_sql.add_string(method, xml_item,
argument = xml_content['info']['argument'])
except Exception as e:
log.error('Failed on adding string: %s' % e)
stack += 1
if stack >= max_trans_roll:
print('%s: %s - %s' % (method, stack, max_trans_roll))
# Commit session
stack = 0
if method in BULK_METHODS:
_sql.bulk_commmit_session(bulk_stack)
bulk_stack = []
else:
_sql.commit_session()
# Commit the leftovers
if max_trans_roll > stack > 0:
print('%s: %s - %s' % (method, stack, max_trans_roll))
if method in BULK_METHODS:
_sql.bulk_commmit_session(bulk_stack)
else:
_sql.commit_session()
else:
log.error(' > File contains: 0 items. File will be skipped')
else:
log.error('Unable to identify XML by Key')
_sql.close_session()
def delete_table_contents():
list_tables = ('main_catalog', 'main_goods', 'main_prices', 'main_rests',
'svc_brands', 'svc_countries', 'svc_managers', 'svc_pricetypes', 'svc_units')
_sql.set_foreign_checks(0)
for table_name in list_tables:
_sql.truncate_table(table_name)
_sql.set_foreign_checks(1)
_sql.close_session()
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
from pprint import pprint
from config import *
from sqlalchemy import create_engine
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker, relationship
import _sql_classes as SC
import os
import logging
log = logging.getLogger(__file__)
log.setLevel(logging.DEBUG)
fh = logging.FileHandler('{path_log}/sql.log'.format(path_log = PATH_LOGS))
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
log.addHandler(fh)
USE_ECHO = False
if os.name == 'nt':
USE_ECHO = True
# Do not forget to:
# Return MariaDB to localhost /etc/mysql/mariadb.conf.d/50-server
# Return priceadmin user to @localhost
engine = create_engine('mysql://{username}:{password}@{host}:{port}/{db}?charset=utf8'.format(
username = SQL_RW_USER,
password = SQL_RW_PASSWORD,
host = SQL_HOST,
port = SQL_PORT,
db = SQL_DB), encoding='utf-8', echo = USE_ECHO)
Session = sessionmaker(bind = engine)
session = Session()
def init_connection():
SC.Base.metadata.create_all(bind = engine)
def add_string(method, item, argument = None):
# method - string
# item - lxml Etree
# We identify fields by config Dict XML_SQL_RELATIONS with provided 'method'
rel_dict = {}
if method in XML_SQL_RELATIONS.keys():
columns = XML_SQL_RELATIONS[method]['columns']
for k in columns.keys():
item_value = item.find(k)
if item_value is not None:
# SQL column name : value to add
rel_dict[columns[k]] = item_value.text
# For some type we add Argument
if method == 'PriceValues' and argument is not None:
rel_dict['id_price'] = argument
# For Hierarchy-type XML, like Catalog we add parent_id
# Parent ID will be found by lxml Etree '..' getparent navigation
if method == 'CatalogHierarchy':
parent_item = item.getparent()
if parent_item is not None:
parent_item = parent_item.getparent()
parent_item_id = None
if parent_item is not None:
parent_item_id = parent_item.find('ID').text if parent_item.find('ID') is not None else None
rel_dict['id_parent'] = parent_item_id
else:
raise Exception('method not declared in XML_SQL_RELATIONS (config)')
if len(rel_dict.keys()) > 0:
new_string = None
# ----------- MAIN
if method == 'GoodsCollection':
new_string = SC.Goods(rel_dict)
elif method == 'CatalogHierarchy':
new_string = SC.Catalogs(rel_dict)
elif method == 'PriceValues':
new_string = SC.Prices(rel_dict)
elif method == 'RestValues':
new_string = SC.Rests(rel_dict)
# ----------- SVC
elif method == 'BrandsCollection':
new_string = SC.Brands(rel_dict)
elif method == 'CountriesCollection':
new_string = SC.Countries(rel_dict)
elif method == 'ManagersCollection':
new_string = SC.Managers(rel_dict)
elif method == 'PricetypesCollection':
new_string = SC.PriceTypes(rel_dict)
elif method == 'UnitsCollection':
new_string = SC.Units(rel_dict)
elif method == 'UsersUpdate':
new_string = SC.Users(rel_dict)
if new_string is not None:
if method in BULK_METHODS:
return new_string
else:
session.add(new_string)
return None
def get_table_content_all(table):
table_content = session.query(table).all()
for row in table_content:
print(row.id)
def set_foreign_checks(value):
_query = text('SET FOREIGN_KEY_CHECKS = {value}'.format(
value = value))
session.execute(_query)
def truncate_table(str_table):
truncate_query = text('TRUNCATE TABLE {table_name}'.format(
table_name = str_table))
session.execute(truncate_query)
try:
session.commit()
log.info('table `{table_name}` truncated'.format(
table_name = str_table))
except Exception as e:
session.rollback()
log.error('table `{table_name}` not truncated: {error_msg}'.format(
table_name = str_table, error_msg = e))
def commit_session():
try:
session.commit()
except Exception as e:
print(e)
session.rollback()
def bulk_commmit_session(bulk_stack):
try:
session.bulk_save_objects(bulk_stack)
session.commit()
except Exception as e:
print(e)
session.rollback()
def merge_session():
try:
session.merge()
except Exception as e:
print(e)
session.rollback()
def close_session():
session.close()
Вы могли заметить, что вызов загрузки также вызывает очистку всех таблиц в базе данных. Да, это так. Специфика нашего проекта позволяет нам просто взять и удалить данные из всех таблиц, в которые мы собираемся что-то загружать. Поэтому мы не тратим время и ресурсы на какую-то сложную логику по определению уже существующих данных, их соответствие загружаемым и т.д. (ON DUPLICATE UPDATE создаст дополнительный слой операций) Мы точно знаем, что перед загрузкой таблица будет пуста и точно знаем, что нужно загрузить абсолютно все. А это, в свою очередь, позволяет нам формировать INSERT-команды пачками по 100-1000 штук и засылать в commit. Таким образом, загрузка данных общей протяженностью около 400.000 строк занимает секунд 15. Если в других таблицах нашей БД (например, в заказах) будут использоваться ключи от очищенных таблиц - они будут оставлены как есть (потому что перед загрузкой мы отключаем для БД ForeignKeyChecks) и после загрузки снова станут актуальны.
Итог
Данные загружены в СУБД, теперь с ними можно работать.
Часть 3. Django, REST Framework
Прежде чем начать, рекомендую обзавестись удобным инструментом для отладки HTTP-запросов - Postman
С помощью него вам будет гораздо удобнее отлаживать свои запросы к API.
Models
Django (Джанго), как и SQLAlchemy, в своей работе тоже использует ORM-принципы, поэтому для того, чтобы научить наш бэкенд понимать таблицы в нашей БД, нам снова придется описать все модели. По одной на каждую таблицу. Но, в отличие от загрузки из XML, тут мы также будем описывать такие модели как "Profile, Order, OrderItems". Опять таки, мы ничего не будем создавать непосредственно в СУБД, механизмы Django сделают это для нас (python manage.py makemigrations и pyhton manage.py migrate).
Это второй нудный этап разработки, нам снова нужно описать все модели и даже больше, чем мы это делали для SQLAlchemy.
И снова пример для Goods и Prices
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
import datetime
from decimal import Decimal
class MainGoods(models.Model):
id = models.CharField(primary_key = True, max_length = 36)
name = models.CharField(max_length = 100, blank = True, null = True)
code = models.CharField(max_length = 11, blank = True, null = True)
article = models.CharField(max_length = 25, blank = True, null = True)
description = models.TextField(blank = True, null = True)
package_qty = models.IntegerField()
id_parent = models.ForeignKey(MainCatalog,
models.SET_NULL,
related_name = 'goods',
db_column = 'id_parent', blank = True, null = True)
name_parent_good = models.CharField(max_length = 100, blank = True, null = True)
id_brand = models.ForeignKey('SvcBrands',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_brand', blank = True, null = True)
name_brand = models.CharField(max_length = 50, blank = True, null = True)
id_unit = models.ForeignKey('SvcUnits',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_unit', blank = True, null = True)
name_unit = models.CharField(max_length = 25, blank = True, null = True)
id_country = models.ForeignKey('SvcCountries',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_country', blank = True, null = True)
name_country = models.CharField(max_length = 60, blank = True, null = True)
image_path = models.CharField(max_length = 150)
# SKU
sku_pcs = models.CharField(
db_column = 'sku_pcs',
max_length = 30, blank = True, null = True, default = '')
sku_pkg = models.CharField(
db_column = 'sku_pkg',
max_length = 30, blank = True, null = True, default = '')
# Categories
category_new = models.IntegerField()
category_promo = models.IntegerField()
category_sale = models.IntegerField()
class Meta:
managed = False
db_table = 'main_goods'
verbose_name = 'Товар'
verbose_name_plural = 'Товары'
ordering = ('name',)
def __str__(self):
return '{article}: {name} ({code})'.format(
article = self.article, name = self.name, code = self.code)
class MainPrices(models.Model):
id = models.IntegerField(primary_key=True)
id_good = models.ForeignKey(MainGoods,
models.CASCADE,
related_name = 'prices',
db_column = 'id_good')
id_price = models.ForeignKey('SvcPricetypes',
models.CASCADE,
related_name = 'prices',
db_column = 'id_price')
value = models.DecimalField(max_digits = 15, decimal_places = 2)
class Meta:
managed = False
db_table = 'main_prices'
verbose_name = 'Цена'
verbose_name_plural = 'Цены'
ordering = ('id',)
def __str__(self):
return '{id_good} ({id_price}) : {value}'.format(
id_good = self.id_good, id_price = self.id_price, value = self.value)
А это уже новая таблица - Profiles
Управление данными в этой таблице будет производиться из 1С. Каждый созданный User автоматически создаст для себя соотв. строку в Profiles. Это обеспечивается двумя Signal-методами в конце.
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
import datetime
from decimal import Decimal
#
# The only managed table for Django. It is not created via XML import
# This table should be linked to User as OneToOne Relation
#
class Profile(models.Model):
# ! --- IMPORTANT --- !
user = models.OneToOneField(User, on_delete = models.CASCADE, related_name = 'profile')
id_one_c = models.CharField(
db_index = True,
max_length = 9,
help_text = '1С - Код элемента (Пользователи ОП)'
)
name = models.CharField(
max_length = 50,
help_text = '1С - Имя элемента (Пользователи ОП)'
)
id_owner = models.CharField(
db_index = True,
max_length = 9,
help_text = '1С - Код элемента (Контрагент)'
)
name_owner = models.CharField(
max_length = 100,
help_text = '1С - Имя элемента (Контрагент)'
)
# User model have those
#login = models.CharField(unique = True, max_length = 30)
#password = models.CharField(max_length = 30)
#email = models.CharField(max_length = 50)
id_manager = models.ForeignKey(
'SvcManagers',
models.SET_NULL,
db_index = True,
related_name = 'users',
db_column = 'id_manager', blank = True, null = True,
help_text = '1С - Менеджер, GUID'
)
id_contract = models.CharField(
max_length = 9,
help_text = '1С - Договор, Код'
)
id_organization = models.CharField(
max_length = 9,
help_text = '1С - Организация, Код'
)
name_organization = models.CharField(
max_length = 50,
help_text = '1С - Организация, Имя'
)
id_price = models.ForeignKey(
'SvcPricetypes',
models.SET_NULL,
db_index = True,
related_name = 'users',
db_column = 'id_price', blank = True, null = True,
help_text