diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 00c22a7b8fc..22a27a3624c 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -719,8 +719,11 @@ class PosConfig(models.Model): """ self.env.cr.execute(query, params + [20000]) product_ids = self.env.cr.fetchall() - products = self.env['product.product'].search_read([('id', 'in', product_ids)], fields=fields) - return products + products = self.env['product.product'].search([('id', 'in', product_ids)]) + product_combo = products.filtered(lambda p: p['detailed_type'] == 'combo') + product_in_combo = product_combo.combo_ids.combo_line_ids.product_id + products_available = products | product_in_combo + return products_available.read(fields) def get_limited_partners_loading(self): self.env.cr.execute(""" diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index a5e2478f580..f77f7547a97 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -1179,6 +1179,10 @@ class PosOrderLine(models.Model): notice = fields.Char(string='Discount Notice') product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], required=True, change_default=True) attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Selected Attributes") + custom_attribute_value_ids = fields.One2many( + comodel_name='product.attribute.custom.value', inverse_name='pos_order_line_id', + string="Custom Values", + store=True, readonly=False) price_unit = fields.Float(string='Unit Price', digits=0) qty = fields.Float('Quantity', digits='Product Unit of Measure', default=1) price_subtotal = fields.Float(string='Subtotal w/o Tax', digits=0, @@ -1313,6 +1317,8 @@ class PosOrderLine(models.Model): return { 'id': orderline.id, 'qty': orderline.qty, + 'attribute_value_ids': orderline.attribute_value_ids.ids, + 'custom_attribute_value_ids': orderline.custom_attribute_value_ids.read(['id', 'name', 'custom_product_template_attribute_value_id', 'custom_value'], load=False), 'price_unit': orderline.price_unit, 'skip_change': orderline.skip_change, 'uuid': orderline.uuid, diff --git a/addons/point_of_sale/models/product.py b/addons/point_of_sale/models/product.py index b223e5e5abd..78caf1118ee 100644 --- a/addons/point_of_sale/models/product.py +++ b/addons/point_of_sale/models/product.py @@ -117,6 +117,10 @@ class ProductProduct(models.Model): 'variants': variant_list } +class ProductAttributeCustomValue(models.Model): + _inherit = "product.attribute.custom.value" + + pos_order_line_id = fields.Many2one('pos.order.line', string="PoS Order Line", ondelete='cascade') class UomCateg(models.Model): _inherit = 'uom.category' diff --git a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js index fec1d3ec129..5d1882bee76 100644 --- a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js +++ b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js @@ -98,7 +98,6 @@ export class ProductScreen extends ControlButtonsMixin(Component) { this.numberBuffer.sendKey(buttonValue); } - selectLine(orderline) { this.numberBuffer.reset(); this.currentOrder.select_orderline(orderline); @@ -211,7 +210,7 @@ export class ProductScreen extends ControlButtonsMixin(Component) { const { product_id = [], packaging = [] } = await this.orm.silent.call( "pos.session", "find_product_by_barcode", - [odoo.pos_session_id, code.base_code], + [odoo.pos_session_id, code.base_code] ); if (product_id.length) { await this.pos._addProducts(product_id, false); diff --git a/addons/point_of_sale/static/src/app/store/db.js b/addons/point_of_sale/static/src/app/store/db.js index f45345a2d5c..0cccdbd29c6 100644 --- a/addons/point_of_sale/static/src/app/store/db.js +++ b/addons/point_of_sale/static/src/app/store/db.js @@ -35,6 +35,9 @@ export class PosDB { this.product_by_category_id = {}; this.product_packaging_by_barcode = {}; + this.attribute_by_id = {}; + this.attribute_value_by_id = {}; + this.partner_sorted = []; this.partner_by_id = {}; this.partner_by_barcode = {}; @@ -52,6 +55,15 @@ export class PosDB { this.combo_line_by_id = {}; } + add_attributes(attribute) { + for (const attr of Object.values(attribute)) { + this.attribute_by_id[attr.id] = attr; + for (const val of attr.values) { + val.attribute_id = attr.id; + this.attribute_value_by_id[val.id] = val; + } + } + } /** * sets an uuid to prevent conflict in locally stored data between multiple PoS Configs. By * using the uuid of the config the local storage from other configs will not get effected nor @@ -340,7 +352,7 @@ export class PosDB { } } add_packagings(productPackagings) { - productPackagings?.forEach(productPackaging => { + productPackagings?.forEach((productPackaging) => { if (productPackaging.product_id[0] in this.product_by_id) { this.product_packaging_by_barcode[productPackaging.barcode] = productPackaging; } diff --git a/addons/point_of_sale/static/src/app/store/models.js b/addons/point_of_sale/static/src/app/store/models.js index 2aff5054ec5..c18b5969851 100644 --- a/addons/point_of_sale/static/src/app/store/models.js +++ b/addons/point_of_sale/static/src/app/store/models.js @@ -22,6 +22,7 @@ import { ComboConfiguratorPopup } from "./combo_configurator_popup/combo_configu import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup"; import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; +import { ProductCustomAttribute } from "./models/product_custom_attribute"; const { DateTime } = luxon; @@ -143,9 +144,10 @@ export class Product extends PosModel { } async getAddProductOptions(code) { let price_extra = 0.0; - let draftPackLotLines, description, packLotLinesToEdit, attribute_value_ids; + let draftPackLotLines, packLotLinesToEdit, attribute_value_ids; let quantity = 1; let comboLines = []; + let attribute_custom_values = {}; if (code && this.pos.db.product_packaging_by_barcode[code.code]) { quantity = this.pos.db.product_packaging_by_barcode[code.code].qty; @@ -165,8 +167,8 @@ export class Product extends PosModel { ); if (confirmed) { - description = payload.selected_attributes.join(", "); attribute_value_ids = payload.attribute_value_ids; + attribute_custom_values = payload.attribute_custom_values; price_extra += payload.price_extra; quantity = payload.quantity; } else { @@ -238,7 +240,7 @@ export class Product extends PosModel { return { draftPackLotLines, quantity, - description, + attribute_custom_values, price_extra, comboLines, attribute_value_ids, @@ -380,13 +382,13 @@ export class Orderline extends PosModel { options.quantity ? this.set_quantity(options.quantity) : this.set_quantity(1); this.discount = 0; this.note = ""; + this.custom_attribute_value_ids = []; this.hasChange = false; this.skipChange = false; this.discountStr = "0"; this.selected = false; - this.description = ""; this.price_extra = 0; - this.full_product_name = options.description || ""; + this.full_product_name = ""; this.id = orderline_id++; this.customerNote = this.customerNote || ""; this.saved_quantity = 0; @@ -404,10 +406,15 @@ export class Orderline extends PosModel { this.price_type = json.price_type; this.set_discount(json.discount); this.set_quantity(json.qty, "do not recompute unit price"); - this.set_description(json.description); this.attribute_value_ids = json.attribute_value_ids || []; this.set_price_extra(json.price_extra); - this.set_full_product_name(json.full_product_name); + this.custom_attribute_value_ids = json.custom_attribute_value_ids.map((attr) => { + if (attr.length > 0) { + attr = attr[2]; + } + return new ProductCustomAttribute(attr); + }); + this.set_full_product_name(); this.id = json.server_id || json.id || orderline_id++; orderline_id = Math.max(this.id + 1, orderline_id); var pack_lot_lines = json.pack_lot_ids; @@ -440,6 +447,7 @@ export class Orderline extends PosModel { } ); orderline.order = null; + orderline.custom_attribute_value_ids = this.custom_attribute_value_ids; orderline.quantity = this.quantity; orderline.quantityStr = this.quantityStr; orderline.discount = this.discount; @@ -555,14 +563,31 @@ export class Orderline extends PosModel { get_discount_str() { return this.discountStr; } - set_description(description) { - this.description = description || ""; - } set_price_extra(price_extra) { this.price_extra = parseFloat(price_extra) || 0.0; } - set_full_product_name(full_product_name) { - this.full_product_name = full_product_name || ""; + set_full_product_name() { + let attributeString = ""; + + if (this.attribute_value_ids && this.attribute_value_ids.length > 0) { + for (const valId of this.attribute_value_ids) { + const value = this.pos.db.attribute_value_by_id[valId]; + if (value.is_custom) { + const customValue = this.custom_attribute_value_ids.find( + (cus) => cus.custom_product_template_attribute_value_id == parseInt(valId) + ); + attributeString += customValue + ? `${value.name}: ${customValue.custom_value}, ` + : `${value.name}, `; + } else { + attributeString += `${value.name}, `; + } + } + attributeString = attributeString.slice(0, -2); + attributeString = `(${attributeString})`; + } + + this.full_product_name = `${this.product.display_name} ${attributeString}`; } get_price_extra() { return this.price_extra; @@ -696,14 +721,7 @@ export class Orderline extends PosModel { return this.product; } get_full_product_name() { - if (this.full_product_name) { - return this.full_product_name; - } - var full_name = this.product.display_name; - if (this.description) { - full_name += ` (${this.description})`; - } - return full_name; + return this.full_product_name || this.product.display_name; } // selects or deselects this orderline set_selected(selected) { @@ -746,7 +764,7 @@ export class Orderline extends PosModel { this.product.tracking === "lot" && (this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots) ) && - this.description === orderline.description && + this.full_product_name === orderline.full_product_name && orderline.get_customer_note() === this.get_customer_note() && !this.refunded_orderline_id && !this.isPartOfCombo() && @@ -770,6 +788,7 @@ export class Orderline extends PosModel { return { uuid: this.uuid, skip_change: this.skipChange, + custom_attribute_value_ids: this.custom_attribute_value_ids.map((attr) => [0, 0, attr]), qty: this.get_quantity(), price_unit: this.get_unit_price(), price_subtotal: this.get_price_without_tax(), @@ -779,8 +798,7 @@ export class Orderline extends PosModel { tax_ids: [[6, false, this.get_applicable_taxes().map((tax) => tax.id)]], id: this.id, pack_lot_ids: pack_lot_ids, - description: this.description, - attribute_value_ids: this.attribute_value_ids, + attribute_value_ids: this.attribute_value_ids || [], full_product_name: this.get_full_product_name(), price_extra: this.get_price_extra(), customer_note: this.get_customer_note(), @@ -818,6 +836,7 @@ export class Orderline extends PosModel { customer_note: this.get_customer_note(), taxed_lst_unit_price: this.get_taxed_lst_unit_price(), isPartOfCombo: this.isPartOfCombo(), + custom_attribute_value_ids: this.custom_attribute_value_ids, unitDisplayPriceBeforeDiscount: this.getUnitDisplayPriceBeforeDiscount(), }; } @@ -1084,7 +1103,7 @@ export class Orderline extends PosModel { return !selectedLine ? false : last_id === selectedLine.cid; } set_customer_note(note) { - this.customerNote = note; + this.customerNote = note || ""; } get_customer_note() { return this.customerNote; @@ -2106,7 +2125,7 @@ export class Order extends PosModel { this.fix_tax_included_price(line); this.set_orderline_options(line, options); - + line.set_full_product_name(); var to_merge_orderline; for (var i = 0; i < this.orderlines.length; i++) { if (this.orderlines.at(i).can_be_merged_with(line) && options.merge !== false) { @@ -2195,9 +2214,29 @@ export class Order extends PosModel { orderline.set_discount(options.discount); } - if (options.description !== undefined) { - orderline.description += options.description; - orderline.attribute_value_ids = options.attribute_value_ids; + if (options.attribute_value_ids) { + orderline.attribute_value_ids = options.attribute_value_ids || []; + } + + if ( + options.attribute_custom_values && + Object.keys(options.attribute_custom_values).length > 0 + ) { + const customAttributeValues = []; + for (const [id, value] of Object.entries(options.attribute_custom_values)) { + if (!value) { + continue; + } + + customAttributeValues.push( + new ProductCustomAttribute({ + custom_product_template_attribute_value_id: parseInt(id), + custom_value: value, + }) + ); + } + + orderline.custom_attribute_value_ids = customAttributeValues; } if (options.extras !== undefined) { diff --git a/addons/point_of_sale/static/src/app/store/models/product_custom_attribute.js b/addons/point_of_sale/static/src/app/store/models/product_custom_attribute.js new file mode 100644 index 00000000000..eeafb12f92b --- /dev/null +++ b/addons/point_of_sale/static/src/app/store/models/product_custom_attribute.js @@ -0,0 +1,21 @@ +/** @odoo-module */ +import { Reactive } from "@web/core/utils/reactive"; + +export class ProductCustomAttribute extends Reactive { + constructor() { + super(); + this.setup(...arguments); + } + + setup({ id, name, custom_value, custom_product_template_attribute_value_id }) { + this.id = id; + this.name = name; + this.custom_value = custom_value; + this.custom_product_template_attribute_value_id = + custom_product_template_attribute_value_id; + } + + get value() { + return this.custom_value; + } +} diff --git a/addons/point_of_sale/static/src/app/store/pos_store.js b/addons/point_of_sale/static/src/app/store/pos_store.js index 1d0b0e88cd0..73e7909c669 100644 --- a/addons/point_of_sale/static/src/app/store/pos_store.js +++ b/addons/point_of_sale/static/src/app/store/pos_store.js @@ -263,6 +263,7 @@ export class PosStore extends Reactive { this.pricelists = loadedData["product.pricelist"]; this.default_pricelist = loadedData["default_pricelist"]; this.currency = loadedData["res.currency"]; + this.db.add_attributes(loadedData["attributes_by_ptal_id"]); this.db.add_categories(loadedData["pos.category"]); this.db.add_combos(loadedData["pos.combo"]); this.db.add_combo_lines(loadedData["pos.combo.line"]); diff --git a/addons/point_of_sale/static/src/app/store/product_configurator_popup/product_configurator_popup.js b/addons/point_of_sale/static/src/app/store/product_configurator_popup/product_configurator_popup.js index 8dd95338a7c..bd1aa294a86 100644 --- a/addons/point_of_sale/static/src/app/store/product_configurator_popup/product_configurator_popup.js +++ b/addons/point_of_sale/static/src/app/store/product_configurator_popup/product_configurator_popup.js @@ -34,6 +34,7 @@ export class BaseProductAttribute extends Component { return { value, valueIds, + custom_value: this.state.custom_value, extra, }; } @@ -105,22 +106,27 @@ export class ProductConfiguratorPopup extends AbstractAwaitablePopup { } getPayload() { - const selected_attributes = []; + const attribute_custom_values = []; let attribute_value_ids = []; var price_extra = 0.0; const quantity = this.state.quantity; this.env.attribute_components.forEach((attribute_component) => { - const { value, valueIds, extra } = attribute_component.getValue(); - selected_attributes.push(value); + const { valueIds, extra, custom_value } = attribute_component.getValue(); attribute_value_ids.push(valueIds); + + if (custom_value) { + // for custom values, it will never be a multiple attribute + attribute_custom_values[valueIds[0]] = custom_value; + } + price_extra += extra; }); attribute_value_ids = attribute_value_ids.flat(); return { - selected_attributes, attribute_value_ids, + attribute_custom_values, price_extra, quantity, }; diff --git a/addons/point_of_sale/static/tests/unit/utils.js b/addons/point_of_sale/static/tests/unit/utils.js index 587c771b249..bfc155cd36c 100644 --- a/addons/point_of_sale/static/tests/unit/utils.js +++ b/addons/point_of_sale/static/tests/unit/utils.js @@ -15,6 +15,7 @@ registry.category("mock_server").add("pos.session/load_pos_data", async function "pos.combo.line": [...(this.models["pos.combo.line"]?.records || [])], "product.pricelist": [...(this.models["product.pricelist"]?.records || [])], "pos.payment.method": [...(this.models["pos.payment.method"]?.records || [])], + attributes_by_ptal_id: {}, "res.currency": { id: 1, name: "USD", diff --git a/addons/pos_self_order/__manifest__.py b/addons/pos_self_order/__manifest__.py index a9ced6b3bd9..ad3eaa4707c 100644 --- a/addons/pos_self_order/__manifest__.py +++ b/addons/pos_self_order/__manifest__.py @@ -58,6 +58,7 @@ "web/static/lib/bootstrap/js/dist/carousel.js", 'web/static/lib/bootstrap/js/dist/scrollspy.js', "pos_self_order/static/src/app/**/*", + "point_of_sale/static/src/app/store/models/product_custom_attribute.js", ], # Assets tests "pos_self_order.assets_tests": [ diff --git a/addons/pos_self_order/controllers/orders.py b/addons/pos_self_order/controllers/orders.py index 36646f46b39..4baf2672d06 100644 --- a/addons/pos_self_order/controllers/orders.py +++ b/addons/pos_self_order/controllers/orders.py @@ -241,6 +241,7 @@ class PosSelfOrderController(http.Controller): 'price_unit': child_price_unit, 'price_subtotal': child_tax_results.get('total_excluded'), 'price_subtotal_incl': child_tax_results.get('total_included'), + 'custom_attribute_value_ids': [[0, 0, cAttr] for cAttr in child.get('custom_attribute_value_ids')] if child.get('custom_attribute_value_ids') else [], 'id': child.get('id'), 'order_id': pos_order_id, 'tax_ids': product.taxes_id, @@ -248,7 +249,7 @@ class PosSelfOrderController(http.Controller): 'product_id': child.get('product_id'), 'qty': child.get('qty'), 'customer_note': child.get('customer_note'), - 'attribute_value_ids': child.get('selected_attributes') or [], + 'attribute_value_ids': child.get('attribute_value_ids') or [], 'full_product_name': child.get('full_product_name'), 'combo_parent_uuid': child.get('combo_parent_uuid'), 'combo_id': child.get('combo_id'), @@ -274,7 +275,8 @@ class PosSelfOrderController(http.Controller): 'product_id': line.get('product_id'), 'qty': line.get('qty'), 'customer_note': line.get('customer_note'), - 'attribute_value_ids': line.get('selected_attributes') or [], + 'attribute_value_ids': line.get('attribute_value_ids') or [], + 'custom_attribute_value_ids': [[0, 0, cAttr] for cAttr in line.get('custom_attribute_value_ids')] if line.get('custom_attribute_value_ids') else [], 'full_product_name': line.get('full_product_name'), 'combo_parent_uuid': line.get('combo_parent_uuid'), 'combo_id': line.get('combo_id'), diff --git a/addons/pos_self_order/models/pos_order.py b/addons/pos_self_order/models/pos_order.py index 127877e23bb..f7265eed186 100644 --- a/addons/pos_self_order/models/pos_order.py +++ b/addons/pos_self_order/models/pos_order.py @@ -103,7 +103,8 @@ class PosOrder(models.Model): "price_subtotal": line.price_subtotal, "price_subtotal_incl": line.price_subtotal_incl, "product_id": line.product_id.id, - "selected_attributes": line.attribute_value_ids.ids, + "attribute_value_ids": line.attribute_value_ids.ids, + "custom_attribute_value_ids": line.custom_attribute_value_ids.read(['id', 'name', 'custom_product_template_attribute_value_id', 'custom_value'], load=False), "uuid": line.uuid, "qty": line.qty, "customer_note": line.customer_note, diff --git a/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.js b/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.js index 536ca8ac9f4..e0c5513985b 100644 --- a/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.js +++ b/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.js @@ -1,6 +1,7 @@ /** @odoo-module */ import { Component, useState } from "@odoo/owl"; +import { ProductCustomAttribute } from "@point_of_sale/app/store/models/product_custom_attribute"; import { useSelfOrder } from "@pos_self_order/app/self_order_service"; import { attributeFlatter, attributeFormatter } from "@pos_self_order/app/utils"; @@ -18,7 +19,7 @@ export class AttributeSelection extends Component { editMode: false, showResume: false, showNext: false, - showCustomInput: false, // FIXME: need to implement this correctly here and in the PoS, maybe add a field ? + showCustomInput: false, }); this.selectedValues = useState(this.env.selectedValues); @@ -35,9 +36,16 @@ export class AttributeSelection extends Component { return this.props.product.attributes[this.state.currentAttribute]; } + get availableAttributeValue() { + return this.selfOrder.config.self_ordering_mode === "kiosk" + ? this.attribute.values.filter((a) => !a.is_custom) + : this.attribute.values; + } + get attributeSelected() { const flatAttribute = attributeFlatter(this.selectedValues); - return attributeFormatter(this.selfOrder.attributeById, flatAttribute); + const customAttribute = this.env.customValues; + return attributeFormatter(this.selfOrder.attributeById, flatAttribute, customAttribute); } initAttribute() { @@ -53,12 +61,18 @@ export class AttributeSelection extends Component { } for (const attribute of attributeSingle) { - if (this.selfOrder.editedLine) { - for (const value of attribute.values) { - if (this.selfOrder.editedLine.selected_attributes.includes(value.id)) { + for (const value of attribute.values) { + if (this.selfOrder.editedLine) { + if (this.selfOrder.editedLine.attribute_value_ids.includes(value.id)) { this.selectedValues[attribute.id] = value.id; } } + + if (value.is_custom) { + this.env.customValues[value.id] = new ProductCustomAttribute({ + custom_product_template_attribute_value_id: value.id, + }); + } } } @@ -67,12 +81,18 @@ export class AttributeSelection extends Component { for (const value of attrMulti.values) { if (this.selfOrder.editedLine) { - if (this.selfOrder.editedLine.selected_attributes.includes(value.id)) { + if (this.selfOrder.editedLine.attribute_value_ids.includes(value.id)) { this.selectedValues[attrMulti.id][value.id] = true; } } else { this.selectedValues[attrMulti.id][value.id] = false; } + + if (value.is_custom) { + this.env.customValues[value.id] = new ProductCustomAttribute({ + custom_product_template_attribute_value_id: value.id, + }); + } } } } diff --git a/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.xml b/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.xml index 1ffbd3da77e..a753fe29eb6 100644 --- a/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.xml +++ b/addons/pos_self_order/static/src/app/components/attribute_selection/attribute_selection.xml @@ -12,7 +12,7 @@