Pull to refresh
0

AllcountJS: Делаем систему для места продажи (POS)

Reading time 12 min
Views 8.8K
Продолжаем знакомство с AllcountJS — фреймворком для быстрой разработки приложений на платформе NodeJS. В этой статье мы рассмотрим пример реализации кастомного интерфейса с использованием AngualrJS и jade, а также некоторые возможности конфигурирования, о которых мы ещё не упоминали.

POS (Point Of Sale) — в прямом смысле точка (место) продажи, но обычно этот термин обозначает рабочее место кассира вместе с торговым оборудованием. Такие терминалы находятся почти в каждом месте где нам что-нибудь продают. И сейчас мы создадим простое приложение, которое позволит вести список товаров с остатками и создавать записи о продажах.

POS main UI

Как обычно, на результат можно посмотреть в демо-галерее.

Модель и бизнес-логика


Начнём с описания самого важного — позиций (товаров), которые будем продавать.
Item: {
    fields: {
        name: Fields.text("Name"),
                stock: Fields.integer("Stock").computed('sum(transactions.quantity)'),
                price: Fields.money("Price"),
                transactions: Fields.relation("Transactions", "Transaction", "item")
    },
    referenceName: "name"
}

Самое интересное тут — это поле с остатками stock. Оно должно считаться автоматически по количеству (quantity) из транзакций (transactions). Именно так и написано в нашей конфигурации: целочисленное поле stock вычисляется как сумма transactions по полю quantity. Только со скобками, точками и кавычками. А поле “transactions” — это список всех транзакций, в которых участвует данная позиция.

Если читать по конфигурации: “transactions” это поле типа “relation” связанное с пока отсутствующей сущностью “Transaction” по полю “item”. Свойство “referenceName” определяет какое из полей сущности отображать в наименованиях ссылок ведущих на эту сущность.

Теперь добавим тип Transaction — транзакции описывающие поступление и выбытие товаров.
Transaction: {
    fields: {
        item: Fields.reference("Item", "Item"),
        order: Fields.reference("Order", "Order"),
        orderItem: Fields.reference("Order item", "OrderItem"),
        quantity: Fields.integer("Quantity")
    },
    showInGrid: ['item', 'order', 'quantity']
} 

Как вы видите, поле “item” ссылается на сущность “Item” и поддерживает вышеописанную связь. В остальном ничего особенного кроме свойства “showInGrid” — оно задаёт список полей, которые будут отображаться в гриде.

Далее опишем центральную часть нашей POS:
Order: {
    fields: {
        number: Fields.integer("Order #"),
        date: Fields.date("Date"),
        total: Fields.money("Total").computed('sum(orderItems.finalPrice)'),
        orderItems: Fields.relation("Items", "OrderItem", "order")
    },
    beforeSave: function (Entity, Dates, Crud) {
        if (!Entity.date) {
            Entity.date = Dates.nowDate();
        }
        return Crud.crudFor('OrderCounter').find({}).then(function (last) {
            if (!Entity.number) {
                Entity.number = last[0].number;
                return Crud.crudFor('OrderCounter').updateEntity({id: last[0].id, number: last[0].number + 1});
            }
        })
    },
    beforeDelete: function (Entity, Crud, Q) {
        var crud = Crud.crudFor('OrderItem');
        return crud.find({filtering: {order: Entity.id}}).then(function (items) {
            return Q.all(items.map(function (i) {
                return crud.deleteEntity(i.id)
            }));
        });
    },
    referenceName: "number",
    views: {
        PointOfSale: {
            customView: 'pos'
        }
    }
}

Я называю её центральной потому что в ней мы определили наше главное представление “PointOfSale”, описанное в файле “pos.jade”, но его мы коснёмся позже. Также вы наверняка заметили функции “beforeSave” и “beforeDelete”. Это обработчики, которые срабатывают когда происходят соответствующие события: перед сохранением и перед удалением. Подробнее о них вы можете прочитать в нашей документации в разделе CRUD hooks. Тут эти функции нужны чтобы обновлять счётчик заказов (order) когда заказ выполнен и для удаления позиций заказа вместе с самим заказом.

Computed и relation поля встречаются и в этом куске кода. Вычисляемое поле используется для расчёта итоговой стоимости заказа, а relation поле “orderItems” можно воспринимать как перечень позиций содержащихся в данном заказе. Оно связано с сущностью OrderItem:
OrderItem: {
    fields: {
        order: Fields.reference("Order", "Order"),
        item: Fields.fixedReference("Item", "Item").required(),
        quantity: Fields.integer("Quantity").required(),
        finalPrice: Fields.money("Final price").readOnly().addToTotalRow()
    },
    showInGrid: ['item', 'quantity', 'finalPrice'],
    beforeSave: function (Crud, Entity) {
        return Crud.crudFor('Item').readEntity(Entity.item.id).then(function (item) {
            Entity.finalPrice = Entity.quantity * item.price;
        })
    },
    afterSave: function (Crud, Entity) {
        var crud = Crud.crudForEntityType('Transaction');
        return removeTransaction(Crud, Entity).then(function () {
            return crud.createEntity({
                order: Entity.order,
                orderItem: {id: Entity.id},
                item: Entity.item,
                quantity: Entity.quantity * -1
            })
        })
    },
    beforeDelete: function (Crud, Entity) {
        return removeTransaction(Crud, Entity);
    }
}

Как вы наверно заметили, каждый тип сущности, который участвует в полях со связями имеют свойство “showInGrid”. Оно нужно чтобы показывать в гриде только определённые поля сущности. Обычно мы не хотим показывать пользователю вышестоящую в отношении сущность, поскольку именно через неё он и вышел на это представление. Но, конечно же, это можно изменить.

Обратите внимание на поле final price — с помощью вызова .addToTotalRow() мы пометили его для суммирования в итоговой строке.

Тут также есть несколько CRUD-hook’ов: перед сохранением обновляем итоговую сумму позиции заказа в зависимости от количества, после сохранения удаляем, и заново создаём новую транзакцию, а также удаляем транзакции при удалении позиции заказа. Следуя принципу DRY, мы повторно используем функцию для удаления транзакций:
function removeTransaction(Crud, Entity) {
    var crud = Crud.crudForEntityType('Transaction');
    return crud.find({filtering: {orderItem: Entity.id}}).then(function (transactions) {
        if (transactions.length) {
            return crud.deleteEntity(transactions[0].id);
        }
    });
}

А для последовательной нумерации заказов у нас будет счётчик заказов:
OrderCounter: {
    fields: {
        number: Fields.integer("Counter")
    }
}

Основной POS интерфейс


Вспомните как вы боролись с перегруженными интерфейсами. И как вы были, вероятно, счастливы нажимать всего лишь пару больших цветных кнопок которые выполняют всю работу. Наши возможности по кастомизации позволяют сделать интерфейс для работы простым и понятным.
Выше мы упомянули что представление “PointOfSale” является центральной частью для приложения. В нём происходит основная работа пользователя с приложением — создание заказов. Оно задаётся в файле pos.jade:
pos.jade
extends main
include mixins
block vars
    - var hasToolbar = false
block content
    div(ng-app='allcount', ng-controller='EntityViewController')
        +defaultList()
        .container.screen-container(ng-cloak)
            .row(ng-controller="PosController")
                .col-md-8
                    .items-bar.row.btn-toolbar(lc-list="'Item'", paging="{}")
                        .col-lg-4.col-md-6.col-xs-12(ng-repeat="item in items")
                            button.btn.btn-lg.btn-block.btn-default(ng-click="addItem(item)")
                                p {{item.name}}
                                p {{(item.price / 100) | currency}}
                    .container-fluid
                        h1 Total: {{viewState.editForm.entity().total/100 | currency}}
                    .row.btn-toolbar
                        .col-md-4
                            button.btn.btn-lg.btn-danger.btn-block(ng-click="deleteEntity()", ng-disabled="!viewState.formEntityId") Cancel
                        .col-md-4(ng-hide='viewState.isFormEditing')
                            +startFormEditingButton()(ng-disabled="!viewState.formEntityId").btn-block.btn-lg
                        .col-md-4(ng-show='viewState.isFormEditing')
                            +doneFormEditingButton()(ng-disabled="!viewState.formEntityId").btn-block.btn-lg
                        .col-md-4
                            button.btn.btn-lg.btn-success.btn-block(ng-click="viewState.mode = 'list'; viewState.formEntityId = undefined", ng-disabled="!viewState.formEntityId") Finish
                .col-md-4
                    +defaultEditForm()(ng-show="true")
                        +defaultFormTemplate()
block js
    +entityJs()
    script.
        angular.module('allcount').controller('PosController', ['$scope', 'lcApi', '$q', function ($scope, lcApi, $q) {
            $scope.addItem = function (item) {
                var promise;
                if (!$scope.viewState.formEntityId) {
                    promise = lcApi.createEntity({entityTypeId: 'Order'}, {}).then(function (orderId) {
                        $scope.navigateTo(orderId)
                        return orderId;
                    })
                } else {
                    promise = $q.when($scope.viewState.formEntityId);
                }
                promise.then(function (orderId) {
                    return lcApi.findRange({entityTypeId: 'OrderItem'}, {filtering: {order: orderId}}).then(function (items) {
                        var existingOrderItem = _.find(items, function (i) {
                            return i.item.id === item.id;
                        })
                        return (existingOrderItem ?
                                        lcApi.updateEntity({entityTypeId: 'OrderItem'}, {
                                            id: existingOrderItem.id,
                                            quantity: 1 + existingOrderItem.quantity
                                        }) :
                                        lcApi.createEntity({entityTypeId: 'OrderItem'}, {
                                            order: {id: orderId},
                                            item: item,
                                            quantity: 1
                                        })
                        ).then(function () {
                                    return $scope.editForm.reloadEntity();
                                })
                    })
                })
            }
        }])
    style.
        .items-bar .btn-block {
            margin-bottom: 10px;
        }


Давайте разберёмся с тем, что там происходит внутри.

В начале мы видим эти две строки:
    extends main
    include mixins

“main” и “mixins” — это встроенные шаблоны. Первый — это фундамент разметки, который просто обязателен до тех пор, пока вы не хотите сделать что-то совсем необычное. Второй предоставляет вам самостоятельные jade-сниппеты для интерфейсных элементов, таких как: таблицы, строки таблиц, поля, подвалы, надписи и т.д.

Пара слов о блоках внутри “main”: там есть блоки “vars”, “content” и “js”, названия которых по сути говорят сами за себя:
“vars” располагается в самом верху
“head” — внутри заголовка
“content” это часть главной области страницы
“js” это последнее внутри тела страницы

Флаг “hasToolbar” это переменная которая определяет нужно ли добавлять двойной отступ под навигационной панелью.
Дальше мы опишем наше главное UI приложение:
  div(ng-app='allcount', ng-controller='EntityViewController') 

Обратите внимание, что мы должны использовать “allcount” в качестве имени для приложения. Это нужно для того, чтобы получить доступ до различных возможностей AllacountJS Angular типа контроллеров и директив.

“+defaultList()” добавляет компонент “lc-list” который мы можем использовать для отображения элементов интерфейса списочного типа. Но конкретно этот “список” находится в состоянии формы и не отображается в виде списка, зато даёт нам кнопку “edit”, которая нужна для редактирования текущего заказа.
       .container.screen-container(ng-cloak)
       .row(ng-controller="PosController")

Тут у нас контейнер с POS контроллером, который мы опишем потом. А далее у нас две колонки:
        .col-md-8
           ...
         .col-md-4     
           …

В первой (большой) колонке отображается список доступных позиций:
 .items-bar.row.btn-toolbar(lc-list="'Item'", paging="{}")
             .col-lg-4.col-md-6.col-xs-12(ng-repeat="item in items")
               button.btn.btn-lg.btn-block.btn-default(ng-click="addItem(item)")
                 p {{item.name}}
                 p {{(item.price / 100) | currency}}

Атрибут lc-list="'Item'" является директивой и требуется для получения списка позиций, а атрибут paging={} говорит о том что пейджинг нам тут не нужен.

Внутренний div:
 .col-lg-4.col-md-6.col-xs-12(ng-repeat="item in items")

проходит по позициям с помощью директивы “ng-repeat” и отображает большие кнопки для конкретной позиции.
item in items
Под списком позиций — итоговая сумма в валюте пользователя:
           .container-fluid
             h1 Total: {{viewState.editForm.entity().total/100 | currency}}

И строка с кнопками:
    .row.btn-toolbar
             .col-md-4
               button.btn.btn-lg.btn-danger.btn-block(ng-click="deleteEntity()", ng-disabled="!viewState.formEntityId") Cancel
             .col-md-4(ng-hide='viewState.isFormEditing')
               +startFormEditingButton()(ng-disabled="!viewState.formEntityId").btn-block.btn-lg
             .col-md-4(ng-show='viewState.isFormEditing')  
               +doneFormEditingButton()(ng-disabled="!viewState.formEntityId").btn-block.btn-lg
             .col-md-4  
               button.btn.btn-lg.btn-success.btn-block(ng-click="viewState.mode = 'list'; viewState.formEntityId = undefined", ng-disabled="!viewState.formEntityId") Finish

Total and buttons toolbar
Ещё у нас есть сниппет из шаблона на форме для текущего заказа в правой колонке:
             +defaultEditForm()(ng-show="true")
             +defaultFormTemplate()

Edit and form template
В конце у нас такой блок с кодом:
    block js
      +entityJs()
      script.
        angular.module('allcount').controller('PosController', ['$scope', 'lcApi', '$q', function ($scope, lcApi, $q) {
          $scope.addItem = function (item) {
            var promise;
            if (!$scope.viewState.formEntityId) {
              promise = lcApi.createEntity({entityTypeId: 'Order'}, {}).then(function (orderId) {
                $scope.navigateTo(orderId)
                return orderId;
              })
            } else {
              promise = $q.when($scope.viewState.formEntityId);
            }
            promise.then(function (orderId) {
              return lcApi.findRange({entityTypeId: 'OrderItem'}, {filtering: {order: orderId}}).then(function (items) {
                var existingOrderItem = _.find(items, function (i) {
                  return i.item.id === item.id;
                })
                return (existingOrderItem ? 
                  lcApi.updateEntity({entityTypeId: 'OrderItem'}, {id: existingOrderItem.id, quantity: 1 + existingOrderItem.quantity}) : 
                  lcApi.createEntity({entityTypeId: 'OrderItem'}, {order: {id: orderId}, item: item, quantity: 1})
                  ).then(function () {
                    return $scope.editForm.reloadEntity();
                  })
              })
            })
          }
        }])
      style.
        .items-bar .btn-block {
          margin-bottom: 10px;
        }

В нём мы задаём наш главный контроллер, который вдохнёт жизнь в POS. По большей части он говорит нашему представлению как добавлять новые позиции в заказ. В основном здесь используется ключевой механизм AllcountJS для AngularJS — провайдер “lcApi”. С помощью него тут происходит поиск, создание и изменения заказа и позиций заказа.

Итого


Что же мы сделали: описали модель, бизнес логику и удобный интерфейс.
Теперь сложим всё вместе. Итоговый файл с конфигурацией будет выглядеть так (из не упомянутого в нём только меню и примеры данных):
app.js
A.app({
    appName: "POS and inventory",
    appIcon: "calculator",
    onlyAuthenticated: true,
    menuItems: [
        {
            name: "Transactions",
            entityTypeId: "Transaction",
            icon: "send-o"
        }, {
            name: "Items",
            entityTypeId: "Item",
            icon: "cubes"
        }, {
            name: "Orders",
            entityTypeId: "Order",
            icon: "shopping-cart"
        },
        {
            name: "POS",
            entityTypeId: "PointOfSale",
            icon: "calculator"
        }
    ],
    entities: function (Fields) {
        return {
            Transaction: {
                fields: {
                    item: Fields.reference("Item", "Item"),
                    order: Fields.reference("Order", "Order"),
                    orderItem: Fields.reference("Order item", "OrderItem"),
                    quantity: Fields.integer("Quantity")
                },
                showInGrid: ['item', 'order', 'quantity']
            },
            Item: {
                fields: {
                    name: Fields.text("Name"),
                    stock: Fields.integer("Stock").computed('sum(transactions.quantity)'),
                    price: Fields.money("Price"),
                    transactions: Fields.relation("Transactions", "Transaction", "item")
                },
                referenceName: "name"
            },
            Order: {
                fields: {
                    number: Fields.integer("Order #"),
                    date: Fields.date("Date"),
                    total: Fields.money("Total").computed('sum(orderItems.finalPrice)'),
                    orderItems: Fields.relation("Items", "OrderItem", "order")
                },
                beforeSave: function (Entity, Dates, Crud) {
                    if (!Entity.date) {
                        Entity.date = Dates.nowDate();
                    }
                    return Crud.crudFor('OrderCounter').find({}).then(function (last) {
                        if (!Entity.number) {
                            Entity.number = last[0].number;
                            return Crud.crudFor('OrderCounter').updateEntity({
                                id: last[0].id,
                                number: last[0].number + 1
                            });
                        }
                    })
                },
                beforeDelete: function (Entity, Crud, Q) {
                    var crud = Crud.crudFor('OrderItem');
                    return crud.find({filtering: {order: Entity.id}}).then(function (items) {
                        return Q.all(items.map(function (i) {
                            return crud.deleteEntity(i.id)
                        }));
                    });
                },
                referenceName: "number",
                views: {
                    PointOfSale: {
                        customView: 'pos'
                    }
                }
            },
            OrderItem: {
                fields: {
                    order: Fields.reference("Order", "Order"),
                    item: Fields.fixedReference("Item", "Item").required(),
                    quantity: Fields.integer("Quantity").required(),
                    finalPrice: Fields.money("Final price").readOnly().addToTotalRow()
                },
                showInGrid: ['item', 'quantity', 'finalPrice'],
                beforeSave: function (Crud, Entity) {
                    return Crud.crudFor('Item').readEntity(Entity.item.id).then(function (item) {
                        Entity.finalPrice = Entity.quantity * item.price;
                    })
                },
                afterSave: function (Crud, Entity) {
                    var crud = Crud.crudForEntityType('Transaction');
                    return removeTransaction(Crud, Entity).then(function () {
                        return crud.createEntity({
                            order: Entity.order,
                            orderItem: {id: Entity.id},
                            item: Entity.item,
                            quantity: Entity.quantity * -1
                        })
                    })
                },
                beforeDelete: function (Crud, Entity) {
                    return removeTransaction(Crud, Entity);
                }
            },
            OrderCounter: {
                fields: {
                    number: Fields.integer("Counter")
                }
            },
        }
    },
    migrations: function (Migrations) {
        return [
            {
                name: "demo-records-1",
                operation: Migrations.insert("Item", [
                    {id: "1", name: "Snickers", price: 299},
                    {id: "2", name: "Coffee", price: 199},
                    {id: "3", name: "Tea", price: 99}
                ])
            },
            {
                name: "demo-records-2",
                operation: Migrations.insert("Transaction", [
                    {id: "1", item: {id: "1"}, quantity: "50"},
                    {id: "2", item: {id: "2"}, quantity: "100"},
                    {id: "3", item: {id: "3"}, quantity: "200"}
                ])
            },
            {
                name: "order-counter",
                operation: Migrations.insert("OrderCounter", [
                    {id: "2", number: 1}
                ])
            }
        ]
    }
});

function removeTransaction(Crud, Entity) {
    var crud = Crud.crudForEntityType('Transaction');
    return crud.find({filtering: {orderItem: Entity.id}}).then(function (transactions) {
        if (transactions.length) {
            return crud.deleteEntity(transactions[0].id);
        }
    });
}


Замечу, что если у вас язык браузера английский, то цены будут отображаться в долларах, а если русский — то в рублях.

Запуск


Если вы уже знакомы с AllcountJS, то вам не составит труда запустить этот код. Для остальных вкратце расскажу, как это сделать:
  • Создайте новую директорию
  • Создайте внутри неё файл app.js и встатье в него код расположенный выше
  • Положите файл pos.jade рядом
  • Устновите allcountjs-cli (как это делается описано в предыдущей статье или на оффициальном сайте)
  • Запустить сервер allcountjs -c app.js (при этом сервис MongoDB должен быть уже запущен)
  • Приложение будет доступно на http://localhost:9080


А настоящий POS?


Итак, мы создали простое приложение для POS-терминала. Как его можно сделать рабочим? Нужно написать аналог представления PointOfSale для мобильного устройства и упаковать веб-приложение в мобильное с помощью ionic, а потом сделать интеграцию c одним из сервисов мобильного эквайринга (например 2can или iBox), и вот у вас уже реальный рабочий POS-терминал.

Надеюсь, вам было интересно. Будем благодарны за любой фидбэк. Тут или в gitter и gitter ru.
Tags:
Hubs:
+9
Comments 2
Comments Comments 2

Articles

Information

Website
allcountjs.com
Registered
Employees
2–10 employees
Location
Россия