Как я писал собственную ERP систему, ver. 2.0
Прошлая моя статья вызвала много вопросов и замечаний у читателей. Действительно получилась достаточно скомкана, не описывала всего. Хочу попробовать ещё раз изложить этот кейс, описав его отталкиваясь от вопросов, полученных в комментариях.
Итак, дано: филиал крупной сети ресторанного бизнеса, специализирующейся на доставке заказов на дом. Несколько кухонь, разбросанных по городу. На каждой кухне есть приёмщик заказов, повара, упаковка готового продукта и доставщики. Клиенты сбрасывают свои заказы через сайт, мобильное приложение, или звонят приёмщику заказов. В идеале после подтверждения заказа через час этот заказ должен оказаться у клиента. Т.е. суммарное время на приготовление и упаковку — 20 минут, время доставки — 40 минут (тут надо сказать, что дело происходит в городе-милионнике, с соответствующими расстояниями и уровнем пробок). Помимо информации о собственно составе заказа, клиент сообщает способ оплаты (наличные/карта, требуется ли сдача, с какой суммы) и адрес доставки.
Основные проблемы существующей системы можно сформулировать так:
- Отсутствие должного контроля за служащими на каждом этапе:
- кроме поваров ни кто не знает текущую нагрузку на кухню, кроме курьеров — текущую нагрузку на них.
- Не фиксируются опоздания (если курьер опоздал больше, чем на 15 минут — это большой не зачёт всей фирме).
- Сложно спрогнозировать сколько в какой день нужно поваров, курьеров.
- Постоянное забывание курьерами второго пакета, если заказ большой, пересорты на кухне/упаковке, и прочие отсюда вытекающие вещи.
- Ошибки в адресах доставки, из-за которых курьер не всегда понимает куда ехать: не правильное написание улиц, номера домов, которые физически отсутствуют и прочие проблемы из-за отсутствия контроля за корректностью этой информации.
- Отсутствия распределения заказов по кухням. Нагрузка на кухни должна быть примерно одинаковая. То есть каждый заказ должен распределяться по точкам исходя из загруженности и пробок. Это невозможно реализовать без реализации контроля из п.1.
Конечно, должна при этом быть полная поддержка кассы (приходники/расходники сдачи, оплаты налом, всякие технические рейсы например для закупки еды), смены — за каждый промежуток времени работы точки должен быть ответственный, который ведёт кассу, заказы, выполняет остальные важные действия. И прочие мелочи, которые здесь наверно перечислять не стоит. ТЗ большое.
Многие спросят:, а как же логистика склада? Как же учёт испорченного товара, логика закупок и пр.? Это решили реализовывать на следующем этапе. Так же как и выгрузку расходников/приходников в 1с, проводку зарплат через неё же и так далее. То есть сначала реализация оперативного контроля, а потом учёта.
Почему нельзя было взять готовую реализацию и интегрировать в этот бизнес? На рынке таковой нет. Заказчик перед тем, как начинать со мной сотрудничать готов был заплатить немалую сумму денег за готовую систему доделанную под его нужды. Но такой не нашлось.
Так же особое пожелание заказчика было, что бы всё работало шустро. Его нынешняя система, облачная, еле ворочается в браузере на i3. А когда, при пиковых нагрузках, в среднем приходит 1 заказ в минуту, наблюдать несколько секундные лаги интерфейса — это реальная потеря клиентов, и, соответственно денег. Но система тоже должна была быть с единым сервером.
Итак, я взялся за проект.
Средством разработки был выбран Rad Studio. Студия по некоторым причинам не очень популярна, но в данном случае мне, во-первых, хотелось получить единый код на всех устройствах, так как я один и не могу себе позволить несколько раз прописать одну и ту же структуру данных в нескольких средах разработки, а потом при любом изменении править в 10 местах. А во-вторых, я на всех платформах получаю компилированное приложение, которое теоретически всегда должно работать быстро. Но прежде всего меня волновала скорость на рабочем месте оператора.
Первое, что понадобилось — это интеграция с существующей системой. Заказы из неё должны попадать в новую без какой-либо задержки. Небольшое использование сниффера показало, что их программа раз в минуту опрашивает сервер, и, если есть новый заказ, то печатает pdf. Через полдня была готова моя программа, которая полностью копировала протокол оригинальной (до байта, спасибо, что исходники компонент дельфи доступны и там можно легко поправить заголовок http не заморачиваясь с raw socket), но помимо печати на принтере парсила эти pdf и вычитывала оттуда заказ, загружая его в базу.
Есть такая чудесная программа pdftotext. Запускаем с параметрами –enc UTF-8 –raw
И у нас .txt файл, который можно без проблем распарсить, вынув всю нужную информацию.
Но этот метод носил некоторые ограничения. Во-первых, выгружалась не вся нужная информация, а пару полей приходилось перезаполнять оператору (например, сдача), а во-вторых, это была лишь синхронизация по заказам. Например, рейсы доставки приходилось формировать и там и там. Поэтому был написан прокси, который анализировал всю проходящую через него информацию — там весь протокол на не шифрованных JSON и эта проблема решилась.
Вторая проблема — это база адресов. Понятно, что надо скачать ФИАС, загрузить его в базу. Но во-первых, вся база не нужна, не нужны квартиры, устаревшие записи и т.д., да и по факту интересен не сам адрес, а координата, куда везти. Я решил выгружать лишь населённые пункты, улицы и дома. И в таблицу домов для каждого добавил поля записи координат. Но так как, мягко говоря, не все дома есть в ФИАС, пришлось добавить возможность ручного ввода дома (и улицы естественно на всякий случай), но в таком формате, что бы при последующих обновлениях базы можно было бы эту запись сопоставить, и привязать соответствующий GUID.
Для геокодирования я подключил яндекс. У нас в городе он сейчас лучший. Итак, адрес из PDF сначала разбираем на улицу, дом, этаж, квартиру и так далее, затем смотрим в базе, есть ли этот дом, смотрим координаты. Если координат нет, геокодируем, проверяя на ~30 км от центра города, так как некоторые точки оказываются на дальнем востоке или в Сибири, причём там вообще нет населённого пункта. С этим глюком яндекса не разбирался.
Если нет такого адреса, то смотрим на предыдущие выгрузки. Вдруг такое сочетание «улица-дом» уже встречалось, тогда берём координаты оттуда. То есть если улица, например, написана в оригинальной базе с ошибкой, то достаточно её 1 раз исправить, а не в каждом новом заказе.
Отображались адреса с помощью яндекс-карт через api 1.1. Оно не требует ID разработчика для всех нужд, что мне были нужны. Правда было серьёзное ограничение у TWebBrowser (компонент, который отображает любую web страницу, в данном случае карту) в нём можно запустить любой скрипт из основной программы в нужный момент, но принять данные не возможно ни в какой форме. Однако, есть интересный хак — когда надо принять данные, в скрипте надо выполнить:
function ApplyPoint(){
var text = 'http://ya.ru/1.htm?&P&&';
text = text + '[' + placemark.getGeoPoint().getY();
text = text + ',' + placemark.getGeoPoint().getX()+']';
window.location = text;
};
procedure CMapElement.ApplyEditPoint;
begin
if not assigned(control) then
exit;
{$IFDEF CLIENT_BUILD}
(control as TTMSFMXWebBrowser).ExecuteJavascript('ApplyPoint();');
{$ENDIF}
end;
А затем:
procedure CMapElement.WebBrowser1DidFinishLoad(ASender: TObject);
var
s,s2 : string;
p : integer;
co : ArrMapCoord;
begin
if not assigned(control) then
exit;
{$IFDEF CLIENT_BUILD}
{$IFDEF MSWINDOWS}
s :=(control as TTMSFMXWebBrowser).FWebBrowser.GetRealURL;
{$ELSE}
assert(false);
{$endif}
if (pos('ya.ru', s) > 0) and not AlreadyReload then
begin
p := Pos('?&',s);
delete(s,1,p+1);
s2 := s;
p := Pos('&&',s);
delete(s,1,p+1);
delete(s2,p,9999);
if s2 = 'P' then //Конец редактирования точки (таскания по карте)
begin
WeEditPoint := false;
self.DecodePolyString(s,co);
if Length(co) = 1 then
begin
AddressPoint.la := co[0].la;
AddressPoint.lo := co[0].lo;
end;
AlreadyReload := true;
end
else
if s2 = 'R' then //Вычесленное расстояние маршрута
begin
WeCalcRoute := false;
if Assigned(g_CurrentScriptRunner) then
begin
g_CurrentScriptRunner.OnExternalAction('MapRouteDist',s);
end;
end
else
if StrToInteger(s2,p) then
begin
Polys[p-1].Poly := s;
end;
AlreadyReload := true;
LoadMap;
end;
MapLoaded := true;
{$ENDIF}
end;
То есть начинаем загружать страницу с параметрами, которые кодируются в url, а на этапе call-back фильтруем этот момент. Вот такой хак делает использование браузера в дельфи очень гибким: без проблем переместить маркер на карте, узнать длину маршрута, отредактировать геополигоны доставки и так далее.
Ну и это всё надо как-то увязать. Я хотел сделать не просто ERP систему под конкретного заказчика. Я хотел, что бы она была достаточно универсальной, настраивалась из конфигурационного файла, а вся логика и интерфейсы не были завязаны на сам исполняемый файл. Сейчас вся конфигурация содержится в одном xml файле. Этот файл со всей структурой базы, со всеми интерфейсами и скриптами считывается сервером, а затем выдаются нужные части клиентам.
Так же я написал редактор интерфейсов и скриптов-обработчиков событий. Отдельные скрипты исполняются при изменении любого поля определённых таблиц, проверяя возможность этого изменения, а так же производят сопутствующие вещи (генерацию приходников например). Все компоненты свои, так как имеют совсем другие свойства, чем стандартные. Например, в колонках таблиц сразу указывается, что выводить, а так же выводить текст или пиктограммку, например, оплаты по карте/наличные.
Скриптовая система была написана с помощью Pascalc. Библиотеке более 15 лет, но она очень удачна: легковесна, легко расширяема и модифицируема под любые нужны. Поэтому включаем для следования стандарту паскаля на мобильных устройствах директиву компилятора{$ZEROBASEDSTRINGS OFF}
Вносим несколько правок типа Destroy→Free, убираем использование win api и вот скрипты без проблем работают под любой ОС, а библиотека обработает подобный скрипт:
CurOrderState := GetNewValueF('Orders','OrderState');
LastOrderState := GetOldValueF('Orders','OrderState');
IsCashbackOnCurier := GetNewValue('Orders','CashbackOnDeliverer') = 'True';
PayType := GetNewValueF('Orders','PayType');
OldPayType := GetOldValueF('Orders','PayType');
OrderTurn := GetNewValueF('Orders','Turn');
CurTurn := GetConstantValue('CurrentTurn');
OrderNo := GetNewValue('Orders','OrderNo');
OrderPrice := GetNewValueF('Orders','Price');
PaySum := GetNewValueF('Orders','PaySum');
LastPaySum := GetOldValueF('Orders','PaySum');
CurTurn := GetConstantValue('CurrentTurn');
CurCashbox := GetConstantValue('CurrentCashbox');
if (OrderTurn > 0) and (OrderTurn <> CurTurn) then
begin
ShowMessage('Нельзя производить действия с заказом другой смены. Заказ №'+IntToStr(OrderNo));
exit;
end;
if (LastOrderState = 9) then
begin
ShowMessage('Нельзя производить действия с завершенным заказом');
exit;
end;
if (LastOrderState = 8) then
begin
ShowMessage('Нельзя производить действия с отменённым заказом');
exit;
end;
if (LastOrderState <> 8) and (CurOrderState = 8) then
begin
TransactionCanAccept;
exit;
end;
if (CurOrderState > 2) and (CurTurn < 1) then
begin
ShowMessage('Смена не открыта. Действие не может быть совершено');
exit;
end;
if (CurOrderState > 4) and (CurCashbox < 1) and (CurOrderState <> 8) then
begin
ShowMessage('Касса не открыта. Действие не может быть совершено');
exit;
end;
//Запрет изменения сумм, так как есть кассовые доки.
if IsCashbackOnCurier then
begin
if PaySum <> LastPaySum then
begin
ShowMessage('Уже нельзя менять суммы оплаты');
exit;
end;
Price := GetNewValueF('Orders','Price');
LastPrice := GetOldValueF('Orders','Price');
if Price <> LastPrice then
begin
ShowMessage('Уже нельзя менять суммы оплаты');
exit;
end;
end;
// Формируем сдачу
if (CurOrderState >= 6) and (CurOrderState < 8) and (PayType = 1) and (not IsCashbackOnCurier) then
begin
if (PaySum - OrderPrice) > 0 then
begin
ss := 'Выдача сдачи для заказа №'+IntToStr(OrderNo);
CreateCashboxDoc(ss,1,OrderPrice - PaySum);
IsCashbackOnCurier := True;
SetValue('Orders','CashbackOnDeliverer',True);
end;
end;
//Отмена сдачи для курьера по статусу
if (CurOrderState < 6) and IsCashbackOnCurier then
begin
if (PaySum - OrderPrice) > 0 then
begin
CreateCashboxDoc('Отмена выдачи сдачи для заказа №'+IntToStr(OrderNo),1,PaySum - OrderPrice);
IsCashbackOnCurier := False;
SetValue('Orders','CashbackOnDeliverer',False);
end;
end;
// Формируем возврат сдачи
if (CurOrderState >= 8) and IsCashbackOnCurier then
begin
if (PaySum - OrderPrice) > 0 then
begin
ss := 'Возврат сдачи для заказа №'+IntToStr(OrderNo);
CreateCashboxDoc(ss,1,PaySum - OrderPrice);
IsCashbackOnCurier := False;
SetValue('Orders','CashbackOnDeliverer',False);
end;
end;
// Формируем приходник на сумму заказа
if (CurOrderState = 9) and (LastOrderState <> 9) and (PayType = 1) then
begin
ss := 'Розничная выручка с заказа №'+IntToStr(OrderNo);
CreateCashboxDoc(ss,2,OrderPrice);
end;
// Назначаем смену и филиал при переходе из статуса новый дальше
if (LastOrderState < 3) and (CurOrderState >= 3) then
begin
if CurTurn <= 0 then
begin
ShowMessage('Смена не открыта. Движение заказа не возможно.');
exit;
end;
SetValue('Orders','Turn',CurTurn);
CurFilial := GetConstantValue('FilialID');
SetValue('Orders','Filial',CurFilial);
end;
if (CurOrderState <= 2) and (LastOrderState > 2) then
begin
SetValue('Orders','Turn',0);
end;
TransactionCanAccept;
То есть произойдёт проверка смены, кассы, если надо выпишутся приходники/расходники и т.д. Так же если поменять в этом алгоритме что-то, то не надо повторно пересобирать программы и обновлять всё везде. Достаточно клиенту перелогиниться. К сожалению, имела место некоторая спешка в случае прописывания API для скриптов. Поэтому например константы, заданные в таблицах в базы здесь имеют просто числовые значения. Но так как это можно в любой момент переписать, и код совсем не сложный и не большой, я решил пока оставить так.
Следующий важный кейс — это программа оператора. Все данные находятся в облаке, необходимо их без задержек получать, генерировать изменения и так далее. Я решил уйти по максимуму от связи с сервером в реальном времени. Зачем по 100 раз тащить одну и ту же запись, если её можно закешировать в ОЗУ, и обращаться в любой момент?
Я выгружаю в память все актуальные заказы, клиентов, адреса и пр. То есть не таблицу целиком, что будет глупо, и память быстро закончится, а лишь ту часть таблицы, что необходима. Как кэш в процессоре. Если данные в нём есть, обратимся сразу, если нет, то придётся подождать, пока сервер отдаст их. Так же был прописан механизм «подписки на изменения» таблиц: если какая-то запись какой-то таблицы изменилась, всем подписчикам придёт об этом уведомление с новыми данными. В результате комфорт работы с приложением в плане латентности стал максимальным. Реакция на изменения фильтра, переупорядочивания, открытия заказа и пр — всё моментально.
Затем было создано отдельное курьерское приложение. Оно отсылает в фоне на сервер трек. То есть оператор может в реальном времени наблюдать за перемещением курьеров. Так же для подтверждения доставки заказа курьер должен находиться в некотором заданном радиусе от точки доставки. Если в этот момент отсутствует интернет, программа передаст информацию о том, что заказ доставлен, как только интернет появится. Естественно, можно из программы запустить яндекс-навигатор, который автоматически проложит путь до адреса; можно посмотреть все адреса на карте или позвонить клиенту, нажав 2 кнопки, и так далее.
Всё это было опробовано и заработало на одном филиале «в боевых» условиях. Не было ни каких нареканий. Точнее сначала была пара проблем с курьерским приложением — оно работало не очень отзывчиво, если интернет связь была плохая. Я дополнительно закешировал данные, провёл ряд оптимизаций, и всё стало работать без каких-либо лагов.
Но дальнейшего развития проект не получил. Заказчик указал следующие к тому причины:
- Запуск «на холодную» курьерского приложения 4 секунды. Слишком долго.
- Надо менять язык программирования и полностью переписывать всё из-за п.1.
На этом мы расстались.
Такая вот история. У меня теперь есть не до конца доделанная система для ресторанного бизнеса с доставкой и дыра в бюджете. Вот теперь думаю, что с ней делать. Выбросить — жалко. Но рынок очень узкий и достаточно закрытый.