[IMP] point_of_sale: Connect with Employee on PoS

=========
PURPOSE
=========

{Any employee can work on a PoS session, even if he has no user. This decreases the cost of PoS App.}

We are not competitive enough on the PoS Market because customers have to pay one user per cashier while they expect to pay one user per cash station.

===============
SPECIFICATIONS
===============

pos.config
- Add a checkbox : "Login with Employees"
- If set to TRUE, an "Allowed Employees" many2many appear
- If employees many2many is empty, all employees can log on this PoS
- If set to FALSE, can only log in with the currently logged user

hr.employee
- The barcode and pin fields were be moved to the hr.employee
- Set a barcode (not pin) by default at the creation of any employee
- No Employees module with PoS > dependency to hr

Open the PoS if "Log in with Employees" = FALSE
- Same behaviour than now, when I open the PoS, I'm logged in with the current user and can start working at once. The only difference is that it is not possible to switch users anymore.
- The button to close the session is visible

Open he PoS if "Log in with Employees" = TRUE
- When I open the PoS, the first screen I see is the login screen.
(a) I choose my employee and enter my pin code (if there is pin code on my employee, otherwise not required) (I can only see employees that have access to that PoS)
(b) I scan my barcode
(c) I scan my rfid card (future development)
- When I click on the employee name, I can log in with another employee
- If the employee is linked to a user that has the pos user/manager access right, I can see the button the close the session. Otherwise I cannot close the session (and thus cannot access backend).
- Add field employee on all records created by PoS (and replace the field user by that one) (on pos.orders)

Lock Screen
- Improve the PoS Interface
- Add a new feature to allow to "lock" the session. When a session has been locked, the user/employee has to re-log-in to access the PoS
- When I try to re-log-in, by default suggest the last employee logged in in the login screen

Migration
Plan a migration strategy > barcode and pin is now on the employee and not on the user anymore

closes odoo/odoo#28567
This commit is contained in:
pha-odoo
2018-12-07 14:31:56 +00:00
parent 9a95781439
commit f14092cbfa
41 changed files with 751 additions and 327 deletions
+16
View File
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import choice
from string import digits
import base64
import logging
@@ -198,6 +200,16 @@ class Employee(models.Model):
# misc
notes = fields.Text('Notes')
color = fields.Integer('Color Index', default=0)
barcode = fields.Char(string="Badge ID", help="ID used for employee identification.", copy=False)
pin = fields.Char(string="PIN", help="PIN used to Check In/Out in Kiosk Mode (if enabled in Configuration).", copy=False)
_sql_constraints = [('barcode_uniq', 'unique (barcode)', "The Badge ID must be unique, this one is already assigned to another employee.")]
@api.constrains('pin')
def _verify_pin(self):
for employee in self:
if employee.pin and not employee.pin.isdigit():
raise ValidationError(_("The PIN must be a sequence of digits."))
@api.onchange('job_id')
def _onchange_job_id(self):
@@ -273,6 +285,10 @@ class Employee(models.Model):
resources = self.mapped('resource_id')
super(Employee, self).unlink()
return resources.unlink()
@api.multi
def generate_random_barcode(self):
for i in self: i.barcode = "".join(choice(digits) for i in range(8))
@api.depends('address_home_id.parent_id')
def _compute_is_address_home_a_company(self):
+5
View File
@@ -139,6 +139,11 @@
<field name="company_id" groups="base.group_multi_company"/>
<field name="user_id" string="Related User"/>
</group>
<group string="Login Information" name="identification_group">
<field name="pin"/>
<field name="barcode"/>
<button string="Generate Badge ID" class="btn btn-primary" type="object" name="generate_random_barcode" attrs="{'invisible': [('barcode', '!=', False)]}"/>
</group>
</group>
</page>
</notebook>
@@ -1,9 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import choice
from string import digits
from odoo import models, fields, api, exceptions, _, SUPERUSER_ID
@@ -11,26 +7,12 @@ class HrEmployee(models.Model):
_inherit = "hr.employee"
_description = "Employee"
def _default_random_pin(self):
return ("".join(choice(digits) for i in range(4)))
def _default_random_barcode(self):
barcode = None
while not barcode or self.env['hr.employee'].search([('barcode', '=', barcode)]):
barcode = "".join(choice(digits) for i in range(8))
return barcode
barcode = fields.Char(string="Badge ID", help="ID used for employee identification.", default=_default_random_barcode, copy=False)
pin = fields.Char(string="PIN", default=_default_random_pin, help="PIN used to Check In/Out in Kiosk Mode (if enabled in Configuration).", copy=False)
attendance_ids = fields.One2many('hr.attendance', 'employee_id', help='list of attendances for the employee')
last_attendance_id = fields.Many2one('hr.attendance', compute='_compute_last_attendance_id', store=True)
attendance_state = fields.Selection(string="Attendance Status", compute='_compute_attendance_state', selection=[('checked_out', "Checked out"), ('checked_in', "Checked in")])
manual_attendance = fields.Boolean(string='Manual Attendance', compute='_compute_manual_attendance', inverse='_inverse_manual_attendance',
help='The employee will have access to the "My Attendances" menu to check in and out from his session')
_sql_constraints = [('barcode_uniq', 'unique (barcode)', "The Badge ID must be unique, this one is already assigned to another employee.")]
@api.multi
def _compute_manual_attendance(self):
for employee in self:
@@ -59,12 +41,6 @@ class HrEmployee(models.Model):
att = employee.last_attendance_id.sudo()
employee.attendance_state = att and not att.check_out and 'checked_in' or 'checked_out'
@api.constrains('pin')
def _verify_pin(self):
for employee in self:
if employee.pin and not employee.pin.isdigit():
raise exceptions.ValidationError(_("The PIN must be a sequence of digits."))
@api.model
def attendance_scan(self, barcode):
""" Receive a barcode scanned from the Kiosk Mode and change the attendances of corresponding employee.
@@ -126,26 +102,3 @@ class HrEmployee(models.Model):
raise exceptions.UserError(_('Cannot perform check out on %(empl_name)s, could not find corresponding check in. '
'Your attendances have probably been modified manually by human resources.') % {'empl_name': self.name, })
return attendance
@api.model_cr_context
def _init_column(self, column_name):
""" Initialize the value of the given column for existing rows.
Overridden here because we need to have different default values
for barcode and pin for every employee.
"""
if column_name not in ["barcode", "pin"]:
super(HrEmployee, self)._init_column(column_name)
else:
default_compute = self._fields[column_name].default
query = 'SELECT id FROM "%s" WHERE "%s" is NULL' % (
self._table, column_name)
self.env.cr.execute(query)
employee_ids = self.env.cr.fetchall()
for employee_id in employee_ids:
default_value = default_compute(self)
query = 'UPDATE "%s" SET "%s"=%%s WHERE id = %s' % (
self._table, column_name, employee_id[0])
self.env.cr.execute(query, (default_value,))
@@ -19,12 +19,8 @@
</div>
</button>
</xpath>
<xpath expr="//group[@name='active_group']" position="after">
<group string='Attendance' groups="hr_attendance.group_hr_attendance_user">
<field name="barcode"/>
<field name="pin" groups="hr_attendance.group_hr_attendance_use_pin"/>
<field name="manual_attendance" attrs="{'invisible': [('user_id', '=', False)]}"/>
</group>
<xpath expr="//group[@name='identification_group']" position="inside">
<field name="manual_attendance" attrs="{'invisible': [('user_id', '=', False)]}"/>
</xpath>
</field>
</record>
-1
View File
@@ -37,7 +37,6 @@
'views/res_config_settings_views.xml',
'views/digest_views.xml',
'views/res_partner_view.xml',
'views/res_users_views.xml',
'views/report_statement.xml',
'views/report_userlabel.xml',
'views/report_saledetails.xml',
@@ -10,9 +10,6 @@
<record id='base.res_partner_12' model='res.partner'> <field name='barcode'>0420800000008</field> </record>
<record id='base.res_partner_18' model='res.partner'> <field name='barcode'>0421800000005</field> </record>
<record id="base.user_root" model="res.users">
<field name="barcode">0410100000006</field>
</record>
<record id="base.user_root" model="res.users">
<field name="barcode">0410100000006</field>
<field name="groups_id" eval="[(4,ref('group_pos_manager'))]"/>
-1
View File
@@ -12,5 +12,4 @@ from . import pos_order
from . import pos_session
from . import product
from . import res_partner
from . import res_users
from . import res_config_settings
@@ -174,6 +174,7 @@ class PosConfig(models.Model):
module_pos_reprint = fields.Boolean(string="Reprint Receipt")
is_posbox = fields.Boolean("PosBox")
is_header_or_footer = fields.Boolean("Header & Footer")
module_pos_hr = fields.Boolean(help="Show employee login screen")
def _compute_is_installed_account_accountant(self):
account_accountant = self.env['ir.module.module'].sudo().search([('name', '=', 'account_accountant'), ('state', '=', 'installed')])
+1 -1
View File
@@ -524,7 +524,7 @@ class PosOrder(models.Model):
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, default=lambda self: self.env.user.company_id)
date_order = fields.Datetime(string='Order Date', readonly=True, index=True, default=fields.Datetime.now)
user_id = fields.Many2one(
comodel_name='res.users', string='Salesperson',
comodel_name='res.users', string='User',
help="Person who uses the cash register. It can be a reliever, a student or an interim employee.",
default=lambda self: self.env.uid,
states={'done': [('readonly', True)], 'invoiced': [('readonly', True)]},
-15
View File
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ResUsers(models.Model):
_inherit = 'res.users'
pos_security_pin = fields.Char(string='Security PIN', size=32, help='A Security PIN used to protect sensible functionality in the Point of Sale')
@api.constrains('pos_security_pin')
def _check_pin(self):
if self.pos_security_pin and not self.pos_security_pin.isdigit():
raise UserError(_("Security PIN can only contain digits"))
@@ -19,7 +19,7 @@ class PosOrderReport(models.Model):
[('draft', 'New'), ('paid', 'Paid'), ('done', 'Posted'),
('invoiced', 'Invoiced'), ('cancel', 'Cancelled')],
string='Status')
user_id = fields.Many2one('res.users', string='Salesperson', readonly=True)
user_id = fields.Many2one('res.users', string='User', readonly=True)
price_total = fields.Float(string='Total Price', readonly=True)
price_sub_total = fields.Float(string='Subtotal w/o discount', readonly=True)
total_discount = fields.Float(string='Total Discount', readonly=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="1128" viewBox="0 0 2000 1128">
<polygon fill-opacity=".03" points="0 1077.844 392.627 778.443 1504.99 1127.745 0 1127.745"/>
<polygon fill-opacity=".02" points="392.216 778.443 283.294 0 0 0 0 666.504"/>
<polygon fill-opacity=".03" points="1000 0 2000 1009.98 2000 439.94 1749.817 0"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

+79 -29
View File
@@ -2149,22 +2149,6 @@ td {
font-family: 'Lato';
font-family: Lato;
}
.pos .modal-dialog .popup-textinput{
display: flex;
flex-direction: column;
}
.pos .modal-dialog .popup-textinput .title,
.pos .modal-dialog .popup-textinput .footer{
flex: none;
}
.pos .modal-dialog .popup-textinput textarea{
resize: none;
flex: auto;
margin: 0 20px 80px;
}
.pos .modal-dialog .popup-confirm{
height: 250px;
}
@@ -2172,19 +2156,6 @@ td {
overflow: auto;
height: 120px;
}
.pos .modal-dialog .popup-textinput{
display: flex;
flex-direction: column;
}
.pos .modal-dialog .popup-textinput .title,
.pos .modal-dialog .popup-textinput .footer{
flex: none;
}
.pos .modal-dialog .popup-textinput textarea{
resize: none;
flex: auto;
margin: 0 20px 80px;
}
.pos .popup .title {
background: rgba(255,255,255,0.5);
margin: 0;
@@ -2471,3 +2442,82 @@ td {
.pos .not-supported-browser img{
border-collapse: separate;
}
/* ********* Login Screen ********* */
.pos .login-overlay{
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: 100%;
height:100%;
z-index:1000;
background: linear-gradient(to right bottom, #77717e, #c9a8a9);
}
.pos .login-overlay:before {
content: '';
background-image: url(../../img/login-bg-overlay.svg);
background-color: rgba(0, 0, 0, 0.3);
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: 100%;
height:100%;
}
.pos .screen-login{
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
margin: auto;
width:550px;
height:300px;
text-align:center;
font-size:20px;
font-weight:bold;
background-color: #F0EEEE;
border-radius: 3px;
z-index:1200;
font-family: 'Lato';
}
.pos .login-title{
height: 55%;
vertical-align: middle;
line-height: 5;
font-size: larger;
}
.pos .login-body{
height:33%;
}
.pos .login-element{
float: left;
width: 45%;
height: 60%;
}
.pos .login-barcode-img{
width: 80px;
height: 55px;
background: white;
border: 0px;
}
.pos .login-barcode-text{
color: #999999;
font-size: 13px;
padding-top: 0.2em;
}
.pos .login-or{
font-size: 15px;
font-style: italic;
float: left;
width: 10%;
height: 100%;
line-height: 5;
}
.pos .login-button{
font-size: initial;
height: 100%;
color: #555555;
border-radius: 5px;
}
+28 -44
View File
@@ -80,8 +80,7 @@ var OrderSelectorWidget = PosBaseWidget.extend({
/* ------- The User Name Widget ------- */
// Displays the current cashier's name and allows
// to switch between cashiers.
// Displays the current cashier's name
var UsernameWidget = PosBaseWidget.extend({
template: 'UsernameWidget',
@@ -89,25 +88,6 @@ var UsernameWidget = PosBaseWidget.extend({
options = options || {};
this._super(parent,options);
},
renderElement: function(){
var self = this;
this._super();
this.$el.click(function(){
self.click_username();
});
},
click_username: function(){
var self = this;
this.gui.select_user({
'security': true,
'current_user': this.pos.get_cashier(),
'title': _t('Change Cashier'),
}).then(function(user){
self.pos.set_cashier(user);
self.renderElement();
});
},
get_name: function(){
var user = this.pos.get_cashier();
if(user){
@@ -130,7 +110,9 @@ var HeaderButtonWidget = PosBaseWidget.extend({
options = options || {};
this._super(parent, options);
this.action = options.action;
this.label = options.label;
this.label = options.label;
this.button_class = options.button_class;
},
renderElement: function(){
var self = this;
@@ -141,8 +123,8 @@ var HeaderButtonWidget = PosBaseWidget.extend({
});
}
},
show: function(){ this.$el.removeClass('oe_hidden'); },
hide: function(){ this.$el.addClass('oe_hidden'); },
show: function() { this.$el.removeClass('oe_hidden'); },
hide: function() { this.$el.addClass('oe_hidden'); },
});
/* --------- The Debug Widget --------- */
@@ -510,7 +492,6 @@ var ClientScreenWidget = PosBaseWidget.extend({
this.$el.click(function(){
self.pos.render_html_for_customer_facing_display().then(function(rendered_html) {
self.pos.proxy.take_ownership_over_client_screen(rendered_html).then(
function(data) {
if (typeof data === 'string') {
data = JSON.parse(data);
@@ -587,7 +568,6 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
this.widget = {}; // contains references to subwidgets instances
this.cleanup_dom();
this.pos.ready.done(function(){
self.build_chrome();
self.build_widgets();
@@ -824,8 +804,9 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
'widget': HeaderButtonWidget,
'append': '.pos-rightheader',
'args': {
label: _lt('Close'),
label: _t('Close'),
action: function(){
this.$el.addClass('close_button');
var self = this;
if (!this.confirmed) {
this.$el.addClass('confirm');
@@ -856,30 +837,33 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
},
],
// This method instantiates all the screens, widgets, etc.
build_widgets: function() {
var classe;
for (var i = 0; i < this.widgets.length; i++) {
var def = this.widgets[i];
if ( !def.condition || def.condition.call(this) ) {
var args = typeof def.args === 'function' ? def.args(this) : def.args;
var w = new def.widget(this, args || {});
if (def.replace) {
w.replace(this.$(def.replace));
} else if (def.append) {
w.appendTo(this.$(def.append));
} else if (def.prepend) {
w.prependTo(this.$(def.prepend));
load_widgets: function(widgets) {
for (var i = 0; i < widgets.length; i++) {
var widget = widgets[i];
if ( !widget.condition || widget.condition.call(this) ) {
var args = typeof widget.args === 'function' ? widget.args(this) : widget.args;
var w = new widget.widget(this, args || {});
if (widget.replace) {
w.replace(this.$(widget.replace));
} else if (widget.append) {
w.appendTo(this.$(widget.append));
} else if (widget.prepend) {
w.prependTo(this.$(widget.prepend));
} else {
w.appendTo(this.$el);
}
this.widget[def.name] = w;
this.widget[widget.name] = w;
}
}
},
// This method instantiates all the screens, widgets, etc.
build_widgets: function() {
this.load_widgets(this.widgets);
this.screens = {};
for (i = 0; i < this.gui.screen_classes.length; i++) {
var classe;
for (var i = 0; i < this.gui.screen_classes.length; i++) {
classe = this.gui.screen_classes[i];
if (!classe.condition || classe.condition.call(this)) {
var screen = new classe.widget(this,{});
+13 -67
View File
@@ -28,18 +28,22 @@ var Gui = core.Class.extend({
this.chrome.ready.then(function(){
self.close_other_tabs();
var order = self.pos.get_order();
if (order) {
self.show_saved_screen(order);
} else {
self.show_screen(self.startup_screen);
}
self._show_first_screen();
self.pos.bind('change:selectedOrder', function(){
self.show_saved_screen(self.pos.get_order());
});
});
},
_show_first_screen: function() {
var order = this.pos.get_order();
if (order) {
this.show_saved_screen(order);
} else {
this.show_screen(this.startup_screen);
}
},
/* ---- Gui: SCREEN MANIPULATION ---- */
// register a screen widget to the gui,
@@ -226,66 +230,8 @@ var Gui = core.Class.extend({
// - only_managers: restricts the list to managers
// - current_user: password will not be asked if this
// user is selected.
// - title: The title of the user selection list.
select_user: function(options){
options = options || {};
var self = this;
var def = new $.Deferred();
var list = [];
for (var i = 0; i < this.pos.users.length; i++) {
var user = this.pos.users[i];
if (!options.only_managers || user.role === 'manager') {
list.push({
'label': user.name,
'item': user,
});
}
}
this.show_popup('selection',{
title: options.title || _t('Select User'),
list: list,
confirm: function(user){ def.resolve(user); },
cancel: function(){ def.reject(); },
is_selected: function(user){ return user === self.pos.get_cashier(); },
});
return def.then(function(user){
if (options.security && user !== options.current_user && user.pos_security_pin) {
return self.ask_password(user.pos_security_pin).then(function(){
return user;
});
} else {
return user;
}
});
},
// Ask for a password, and checks if it this
// the same as specified by the function call.
// returns a deferred that resolves on success,
// fails on failure.
ask_password: function(password) {
var self = this;
var ret = new $.Deferred();
if (password) {
this.show_popup('password',{
'title': _t('Password ?'),
confirm: function(pw) {
if (pw !== password) {
self.show_popup('error',_t('Incorrect Password'));
ret.reject();
} else {
ret.resolve();
}
},
});
} else {
ret.resolve();
}
return ret;
},
// - title: The title of the employee selection list.
// checks if the current user (or the user provided) has manager
// access rights. If not, a popup is shown allowing the user to
@@ -298,7 +244,7 @@ var Gui = core.Class.extend({
if (user.role === 'manager') {
return new $.Deferred().resolve(user);
} else {
return this.select_user({
return this.select_employee({
security: true,
only_managers: true,
title: _t('Login as a Manager'),
+19 -41
View File
@@ -55,6 +55,8 @@ exports.PosModel = Backbone.Model.extend({
this.company = null;
this.user = null;
this.users = [];
this.employee = {name: null, id: null, barcode: null, user_id:null, pin:null};
this.employees = [];
this.partners = [];
this.cashregisters = [];
this.taxes = [];
@@ -89,7 +91,7 @@ exports.PosModel = Backbone.Model.extend({
// We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched,
// Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched.
// when all the data has loaded, we compute some stuff, and declare the Pos ready to be used.
// when all the data has loaded, we compute some stuff, and declare the Pos ready to be used.
this.ready = this.load_server_data().then(function(){
return self.after_load_server_data();
});
@@ -162,9 +164,16 @@ exports.PosModel = Backbone.Model.extend({
},{
model: 'res.users',
fields: ['name','company_id'],
fields: ['name','company_id', 'id'],
ids: function(self){ return [session.uid]; },
loaded: function(self,users){ self.user = users[0]; },
loaded: function(self,users){
self.user = users[0];
self.user.role = 'manager';
self.employee.name = self.user.name;
self.employee.user_id = [self.user.id, self.user.name];
self.employees = [self.employee];
self.set_cashier(self.employee);
},
},{
model: 'res.company',
fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'tax_calculation_rounding_method'],
@@ -262,39 +271,6 @@ exports.PosModel = Backbone.Model.extend({
self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1);
}
},
},{
model: 'res.users',
fields: ['name','pos_security_pin','groups_id','barcode'],
domain: function(self){ return [['company_id','=',self.user.company_id[0]],'|', ['groups_id','=', self.config.group_pos_manager_id[0]],['groups_id','=', self.config.group_pos_user_id[0]]]; },
loaded: function(self,users){
// we attribute a role to the user, 'cashier' or 'manager', depending
// on the group the user belongs.
var pos_users = [];
var current_cashier = self.get_cashier();
for (var i = 0; i < users.length; i++) {
var user = users[i];
for (var j = 0; j < user.groups_id.length; j++) {
var group_id = user.groups_id[j];
if (group_id === self.config.group_pos_manager_id[0]) {
user.role = 'manager';
break;
} else if (group_id === self.config.group_pos_user_id[0]) {
user.role = 'cashier';
}
}
if (user.role) {
pos_users.push(user);
}
// replace the current user with its updated version
if (user.id === self.user.id) {
self.user = user;
}
if (user.id === current_cashier.id) {
self.set_cashier(user);
}
}
self.users = pos_users;
},
},{
model: 'stock.location',
fields: [],
@@ -653,13 +629,13 @@ exports.PosModel = Backbone.Model.extend({
get_cashier: function(){
// reset the cashier to the current user if session is new
if (this.db.load('pos_session_id') !== this.pos_session.id) {
this.set_cashier(this.user);
this.set_cashier(this.employee);
}
return this.db.get_cashier() || this.get('cashier') || this.user;
return this.db.get_cashier() || this.get('cashier') || this.employee;
},
// changes the current cashier
set_cashier: function(user){
this.set('cashier', user);
set_cashier: function(employee){
this.set('cashier', employee);
this.db.set_cashier(this.get('cashier'));
},
//creates a new empty order and sets it as the current order
@@ -2007,6 +1983,7 @@ exports.Order = Backbone.Model.extend({
this.orderlines = new OrderlineCollection();
this.paymentlines = new PaymentlineCollection();
this.pos_session_id = this.pos.pos_session.id;
this.employee = this.pos.employee;
this.finalized = false; // if true, cannot be modified.
this.set_pricelist(this.pos.default_pricelist);
@@ -2131,7 +2108,8 @@ exports.Order = Backbone.Model.extend({
pos_session_id: this.pos_session_id,
pricelist_id: this.pricelist ? this.pricelist.id : false,
partner_id: this.get_client() ? this.get_client().id : false,
user_id: this.pos.get_cashier().id,
user_id: this.pos.user.id,
employee_id: this.pos.get_cashier().id,
uid: this.uid,
sequence_number: this.sequence_number,
creation_date: this.validation_date || this.creation_date, // todo: rename creation_date in master
@@ -68,32 +68,6 @@ var ScreenWidget = PosBaseWidget.extend({
this.barcode_error_action(code);
}
},
// what happens when a cashier id barcode is scanned.
// the default behavior is the following :
// - if there's a user with a matching barcode, put it as the active 'cashier', go to cashier mode, and return true
// - else : do nothing and return false. You probably want to extend this to show and appropriate error popup...
barcode_cashier_action: function(code){
var self = this;
var users = this.pos.users;
for(var i = 0, len = users.length; i < len; i++){
if(users[i].barcode === code.code){
if (users[i].id !== this.pos.get_cashier().id && users[i].pos_security_pin) {
return this.gui.ask_password(users[i].pos_security_pin).then(function(){
self.pos.set_cashier(users[i]);
self.chrome.widget.username.renderElement();
return true;
});
} else {
this.pos.set_cashier(users[i]);
this.chrome.widget.username.renderElement();
return true;
}
}
}
this.barcode_error_action(code);
return false;
},
// what happens when a client id barcode is scanned.
// the default behavior is the following :
@@ -139,7 +113,6 @@ var ScreenWidget = PosBaseWidget.extend({
}
this.pos.barcode_reader.set_action_callback({
'cashier': _.bind(self.barcode_cashier_action, self),
'product': _.bind(self.barcode_product_action, self),
'weight': _.bind(self.barcode_product_action, self),
'price': _.bind(self.barcode_product_action, self),
+1 -1
View File
@@ -359,7 +359,7 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
trigger: ".header-button",
}, {
content: "confirm closing the frontend",
trigger: ".header-button.confirm",
trigger: ".header-button",
run: function() {}, //it's a check,
}]);
+9 -2
View File
@@ -6,11 +6,13 @@
<div class="pos-topheader">
<div class="pos-branding">
<img class="pos-logo" src="/point_of_sale/static/src/img/logo.png" alt="Logo"/>
<span class="placeholder-UsernameWidget"></span>
</div>
<div class="pos-rightheader">
<span class="placeholder-OrderSelectorWidget"></span>
<!-- here goes header buttons -->
<div class="oe_status">
<span class="placeholder-UsernameWidget"></span>
</div>
</div>
</div>
@@ -109,7 +111,12 @@
<t t-name="HeaderButtonWidget">
<div class="header-button">
<t t-esc="widget.label" />
<t t-if="!widget.icon">
<t t-esc="widget.label" />
</t>
<t t-else="">
<i class='fa fa-fw' role="img"/>
</t>
</div>
</t>
+22 -16
View File
@@ -41,6 +41,14 @@
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
</div>
<div class="row mt16 o_settings_container">
<div id="company" class="col-12 col-lg-6 o_setting_box" groups="base.group_multi_company">
<div class="o_setting_right_pane">
<label for="company_id"/>
<div><field name="company_id"/></div>
</div>
</div>
</div>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
@@ -51,10 +59,19 @@
<button string="Save" type="object" name="execute" class="oe_highlight" id="btn_use_pos_restaurant" attrs="{'invisible': [('module_pos_restaurant','=',False)]}" />
</div>
</div>
</div>
<div class="row mt16" id="company" groups="base.group_multi_company">
<label for="company_id" class="col-lg-2"/>
<field name="company_id"/>
<div class="col-12 col-lg-6 o_setting_box"
title="Employees can scan their badge or enter a PIN to log in to a PoS session. These credentials are configurable in the *HR Settings* tab of the employee form.">
<div class="o_setting_left_pane">
<field name="module_pos_hr"/>
</div>
<div class="o_setting_right_pane" id="login_with_employees">
<span class="o_form_label">Log in with Employees</span>
<div class="text-muted">
Use employee credentials to log in to the PoS session and switch cashier
</div>
<button string="Save" type="object" name="execute" class="oe_highlight" id="btn_use_employees" attrs="{'invisible': [('module_pos_hr','=',False)]}" />
</div>
</div>
</div>
<h2 name="order">Order Interface</h2>
<div class="row mt16 o_settings_container">
@@ -224,7 +241,7 @@
</div>
</div>
<h2>Pricing</h2>
<div class="row mt16 o_settings_container">
<div class="row mt16 o_settings_container" id="pricing">
<div class="col-xs-12 col-lg-6 o_setting_box" id="product_prices">
<div class="o_setting_right_pane">
<label for="iface_tax_included" string="Product Prices"/>
@@ -295,17 +312,6 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" title="Only users with Manager access rights for PoS app can modify the product prices on orders.">
<div class="o_setting_left_pane">
<field name="restrict_price_control"/>
</div>
<div class="o_setting_right_pane">
<label for="restrict_price_control" string="Price Control"/>
<div class="text-muted">
Restrict price modification to managers
</div>
</div>
</div>
</div>
<h2>Payments</h2>
<div class="row mt16 o_settings_container">
@@ -30,17 +30,15 @@
<field name="arch" type="xml">
<search string="Point of Sale Analysis">
<field name="date"/>
<filter string="My Sales" name="my_sales" help="My Sales" domain="[('user_id','=',uid)]"/>
<separator/>
<filter string="Invoiced" name="invoiced" domain="[('state','=',('invoiced'))]"/>
<filter string="Not Invoiced" name="not_invoiced" domain="[('state','=',('paid'))]"/>
<field name="config_id"/>
<field name="partner_id"/>
<field name="user_id"/>
<field name="product_id"/>
<field name="product_categ_id"/>
<group expand="1" string="Group By">
<filter string="Salesperson" name="User" context="{'group_by':'user_id'}"/>
<filter string="User" name="User" context="{'group_by':'user_id'}"/>
<filter string="Point of Sale" name="pos" context="{'group_by':'config_id'}"/>
<filter string="Product" name="product" context="{'group_by':'product_id'}"/>
<filter string="Product Category" name="product_category" context="{'group_by':'product_categ_id'}"/>
@@ -27,6 +27,7 @@
<field name="name"/>
<field name="date_order"/>
<field name="session_id" />
<field string="User" name="user_id"/>
<field name="partner_id" domain="[('customer', '=', True)]" context="{'search_default_customer':1}" attrs="{'readonly': [('state','=','invoiced')]}"/>
<field name="fiscal_position_id" options="{'no_create': True}"/>
<field name="invoice_group" invisible="1"/>
@@ -88,7 +89,6 @@
<group string="General Information">
<field name="company_id" groups="base.group_multi_company"/>
<field name="location_id" widget="selection" groups="stock.group_stock_multi_locations"/>
<field name="user_id"/>
<field name="pricelist_id" groups="product.group_sale_pricelist"/>
<field name="picking_id" readonly="1"/>
<field name="pos_reference"/>
@@ -127,7 +127,11 @@
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><span><t t-esc="record.user_id.value"/></span></strong>
<strong class="o_kanban_record_title">
<span>
<t t-esc="record.user_id"/>
</span>
</strong>
</div>
<strong><span class="badge badge-pill"><i class="fa fa-money" role="img" aria-label="Amount" title="Amount"/> <field name="amount_total"/></span></strong>
</div>
@@ -306,7 +310,7 @@
<field name="config_id"/>
<group expand="0" string="Group By">
<filter string="Customer" name="customer" domain="[]" context="{'group_by':'partner_id'}"/>
<filter string="Salesperson" name="salesman" domain="[]" context="{'group_by':'user_id'}"/>
<filter string="User" name="user_id" domain="[]" context="{'group_by':'user_id'}"/>
<filter string="Session" name="session" domain="[]" context="{'group_by':'session_id'}"/>
<filter string="Status" name="status" domain="[]" context="{'group_by':'state'}"/>
<filter string="Order Date" name="order_month" domain="[]" context="{'group_by':'date_order'}"/>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="res_users_view_form" model="ir.ui.view">
<field name="name">res.users.form.view</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form" />
<field name="arch" type="xml">
<notebook position="inside">
<page string="Point of Sale">
<group>
<field name="barcode" />
<field name="pos_security_pin" />
</group>
</page>
</notebook>
</field>
</record>
</odoo>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import report
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "pos_hr",
'category': "Hidden",
'summary': 'Link module between Point of Sale and HR',
'description': """
This module adds the possibility to log in to the PoS with Employees using barcode, pin, or both.
The PoS still requires one user, but it can have an unlimited number of employees.
""",
'depends': ['point_of_sale', 'hr'],
'data': [
'views/pos_config.xml',
'views/point_of_sale.xml',
'views/pos_order_view.xml',
'views/pos_order_report_view.xml',
],
'installable': True,
'auto-install': True,
'qweb': ['static/src/xml/pos.xml'],
}
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import pos_config
from . import pos_order
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from functools import partial
from odoo import models, fields
class PosConfig(models.Model):
_inherit = 'pos.config'
employee_ids = fields.Many2many(
'hr.employee', string="Employees with access",
help='If left empty, all employees can log in to the PoS session')
+23
View File
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class PosOrder(models.Model):
_inherit = "pos.order"
employee_id = fields.Many2one('hr.employee', help="Person who uses the cash register. It can be a reliever, a student or an interim employee.", states={'done': [('readonly', True)], 'invoiced': [('readonly', True)]})
cashier = fields.Char(string="Cashier", compute="_compute_cashier", store=True)
@api.model
def _order_fields(self, ui_order):
order_fields = super(PosOrder, self)._order_fields(ui_order)
order_fields['employee_id'] = ui_order.get('employee_id')
return order_fields
@api.depends('employee_id', 'user_id')
def _compute_cashier(self):
for order in self:
if order.employee_id:
order.cashier = order.employee_id.name
else:
order.cashier = order.user_id.name
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import pos_order_report
+17
View File
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from functools import partial
from odoo import models, fields
class PosOrderReport(models.Model):
_inherit = "report.pos.order"
employee_id = fields.Many2one(
'hr.employee', string='Employee', readonly=True)
def _select(self):
return super(PosOrderReport, self)._select() + ',s.employee_id AS employee_id'
def _group_by(self):
return super(PosOrderReport, self)._group_by() + ',s.employee_id'
+121
View File
@@ -0,0 +1,121 @@
odoo.define('pos_hr.chrome', function (require) {
"use strict";
var core = require('web.core');
var chrome = require('point_of_sale.chrome');
var _t = core._t;
var _lt = core._lt;
var QWeb = core.qweb;
/* ------- The User Name Widget ------- */
// Displays the current cashier's name and allows
// to switch between cashiers.
chrome.UsernameWidget.include({
renderElement: function(){
var self = this;
this._super();
this.$el.click(function(){
self.click_username();
});
},
click_username: function(){
if(!this.pos.config.module_pos_hr) { return; }
var self = this;
this.gui.select_employee({
'security': true,
'current_employee': this.pos.get_cashier(),
'title': _t('Change Cashier'),
}).then(function(employee){
self.pos.set_cashier(employee);
self.chrome.widget.username.renderElement();
self.renderElement();
});
},
});
var HeaderCloseButtonWidget = chrome.HeaderButtonWidget.extend({
start: function(){
if (this.pos.config.module_pos_hr) {
var self = this;
this.pos.bind('change:cashier', this._show_hide_close_button , this);
}
return this._super();
},
_show_hide_close_button: function(){
if (this.pos.get('cashier').role == 'manager') {
this.show();
} else {
this.hide();
}
}
});
var HeaderLockButtonWidget = chrome.HeaderButtonWidget.extend({
init: function(parent, options) {
this._super(parent, options);
this.icon = 'fa-unlock';
this.icon_color = 'green';
this.icon_mouseover = 'fa-lock';
this.icon_color_mouseover = 'red';
},
start: function(){
if (this.pos.config.module_pos_hr) {
this.show();
} else {
this.hide();
}
return this._super();
},
renderElement: function() {
var self = this;
this._super();
this.iconElement = this.$el.find("i");
this.$el.css('font-size','20px');
this.iconElement.addClass(this.icon);
this.$el.css('color',this.icon_color);
this.$el.mouseover(function(){
self.iconElement.addClass(self.icon_mouseover).removeClass(self.icon);
$(this).css('color', self.icon_color_mouseover);
}).mouseleave(function(){
self.iconElement.addClass(self.icon).removeClass(self.icon_mouseover);
$(this).css('color', self.icon_color);
});
},
});
chrome.Chrome.include({
lock_button_widget: {
'name': 'lock_button',
'widget': HeaderLockButtonWidget,
'append': '.pos-rightheader',
'args': {
label: _lt('Lock'),
action: function() {
this.gui.show_screen('login');
}
}
},
build_widgets: function() {
var self = this;
this.widgets.some(function(widget, index){
if (widget.name === 'close_button'){
widget.widget = HeaderCloseButtonWidget;
self.widgets.splice(index, 0, self.lock_button_widget);
return true;
}
return false;
});
this._super();
}
});
return {
HeaderLockButtonWidget: HeaderLockButtonWidget,
HeaderCloseButtonWidget: HeaderCloseButtonWidget,
};
});
+70
View File
@@ -0,0 +1,70 @@
odoo.define('pos_hr.gui', function (require) {
"use strict";
var core = require('web.core');
var gui = require('point_of_sale.gui');
var _t = core._t;
gui.Gui.include({
_show_first_screen: function () {
if (this.pos.config.module_pos_hr) {
this.show_screen('login');
} else {
this._super();
}
},
select_employee: function(options) {
options = options || {};
var self = this;
var def = new $.Deferred();
var list = [];
this.pos.employees.forEach(function(employee) {
if (!options.only_managers || employee.role === 'manager') {
list.push({
'label': employee.name,
'item': employee,
});
}
});
this.show_popup('selection', {
title: options.title || _t('Select User'),
list: list,
confirm: function(employee){ def.resolve(employee); },
cancel: function(){ def.reject(); },
is_selected: function(employee){ return employee === self.pos.get_cashier(); },
});
return def.then(function(employee){
return self.ask_password(employee.pin).then(function(){
return employee;
});
});
},
// Ask for a password, and checks if it this
// the same as specified by the function call.
// returns a deferred that resolves on success,
// fails on failure.
ask_password: function(password) {
var self = this;
var ret = new $.Deferred();
if (password) {
this.show_popup('password',{
'title': _t('Password ?'),
confirm: function(pw) {
if (pw !== password) {
self.show_popup('error',_t('Incorrect Password'));
ret.reject();
} else {
ret.resolve();
}
},
});
} else {
ret.resolve();
}
return ret;
},
});
});
+56
View File
@@ -0,0 +1,56 @@
odoo.define('pos_hr.employees', function (require) {
"use strict";
var models = require('point_of_sale.models');
models.load_models([{
model: 'res.users',
fields: ['name','groups_id'],
domain: function(self){ return [['company_id','=',self.user.company_id[0]],'|', ['groups_id','=', self.config.group_pos_manager_id[0]],['groups_id','=', self.config.group_pos_user_id[0]]]; },
loaded: function(self,users){
// we attribute a role to the user, 'cashier' or 'manager', depending
// on the group the user belongs.
self.users = users;
self.users.forEach(function(user) {
user.role = 'cashier';
user.groups_id.some(function(group_id) {
if (group_id === self.config.group_pos_manager_id[0]) {
user.role = 'manager';
return true;
}
});
// replace the current user with its updated version
if (user.id === self.user.id) {
self.user = user;
self.employee = self.user;
}
});
},
},{
model: 'hr.employee',
fields: ['name', 'id', 'barcode', 'pin', 'user_id'],
loaded: function(self, employees) {
if (self.config.module_pos_hr) {
if (self.config.employee_ids.length > 0) {
self.employees = employees.filter(function(employee) {
return self.config.employee_ids.includes(employee.id) || employee.user_id[0] === self.user.id;
});
} else {
self.employees = employees;
}
self.employees.forEach(function(employee) {
var hasUser = self.users.some(function(user) {
if (user.id === employee.user_id[0]) {
employee.role = user.role;
return true;
}
return false;
});
if (!hasUser) {
employee.role = 'cashier';
}
});
}
}
}]);
});
+91
View File
@@ -0,0 +1,91 @@
odoo.define('pos_hr.screens', function (require) {
"use strict";
var core = require('web.core');
var gui = require('point_of_sale.gui');
var ScreenWidget = require('point_of_sale.screens').ScreenWidget;
var _t = core._t;
ScreenWidget.include({
// what happens when a cashier id barcode is scanned.
// the default behavior is the following :
// - if there's an employee with a matching barcode, put it as the active 'cashier', go to cashier mode, and return true
// - else : do nothing and return false. You probably want to extend this to show and appropriate error popup...
barcode_cashier_action: function(code){
var self = this;
var employees = this.pos.employees;
for(var i = 0, len = employees.length; i < len; i++){
if(employees[i].barcode === code.code){
if (employees[i].id !== this.pos.get_cashier().id && employees[i].pin) {
return this.gui.ask_password(employees[i].pin).then(function(){
self.pos.set_cashier(employees[i]);
self.chrome.widget.username.renderElement();
return true;
});
} else {
this.pos.set_cashier(employees[i]);
this.chrome.widget.username.renderElement();
return true;
}
}
}
this.barcode_error_action(code);
return false;
},
show: function() {
this._super();
this.pos.barcode_reader.set_action_callback('cashier', _.bind(this.barcode_cashier_action, this));
},
});
/*--------------------------------------*\
| THE LOGIN SCREEN |
\*======================================*/
// The login screen enables employees to log in to the PoS
// at startup or after it was locked, with either barcode, pin, or both.
var LoginScreenWidget = ScreenWidget.extend({
template: 'LoginScreenWidget',
/**
* @override
*/
show: function() {
var self = this;
this.$('.select-employee').click(function() {
self.gui.select_employee({
'security': true,
'current_employee': self.pos.get_cashier(),
'title':_t('Change Cashier'),})
.then(function(employee){
self.pos.set_cashier(employee);
self.chrome.widget.username.renderElement();
self.unlock_screen();
});
});
this._super();
},
/**
* @override
*/
barcode_cashier_action: function(code) {
this._super(code);
this.unlock_screen();
},
unlock_screen: function() {
var screen = (this.gui.pos.get_order() ? this.gui.pos.get_order().get_screen_data('previous-screen') : this.gui.default_screen) || this.gui.default_screen;
this.gui.show_screen(screen);
}
});
gui.define_screen({name:'login', widget: LoginScreenWidget});
return {
LoginScreenWidget: LoginScreenWidget
};
});
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="LoginScreenWidget">
<div class="login-overlay">
<div class="screen-login">
<div class="login-title"><small>Log in to </small><t t-esc="widget.pos.config.name"/></div>
<div class="login-body">
<span class="login-element">
<img class="login-barcode-img" src="/point_of_sale/static/img/barcode.png"/>
<div class="login-barcode-text">Scan your badge</div>
</span>
<span class="login-or">or</span>
<span class="login-element">
<button class="login-button select-employee">Select Cashier</button>
</span>
</div>
</div>
</div>
</t>
</templates>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets" inherit_id="point_of_sale.assets">
<xpath expr="." position="inside">
<script type="text/javascript" src="/pos_hr/static/src/js/models.js"></script>
<script type="text/javascript" src="/pos_hr/static/src/js/screens.js"></script>
<script type="text/javascript" src="/pos_hr/static/src/js/gui.js"></script>
<script type="text/javascript" src="/pos_hr/static/src/js/chrome.js"></script>
</xpath>
</template>
</odoo>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="pos_config_form_view_inherit" model="ir.ui.view">
<field name="name">pos.config.form.view.inherit</field>
<field name="model">pos.config</field>
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@id='btn_use_employees']" position='replace'>
<div attrs="{'invisible': [('module_pos_hr', '=', False)]}">
<span class="o_form_label oe_edit_only">Allowed Employees </span>
<field name="employee_ids" widget="many2many_tags"/>
</div>
</xpath>
<xpath expr="//div[@id='pricing']" position='inside'>
<div class="col-12 col-lg-6 o_setting_box price_control" title="Only users with Manager access rights for PoS app can modify the product prices on orders.">
<div class="o_setting_left_pane">
<field name="restrict_price_control"/>
</div>
<div class="o_setting_right_pane">
<label for="restrict_price_control" string="Price Control"/>
<div class="text-muted">
Restrict price modification to managers
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_report_pos_order_search_inherit" model="ir.ui.view">
<field name="name">report.pos.order.search.inherit</field>
<field name="model">report.pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_report_pos_order_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='User']" position='after'>
<filter string="Employee" name="employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
</xpath>
</field>
</record>
</odoo>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="pos_order_form_inherit" model="ir.ui.view">
<field name="name">pos.order.form.inherit</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_pos_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='user_id']" position='replace'>
<field name="employee_id" readonly="1" attrs="{'invisible': [('employee_id','=',False)]}"/>
<field name="user_id" readonly="1" attrs="{'invisible': [('employee_id','!=',False)]}"/>
</xpath>
</field>
</record>
<record id="pos_order_list_select_inherit" model="ir.ui.view">
<field name="name">pos.order.list.select.inherit</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_order_filter"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='posted']" position='after'>
<field name="employee_id"/>
</xpath>
<xpath expr="//filter[@name='customer']" position='after'>
<filter string="Employee" name="employee_id" domain="[]" context="{'group_by':'employee_id'}"/>
</xpath>
</field>
</record>
<record id="view_pos_order_tree_inherit" model="ir.ui.view">
<field name="name">pos.order.tree.inherit</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_order_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='user_id']" position="replace">
<field name="cashier"/>
</xpath>
</field>
</record>
</odoo>
@@ -22,5 +22,4 @@
</div>
</field>
</record>
</odoo>