📜 ⬆️ ⬇️

AllcountJS: Making a system for point of sale (POS)

We continue our acquaintance with AllcountJS - a framework for rapid application development on the NodeJS platform. In this article, we will look at an example of implementing a custom interface using AngualrJS and jade, as well as some configuration options that we have not mentioned yet.

POS (Point Of Sale) - in the literal sense of the point (place) of sale, but usually this term refers to the workplace of the cashier, along with commercial equipment. Such terminals are located in almost every place where we sell something. And now we will create a simple application that will allow you to maintain a list of products with balances and create sales records.

POS main UI
')
As usual, the result can be viewed in the demo gallery .

Model and business logic


Let's start with a description of the most important - the positions (goods) that we will sell.
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" } 

The most interesting thing here is a field with the remnants of stock. It should be calculated automatically by quantity (quantity) from transactions (transactions). This is exactly what is written in our configuration: the integer stock field is calculated as the sum of transactions for the quantity field. Only with brackets, dots and quotes. And the “transactions” field is a list of all transactions in which this position participates.

If you read the configuration: “transactions” is a field of “ relation ” type associated with the currently missing entity “Transaction” in the field “item”. The “referenceName” property determines which of the entity fields to display in the names of links leading to this entity.

Now we add the Transaction type - transactions describing the arrival and departure of goods.
 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'] } 

As you can see, the “item” field refers to the “Item” entity and supports the connection described above. Otherwise nothing special except the “showInGrid” property - it sets the list of fields that will be displayed in the grid.

Next, we describe the central part of our 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' } } } 

I call it central because in it we defined our main view “PointOfSale”, described in the file “pos.jade”, but we'll touch on it later. Also, you probably noticed the “beforeSave” and “beforeDelete” functions. These are handlers that are triggered when the corresponding events occur: before saving and before deleting. You can read more about them in our documentation in the CRUD hooks section. Here these functions are needed to update the order counter (order) when the order is completed and to delete order items along with the order itself.

Computed and relation fields are also found in this piece of code. The calculated field is used to calculate the total cost of the order, and the relation field “orderItems” can be taken as a list of the positions contained in this order. It is associated with the OrderItem entity:
 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); } } 

As you may have noticed, every type of entity that participates in fields with links has a “showInGrid” property. It is necessary to show only certain fields of the entity in the grid. Usually, we don’t want to show the user a higher entity in relation to it, because it is through her that he came to this view. But, of course, this can be changed.

Pay attention to the final price field - using the .addToTotalRow () call, we marked it for summarizing in the summary line.

There are also several CRUD hooks: before saving, we update the total amount of the order item depending on the quantity, after saving, we delete, and re-create a new transaction, and also delete transactions when the order item is deleted. Following the principle of DRY , we reuse the function to delete transactions:
 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); } }); } 

And for the sequential numbering of orders, we will have an order counter:
 OrderCounter: { fields: { number: Fields.integer("Counter") } } 

Main POS Interface


Remember how you struggled with overloaded interfaces. And as you were probably happy to press just a couple of large color buttons that do all the work. Our customization capabilities make the interface for work simple and straightforward.
Above, we mentioned that the “PointOfSale” view is central to the application. It is the main user experience with the application - the creation of orders. It is set in the pos.jade file:
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; } 


Let's deal with what's going on inside there.

At the beginning we see these two lines:
  extends main include mixins 

“Main” and “mixins” are built-in templates. The first is the foundation of markup, which is simply indispensable as long as you do not want to do something completely unusual. The second provides you with separate jade snippets for interface elements, such as: tables, table rows, fields, basements, labels, etc.

A couple of words about the blocks inside the “main”: there are blocks “vars”, “content” and “js”, the names of which, in fact, speak for themselves:
“Vars” is located at the top
“Head” - inside the title
“Content” is part of the main area of ​​the page.
“Js” is the last one inside the body of the page.

The “hasToolbar” flag is a variable that determines whether to add a double indent under the navigation bar.
Next we describe our main UI application:
  div(ng-app='allcount', ng-controller='EntityViewController') 

Note that we must use “allcount” as the name for the application. This is necessary in order to gain access to the various possibilities of AllacountJS Angular type controllers and directives.

“+ DefaultList ()” adds an “lc-list” component that we can use to display list-type interface elements. But specifically, this “list” is in the form state and is not displayed as a list, but it gives us the “edit” button, which is needed to edit the current order.
  .container.screen-container(ng-cloak) .row(ng-controller="PosController") 

Here we have a container with a POS controller, which we describe later. And then we have two columns:
  .col-md-8 ... .col-md-4 … 

The first (large) column displays a list of available positions:
  .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}} 

The attribute lc-list = "'Item'" is a directive and is required to get a list of positions, and the attribute paging = {} says that we do not need paging here.

Internal div:
  .col-lg-4.col-md-6.col-xs-12(ng-repeat="item in items") 

traverses positions using the “ng-repeat” directive and displays large buttons for a specific position.
item in items
Under the list of positions - the total amount in the user currency:
  .container-fluid h1 Total: {{viewState.editForm.entity().total/100 | currency}} 

And a line with buttons:
  .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
We also have a snippet from the template on the form for the current order in the right column:
  +defaultEditForm()(ng-show="true") +defaultFormTemplate() 

Edit and form template
At the end we have such a block with the code:
  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; } 

In it, we set our main controller, which will breathe life into the POS. For the most part, he tells our submission how to add new items to an order. Basically, the key mechanism used here is AllcountJS for AngularJS - provider “lcApi”. With the help of it there is a search, create and change the order and order items.

Total


What we have done: described the model, business logic and user-friendly interface.
Now let's put it all together. The final configuration file will look like this (from the menu and examples of data not mentioned in it only):
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); } }); } 


I note that if your browser language is English, then prices will be displayed in dollars, and if Russian, then in rubles.

Launch


If you are already familiar with AllcountJS, then you will not be difficult to run this code. For the rest, briefly tell you how to do it:


What about real POS?


So, we have created a simple application for the POS-terminal. How can it be made working? You need to write an analogue of the PointOfSale representation for a mobile device and package the web application in a mobile with ionic, and then do the integration with one of the mobile acquiring services (for example, 2can or iBox), and you already have a real working POS terminal.

I hope you were interested. We will be grateful for any feedback. Here or in gitter and gitter ru .

Source: https://habr.com/ru/post/272031/


All Articles