diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js index f5158428820..5cbebde9141 100644 --- a/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js @@ -64,15 +64,15 @@ odoo.define('point_of_sale.DebugWidget', function (require) { this.state.weightInput = ''; this.env.pos.proxy.debug_reset_weight(); } - barcodeScan() { - this.env.pos.barcode_reader.scan(this.state.barcodeInput); + async barcodeScan() { + await this.env.pos.barcode_reader.scan(this.state.barcodeInput); } - barcodeScanEAN() { + async barcodeScanEAN() { const ean = this.env.pos.barcode_reader.barcode_parser.sanitize_ean( this.state.barcodeInput || '0' ); this.state.barcodeInput = ean; - this.env.pos.barcode_reader.scan(ean); + await this.env.pos.barcode_reader.scan(ean); } async deleteOrders() { const { confirmed } = await this.showPopup('ConfirmPopup', { diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js index 398c461b774..3e155a71312 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js @@ -8,7 +8,8 @@ odoo.define('point_of_sale.ProductScreen', function(require) { const Registries = require('point_of_sale.Registries'); const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks'); const { Gui } = require('point_of_sale.Gui'); - const { useState } = owl.hooks; + const { isRpcError } = require('point_of_sale.utils'); + const { useState, onMounted } = owl.hooks; const { parse } = require('web.field_utils'); class ProductScreen extends ControlButtonsMixin(PosComponent) { @@ -34,6 +35,10 @@ odoo.define('point_of_sale.ProductScreen', function(require) { triggerAtInput: 'update-selected-orderline', useWithBarcode: true, }); + // Call `reset` when the `onMounted` callback in `NumberBuffer.use` is done. + // We don't do this in the `mounted` lifecycle method because it is called before + // the callbacks in `onMounted` hook. + onMounted(() => NumberBuffer.reset()); this.state = useState({ numpadMode: 'quantity' }); this.mobile_pane = this.props.mobile_pane || 'right'; } @@ -184,6 +189,7 @@ odoo.define('point_of_sale.ProductScreen', function(require) { } async _newOrderlineSelected() { NumberBuffer.reset(); + this.state.numpadMode = 'quantity'; } _setValue(val) { if (this.currentOrder.get_selected_orderline()) { @@ -202,9 +208,34 @@ odoo.define('point_of_sale.ProductScreen', function(require) { } } async _barcodeProductAction(code) { - const product = this.env.pos.db.get_product_by_barcode(code.base_code) + let product = this.env.pos.db.get_product_by_barcode(code.base_code); if (!product) { - return this._barcodeErrorAction(code); + // find the barcode in the backend + let foundProductIds = []; + try { + foundProductIds = await this.rpc({ + model: 'product.product', + method: 'search', + args: [[['barcode', '=', code.base_code]]], + context: this.env.session.user_context, + }); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + return this.showPopup('OfflineErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t("Product is not loaded. Tried loading the product from the server but there is a network error."), + }); + } else { + throw error; + } + } + if (foundProductIds.length) { + await this.env.pos._addProducts(foundProductIds); + // assume that the result is unique. + product = this.env.pos.db.get_product_by_id(foundProductIds[0]); + } else { + return this._barcodeErrorAction(code); + } } const options = await this._getAddProductOptions(product); // Do not proceed on adding the product when no options is returned. diff --git a/addons/point_of_sale/static/src/js/barcode_reader.js b/addons/point_of_sale/static/src/js/barcode_reader.js index c3f0e06dd17..8682ef75e7c 100644 --- a/addons/point_of_sale/static/src/js/barcode_reader.js +++ b/addons/point_of_sale/static/src/js/barcode_reader.js @@ -1,7 +1,9 @@ odoo.define('point_of_sale.BarcodeReader', function (require) { "use strict"; +var concurrency = require('web.concurrency'); var core = require('web.core'); +var Mutex = concurrency.Mutex; // this module interfaces with the barcode reader. It assumes the barcode reader // is set-up to act like a keyboard. Use connect() and disconnect() to activate @@ -15,6 +17,7 @@ var BarcodeReader = core.Class.extend({ ], init: function (attributes) { + this.mutex = new Mutex(); this.pos = attributes.pos; this.action_callbacks = {}; this.exclusive_callbacks = {}; @@ -27,7 +30,10 @@ var BarcodeReader = core.Class.extend({ this.action_callback_stack = []; core.bus.on('barcode_scanned', this, function (barcode) { - this.scan(barcode); + // use mutex to make sure scans are done one after the other + this.mutex.exec(async () => { + await this.scan(barcode); + }); }); }, @@ -98,7 +104,7 @@ var BarcodeReader = core.Class.extend({ } }, - scan: function (code) { + scan: async function (code) { if (!code) return; const callbacks = Object.keys(this.exclusive_callbacks).length @@ -110,7 +116,9 @@ var BarcodeReader = core.Class.extend({ } for (const parsed_result of parsed_results) { if (callbacks[parsed_result.type]) { - [...callbacks[parsed_result.type]].map(cb => cb(parsed_result)); + for (const cb of callbacks[parsed_result.type]) { + await cb(parsed_result); + } } else if (callbacks.error) { [...callbacks.error].map(cb => cb(parsed_result)); } else { diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 1ba23ddc846..1bb2c89be83 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1162,26 +1162,6 @@ exports.PosModel = Backbone.Model.extend({ return server_ids; }, - scan_product: function(parsed_code){ - var selectedOrder = this.get_order(); - var product = this.db.get_product_by_barcode(parsed_code.base_code); - - if(!product){ - return false; - } - - if(parsed_code.type === 'price'){ - selectedOrder.add_product(product, {price:parsed_code.value}); - }else if(parsed_code.type === 'weight'){ - selectedOrder.add_product(product, {quantity:parsed_code.value, merge:false}); - }else if(parsed_code.type === 'discount'){ - selectedOrder.add_product(product, {discount:parsed_code.value, merge:false}); - }else{ - selectedOrder.add_product(product); - } - return true; - }, - // Exports the paid orders (the ones waiting for internet connection) export_paid_orders: function() { return JSON.stringify({ @@ -1390,6 +1370,26 @@ exports.PosModel = Backbone.Model.extend({ getCurrencySymbol() { return this.currency ? this.currency.symbol : '$'; }, + /** + * Make the products corresponding to the given ids to be available_in_pos and + * fetch them to be added on the loaded products. + */ + async _addProducts(ids){ + await this.rpc({ + model: 'product.product', + method: 'write', + args: [ids, {'available_in_pos': true}], + context: this.session.user_context, + }); + let product_model = _.find(this.models, (model) => model.model === 'product.product'); + let product = await this.rpc({ + model: 'product.product', + method: 'read', + args: [ids, product_model.fields], + context: { ...this.session.user_context, ...product_model.context() }, + }); + product_model.loaded(this, product); + }, }); /** diff --git a/addons/pos_sale/__manifest__.py b/addons/pos_sale/__manifest__.py index 103b6b2cd48..87d9569f52d 100644 --- a/addons/pos_sale/__manifest__.py +++ b/addons/pos_sale/__manifest__.py @@ -17,10 +17,30 @@ This module adds a custom Sales Team for the Point of Sale. This enables you to 'data/pos_sale_data.xml', 'security/pos_sale_security.xml', 'security/ir.model.access.csv', + 'views/point_of_sale_report.xml', + 'views/sale_order_views.xml', + 'views/pos_order_views.xml', 'views/sales_team_views.xml', 'views/pos_config_views.xml', + 'views/stock_template.xml', ], 'installable': True, 'auto_install': True, + 'assets': { + 'point_of_sale.assets': [ + 'pos_sale/static/src/css/pos_sale.css', + 'pos_sale/static/src/js/models.js', + 'pos_sale/static/src/js/SetSaleOrderButton.js', + 'pos_sale/static/src/js/OrderManagementScreen/SaleOrderFetcher.js', + 'pos_sale/static/src/js/OrderManagementScreen/SaleOrderList.js', + 'pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementControlPanel.js', + 'pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementScreen.js', + 'pos_sale/static/src/js/OrderManagementScreen/SaleOrderRow.js', + 'pos_sale/static/src/js/ProductScreen.js', + ], + 'web.assets_qweb': [ + 'pos_sale/static/src/xml/**/*', + ], + }, 'license': 'LGPL-3', } diff --git a/addons/pos_sale/data/pos_sale_data.xml b/addons/pos_sale/data/pos_sale_data.xml index fc4c0dff1f1..2e6fb7e0abf 100644 --- a/addons/pos_sale/data/pos_sale_data.xml +++ b/addons/pos_sale/data/pos_sale_data.xml @@ -4,6 +4,24 @@ + + + Down Payment (POS) + False + 0.00 + 0.00 + 0.00 + service + + + + + False + + + + + diff --git a/addons/pos_sale/models/__init__.py b/addons/pos_sale/models/__init__.py index a1233390863..b23ba5d574e 100644 --- a/addons/pos_sale/models/__init__.py +++ b/addons/pos_sale/models/__init__.py @@ -5,3 +5,4 @@ from . import pos_config from . import pos_order from . import crm_team from . import pos_session +from . import sale_order diff --git a/addons/pos_sale/models/pos_config.py b/addons/pos_sale/models/pos_config.py index 26e35bef430..8a1363845f2 100644 --- a/addons/pos_sale/models/pos_config.py +++ b/addons/pos_sale/models/pos_config.py @@ -12,6 +12,9 @@ class PosConfig(models.Model): crm_team_id = fields.Many2one( 'crm.team', string="Sales Team", ondelete="set null", help="This Point of sale's sales will be related to this Sales Team.") + down_payment_product_id = fields.Many2one('product.product', + string="Down Payment Product", + help="This product will be used as down payment on a sale order.") @api.onchange('company_id') def _get_default_pos_team(self): diff --git a/addons/pos_sale/models/pos_order.py b/addons/pos_sale/models/pos_order.py index 596cfc1f57c..4ca3b0f39b9 100644 --- a/addons/pos_sale/models/pos_order.py +++ b/addons/pos_sale/models/pos_order.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.tools import float_compare, float_is_zero class PosOrder(models.Model): @@ -9,6 +10,11 @@ class PosOrder(models.Model): currency_rate = fields.Float(compute='_compute_currency_rate', store=True, digits=0, readonly=True) crm_team_id = fields.Many2one('crm.team', string="Sales Team", ondelete="set null") + sale_order_count = fields.Integer(string='Sale Order Count', compute='_count_sale_order', readonly=True, groups="sales_team.group_sale_salesman") + + def _count_sale_order(self): + for order in self: + order.sale_order_count = len(order.lines.mapped('sale_order_origin_id')) @api.model def _complete_values_from_session(self, session, values): @@ -26,3 +32,91 @@ class PosOrder(models.Model): invoice_vals = super(PosOrder, self)._prepare_invoice_vals() invoice_vals['team_id'] = self.crm_team_id return invoice_vals + + @api.model + def create_from_ui(self, orders, draft=False): + order_ids = super(PosOrder, self).create_from_ui(orders, draft) + for order in self.sudo().browse([o['id'] for o in order_ids]): + for line in order.lines.filtered(lambda l: l.product_id == order.config_id.down_payment_product_id and l.qty > 0 and l.sale_order_origin_id): + sale_lines = line.sale_order_origin_id.order_line + sale_line = self.env['sale.order.line'].create({ + 'order_id': line.sale_order_origin_id.id, + 'product_id': line.product_id.id, + 'price_unit': line.price_unit, + 'product_uom_qty': 0, + 'tax_id': [(6, 0, line.tax_ids.ids)], + 'is_downpayment': True, + 'discount': line.discount, + 'sequence': sale_lines and sale_lines[-1].sequence + 1 or 10, + }) + sale_line._compute_tax_id() + line.sale_order_line_id = sale_line + + so_lines = order.lines.mapped('sale_order_line_id') + + # confirm the unconfirmed sale orders that are linked to the sale order lines + sale_orders = so_lines.mapped('order_id') + for sale_order in sale_orders.filtered(lambda so: so.state in ['draft', 'sent']): + sale_order.action_confirm() + + # update the demand qty in the stock moves related to the sale order line + # flush the qty_delivered to make sure the updated qty_delivered is used when + # updating the demand value + so_lines.flush(['qty_delivered']) + # track the waiting pickings + waiting_picking_ids = set() + for so_line in so_lines: + for stock_move in so_line.move_ids: + picking = stock_move.picking_id + if not picking.state in ['waiting', 'confirmed', 'assigned']: + continue + new_qty = so_line.product_uom_qty - so_line.qty_delivered + if float_compare(new_qty, 0, precision_rounding=stock_move.product_uom.rounding) <= 0: + new_qty = 0 + stock_move.product_uom_qty = so_line.product_uom._compute_quantity(new_qty, stock_move.product_uom, False) + waiting_picking_ids.add(picking.id) + + def is_product_uom_qty_zero(move): + return float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding) + + # cancel the waiting pickings if each product_uom_qty of move is zero + for picking in self.env['stock.picking'].browse(waiting_picking_ids): + if all(is_product_uom_qty_zero(move) for move in picking.move_lines): + picking.action_cancel() + + return order_ids + + def action_view_sale_order(self): + self.ensure_one() + linked_orders = self.lines.mapped('sale_order_origin_id') + return { + 'type': 'ir.actions.act_window', + 'name': _('Linked Sale Orders'), + 'res_model': 'sale.order', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', linked_orders.ids)], + } + + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + sale_order_origin_id = fields.Many2one('sale.order', string="Linked Sale Order") + sale_order_line_id = fields.Many2one('sale.order.line', string="Source Sale Order Line") + down_payment_details = fields.Text(string="Down Payment Details") + + def _export_for_ui(self, orderline): + result = super()._export_for_ui(orderline) + # NOTE We are not exporting 'sale_order_line_id' because it is being used in any views in the POS App. + result['down_payment_details'] = bool(orderline.down_payment_details) and orderline.down_payment_details + result['sale_order_origin_id'] = bool(orderline.sale_order_origin_id) and orderline.sale_order_origin_id.read(fields=['name'])[0] + return result + + def _order_line_fields(self, line, session_id): + result = super()._order_line_fields(line, session_id) + vals = result[2] + if vals.get('sale_order_origin_id', False): + vals['sale_order_origin_id'] = vals['sale_order_origin_id']['id'] + if vals.get('sale_order_line_id', False): + vals['sale_order_line_id'] = vals['sale_order_line_id']['id'] + return result diff --git a/addons/pos_sale/models/sale_order.py b/addons/pos_sale/models/sale_order.py new file mode 100644 index 00000000000..4dad36fdfb0 --- /dev/null +++ b/addons/pos_sale/models/sale_order.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + pos_order_line_ids = fields.One2many('pos.order.line', 'sale_order_origin_id', string="Order lines Transfered to Point of Sale", readonly=True, groups="point_of_sale.group_pos_user") + pos_order_count = fields.Integer(string='Pos Order Count', compute='_count_pos_order', readonly=True, groups="point_of_sale.group_pos_user") + + def _count_pos_order(self): + for order in self: + linked_orders = order.pos_order_line_ids.mapped('order_id') + order.pos_order_count = len(linked_orders) + + def action_view_pos_order(self): + self.ensure_one() + linked_orders = self.pos_order_line_ids.mapped('order_id') + return { + 'type': 'ir.actions.act_window', + 'name': _('Linked POS Orders'), + 'res_model': 'pos.order', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', linked_orders.ids)], + } + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + pos_order_line_ids = fields.One2many('pos.order.line', 'sale_order_line_id', string="Order lines Transfered to Point of Sale", readonly=True, groups="point_of_sale.group_pos_user") + + @api.depends('pos_order_line_ids.qty') + def _compute_qty_delivered(self): + super()._compute_qty_delivered() + for sale_line in self: + sale_line.qty_delivered += sum([self._convert_qty(sale_line, pos_line.qty, 'p2s') for pos_line in sale_line.pos_order_line_ids if sale_line.product_id.type != 'service'], 0) + + @api.depends('pos_order_line_ids.qty') + def _get_invoice_qty(self): + super()._get_invoice_qty() + for sale_line in self: + sale_line.qty_invoiced += sum([self._convert_qty(sale_line, pos_line.qty, 'p2s') for pos_line in sale_line.pos_order_line_ids], 0) + + def read_converted(self): + field_names = ["product_id", "price_unit", "product_uom_qty", "tax_id", "qty_delivered", "qty_invoiced", "discount", "qty_to_invoice", "price_total"] + results = [] + for sale_line in self: + if sale_line.product_type: + product_uom = sale_line.product_id.uom_id + sale_line_uom = sale_line.product_uom + item = sale_line.read(field_names)[0] + if sale_line.product_id.tracking != 'none': + item['lot_names'] = sale_line.move_ids.move_line_ids.lot_id.mapped('name') + if product_uom == sale_line_uom: + results.append(item) + continue + item['product_uom_qty'] = self._convert_qty(sale_line, item['product_uom_qty'], 's2p') + item['qty_delivered'] = self._convert_qty(sale_line, item['qty_delivered'], 's2p') + item['qty_invoiced'] = self._convert_qty(sale_line, item['qty_invoiced'], 's2p') + item['qty_to_invoice'] = self._convert_qty(sale_line, item['qty_to_invoice'], 's2p') + item['price_unit'] = sale_line_uom._compute_price(item['price_unit'], product_uom) + results.append(item) + + elif sale_line.display_type == 'line_note': + if results: + results[-1]['customer_note'] = sale_line.name + + return results + + @api.model + def _convert_qty(self, sale_line, qty, direction): + """Converts the given QTY based on the given SALE_LINE and DIR. + + if DIR='s2p': convert from sale line uom to product uom + if DIR='p2s': convert from product uom to sale line uom + """ + product_uom = sale_line.product_id.uom_id + sale_line_uom = sale_line.product_uom + if direction == 's2p': + return sale_line_uom._compute_quantity(qty, product_uom, False) + elif direction == 'p2s': + return product_uom._compute_quantity(qty, sale_line_uom, False) diff --git a/addons/pos_sale/report/sale_report.py b/addons/pos_sale/report/sale_report.py index 26836622374..59632df0c99 100644 --- a/addons/pos_sale/report/sale_report.py +++ b/addons/pos_sale/report/sale_report.py @@ -79,6 +79,10 @@ class SaleReport(models.Model): ''' return from_ + def _where_pos(self): + where_ = 'l.sale_order_line_id is NULL' + return where_ + def _group_by_pos(self): groupby_ = ''' l.order_id, @@ -108,7 +112,7 @@ class SaleReport(models.Model): if not fields: fields = {} res = super()._query(with_clause, fields, groupby, from_clause) - current = '(SELECT %s FROM %s GROUP BY %s)' % \ - (self._select_pos(fields), self._from_pos(), self._group_by_pos()) + current = '(SELECT %s FROM %s WHERE %s GROUP BY %s)' % \ + (self._select_pos(fields), self._from_pos(), self._where_pos(), self._group_by_pos()) return '%s UNION ALL %s' % (res, current) diff --git a/addons/pos_sale/static/src/css/pos_sale.css b/addons/pos_sale/static/src/css/pos_sale.css new file mode 100644 index 00000000000..00c73605efe --- /dev/null +++ b/addons/pos_sale/static/src/css/pos_sale.css @@ -0,0 +1,6 @@ +.sale-order-info td { + padding-right: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderFetcher.js b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderFetcher.js new file mode 100644 index 00000000000..aac4119fd3a --- /dev/null +++ b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderFetcher.js @@ -0,0 +1,132 @@ +odoo.define('pos_sale.SaleOrderFetcher', function (require) { + 'use strict'; + + const { EventBus } = owl.core; + const { Gui } = require('point_of_sale.Gui'); + const { isRpcError } = require('point_of_sale.utils'); + const models = require('point_of_sale.models'); + + class SaleOrderFetcher extends EventBus { + constructor() { + super(); + this.currentPage = 1; + this.ordersToShow = []; + this.totalCount = 0; + } + + + /** + * for nPerPage = 10 + * +--------+----------+ + * | nItems | lastPage | + * +--------+----------+ + * | 2 | 1 | + * | 10 | 1 | + * | 11 | 2 | + * | 30 | 3 | + * | 35 | 4 | + * +--------+----------+ + */ + get lastPage() { + const nItems = this.totalCount; + return Math.trunc(nItems / (this.nPerPage + 1)) + 1; + } + /** + * Calling this methods populates the `ordersToShow` then trigger `update` event. + * @related get + * + * NOTE: This is tightly-coupled with pagination. So if the current page contains all + * active orders, it will not fetch anything from the server but only sets `ordersToShow` + * to the active orders that fits the current page. + */ + async fetch() { + try { + let limit, offset; + let start, end; + // Show orders from the backend. + offset = + this.nPerPage + + (this.currentPage - 1 - 1) * + this.nPerPage; + limit = this.nPerPage; + this.ordersToShow = await this._fetch(limit, offset); + + this.trigger('update'); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + Gui.showPopup('ErrorPopup', { + title: this.comp.env._t('Network Error'), + body: this.comp.env._t('Unable to fetch orders if offline.'), + }); + Gui.setSyncStatus('error'); + } else { + throw error; + } + } + } + /** + * This returns the orders from the backend that needs to be shown. + * If the order is already in cache, the full information about that + * order is not fetched anymore, instead, we use info from cache. + * + * @param {number} limit + * @param {number} offset + */ + async _fetch(limit, offset) { + const sale_orders = await this._getOrderIdsForCurrentPage(limit, offset); + + this.totalCount = sale_orders.length; + return sale_orders; + } + async _getOrderIdsForCurrentPage(limit, offset) { + return await this.rpc({ + model: 'sale.order', + method: 'search_read', + args: [this.searchDomain ? this.searchDomain : [], ['name', 'partner_id', 'amount_total', 'date_order', 'state', 'user_id'], offset, limit], + context: this.comp.env.session.user_context, + }); + } + + nextPage() { + if (this.currentPage < this.lastPage) { + this.currentPage += 1; + this.fetch(); + } + } + prevPage() { + if (this.currentPage > 1) { + this.currentPage -= 1; + this.fetch(); + } + } + /** + * @param {integer|undefined} id id of the cached order + * @returns {Array} + */ + get(id) { + return this.ordersToShow; + } + setSearchDomain(searchDomain) { + this.searchDomain = searchDomain; + } + setComponent(comp) { + this.comp = comp; + return this; + } + setNPerPage(val) { + this.nPerPage = val; + } + setPage(page) { + this.currentPage = page; + } + + async rpc() { + Gui.setSyncStatus('connecting'); + const result = await this.comp.rpc(...arguments); + Gui.setSyncStatus('connected'); + return result; + } + } + + return new SaleOrderFetcher(); +}); diff --git a/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderList.js b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderList.js new file mode 100644 index 00000000000..9019f19c279 --- /dev/null +++ b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderList.js @@ -0,0 +1,31 @@ +odoo.define('pos_sale.SaleOrderList', function (require) { + 'use strict'; + + const { useState } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} [initHighlightedOrder] initially highligted order + * @props {Array} orders + */ + class SaleOrderList extends PosComponent { + constructor() { + super(...arguments); + useListener('click-order', this._onClickOrder); + this.state = useState({ highlightedOrder: this.props.initHighlightedOrder || null }); + } + get highlightedOrder() { + return this.state.highlightedOrder; + } + _onClickOrder({ detail: order }) { + this.state.highlightedOrder = order; + } + } + SaleOrderList.template = 'SaleOrderList'; + + Registries.Component.add(SaleOrderList); + + return SaleOrderList; +}); diff --git a/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementControlPanel.js b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementControlPanel.js new file mode 100644 index 00000000000..a8340ba221d --- /dev/null +++ b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementControlPanel.js @@ -0,0 +1,129 @@ +odoo.define('pos_sale.SaleOrderManagementControlPanel', function (require) { + 'use strict'; + + const { useContext } = owl.hooks; + const { useAutofocus, useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const SaleOrderFetcher = require('pos_sale.SaleOrderFetcher'); + const contexts = require('point_of_sale.PosContext'); + + // NOTE: These are constants so that they are only instantiated once + // and they can be used efficiently by the OrderManagementControlPanel. + const VALID_SEARCH_TAGS = new Set(['date', 'customer', 'client', 'name', 'order']); + const FIELD_MAP = { + date: 'date_order', + customer: 'partner_id.display_name', + client: 'partner_id.display_name', + name: 'name', + order: 'name', + }; + const SEARCH_FIELDS = ['name', 'partner_id.display_name', 'date_order']; + + /** + * @emits close-screen + * @emits prev-page + * @emits next-page + * @emits search + */ + class SaleOrderManagementControlPanel extends PosComponent { + constructor() { + super(...arguments); + // We are using context because we want the `searchString` to be alive + // even if this component is destroyed (unmounted). + this.orderManagementContext = useContext(contexts.orderManagement); + useListener('clear-search', this._onClearSearch); + useAutofocus({ selector: 'input' }); + + let currentClient = this.env.pos.get_client(); + if (currentClient) { + this.orderManagementContext.searchString = currentClient.name; + let domain = this._computeDomain(); + SaleOrderFetcher.setSearchDomain(domain); + } + } + onInputKeydown(event) { + if (event.key === 'Enter') { + this.trigger('search', this._computeDomain()); + } + } + get showPageControls() { + return SaleOrderFetcher.lastPage > 1; + } + get pageNumber() { + const currentPage = SaleOrderFetcher.currentPage; + const lastPage = SaleOrderFetcher.lastPage; + return isNaN(lastPage) ? '' : `(${currentPage}/${lastPage})`; + } + get validSearchTags() { + return VALID_SEARCH_TAGS; + } + get fieldMap() { + return FIELD_MAP; + } + get searchFields() { + return SEARCH_FIELDS; + } + /** + * E.g. 1 + * ``` + * searchString = 'Customer 1' + * result = [ + * '|', + * '|', + * ['pos_reference', 'ilike', '%Customer 1%'], + * ['partner_id.display_name', 'ilike', '%Customer 1%'], + * ['date_order', 'ilike', '%Customer 1%'] + * ] + * ``` + * + * E.g. 2 + * ``` + * searchString = 'date: 2020-05' + * result = [ + * ['date_order', 'ilike', '%2020-05%'] + * ] + * ``` + * + * E.g. 3 + * ``` + * searchString = 'customer: Steward, date: 2020-05-01' + * result = [ + * ['partner_id.display_name', 'ilike', '%Steward%'], + * ['date_order', 'ilike', '%2020-05-01%'] + * ] + * ``` + */ + _computeDomain() { + let domain = [['state', '!=', 'cancel'],['invoice_status', '!=', 'invoiced']]; + const input = this.orderManagementContext.searchString.trim(); + if (!input) return domain; + + const searchConditions = this.orderManagementContext.searchString.split(/[,&]\s*/); + if (searchConditions.length === 1) { + let cond = searchConditions[0].split(/:\s*/); + if (cond.length === 1) { + domain = domain.concat(Array(this.searchFields.length - 1).fill('|')); + domain = domain.concat(this.searchFields.map((field) => [field, 'ilike', `%${cond[0]}%`])); + return domain; + } + } + + for (let cond of searchConditions) { + let [tag, value] = cond.split(/:\s*/); + if (!this.validSearchTags.has(tag)) continue; + domain.push([this.fieldMap[tag], 'ilike', `%${value}%`]); + } + return domain; + } + _onClearSearch() { + this.orderManagementContext.searchString = ''; + this.onInputKeydown({ key: 'Enter' }); + } + } + SaleOrderManagementControlPanel.template = 'SaleOrderManagementControlPanel'; + + Registries.Component.add(SaleOrderManagementControlPanel); + + return SaleOrderManagementControlPanel; +}); diff --git a/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementScreen.js b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementScreen.js new file mode 100644 index 00000000000..735fdc6e70c --- /dev/null +++ b/addons/pos_sale/static/src/js/OrderManagementScreen/SaleOrderManagementScreen.js @@ -0,0 +1,261 @@ +odoo.define('pos_sale.SaleOrderManagementScreen', function (require) { + 'use strict'; + + const { sprintf } = require('web.utils'); + const { parse } = require('web.field_utils'); + const { useContext } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const Registries = require('point_of_sale.Registries'); + const SaleOrderFetcher = require('pos_sale.SaleOrderFetcher'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const contexts = require('point_of_sale.PosContext'); + const models = require('point_of_sale.models'); + + class SaleOrderManagementScreen extends ControlButtonsMixin(IndependentToOrderScreen) { + constructor() { + super(...arguments); + useListener('close-screen', this.close); + useListener('click-sale-order', this._onClickSaleOrder); + useListener('next-page', this._onNextPage); + useListener('prev-page', this._onPrevPage); + useListener('search', this._onSearch); + + SaleOrderFetcher.setComponent(this); + this.orderManagementContext = useContext(contexts.orderManagement); + } + mounted() { + SaleOrderFetcher.on('update', this, this.render); + this.env.pos.get('orders').on('add remove', this.render, this); + + // calculate how many can fit in the screen. + // It is based on the height of the header element. + // So the result is only accurate if each row is just single line. + const flexContainer = this.el.querySelector('.flex-container'); + const cpEl = this.el.querySelector('.control-panel'); + const headerEl = this.el.querySelector('.order-row.header'); + const val = Math.trunc( + (flexContainer.offsetHeight - cpEl.offsetHeight - headerEl.offsetHeight) / + headerEl.offsetHeight + ); + SaleOrderFetcher.setNPerPage(val); + + // Fetch the order after mounting so that order management screen + // is shown while fetching. + setTimeout(() => SaleOrderFetcher.fetch(), 0); + } + willUnmount() { + SaleOrderFetcher.off('update', this); + this.env.pos.get('orders').off('add remove', null, this); + } + get selectedClient() { + const order = this.orderManagementContext.selectedOrder; + return order ? order.get_client() : null; + } + get orders() { + return SaleOrderFetcher.get(); + } + async _setNumpadMode(event) { + const { mode } = event.detail; + this.numpadMode = mode; + NumberBuffer.reset(); + } + _onNextPage() { + SaleOrderFetcher.nextPage(); + } + _onPrevPage() { + SaleOrderFetcher.prevPage(); + } + _onSearch({ detail: domain }) { + SaleOrderFetcher.setSearchDomain(domain); + SaleOrderFetcher.setPage(1); + SaleOrderFetcher.fetch(); + } + async _onClickSaleOrder(event) { + const clickedOrder = event.detail; + const { confirmed, payload: selectedOption } = await this.showPopup('SelectionPopup', + { + title: this.env._t('What do you want to do?'), + list: [{id:"0", label: "Apply a down payment", item: false}, {id:"1", label: "Settle the order", item: true}], + }); + + if(confirmed){ + let currentPOSOrder = this.env.pos.get_order(); + let sale_order = await this._getSaleOrder(clickedOrder.id); + try { + await this.env.pos.load_new_partners(); + } + catch (error){ + } + currentPOSOrder.set_client(this.env.pos.db.get_partner_by_id(sale_order.partner_id[0])); + let orderFiscalPos = sale_order.fiscal_position_id ? this.env.pos.fiscal_positions.find( + (position) => position.id === sale_order.fiscal_position_id[0] + ) + : false; + if (orderFiscalPos){ + currentPOSOrder.fiscal_position = orderFiscalPos; + } + let orderPricelist = sale_order.pricelist_id ? this.env.pos.pricelists.find( + (pricelist) => pricelist.id === sale_order.pricelist_id[0] + ) + : false; + if (orderPricelist){ + currentPOSOrder.set_pricelist(orderPricelist); + } + + if (selectedOption){ + // settle the order + let lines = sale_order.order_line; + let product_to_add_in_pos = lines.filter(line => !this.env.pos.db.get_product_by_id(line.product_id[0])).map(line => line.product_id[0]); + if (product_to_add_in_pos.length){ + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Products not available in POS'), + body: + this.env._t( + 'Some of the products in your Sale Order are not available in POS, do you want to import them?' + ), + confirmText: this.env._t('Yes'), + cancelText: this.env._t('No'), + }); + if (confirmed){ + await this.env.pos._addProducts(product_to_add_in_pos); + } + + } + + /** + * This variable will have 3 values, `undefined | false | true`. + * Initially, it is `undefined`. When looping thru each sale.order.line, + * when a line comes with lots (`.lot_names`), we use these lot names + * as the pack lot of the generated pos.order.line. We ask the user + * if he wants to use the lots that come with the sale.order.lines to + * be used on the corresponding pos.order.line only once. So, once the + * `useLoadedLots` becomes true, it will be true for the succeeding lines, + * and vice versa. + */ + let useLoadedLots; + + for (var i = 0; i < lines.length; i++) { + let line = lines[i]; + if (!this.env.pos.db.get_product_by_id(line.product_id[0])){ + continue; + } + + let new_line = new models.Orderline({}, { + pos: this.env.pos, + order: this.env.pos.get_order(), + product: this.env.pos.db.get_product_by_id(line.product_id[0]), + price: line.price_unit, + price_manually_set: true, + sale_order_origin_id: clickedOrder, + sale_order_line_id: line, + customer_note: line.customer_note, + }); + + if ( + new_line.get_product().tracking !== 'none' && + (this.env.pos.picking_type.use_create_lots || this.env.pos.picking_type.use_existing_lots) && + line.lot_names.length > 0 + ) { + // Ask once when `useLoadedLots` is undefined, then reuse it's value on the succeeding lines. + const { confirmed } = + useLoadedLots === undefined + ? await this.showPopup('ConfirmPopup', { + title: this.env._t('SN/Lots Loading'), + body: this.env._t( + 'Do you want to load the SN/Lots linked to the Sales Order?' + ), + confirmText: this.env._t('Yes'), + cancelText: this.env._t('No'), + }) + : { confirmed: useLoadedLots }; + useLoadedLots = confirmed; + if (useLoadedLots) { + new_line.setPackLotLines({ + modifiedPackLotLines: [], + newPackLotLines: (line.lot_names || []).map((name) => ({ lot_name: name })), + }); + } + } + new_line.setQuantityFromSOL(line); + new_line.set_unit_price(line.price_unit); + new_line.set_discount(line.discount); + this.env.pos.get_order().add_orderline(new_line); + } + } + else { + // apply a downpayment + let lines = sale_order.order_line; + let tab = []; + + for (let i=0; i + class extends ProductScreen { + async _newOrderlineSelected() { + await super._newOrderlineSelected(); + this._setNumpadModeToPrice(); + } + mounted() { + super.mounted(); + this._setNumpadModeToPrice(); + } + _setNumpadModeToPrice() { + if (this.currentOrder) { + const selectedOrderline = this.currentOrder.get_selected_orderline(); + if (selectedOrderline && selectedOrderline.product.id === selectedOrderline.pos.config.down_payment_product_id[0]) { + this.state.numpadMode = 'price'; + } + } + } + }; + + Registries.Component.extend(ProductScreen, PosSaleProductScreen); + + return ProductScreen; +}); diff --git a/addons/pos_sale/static/src/js/SetSaleOrderButton.js b/addons/pos_sale/static/src/js/SetSaleOrderButton.js new file mode 100644 index 00000000000..5a8ac15a61d --- /dev/null +++ b/addons/pos_sale/static/src/js/SetSaleOrderButton.js @@ -0,0 +1,60 @@ +odoo.define('pos_sale.SetSaleOrderButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ProductScreen = require('point_of_sale.ProductScreen'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const { isRpcError } = require('point_of_sale.utils'); + + class SetSaleOrderButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + mounted() { + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + get currentOrder() { + return this.env.pos.get_order(); + } + async onClick() { + try { + // ping the server, if no error, show the screen + await this.rpc({ + model: 'sale.order', + method: 'browse', + args: [[]], + kwargs: { context: this.env.session.user_context }, + }); + this.showScreen('SaleOrderManagementScreen'); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Cannot access order management screen if offline.'), + }); + } else { + throw error; + } + } + } + } + SetSaleOrderButton.template = 'SetSaleOrderButton'; + + ProductScreen.addControlButton({ + component: SetSaleOrderButton, + condition: function() { + return true; + }, + }); + + Registries.Component.add(SetSaleOrderButton); + + return SetSaleOrderButton; +}); diff --git a/addons/pos_sale/static/src/js/models.js b/addons/pos_sale/static/src/js/models.js new file mode 100644 index 00000000000..a1039368f33 --- /dev/null +++ b/addons/pos_sale/static/src/js/models.js @@ -0,0 +1,83 @@ +odoo.define('pos_sale.models', function (require) { + "use strict"; + +var models = require('point_of_sale.models'); + + + +var existing_models = models.PosModel.prototype.models; +var product_index = _.findIndex(existing_models, function (model) { + return model.model === "product.product"; +}); +var product_model = existing_models[product_index]; + +models.load_models([{ + model: product_model.model, + fields: product_model.fields, + order: product_model.order, + domain: function(self) {return [['id', '=', self.config.down_payment_product_id[0]]];}, + context: product_model.context, + loaded: product_model.loaded, +}]); + +models.load_fields("product.product", ["invoice_policy", "type"]); + +var super_order_line_model = models.Orderline.prototype; +models.Orderline = models.Orderline.extend({ + initialize: function (attributes, options) { + super_order_line_model.initialize.apply(this, arguments); + // It is possible that this orderline is initialized using `init_from_JSON`, + // meaning, it is loaded from localStorage or from export_for_ui. This means + // that some fields has already been assigned. Therefore, we only set the options + // when the original value is falsy. + this.sale_order_origin_id = this.sale_order_origin_id || options.sale_order_origin_id; + this.sale_order_line_id = this.sale_order_line_id || options.sale_order_line_id; + this.down_payment_details = this.down_payment_details || options.down_payment_details; + this.customerNote = this.customerNote || options.customer_note; + }, + init_from_JSON: function (json) { + super_order_line_model.init_from_JSON.apply(this, arguments); + this.sale_order_origin_id = json.sale_order_origin_id; + this.sale_order_line_id = json.sale_order_line_id; + this.down_payment_details = json.down_payment_details && JSON.parse(json.down_payment_details); + }, + export_as_JSON: function () { + const json = super_order_line_model.export_as_JSON.apply(this, arguments); + json.sale_order_origin_id = this.sale_order_origin_id; + json.sale_order_line_id = this.sale_order_line_id; + json.down_payment_details = this.down_payment_details && JSON.stringify(this.down_payment_details); + return json; + }, + get_sale_order: function(){ + if(this.sale_order_origin_id) { + let value = { + 'name': this.sale_order_origin_id.name, + 'details': this.down_payment_details || false + } + + return value; + } + return false; + }, + export_for_printing: function() { + var json = super_order_line_model.export_for_printing.apply(this,arguments); + json.down_payment_details = this.down_payment_details; + if (this.sale_order_origin_id) { + json.so_reference = this.sale_order_origin_id.name; + } + return json; + }, + /** + * Set quantity based on the give sale order line. + * @param {'sale.order.line'} saleOrderLine + */ + setQuantityFromSOL: function(saleOrderLine) { + if (this.product.type === 'service') { + this.set_quantity(saleOrderLine.qty_to_invoice); + } else { + this.set_quantity(saleOrderLine.product_uom_qty - Math.max(saleOrderLine.qty_delivered, saleOrderLine.qty_invoiced)); + } + } +}); + +}); diff --git a/addons/pos_sale/static/src/xml/OrderManagementScreen/MobileSaleOrderManagementScreen.xml b/addons/pos_sale/static/src/xml/OrderManagementScreen/MobileSaleOrderManagementScreen.xml new file mode 100644 index 00000000000..f1ca0d070c5 --- /dev/null +++ b/addons/pos_sale/static/src/xml/OrderManagementScreen/MobileSaleOrderManagementScreen.xml @@ -0,0 +1,30 @@ + + + +
+
+ +
+
+ + + +
+
+ + +
+
+
+ Back to list +
+
+
+
+ + +
+
+
+ +
diff --git a/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderList.xml b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderList.xml new file mode 100644 index 00000000000..36fa0df8941 --- /dev/null +++ b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderList.xml @@ -0,0 +1,22 @@ + + + + +
+
+
Order
+
Date
+
Customer
+
Salesperson
+
Total
+
State
+
+
+ + + +
+
+
+ +
diff --git a/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderManagementControlPanel.xml b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderManagementControlPanel.xml new file mode 100644 index 00000000000..1544c67a93a --- /dev/null +++ b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderManagementControlPanel.xml @@ -0,0 +1,36 @@ + + + + +
+
+ + Back +
+ +
+
+ + +
+
+ +
+
+
+
+
+ +
diff --git a/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderManagementScreen.xml b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderManagementScreen.xml new file mode 100644 index 00000000000..7101ecba9b3 --- /dev/null +++ b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderManagementScreen.xml @@ -0,0 +1,18 @@ + + + + +
+
+
+
+ + +
+
+
+ +
+
+ +
diff --git a/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderRow.xml b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderRow.xml new file mode 100644 index 00000000000..c4ba4ee1d14 --- /dev/null +++ b/addons/pos_sale/static/src/xml/OrderManagementScreen/SaleOrderRow.xml @@ -0,0 +1,17 @@ + + + + +
+
+
+
+
+
+
+
+
+ +
diff --git a/addons/pos_sale/static/src/xml/ProductScreen/Orderline.xml b/addons/pos_sale/static/src/xml/ProductScreen/Orderline.xml new file mode 100644 index 00000000000..4e256080c0d --- /dev/null +++ b/addons/pos_sale/static/src/xml/ProductScreen/Orderline.xml @@ -0,0 +1,27 @@ + + + + + + +
  • + + +
  • + + + + + + + + + +
    x + + : (tax incl.)
    +
    +
    +
    + +
    diff --git a/addons/pos_sale/static/src/xml/ReceiptScreen/OrderReceipt.xml b/addons/pos_sale/static/src/xml/ReceiptScreen/OrderReceipt.xml new file mode 100644 index 00000000000..4acd14755d2 --- /dev/null +++ b/addons/pos_sale/static/src/xml/ReceiptScreen/OrderReceipt.xml @@ -0,0 +1,20 @@ + + + + + +
    From
    +
    + + + + + +
    x + +
    +
    +
    +
    + +
    diff --git a/addons/pos_sale/static/src/xml/SetSaleOrderButton.xml b/addons/pos_sale/static/src/xml/SetSaleOrderButton.xml new file mode 100644 index 00000000000..8504e372687 --- /dev/null +++ b/addons/pos_sale/static/src/xml/SetSaleOrderButton.xml @@ -0,0 +1,11 @@ + + + + +
    + Quotation/Order +
    +
    + +
    diff --git a/addons/pos_sale/views/point_of_sale_report.xml b/addons/pos_sale/views/point_of_sale_report.xml new file mode 100644 index 00000000000..0e295a04cd4 --- /dev/null +++ b/addons/pos_sale/views/point_of_sale_report.xml @@ -0,0 +1,55 @@ + + + + diff --git a/addons/pos_sale/views/pos_config_views.xml b/addons/pos_sale/views/pos_config_views.xml index 54ae37ab128..86fbc27062c 100644 --- a/addons/pos_sale/views/pos_config_views.xml +++ b/addons/pos_sale/views/pos_config_views.xml @@ -7,7 +7,7 @@
    -

    Sales Reporting

    +

    Sales

    @@ -18,6 +18,19 @@
    +
    +
    + Down Payment Product +
    + This product will be applied when down payment is made +
    + +
    +
    diff --git a/addons/pos_sale/views/pos_order_views.xml b/addons/pos_sale/views/pos_order_views.xml new file mode 100644 index 00000000000..3ab85e109af --- /dev/null +++ b/addons/pos_sale/views/pos_order_views.xml @@ -0,0 +1,26 @@ + + + + + pos.order.form.pos.sale + pos.order + + + + + + + + + diff --git a/addons/pos_sale/views/sale_order_views.xml b/addons/pos_sale/views/sale_order_views.xml new file mode 100644 index 00000000000..dda98f44efd --- /dev/null +++ b/addons/pos_sale/views/sale_order_views.xml @@ -0,0 +1,26 @@ + + + + + sale.order.form.pos.sale + sale.order + + + + + + + + + diff --git a/addons/pos_sale/views/stock_template.xml b/addons/pos_sale/views/stock_template.xml new file mode 100644 index 00000000000..c0fa478d7a4 --- /dev/null +++ b/addons/pos_sale/views/stock_template.xml @@ -0,0 +1,18 @@ + + + + diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index 017195ad257..32ec48d5aba 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -326,7 +326,7 @@ class SaleOrderLine(models.Model): # task_global_project: create task in global project for so_line in so_line_task_global_project: if not so_line.task_id: - if map_sol_project.get(so_line.id): + if map_sol_project.get(so_line.id) and so_line.product_uom_qty > 0: so_line._timesheet_create_task(project=map_sol_project[so_line.id]) # project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too. diff --git a/odoo/models.py b/odoo/models.py index 7a42e9a8754..2a842ce6789 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -1409,7 +1409,7 @@ class BaseModel(metaclass=MetaModel): # convert default values to the right format # # we explicitly avoid using _convert_to_write() for x2many fields, - # because the latter leaves values like [(Command.LINK, 2), + # because the latter leaves values like [(Command.LINK, 2), # (Command.LINK, 3)], which are not supported by the web client as # default values; stepping through the cache allows to normalize # such a list to [(Command.SET, 0, [2, 3])], which is properly @@ -2316,8 +2316,8 @@ class BaseModel(metaclass=MetaModel): @api.model def _read_group_format_result(self, data, annotated_groupbys, groupby, domain): """ - Helper method to format the data contained in the dictionary data by - adding the domain corresponding to its values, the groupbys in the + Helper method to format the data contained in the dictionary data by + adding the domain corresponding to its values, the groupbys in the context and by properly formatting the date/datetime values. :param data: a single group @@ -2396,10 +2396,10 @@ class BaseModel(metaclass=MetaModel): The possible aggregation functions are the ones provided by PostgreSQL (https://www.postgresql.org/docs/current/static/functions-aggregate.html) and 'count_distinct', with the expected meaning. - :param list groupby: list of groupby descriptions by which the records will be grouped. + :param list groupby: list of groupby descriptions by which the records will be grouped. A groupby description is either a field (then it will be grouped by that field) or a string 'field:groupby_function'. Right now, the only functions supported - are 'day', 'week', 'month', 'quarter' or 'year', and they only make sense for + are 'day', 'week', 'month', 'quarter' or 'year', and they only make sense for date/datetime fields. :param int offset: optional number of records to skip :param int limit: optional max number of records to return @@ -2407,7 +2407,7 @@ class BaseModel(metaclass=MetaModel): overriding the natural sort ordering of the groups, see also :py:meth:`~osv.osv.osv.search` (supported only for many2one fields currently) - :param bool lazy: if true, the results are only grouped by the first groupby and the + :param bool lazy: if true, the results are only grouped by the first groupby and the remaining groupbys are put in the __context key. If false, all the groupbys are done in one call. :return: list of dictionaries(one dictionary for each record) containing: @@ -4132,7 +4132,7 @@ Fields: # that this limit is well managed by PostgreSQL. # In INSERT queries, we inject integers (small) and larger data (TEXT blocks for # example). - # + # # The problem then becomes: how to "estimate" the right size of the batch to have # good performance? # @@ -6540,7 +6540,7 @@ Fields: field_generators = self._populate_factories() if not field_generators: return self.browse() # maybe create an automatic generator? - + records_batches = [] generator = populate.chain_factories(field_generators, self._name) while record_count <= min_size or not complete: