[FIX] point_of_sale,pos_self_order: correct component prices

In this commit, we are changing the way prices of the components of a combo
product is calculated. The idea is that a combo product's price should behave
similarly as normal products.

This results to a simpler logic in calculating the prices of the components. In
order to get the fraction that will be used to scale the prices of the component
products, the lst_price of the combo product is divided by the total of the
pos.combo.base_price. This ratio is then multiplied to each pos.combo.base_price
to get the pro-rated unit price of each component. We then proceed to normal
calculation of taxes based on the tax configuration of the selected products.

Furthermore, we are now taking into account the extra prices from the selected
attributes. If the orderline is a component of a combo (menu) product, the extra
prices of the attributes are added on top of the prorated unit price, just like
we do with the `pos.combo.line`'s `combo_price`.

E.g.:
```
product.product.1 -> lst_price = 10
A1 -> price_extra = 5
A3 -> price_extra = 3

pos.order.line.1, product.product.1, attributes=[A1,A3]
  -> price_unit = 10 + 5 + 3 = 18
```

One more thing, there is a regression in the combo configurator popup where the
total price of the selection is removed. It should be restored in the future
with calculation that properly follows the description above.

closes odoo/odoo#139915

Signed-off-by: Adrien Guilliams (adgu) <adgu@odoo.com>
This commit is contained in:
Joseph Caburnay
2023-10-27 13:02:17 +00:00
parent 7510441cc3
commit 8bbe9c8be3
13 changed files with 144 additions and 129 deletions
+4 -1
View File
@@ -1638,7 +1638,10 @@ class PosSession(models.Model):
for key, group in groupby(sorted(product_template_attribute_values, key=key1), key=key2):
attribute_line_id, attribute = key
values = [{**ptav.product_attribute_value_id.read(['name', 'is_custom', 'html_color', 'image'])[0],
'price_extra': ptav.price_extra} for ptav in list(group)]
'price_extra': ptav.price_extra,
# id of a value should be from the "product.template.attribute.value" record
'id': ptav.id,
} for ptav in list(group)]
res[attribute_line_id] = {
'id': attribute_line_id,
'name': attribute.name,
@@ -17,15 +17,6 @@ export class ComboConfiguratorPopup extends AbstractAwaitablePopup {
});
}
getTotalPrice() {
const selectedComponents = this.getPayload();
const extraPrice = selectedComponents.reduce((acc, x) => {
const product = this.pos.db.product_by_id[x.product_id[0]];
return acc + product.get_display_price({ price: x.combo_price });
}, 0);
return this.props.product.lst_price + extraPrice;
}
areAllCombosSelected() {
return Object.values(this.state.combo).every((x) => Boolean(x));
}
@@ -34,16 +34,20 @@
</div>
</main>
<footer class="footer footer-flex modal-footer">
<t t-set="_allSelected" t-value="areAllCombosSelected()" />
<button class="button highlight confirm btn btn-lg btn-primary"
t-att-disabled="!areAllCombosSelected()"
t-att-disabled="!_allSelected"
t-on-click="confirm">
Add to order
</button>
<button class="button cancel btn btn-lg btn-secondary" t-on-click="cancel">
Discard
</button>
<div class="ms-auto total-amount">
Total <t t-esc="env.utils.formatCurrency(getTotalPrice())"/>
<div class="ms-auto">
<!-- TODO: Restore the feature the shows the price of the selection. -->
<t t-if="!_allSelected">
Complete the selection to proceed
</t>
</div>
</footer>
</div>
@@ -2027,49 +2027,44 @@ export class Order extends PosModel {
}
}
async addComboLines(comboParent, options) {
const originalPrices = {};
let originalTotal = 0;
for (const comboLine of options.comboLines) {
const product = this.pos.db.product_by_id[comboLine.product_id[0]];
const parentLstPrice = comboParent.product.lst_price;
const originalTotal = options.comboLines.reduce((acc, comboLine) => {
const originalPrice = this.pos.db.combo_by_id[comboLine.combo_id[0]].base_price;
originalTotal += product.get_display_price({ price: originalPrice });
return acc + originalPrice;
}, 0);
// Keep track of the original price of each product for the subsequent for loop.
originalPrices[product.id] = originalPrice;
}
let remainingTotal = parentLstPrice;
const targetPrice = comboParent.product.lst_price;
const childPriceFactor = targetPrice / originalTotal;
for (const comboLine of options.comboLines) {
for (let i = 0; i < options.comboLines.length; i++) {
const comboLine = options.comboLines[i];
const product = this.pos.db.product_by_id[comboLine.product_id[0]];
const childUnitPrice = originalPrices[product.id] * childPriceFactor;
const combo = this.pos.db.combo_by_id[comboLine.combo_id[0]];
let priceUnit = round_di(
(combo.base_price * parentLstPrice) / originalTotal,
this.pos.dp["Product Price"]
);
remainingTotal -= priceUnit;
if (i == options.comboLines.length - 1) {
priceUnit += remainingTotal;
}
await this.pos.addProductToCurrentOrder(product, {
price: childUnitPrice,
price: priceUnit,
comboParent,
});
}
// Adjust the price of the last combo line to make sure the total price of the combo
// is the same as the target price.
// Take into account the extra prices.
const childLines = this.get_orderlines().filter(
(l) => l.comboParent?.uuid === comboParent.uuid
);
const totalComboPrice = childLines.reduce((acc, l) => acc + l.get_display_price(), 0);
const diff = targetPrice - totalComboPrice;
if (!this.env.utils.floatIsZero(diff)) {
const lastLine = childLines[childLines.length - 1];
lastLine.set_unit_price(lastLine.get_unit_price() + diff);
}
// Finally, take into account the combo price for each combo line.
for (const comboLine of options.comboLines) {
if (this.env.utils.floatIsZero(comboLine.combo_price)) {
continue;
}
const presentLine = childLines.find((l) => l.product.id === comboLine.product_id[0]);
if (presentLine) {
presentLine.set_unit_price(presentLine.get_unit_price() + comboLine.combo_price);
const attributesPriceExtra = (presentLine.attribute_value_ids ?? [])
.map((id) => this.pos.db.attribute_value_by_id[id]?.price_extra || 0)
.reduce((acc, price) => acc + price, 0);
const totalPriceExtra = attributesPriceExtra + comboLine.combo_price;
presentLine.set_unit_price(presentLine.get_unit_price() + totalPriceExtra);
}
}
}
@@ -34,19 +34,14 @@ registry.category("web_tour.tours").add("PosComboPriceTaxIncludedTour", {
combo.select("Combo Product 8"),
combo.isSelected("Combo Product 8"),
combo.isNotSelected("Combo Product 7"),
{
content: "check that the total amount is properly displayed",
trigger: `.combo-configurator-popup .total-amount:contains("59.60")`,
isCheck: true,
},
combo.confirm(),
...ProductScreen.selectedOrderlineHas("Office Combo"),
...ProductScreen.clickOrderline("Combo Product 3"),
...ProductScreen.selectedOrderlineHas("Combo Product 3", "1.0", "12.92"),
...ProductScreen.selectedOrderlineHas("Combo Product 3", "1.0", "13.43"),
...ProductScreen.clickOrderline("Combo Product 5"),
...ProductScreen.selectedOrderlineHas("Combo Product 5", "1.0", "17.87"),
...ProductScreen.selectedOrderlineHas("Combo Product 5", "1.0", "18.67"),
...ProductScreen.clickOrderline("Combo Product 8"),
...ProductScreen.selectedOrderlineHas("Combo Product 8", "1.0", "28.81"),
...ProductScreen.selectedOrderlineHas("Combo Product 8", "1.0", "30.00"),
// check that you cannot change the quantity of a combo product
...ProductScreen.pressNumpad("2"),
@@ -61,7 +56,7 @@ registry.category("web_tour.tours").add("PosComboPriceTaxIncludedTour", {
combo.select("Combo Product 5"),
combo.select("Combo Product 8"),
combo.confirm(),
...ProductScreen.totalAmountIs("59.60"),
...ProductScreen.totalAmountIs("62.10"),
...ProductScreen.clickPayButton(),
...PaymentScreen.clickPaymentMethod("Bank"),
...PaymentScreen.clickValidate(),
@@ -74,8 +69,8 @@ registry.category("web_tour.tours").add("PosComboPriceTaxIncludedTour", {
combo.select("Combo Product 4"),
combo.select("Combo Product 6"),
combo.confirm(),
...ProductScreen.totalAmountIs("50.00"),
...inLeftSide(Order.hasTax("8.92")),
...ProductScreen.totalAmountIs("59.17"),
...inLeftSide(Order.hasTax("10.56")),
// the split screen is tested in `pos_restaurant`
],
});
@@ -170,7 +170,7 @@ registry.category("web_tour.tours").add("SplitBillScreenTour4PosCombo", {
...SplitBillScreen.orderlineHas("Combo Product 4", "1", "0"),
...SplitBillScreen.orderlineHas("Combo Product 7", "1", "0"),
...SplitBillScreen.subtotalIs("52.13"),
...SplitBillScreen.subtotalIs("54.13"),
...SplitBillScreen.clickPay(),
...PaymentScreen.clickPaymentMethod("Bank"),
...PaymentScreen.clickValidate(),
@@ -181,11 +181,11 @@ registry.category("web_tour.tours").add("SplitBillScreenTour4PosCombo", {
...ProductScreen.selectedOrderlineHas("Minute Maid", "1.0"),
...ProductScreen.clickOrderline("Office Combo"),
...ProductScreen.clickOrderline("Combo Product 2"),
...ProductScreen.selectedOrderlineHas("Combo Product 2", "1.0", "6.15", "Office Combo"),
...ProductScreen.selectedOrderlineHas("Combo Product 2", "1.0", "6.67", "Office Combo"),
...ProductScreen.clickOrderline("Combo Product 4"),
...ProductScreen.selectedOrderlineHas("Combo Product 4", "1.0", "13.54", "Office Combo"),
...ProductScreen.selectedOrderlineHas("Combo Product 4", "1.0", "14.66", "Office Combo"),
...ProductScreen.clickOrderline("Combo Product 7"),
...ProductScreen.selectedOrderlineHas("Combo Product 7", "1.0", "20.31", "Office Combo"),
...ProductScreen.totalAmountIs("42.53"),
...ProductScreen.selectedOrderlineHas("Combo Product 7", "1.0", "22.00", "Office Combo"),
...ProductScreen.totalAmountIs("45.86"),
],
});
+48 -49
View File
@@ -4,6 +4,7 @@ import uuid
from datetime import timedelta
from odoo import http, fields
from odoo.http import request
from odoo.tools import float_round
from werkzeug.exceptions import NotFound, BadRequest, Unauthorized
class PosSelfOrderController(http.Controller):
@@ -202,57 +203,57 @@ class PosSelfOrderController(http.Controller):
appended_uuid = []
newLines = []
pricelist = pos_config.pricelist_id
if take_away and pos_config.self_ordering_mode == 'kiosk':
config_fiscal_pos = pos_config.self_ordering_alternative_fp_id
else:
config_fiscal_pos = pos_config.default_fiscal_position_id
sale_price_digits = pos_config.env['decimal.precision'].precision_get('Product Price')
combo_line_ids = [line['combo_line_id'] for line in lines if line.get('combo_line_id')]
combo_lines = pos_config.env['pos.combo.line'].search([('id', 'in', combo_line_ids)])
attribute_value_ids = sum([line.get('attribute_value_ids', []) for line in lines], [])
fetched_attributes = pos_config.env['product.template.attribute.value'].search([('id', 'in', attribute_value_ids)])
fiscal_pos = pos_config.default_fiscal_position_id
if take_away and pos_config.self_ordering_alternative_fp_id:
fiscal_pos = pos_config.self_ordering_alternative_fp_id
for line in lines:
if line.get('uuid') in appended_uuid or not line.get('product_id'):
continue
line_qty = line.get('qty')
product = pos_config.env['product.product'].browse(int(line.get('product_id')))
# todo take into account the price extra
price_unit = pricelist._get_product_price(product, quantity=line.get('qty')) if pricelist else product.lst_price
selected_account_tax = config_fiscal_pos.map_tax(product.taxes_id) if config_fiscal_pos else product.taxes_id
lst_price = pricelist._get_product_price(product, quantity=line_qty) if pricelist else product.lst_price
# parent_product_taxe_ids = None
children = [l for l in lines if l.get('combo_parent_uuid') == line.get('uuid')]
pos_combo_lines = combo_lines.browse([child.get('combo_line_id') for child in children])
if len(children) > 0:
total_price = 0
unit_price_by_id = {}
for child in children:
product = pos_config.env['product.product'].browse(int(child.get('product_id')))
child_selected_account_tax = config_fiscal_pos.map_tax(product.taxes_id) if config_fiscal_pos else product.taxes_id
tax_results = child_selected_account_tax.compute_all(
pricelist._get_product_price(product, quantity=line.get('qty')) if pricelist else product.lst_price,
pos_config.currency_id,
child.get('qty'),
product,
)
unit_price_by_id[child['uuid']] = pricelist._get_product_price(product, quantity=line.get('qty')) if pricelist else product.lst_price
total_price += tax_results.get('total_included') if pos_config.iface_tax_included == 'total' else tax_results.get('total_excluded')
ratio = (price_unit * line.get('qty') / total_price)
for child in children:
child_line_combo = pos_config.env['pos.combo'].browse(int(child.get('combo_id')))
child_line_combo_line = child_line_combo.combo_line_ids.filtered(lambda l: l.product_id.id == child.get('product_id'))[0]
child_product = pos_config.env["product.product"].browse(int(child.get('product_id')))
child_price_unit = ratio * unit_price_by_id[child['uuid']] + child_line_combo_line.combo_price
child_selected_account_tax = config_fiscal_pos.map_tax(child_product.taxes_id) if config_fiscal_pos else child_product.taxes_id
child_tax_results = child_selected_account_tax.compute_all(
child_price_unit,
pos_config.currency_id,
child.get('qty'),
child_product,
)
original_total = sum(pos_combo_lines.mapped("combo_id.base_price"))
remaining_total = lst_price
factor = lst_price / original_total
for i, child in enumerate(children):
child_product = pos_config.env['product.product'].browse(int(child.get('product_id')))
pos_combo_line = pos_combo_lines.browse(child.get('combo_line_id'))
price_unit = float_round(pos_combo_line.combo_id.base_price * factor, precision_digits=sale_price_digits)
remaining_total -= price_unit
if i == len(children) - 1:
price_unit += remaining_total
selected_attributes = fetched_attributes.browse(child.get('attribute_value_ids', []))
price_unit += pos_combo_line.combo_price + sum([attr.price_extra for attr in selected_attributes])
price_unit_fp = child_product._get_price_unit_after_fp(price_unit, pos_config.currency_id, fiscal_pos)
taxes = fiscal_pos.map_tax(child_product.taxes_id) if fiscal_pos else child_product.taxes_id
pdetails = taxes.compute_all(price_unit_fp, pos_config.currency_id, line_qty, child_product)
newLines.append({
'price_unit': child_price_unit,
'price_subtotal': child_tax_results.get('total_excluded'),
'price_subtotal_incl': child_tax_results.get('total_included'),
'price_unit': price_unit_fp,
'price_subtotal': pdetails.get('total_excluded'),
'price_subtotal_incl': pdetails.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,
'tax_ids': child_product.taxes_id,
'uuid': child.get('uuid'),
'product_id': child.get('product_id'),
'qty': child.get('qty'),
@@ -263,25 +264,23 @@ class PosSelfOrderController(http.Controller):
'combo_id': child.get('combo_id'),
})
appended_uuid.append(child.get('uuid'))
price_unit = 0
tax_results = selected_account_tax.compute_all(
price_unit,
pos_config.currency_id,
line.get('qty'),
product,
)
lst_price = 0
price_unit_fp = product._get_price_unit_after_fp(lst_price, pos_config.currency_id, fiscal_pos)
taxes_after_fp = fiscal_pos.map_tax(product.taxes_id) if fiscal_pos else product.taxes_id
pdetails = taxes_after_fp.compute_all(price_unit_fp, pos_config.currency_id, line_qty, product)
newLines.append({
'price_unit': price_unit,
'price_subtotal': tax_results.get('total_excluded'),
'price_subtotal_incl': tax_results.get('total_included'),
'price_unit': price_unit_fp,
'price_subtotal': pdetails.get('total_excluded'),
'price_subtotal_incl': pdetails.get('total_included'),
'id': line.get('id'),
'order_id': pos_order_id,
'tax_ids': product.taxes_id,
'uuid': line.get('uuid'),
'product_id': line.get('product_id'),
'qty': line.get('qty'),
'qty': line_qty,
'customer_note': line.get('customer_note'),
'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 [],
@@ -348,6 +348,7 @@ class PosConfig(models.Model):
"custom_links": self._get_self_order_custom_links(),
"currency_id": self.currency_id.id,
"pos_payment_methods": payment_methods if self.self_ordering_mode == "kiosk" else [],
"currency_decimals": self.currency_id.decimal_places,
"pos_category": self._get_available_categories().read(["name", "sequence", "has_image"]),
"products": self._get_available_products()._get_self_order_data(self),
"combos": self._get_combos_data(),
@@ -4,6 +4,7 @@ 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";
import { floatIsZero } from "@web/core/utils/numbers";
export class AttributeSelection extends Component {
static template = "pos_self_order.AttributeSelection";
@@ -90,4 +91,22 @@ export class AttributeSelection extends Component {
? this.selectedValues[attribute.id][value.id]
: parseInt(this.selectedValues[attribute.id]) === value.id;
}
_getPriceExtra(value) {
const isTakeAway = this.selfOrder.take_away;
const priceExtra = isTakeAway
? value.price_extra.display_price_default
: value.price_extra.display_price_alternative;
return priceExtra;
}
shouldShowPriceExtra(value) {
const priceExtra = this._getPriceExtra(value);
return !floatIsZero(priceExtra, this.selfOrder.config.currency_decimals);
}
getfPriceExtra(value) {
const priceExtra = this._getPriceExtra(value);
return this.selfOrder.formatMonetary(priceExtra);
}
}
@@ -14,8 +14,8 @@
d-flex align-items-center justify-content-center h-100 rounded border ratio ratio-16x9">
<div class="name position-relative d-flex flex-column justify-content-center align-items-center flex-grow-1 w-100 p-4 text-center">
<span t-out="value.name"/>
<span t-if="value.price_extra.list_price">
+ <t t-out="selfOrder.formatMonetary(value.price_extra.list_price)" />
<span t-if="shouldShowPriceExtra(value)">
+ <t t-out="getfPriceExtra(value)" />
</span>
</div>
</label>
@@ -14,6 +14,11 @@ export class ComboSelection extends Component {
}
productClicked(lineId) {
// Keep track of the current combo line id.
// It servers as additional info for each line so that when calculating prices,
// no need to look for the specific combo line the product belongs to.
this.env.currentComboLineId.value = lineId;
const comboLine = this.props.combo.combo_line_ids.find((line) => line.id == lineId);
const productSelected = this.selfOrder.productByIds[comboLine.product_id[0]];
if (!productSelected.self_order_available) {
@@ -18,6 +18,7 @@ export class Line extends Reactive {
custom_attribute_value_ids,
combo_parent_uuid,
combo_id,
combo_line_id,
child_lines,
}) {
super();
@@ -39,6 +40,7 @@ export class Line extends Reactive {
this.custom_attribute_value_ids = line.custom_attribute_value_ids || [];
this.combo_parent_uuid = line.combo_parent_uuid || null;
this.combo_id = line.combo_id || null;
this.combo_line_id = line.combo_line_id || null;
this.child_lines = line.child_lines || [];
this.initCustomAttribute();
@@ -17,7 +17,14 @@ export class ComboPage extends Component {
this.selfOrder = useSelfOrder();
this.selfOrder.lastEditedProductId = this.props.product.id;
this.router = useService("router");
useSubEnv({ selectedValues: {}, customValues: {}, editable: this.editableProductLine });
useSubEnv({
selectedValues: {},
customValues: {},
editable: this.editableProductLine,
currentComboLineId: {
value: null,
},
});
if (!this.props.product) {
this.router.navigate("productList");
@@ -79,28 +86,21 @@ export class ComboPage extends Component {
next() {
const combo = this.currentCombo;
const index = this.state.selectedCombos.findIndex((c) => c.id === combo.id);
const selectedCombo = {
id: combo.id,
name: combo.name,
combo_line_id: this.env.currentComboLineId.value,
product: {
id: this.state.selectedProduct.id,
name: this.state.selectedProduct.name,
variants: { ...this.env.selectedValues },
customValues: { ...this.env.customValues },
},
};
if (index !== -1) {
this.state.selectedCombos[index] = {
id: combo.id,
name: combo.name,
product: {
id: this.state.selectedProduct.id,
name: this.state.selectedProduct.name,
variants: { ...this.env.selectedValues },
customValues: { ...this.env.customValues },
},
};
this.state.selectedCombos[index] = selectedCombo;
} else {
this.state.selectedCombos.push({
id: combo.id,
name: combo.name,
product: {
id: this.state.selectedProduct.id,
name: this.state.selectedProduct.name,
variants: { ...this.env.selectedValues },
customValues: { ...this.env.customValues },
},
});
this.state.selectedCombos.push(selectedCombo);
}
this.resetState();
if (this.state.editMode) {
@@ -156,6 +156,7 @@ export class ComboPage extends Component {
custom_attribute_value_ids: Object.values(combo.product.customValues),
combo_parent_uuid: parent_line.uuid,
combo_id: combo.id,
combo_line_id: combo.combo_line_id,
});
child_line.full_product_name = constructFullProductName(
child_line,